@brianmsm/quarto-bento 0.0.0-stage → 0.1.1
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 +93 -2
- package/bin/qbento-npm.mjs +62 -0
- package/distribution/LICENSE +21 -0
- package/distribution/PACKAGING.md +59 -0
- package/distribution/bin/qbento +5 -0
- package/distribution/bin/qbento.cmd +4 -0
- package/distribution/manifest.json +720 -0
- package/distribution/runtime/LICENSE +21 -0
- package/distribution/runtime/_extensions/qbento/_extension.yml +24 -0
- package/distribution/runtime/_extensions/qbento/capture.lua +21 -0
- package/distribution/runtime/_extensions/qbento/guard.lua +7 -0
- package/distribution/runtime/_extensions/qbento/post.ts +2 -0
- package/distribution/runtime/_extensions/qbento/pre.ts +2 -0
- package/distribution/runtime/_extensions/qbento/producer-activation.js +129 -0
- package/distribution/runtime/src/bridge/browser.js +168 -0
- package/distribution/runtime/src/bridge/isolation.ts +55 -0
- package/distribution/runtime/src/browser/chromium.ts +218 -0
- package/distribution/runtime/src/cli.ts +232 -0
- package/distribution/runtime/src/core/agent.ts +546 -0
- package/distribution/runtime/src/core/annotations.ts +384 -0
- package/distribution/runtime/src/core/capture.ts +203 -0
- package/distribution/runtime/src/core/component-actions.ts +550 -0
- package/distribution/runtime/src/core/component-frames.ts +59 -0
- package/distribution/runtime/src/core/component-sync.ts +290 -0
- package/distribution/runtime/src/core/design-diagnostics.ts +78 -0
- package/distribution/runtime/src/core/design-registry.ts +287 -0
- package/distribution/runtime/src/core/graph-relations.ts +354 -0
- package/distribution/runtime/src/core/graph-scope.ts +63 -0
- package/distribution/runtime/src/core/graph-sync.ts +228 -0
- package/distribution/runtime/src/core/io.ts +27 -0
- package/distribution/runtime/src/core/kpi-appearance.ts +205 -0
- package/distribution/runtime/src/core/layouts.ts +245 -0
- package/distribution/runtime/src/core/project.ts +851 -0
- package/distribution/runtime/src/core/reconcile.ts +384 -0
- package/distribution/runtime/src/core/recovery.ts +1255 -0
- package/distribution/runtime/src/core/slide-actions.ts +158 -0
- package/distribution/runtime/src/core/source-scope.ts +50 -0
- package/distribution/runtime/src/core/sync.ts +2557 -0
- package/distribution/runtime/src/core/widgets.ts +300 -0
- package/distribution/runtime/src/integration/install.ts +98 -0
- package/distribution/runtime/src/integration/invocation.ts +100 -0
- package/distribution/runtime/src/integration/quarto.ts +457 -0
- package/distribution/runtime/src/render/assets.ts +66 -0
- package/distribution/runtime/src/render/capture.lua +8 -0
- package/distribution/runtime/src/render/chart.ts +165 -0
- package/distribution/runtime/src/render/components.ts +199 -0
- package/distribution/runtime/src/render/design.ts +820 -0
- package/distribution/runtime/src/render/diagnostics.ts +36 -0
- package/distribution/runtime/src/render/graphs.ts +747 -0
- package/distribution/runtime/src/render/html.ts +82 -0
- package/distribution/runtime/src/render/images.ts +268 -0
- package/distribution/runtime/src/render/inline.ts +259 -0
- package/distribution/runtime/src/render/kpi.ts +385 -0
- package/distribution/runtime/src/render/lists.ts +183 -0
- package/distribution/runtime/src/render/math.ts +413 -0
- package/distribution/runtime/src/render/measure.ts +185 -0
- package/distribution/runtime/src/render/native-layout.ts +174 -0
- package/distribution/runtime/src/render/producer-capture.ts +319 -0
- package/distribution/runtime/src/render/producer-environment.R +49 -0
- package/distribution/runtime/src/render/producer-environment.ts +96 -0
- package/distribution/runtime/src/render/producer-profile.R +91 -0
- package/distribution/runtime/src/render/producer-site-profile.R +18 -0
- package/distribution/runtime/src/render/project.ts +1806 -0
- package/distribution/runtime/src/render/quarto.ts +127 -0
- package/distribution/runtime/src/render/registry.ts +233 -0
- package/distribution/runtime/src/render/resources.ts +292 -0
- package/distribution/runtime/src/render/shell.ts +110 -0
- package/distribution/runtime/src/render/source-inline.ts +169 -0
- package/distribution/runtime/src/render/table.ts +220 -0
- package/distribution/runtime/src/render/text-overflow.ts +208 -0
- package/distribution/runtime/src/render/types.ts +71 -0
- package/distribution/runtime/src/render/validate.ts +572 -0
- package/distribution/runtime/src/rich/project.ts +117 -0
- package/distribution/runtime/src/rich/table.ts +374 -0
- package/distribution/runtime/src/source/annotations.ts +490 -0
- package/distribution/runtime/src/source/attributes.ts +415 -0
- package/distribution/runtime/src/source/chart.ts +165 -0
- package/distribution/runtime/src/source/code-language.ts +277 -0
- package/distribution/runtime/src/source/component-insertion.ts +160 -0
- package/distribution/runtime/src/source/deletion.ts +278 -0
- package/distribution/runtime/src/source/graphs.ts +517 -0
- package/distribution/runtime/src/source/identities.ts +256 -0
- package/distribution/runtime/src/source/identity-id.ts +6 -0
- package/distribution/runtime/src/source/image.ts +50 -0
- package/distribution/runtime/src/source/kpi.ts +196 -0
- package/distribution/runtime/src/source/mod.ts +876 -0
- package/distribution/runtime/src/source/notes.ts +308 -0
- package/distribution/runtime/src/source/structure.ts +647 -0
- package/distribution/runtime/src/source/table.ts +290 -0
- package/distribution/runtime/src/source/text-conversion.ts +146 -0
- package/distribution/runtime/src/state/components.ts +281 -0
- package/distribution/runtime/src/state/graphs.ts +200 -0
- package/distribution/runtime/src/state/json.ts +162 -0
- package/distribution/runtime/src/state/merge.ts +1034 -0
- package/distribution/runtime/src/state/mod.ts +5 -0
- package/distribution/runtime/src/state/store.ts +226 -0
- package/distribution/runtime/src/state/types.ts +151 -0
- package/distribution/runtime/src/studio/client.js +892 -0
- package/distribution/runtime/src/studio/components.js +188 -0
- package/distribution/runtime/src/studio/index.html +283 -0
- package/distribution/runtime/src/studio/layouts.js +170 -0
- package/distribution/runtime/src/studio/registry.js +283 -0
- package/distribution/runtime/src/studio/relations.js +169 -0
- package/distribution/runtime/src/studio/server.ts +461 -0
- package/distribution/runtime/src/widgets/deck.ts +39 -0
- package/distribution/runtime/src/widgets/plotly.ts +354 -0
- package/distribution/runtime/src/widgets/presenter.ts +367 -0
- package/distribution/runtime/src/widgets/snapshot.ts +116 -0
- package/distribution/runtime/vendor/bento/FONT_NOTICES.md +49 -0
- package/distribution/runtime/vendor/bento/LICENSE +21 -0
- package/distribution/runtime/vendor/bento/THIRD_PARTY_NOTICES.md +51 -0
- package/distribution/runtime/vendor/bento/font-licenses/fraunces-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/font-licenses/instrument-sans-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/pin.json +69 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package-lock.json +1928 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package.json +28 -0
- package/distribution/runtime/vendor/bento/runtime-dependencies.json +244 -0
- package/distribution/runtime/vendor/bento/runtime-licenses.json +4038 -0
- package/distribution/runtime/vendor/bento/shell.html +269 -0
- package/distribution/runtime/vendor/bento/slides.schema.json +2379 -0
- package/package.json +40 -4
|
@@ -0,0 +1,1806 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertImageShape,
|
|
3
|
+
authoredImageData,
|
|
4
|
+
executedImageShape,
|
|
5
|
+
imageNativeIdentities,
|
|
6
|
+
} from "./images.ts";
|
|
7
|
+
import { readSourceInline } from "./source-inline.ts";
|
|
8
|
+
import { hashSource } from "../source/mod.ts";
|
|
9
|
+
import { producerOutputChildren, producerReceipt } from "./producer-capture.ts";
|
|
10
|
+
import { type KpiAppearance, projectKpi } from "./kpi.ts";
|
|
11
|
+
import { kpiSource, kpiSourceAttributes } from "../source/kpi.ts";
|
|
12
|
+
import { refreshOverflowDiagnostics } from "./diagnostics.ts";
|
|
13
|
+
import { projectGraph, validateGraphOrigin } from "./graphs.ts";
|
|
14
|
+
import { sourceGraphs } from "../source/graphs.ts";
|
|
15
|
+
import {
|
|
16
|
+
collectRichTableCandidates,
|
|
17
|
+
type RichTableSnapshot,
|
|
18
|
+
} from "../rich/table.ts";
|
|
19
|
+
import { richTableElement, verifyRichTableSnapshot } from "../rich/project.ts";
|
|
20
|
+
import { collectPlotlyCandidates } from "../widgets/plotly.ts";
|
|
21
|
+
import {
|
|
22
|
+
assertCompatibleComponentSource,
|
|
23
|
+
projectComponent,
|
|
24
|
+
sourceComponentClass,
|
|
25
|
+
} from "./components.ts";
|
|
26
|
+
import type { SourceDocument } from "../source/mod.ts";
|
|
27
|
+
import { isNativeIdentity, isSourceIdentity } from "../source/identity-id.ts";
|
|
28
|
+
import type { SourceInlineProof } from "./source-inline.ts";
|
|
29
|
+
import {
|
|
30
|
+
NOTES_CODEC_VERSION,
|
|
31
|
+
renderPlainNotes,
|
|
32
|
+
sourceNotes,
|
|
33
|
+
} from "../source/notes.ts";
|
|
34
|
+
import { projectTable } from "./table.ts";
|
|
35
|
+
import { executedChartTable, projectChart } from "./chart.ts";
|
|
36
|
+
import {
|
|
37
|
+
chartSource,
|
|
38
|
+
chartSourceAttributes,
|
|
39
|
+
isSourceChart,
|
|
40
|
+
validateAuthoredChart,
|
|
41
|
+
} from "../source/chart.ts";
|
|
42
|
+
import {
|
|
43
|
+
authoredTableBlock,
|
|
44
|
+
literalTableBlocks,
|
|
45
|
+
matchBareTable,
|
|
46
|
+
tableNativeIdentity,
|
|
47
|
+
tableProducer,
|
|
48
|
+
validateAuthoredTable,
|
|
49
|
+
} from "../source/table.ts";
|
|
50
|
+
import { assetData } from "./assets.ts";
|
|
51
|
+
import { renderInline as inline } from "./inline.ts";
|
|
52
|
+
import { renderList } from "./lists.ts";
|
|
53
|
+
import { mathToNativeHtml } from "./math.ts";
|
|
54
|
+
import {
|
|
55
|
+
applyElementDesign,
|
|
56
|
+
componentFrames,
|
|
57
|
+
type DesignDefaults,
|
|
58
|
+
DesignError,
|
|
59
|
+
designTheme,
|
|
60
|
+
type Frame,
|
|
61
|
+
parseDesignAttributes,
|
|
62
|
+
} from "./design.ts";
|
|
63
|
+
import type {
|
|
64
|
+
Ast,
|
|
65
|
+
BentoDoc,
|
|
66
|
+
Element,
|
|
67
|
+
Json,
|
|
68
|
+
Node,
|
|
69
|
+
Origin,
|
|
70
|
+
Projection,
|
|
71
|
+
Slide,
|
|
72
|
+
} from "./types.ts";
|
|
73
|
+
type Attr = [string, string[], [string, string][]];
|
|
74
|
+
function displayFormula(content: Node[]): string | undefined {
|
|
75
|
+
if (!content.some((node) => node.t === "Math")) return undefined;
|
|
76
|
+
const math = content[0]?.c as [{ t: string }, string] | undefined;
|
|
77
|
+
if (
|
|
78
|
+
content.length !== 1 || content[0].t !== "Math" ||
|
|
79
|
+
math?.[0]?.t !== "DisplayMath" || typeof math[1] !== "string"
|
|
80
|
+
) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
"MIXED_MATH_TEXT: display equations need one standalone formula; inline/mixed math requires a separate supported projection",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return math[1];
|
|
86
|
+
}
|
|
87
|
+
function images(nodes: Node[]): Node[] {
|
|
88
|
+
const result: Node[] = [];
|
|
89
|
+
const walk = (value: unknown) => {
|
|
90
|
+
if (Array.isArray(value)) value.forEach(walk);
|
|
91
|
+
else if (value && typeof value === "object") {
|
|
92
|
+
const n = value as Node;
|
|
93
|
+
if (n.t === "Image") result.push(n);
|
|
94
|
+
else if ("c" in n) walk(n.c);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
walk(nodes);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
export interface WidgetProjection {
|
|
101
|
+
element: Element;
|
|
102
|
+
origin: Origin;
|
|
103
|
+
slideId: string;
|
|
104
|
+
outputHash: string;
|
|
105
|
+
}
|
|
106
|
+
export async function projectAst(
|
|
107
|
+
ast: Ast,
|
|
108
|
+
docId: string,
|
|
109
|
+
assetRoots: string[],
|
|
110
|
+
source?: SourceDocument,
|
|
111
|
+
widgets: ReadonlyMap<string, WidgetProjection> = new Map(),
|
|
112
|
+
defaults: DesignDefaults = {},
|
|
113
|
+
richTables: ReadonlyMap<string, RichTableSnapshot> = new Map(),
|
|
114
|
+
retainedFrames: ReadonlyMap<string, Frame> = new Map(),
|
|
115
|
+
kpiAppearances: ReadonlyMap<string, KpiAppearance> = new Map(),
|
|
116
|
+
inlineProofs?: ReadonlyMap<string, SourceInlineProof>,
|
|
117
|
+
authoredImageRoot = assetRoots.at(-1),
|
|
118
|
+
): Promise<Projection> {
|
|
119
|
+
const expectedRichTables = new Map(
|
|
120
|
+
source
|
|
121
|
+
? collectRichTableCandidates(ast, source).map(
|
|
122
|
+
(candidate) => [candidate.source.sourceId, candidate],
|
|
123
|
+
)
|
|
124
|
+
: [],
|
|
125
|
+
);
|
|
126
|
+
if (expectedRichTables.size !== richTables.size) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"RICH_TABLE_PROJECTION_MISMATCH: every executed rich table needs exactly one fresh verified capture",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
for (const [id, snapshot] of richTables) {
|
|
132
|
+
const expected = expectedRichTables.get(id);
|
|
133
|
+
if (!expected) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
"RICH_TABLE_PROJECTION_MISMATCH: unexpected capture identity",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
await verifyRichTableSnapshot(expected, snapshot);
|
|
139
|
+
}
|
|
140
|
+
const imageProofs = inlineProofs ??
|
|
141
|
+
(source ? await readSourceInline(source, authoredImageRoot) : new Map());
|
|
142
|
+
const consumedImages = new Set<string>();
|
|
143
|
+
const consumedRichTables = new Set<string>();
|
|
144
|
+
const theme = designTheme(defaults);
|
|
145
|
+
const consumedWidgets = new Set<string>();
|
|
146
|
+
const expectedWidgets = new Map(
|
|
147
|
+
widgets.size && source
|
|
148
|
+
? collectPlotlyCandidates(ast, source).map((c) => [c.source.sourceId, c])
|
|
149
|
+
: [],
|
|
150
|
+
);
|
|
151
|
+
for (const [id, widget] of widgets) {
|
|
152
|
+
if (
|
|
153
|
+
!expectedWidgets.has(id) ||
|
|
154
|
+
expectedWidgets.get(id)!.source.outputHash !== widget.outputHash
|
|
155
|
+
) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
"WIDGET_PROJECTION_MISMATCH: widget does not match the checked executed AST",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const document: BentoDoc = {
|
|
162
|
+
format: "bento/slides",
|
|
163
|
+
version: 1,
|
|
164
|
+
docId,
|
|
165
|
+
title: ast.meta.title?.t === "MetaInlines"
|
|
166
|
+
? inline(ast.meta.title.c as Node[]).replace(/<[^>]+>/g, "")
|
|
167
|
+
: "Untitled",
|
|
168
|
+
size: { width: 1280, height: 720 },
|
|
169
|
+
theme,
|
|
170
|
+
slides: [],
|
|
171
|
+
};
|
|
172
|
+
const result: Projection = {
|
|
173
|
+
document,
|
|
174
|
+
origins: {},
|
|
175
|
+
diagnostics: [],
|
|
176
|
+
design: {},
|
|
177
|
+
};
|
|
178
|
+
let slide: Slide | undefined;
|
|
179
|
+
let y = 166;
|
|
180
|
+
let counter = 0;
|
|
181
|
+
let contentFrame: Frame | undefined;
|
|
182
|
+
const tableSources = new Set<string>();
|
|
183
|
+
const expectedCharts = new Set<string>();
|
|
184
|
+
const consumedCharts = new Set<string>();
|
|
185
|
+
const chartAncestors = new Map<string, { attr: Attr; parentId?: string }>();
|
|
186
|
+
const kpiAncestors = new Map<string, { attr: Attr; parentId?: string }>();
|
|
187
|
+
if (source) {
|
|
188
|
+
for (const block of source.blocks) {
|
|
189
|
+
if (
|
|
190
|
+
block.kind === "div" &&
|
|
191
|
+
isSourceChart(block)
|
|
192
|
+
) {
|
|
193
|
+
expectedCharts.add(block.id);
|
|
194
|
+
for (const id of chartSource(source, block.id).ancestors) {
|
|
195
|
+
const ancestor = source.blocks.find((candidate) =>
|
|
196
|
+
candidate.id === id
|
|
197
|
+
)!;
|
|
198
|
+
chartAncestors.set(id, {
|
|
199
|
+
attr: chartSourceAttributes(ancestor),
|
|
200
|
+
parentId: ancestor.parentId,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (
|
|
205
|
+
block.kind !== "div" || sourceComponentClass(block.raw) !== ".bento-kpi"
|
|
206
|
+
) continue;
|
|
207
|
+
for (const id of kpiSource(source, block.id).ancestors) {
|
|
208
|
+
const ancestor = source.blocks.find((candidate) =>
|
|
209
|
+
candidate.id === id
|
|
210
|
+
)!;
|
|
211
|
+
kpiAncestors.set(id, {
|
|
212
|
+
attr: kpiSourceAttributes(ancestor),
|
|
213
|
+
parentId: ancestor.parentId,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const measuredComponents = {
|
|
219
|
+
fixed: new Set<string>(),
|
|
220
|
+
omitted: new Set<string>(),
|
|
221
|
+
};
|
|
222
|
+
// A producer may emit code through several transparent execution wrappers.
|
|
223
|
+
// Count across those wrappers while keeping different slides/producers apart.
|
|
224
|
+
const computedCodeIndexes = new Map<string, number>();
|
|
225
|
+
// Only the authenticated execution boundary may admit a weak table producer.
|
|
226
|
+
let verifiedTableProducer: string | undefined;
|
|
227
|
+
function startSlide(id: string): Slide {
|
|
228
|
+
if (document.slides.some((s) => s.id === id)) {
|
|
229
|
+
throw new Error(`DUPLICATE_ID: ${id}`);
|
|
230
|
+
}
|
|
231
|
+
slide = {
|
|
232
|
+
id,
|
|
233
|
+
background: theme.background,
|
|
234
|
+
transition: "fade",
|
|
235
|
+
notes: "",
|
|
236
|
+
elements: [],
|
|
237
|
+
};
|
|
238
|
+
document.slides.push(slide);
|
|
239
|
+
result.origins[id] = {};
|
|
240
|
+
y = 166;
|
|
241
|
+
counter = 0;
|
|
242
|
+
return slide;
|
|
243
|
+
}
|
|
244
|
+
function add(el: Element, origin: Origin) {
|
|
245
|
+
if (!slide) startSlide("qb-introduction");
|
|
246
|
+
if (slide!.elements.some((item) => item.id === el.id)) {
|
|
247
|
+
throw new Error(`DUPLICATE_ID: ${el.id}`);
|
|
248
|
+
}
|
|
249
|
+
slide!.elements.push(el);
|
|
250
|
+
result.origins[slide!.id][el.id] = origin;
|
|
251
|
+
}
|
|
252
|
+
function text(id: string, html: string, heading: boolean, origin: Origin) {
|
|
253
|
+
// A list is one editable object, but its default box needs room for its
|
|
254
|
+
// item/paragraph lines. This is conservative initial layout, not font-ink
|
|
255
|
+
// measurement; explicit layout frames and saved geometry retain authority.
|
|
256
|
+
const listLines = /^<(ul|ol)>/.test(html)
|
|
257
|
+
? html.replace(/<\/(?:li|p)>|<br>/g, "\n").replace(/<[^>]*>/g, "")
|
|
258
|
+
.split("\n").filter((line) => line.trim())
|
|
259
|
+
.reduce(
|
|
260
|
+
(sum, line) => sum + Math.max(1, Math.ceil(line.length / 55)),
|
|
261
|
+
0,
|
|
262
|
+
)
|
|
263
|
+
: 0;
|
|
264
|
+
const h = contentFrame && !heading
|
|
265
|
+
? contentFrame.h
|
|
266
|
+
: Math.max(90, listLines * 40 + 20);
|
|
267
|
+
add({
|
|
268
|
+
id,
|
|
269
|
+
type: "text",
|
|
270
|
+
x: contentFrame?.x ?? 80,
|
|
271
|
+
y: heading ? 52 : y,
|
|
272
|
+
w: contentFrame?.w ?? 1120,
|
|
273
|
+
h,
|
|
274
|
+
rotation: 0,
|
|
275
|
+
opacity: 1,
|
|
276
|
+
html,
|
|
277
|
+
fontSize: heading ? 42 : 26,
|
|
278
|
+
fontFamily: theme.fontFamily,
|
|
279
|
+
fontWeight: heading ? 700 : 400,
|
|
280
|
+
color: theme.color,
|
|
281
|
+
align: "left",
|
|
282
|
+
valign: "top",
|
|
283
|
+
lineHeight: 1.25,
|
|
284
|
+
}, origin);
|
|
285
|
+
if (!heading) y += h + 20;
|
|
286
|
+
}
|
|
287
|
+
function math(id: string, tex: string, origin: Origin) {
|
|
288
|
+
const html = mathToNativeHtml(tex);
|
|
289
|
+
const height = Math.min(
|
|
290
|
+
330,
|
|
291
|
+
Math.max(110, (tex.trim().split("\n").length + 1) * 44),
|
|
292
|
+
);
|
|
293
|
+
text(id, html, false, origin);
|
|
294
|
+
const element = slide!.elements.at(-1)!;
|
|
295
|
+
element.h = height;
|
|
296
|
+
element.fontSize = 32;
|
|
297
|
+
element.align = "center";
|
|
298
|
+
element.valign = "middle";
|
|
299
|
+
y += height - 90;
|
|
300
|
+
}
|
|
301
|
+
function design(attr: Attr, elements: Element[]) {
|
|
302
|
+
const declaration = parseDesignAttributes(attr);
|
|
303
|
+
if (declaration.component === "code") {
|
|
304
|
+
if (elements.length !== 1 || elements[0].type !== "code") {
|
|
305
|
+
throw new DesignError(
|
|
306
|
+
"CODE_COMPONENT_CONTENT",
|
|
307
|
+
"A .bento-code component needs exactly one displayed code fence.",
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
delete declaration.component;
|
|
311
|
+
} else if (declaration.component) {
|
|
312
|
+
throw new DesignError(
|
|
313
|
+
"COMPONENT_TARGET",
|
|
314
|
+
"Card/layout components belong to fenced divs.",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (!Object.keys(declaration.properties).length) return;
|
|
318
|
+
if (!declaration.sourceId || elements.length !== 1 || !slide) {
|
|
319
|
+
throw new DesignError(
|
|
320
|
+
"AMBIGUOUS_DESIGN_TARGET",
|
|
321
|
+
"Explicit QMD fields need one object and a stable source #id.",
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
const element = elements[0],
|
|
325
|
+
applied = applyElementDesign(element, declaration);
|
|
326
|
+
const fields = (result.design![slide.id] ??= {});
|
|
327
|
+
const previous = fields[element.id] ?? { ownedPaths: [], bindings: {} };
|
|
328
|
+
if (
|
|
329
|
+
applied.ownedPaths.some((field) => previous.ownedPaths.includes(field))
|
|
330
|
+
) {
|
|
331
|
+
throw new DesignError(
|
|
332
|
+
"AMBIGUOUS_DESIGN_OWNERSHIP",
|
|
333
|
+
"Several source declarations own the same visual field.",
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
fields[element.id] = {
|
|
337
|
+
ownedPaths: [...previous.ownedPaths, ...applied.ownedPaths],
|
|
338
|
+
bindings: {
|
|
339
|
+
...previous.bindings,
|
|
340
|
+
...Object.fromEntries(
|
|
341
|
+
Object.entries(applied.bindings).map((
|
|
342
|
+
[field, attribute],
|
|
343
|
+
) => [field, { sourceId: declaration.sourceId, attribute }]),
|
|
344
|
+
),
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
slide.elements[slide.elements.findIndex((e) => e.id === element.id)] =
|
|
348
|
+
applied.element;
|
|
349
|
+
}
|
|
350
|
+
async function authoredFigure(node: Node, parentId?: string, outer?: Attr) {
|
|
351
|
+
if (!source || !authoredImageRoot || !slide) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
"UNMAPPED_AUTHORED_IMAGE: original source and source asset directory are required",
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const block = source.blocks.find((candidate) =>
|
|
357
|
+
candidate.kind === "image" && candidate.origin === "authored" &&
|
|
358
|
+
candidate.parentId === parentId && !consumedImages.has(candidate.id)
|
|
359
|
+
);
|
|
360
|
+
const proof = block && imageProofs.get(block.id);
|
|
361
|
+
if (!block || !proof?.image?.data || proof.sourceHash !== source.hash) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
"UNMAPPED_AUTHORED_IMAGE: image lacks its original independent source/asset proof",
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
const shape = executedImageShape(node);
|
|
367
|
+
assertImageShape({
|
|
368
|
+
target: proof.image.target,
|
|
369
|
+
captionHtml: proof.image.captionHtml,
|
|
370
|
+
captioned: proof.image.captioned,
|
|
371
|
+
}, shape);
|
|
372
|
+
const ids = imageNativeIdentities(source, block);
|
|
373
|
+
if (
|
|
374
|
+
outer &&
|
|
375
|
+
(!ids.attributes ||
|
|
376
|
+
JSON.stringify(outer) !== JSON.stringify(ids.attributes))
|
|
377
|
+
) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
"UNMAPPED_AUTHORED_IMAGE: executed image binding or ancestry differs from original source",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
const data = await authoredImageData(proof.image, authoredImageRoot);
|
|
383
|
+
if (data !== proof.image.data) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
"AUTHORED_IMAGE_ASSET_CHANGED: source asset changed during execution; preserve the project and render from a stable source asset",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
const shared = {
|
|
389
|
+
target: shape.target,
|
|
390
|
+
assetDigest: hashSource(data),
|
|
391
|
+
imageId: ids.imageId,
|
|
392
|
+
captionId: ids.captionId,
|
|
393
|
+
};
|
|
394
|
+
const imageHeight = contentFrame
|
|
395
|
+
? Math.max(100, contentFrame.h - 90)
|
|
396
|
+
: Math.max(100, Math.min(400, 570 - y));
|
|
397
|
+
const x = contentFrame?.x ?? 160, w = contentFrame?.w ?? 880;
|
|
398
|
+
add({
|
|
399
|
+
id: ids.imageId,
|
|
400
|
+
type: "image",
|
|
401
|
+
x,
|
|
402
|
+
y,
|
|
403
|
+
w,
|
|
404
|
+
h: imageHeight,
|
|
405
|
+
rotation: 0,
|
|
406
|
+
opacity: 1,
|
|
407
|
+
src: data,
|
|
408
|
+
fit: "contain",
|
|
409
|
+
groupId: ids.groupId,
|
|
410
|
+
}, {
|
|
411
|
+
origin: "authored",
|
|
412
|
+
sourceId: block.id,
|
|
413
|
+
image: { ...shared, role: "asset" },
|
|
414
|
+
});
|
|
415
|
+
add({
|
|
416
|
+
id: ids.captionId,
|
|
417
|
+
type: "text",
|
|
418
|
+
x,
|
|
419
|
+
y: y + imageHeight + 10,
|
|
420
|
+
w,
|
|
421
|
+
h: 80,
|
|
422
|
+
rotation: 0,
|
|
423
|
+
opacity: 1,
|
|
424
|
+
html: shape.captionHtml,
|
|
425
|
+
fontSize: 20,
|
|
426
|
+
fontFamily: theme.fontFamily,
|
|
427
|
+
fontWeight: 400,
|
|
428
|
+
color: theme.color,
|
|
429
|
+
align: "center",
|
|
430
|
+
valign: "top",
|
|
431
|
+
lineHeight: 1.25,
|
|
432
|
+
groupId: ids.groupId,
|
|
433
|
+
}, {
|
|
434
|
+
origin: "authored",
|
|
435
|
+
sourceId: block.id,
|
|
436
|
+
image: { ...shared, role: "caption" },
|
|
437
|
+
});
|
|
438
|
+
consumedImages.add(block.id);
|
|
439
|
+
y += imageHeight + 110;
|
|
440
|
+
}
|
|
441
|
+
async function figure(
|
|
442
|
+
node: Node,
|
|
443
|
+
computed: boolean,
|
|
444
|
+
parentId?: string,
|
|
445
|
+
producerId = parentId,
|
|
446
|
+
) {
|
|
447
|
+
const [attr, , target] = node.c as [Attr, Node[], [string, string]];
|
|
448
|
+
const id = attr[0] || parentId || `qb-image-${++counter}`;
|
|
449
|
+
add({
|
|
450
|
+
id,
|
|
451
|
+
type: "image",
|
|
452
|
+
x: contentFrame?.x ?? 160,
|
|
453
|
+
y,
|
|
454
|
+
w: contentFrame?.w ?? 880,
|
|
455
|
+
h: contentFrame?.h ?? Math.max(100, Math.min(400, 670 - y)),
|
|
456
|
+
rotation: 0,
|
|
457
|
+
opacity: 1,
|
|
458
|
+
src: await assetData(target[0], assetRoots),
|
|
459
|
+
fit: "contain",
|
|
460
|
+
}, {
|
|
461
|
+
origin: computed ? "computed" : "authored",
|
|
462
|
+
sourceId: id,
|
|
463
|
+
...(computed ? { producer: producerId ?? id } : {}),
|
|
464
|
+
});
|
|
465
|
+
y += 420;
|
|
466
|
+
}
|
|
467
|
+
async function walk(
|
|
468
|
+
nodes: Node[],
|
|
469
|
+
computed = false,
|
|
470
|
+
parentId?: string,
|
|
471
|
+
inComponent = false,
|
|
472
|
+
groups?: Element[][],
|
|
473
|
+
frames?: Frame[],
|
|
474
|
+
) {
|
|
475
|
+
let headingIndex = 0;
|
|
476
|
+
let paragraphIndex = 0, listIndex = 0;
|
|
477
|
+
let codeIndex = 0;
|
|
478
|
+
let mathIndex = 0;
|
|
479
|
+
let sourceMarker: string | undefined;
|
|
480
|
+
let slot = 0;
|
|
481
|
+
for (const node of nodes) {
|
|
482
|
+
const previousFrame = contentFrame;
|
|
483
|
+
if (frames && node.t !== "RawBlock") contentFrame = frames[slot++];
|
|
484
|
+
const groupSlide = slide, groupStart = slide?.elements.length ?? 0;
|
|
485
|
+
// Child subtrees are measured independently before the component places them.
|
|
486
|
+
if (groups) y = contentFrame?.y ?? 166;
|
|
487
|
+
if (
|
|
488
|
+
![
|
|
489
|
+
"Para",
|
|
490
|
+
"Plain",
|
|
491
|
+
"RawBlock",
|
|
492
|
+
"CodeBlock",
|
|
493
|
+
"BulletList",
|
|
494
|
+
"OrderedList",
|
|
495
|
+
"Table",
|
|
496
|
+
]
|
|
497
|
+
.includes(node.t)
|
|
498
|
+
) {
|
|
499
|
+
sourceMarker = undefined;
|
|
500
|
+
}
|
|
501
|
+
if (node.t === "Header") {
|
|
502
|
+
const [level, attr, content] = node.c as [number, Attr, Node[]];
|
|
503
|
+
if (level <= 2 && !inComponent) {
|
|
504
|
+
const id = attr[0] || `qb-slide-${document.slides.length + 1}`;
|
|
505
|
+
startSlide(id);
|
|
506
|
+
const nativeTitleId = attr[2].find(([name]) =>
|
|
507
|
+
name === "bento-title-id"
|
|
508
|
+
)?.[1];
|
|
509
|
+
if (
|
|
510
|
+
nativeTitleId !== undefined &&
|
|
511
|
+
!isNativeIdentity(nativeTitleId)
|
|
512
|
+
) {
|
|
513
|
+
throw new Error(
|
|
514
|
+
"INVALID_ID: bento-title-id must be a safe explicit identity",
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
text(nativeTitleId ?? `${id}-title`, inline(content), true, {
|
|
518
|
+
origin: computed ? "computed" : "authored",
|
|
519
|
+
sourceId: id,
|
|
520
|
+
...(computed && parentId ? { producer: parentId } : {}),
|
|
521
|
+
});
|
|
522
|
+
} else {
|
|
523
|
+
headingIndex++;
|
|
524
|
+
const mapped = inComponent && source && parentId
|
|
525
|
+
? source.blocks.filter((b) =>
|
|
526
|
+
b.parentId === parentId && b.kind === "heading"
|
|
527
|
+
)[headingIndex - 1]
|
|
528
|
+
: undefined;
|
|
529
|
+
const sourceId = mapped?.id ?? (attr[0] || undefined);
|
|
530
|
+
text(
|
|
531
|
+
sourceId || `${parentId || slide?.id}-heading-${++counter}`,
|
|
532
|
+
inline(content),
|
|
533
|
+
false,
|
|
534
|
+
{
|
|
535
|
+
origin: computed ? "computed" : "authored",
|
|
536
|
+
sourceId,
|
|
537
|
+
...(computed && parentId ? { producer: parentId } : {}),
|
|
538
|
+
},
|
|
539
|
+
);
|
|
540
|
+
if (inComponent) {
|
|
541
|
+
slide!.elements.at(-1)!.fontSize = 32;
|
|
542
|
+
slide!.elements.at(-1)!.fontWeight = 700;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
design(attr, [slide!.elements.at(-1)!]);
|
|
546
|
+
} else if (node.t === "Figure" && source && !computed) {
|
|
547
|
+
await authoredFigure(node, parentId);
|
|
548
|
+
} else if (node.t === "Div") {
|
|
549
|
+
const [attr, children] = node.c as [Attr, Node[]];
|
|
550
|
+
const chartAncestor = chartAncestors.get(attr[0]);
|
|
551
|
+
if (
|
|
552
|
+
chartAncestor && (chartAncestor.parentId !== parentId ||
|
|
553
|
+
JSON.stringify([...attr[1]].sort()) !==
|
|
554
|
+
JSON.stringify([...chartAncestor.attr[1]].sort()) ||
|
|
555
|
+
JSON.stringify([...attr[2]].sort()) !==
|
|
556
|
+
JSON.stringify([...chartAncestor.attr[2]].sort()))
|
|
557
|
+
) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
"UNMAPPED_CHART_SOURCE: executed chart ancestor attributes or ancestry differ from original source",
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
const originalDiv = attr[0] &&
|
|
563
|
+
source?.blocks.find((block) =>
|
|
564
|
+
block.id === attr[0] && block.kind === "div" &&
|
|
565
|
+
block.origin === "authored"
|
|
566
|
+
);
|
|
567
|
+
if (
|
|
568
|
+
originalDiv && !kpiAncestors.has(attr[0]) &&
|
|
569
|
+
!chartAncestors.has(attr[0]) && !isSourceChart(originalDiv)
|
|
570
|
+
) {
|
|
571
|
+
// Transparent anonymous divs do not introduce a renderer source ID.
|
|
572
|
+
// Every identified source ancestor is checked in turn, so moving an
|
|
573
|
+
// intact subtree cannot retain a fabricated inherited context.
|
|
574
|
+
let sourceParent = originalDiv.parentId;
|
|
575
|
+
while (sourceParent) {
|
|
576
|
+
const parent = source!.blocks.find((block) =>
|
|
577
|
+
block.id === sourceParent
|
|
578
|
+
);
|
|
579
|
+
if (!parent || parent.stable) break;
|
|
580
|
+
sourceParent = parent.parentId;
|
|
581
|
+
}
|
|
582
|
+
if (sourceParent !== parentId) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
"UNMAPPED_PROVENANCE: authored source wrapper moved outside its original ancestry",
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (
|
|
589
|
+
source && !computed &&
|
|
590
|
+
(attr[1].includes("quarto-figure") ||
|
|
591
|
+
attr[2].some(([name]) => name === "bento-image-id"))
|
|
592
|
+
) {
|
|
593
|
+
if (attr[2].some(([name]) => name === "bento-image-id")) {
|
|
594
|
+
if (children.length !== 1) {
|
|
595
|
+
throw new Error(
|
|
596
|
+
"UNMAPPED_AUTHORED_IMAGE: bound image wrapper needs exactly one source image",
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
await authoredFigure(children[0], attr[0], attr);
|
|
600
|
+
design(attr, slide!.elements.slice(groupStart));
|
|
601
|
+
} else await authoredFigure(node, parentId);
|
|
602
|
+
if (groups) groups.push(slide!.elements.slice(groupStart));
|
|
603
|
+
contentFrame = previousFrame;
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const ancestor = kpiAncestors.get(attr[0]);
|
|
607
|
+
if (
|
|
608
|
+
ancestor && (ancestor.parentId !== parentId ||
|
|
609
|
+
JSON.stringify([...attr[1]].sort()) !==
|
|
610
|
+
JSON.stringify([...ancestor.attr[1]].sort()) ||
|
|
611
|
+
JSON.stringify([...attr[2]].sort()) !==
|
|
612
|
+
JSON.stringify([...ancestor.attr[2]].sort()))
|
|
613
|
+
) {
|
|
614
|
+
throw new DesignError(
|
|
615
|
+
"KPI_SOURCE_CONTEXT",
|
|
616
|
+
"Executed KPI ancestor attributes or ancestry differ from original source.",
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
const richTable = richTables.get(attr[0]);
|
|
620
|
+
if (richTable) {
|
|
621
|
+
if (
|
|
622
|
+
inComponent || computed || !slide ||
|
|
623
|
+
slide.id !== richTable.candidate.source.slideId ||
|
|
624
|
+
consumedRichTables.has(attr[0])
|
|
625
|
+
) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
"RICH_TABLE_PROJECTION_MISMATCH: use one original standalone rich table wrapper under its original slide",
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
const { element, origin } = richTableElement(richTable);
|
|
631
|
+
if (y + Number(element.h) > 700) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
"UNSUPPORTED_RICH_TABLE: complete table cannot fit after preceding content; move it to a separate slide instead of clipping or shrinking",
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
element.y = y;
|
|
637
|
+
add(element, origin);
|
|
638
|
+
result.diagnostics.push({
|
|
639
|
+
...richTable.diagnostic,
|
|
640
|
+
elementId: element.id,
|
|
641
|
+
});
|
|
642
|
+
y += Number(element.h) + 20;
|
|
643
|
+
consumedRichTables.add(attr[0]);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const widget = widgets.get(attr[0]);
|
|
647
|
+
if (widget) {
|
|
648
|
+
if (
|
|
649
|
+
inComponent || computed || !slide || slide.id !== widget.slideId ||
|
|
650
|
+
consumedWidgets.has(attr[0]) || attr[2].some(([key]) =>
|
|
651
|
+
key === "bento-element-id"
|
|
652
|
+
)
|
|
653
|
+
) {
|
|
654
|
+
throw new Error(
|
|
655
|
+
"WIDGET_PROJECTION_MISMATCH: use a single identified widget wrapper outside components under its original slide",
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
const element = structuredClone(widget.element);
|
|
659
|
+
element.y = y;
|
|
660
|
+
element.h = Math.min(Number(element.h), Math.max(100, 670 - y));
|
|
661
|
+
add(element, widget.origin);
|
|
662
|
+
design(attr, [element]);
|
|
663
|
+
y += Number(element.h) + 20;
|
|
664
|
+
consumedWidgets.add(attr[0]);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const declaration = parseDesignAttributes(attr);
|
|
669
|
+
if (declaration.component === "kpi") {
|
|
670
|
+
if (
|
|
671
|
+
!source || computed ||
|
|
672
|
+
attr[1].some((c) => c === "cell" || c.startsWith("cell-output"))
|
|
673
|
+
) {
|
|
674
|
+
throw new DesignError(
|
|
675
|
+
"KPI_SOURCE_CONTEXT",
|
|
676
|
+
"Declare an original KPI outside execution output; only verified source cards/layouts can contain it.",
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
const binding = kpiSource(source, attr[0]);
|
|
680
|
+
if (binding.wrapper.parentId !== parentId) {
|
|
681
|
+
throw new DesignError(
|
|
682
|
+
"KPI_SOURCE_CONTEXT",
|
|
683
|
+
"Executed KPI ancestry differs from its original source component chain.",
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
if (!slide) startSlide("qb-introduction");
|
|
687
|
+
const component = await projectKpi(
|
|
688
|
+
source,
|
|
689
|
+
attr,
|
|
690
|
+
children,
|
|
691
|
+
contentFrame ?? retainedFrames.get(attr[0]) ?? {
|
|
692
|
+
x: 80,
|
|
693
|
+
y,
|
|
694
|
+
w: 1120,
|
|
695
|
+
h: Math.min(470, 680 - y),
|
|
696
|
+
},
|
|
697
|
+
defaults,
|
|
698
|
+
kpiAppearances.get(slide!.id),
|
|
699
|
+
binding.ancestors.some((id) => {
|
|
700
|
+
const original = kpiAncestors.get(id)!;
|
|
701
|
+
const properties =
|
|
702
|
+
parseDesignAttributes(original.attr).properties;
|
|
703
|
+
return ["x", "y", "w", "h"].some((field) => field in properties);
|
|
704
|
+
}),
|
|
705
|
+
);
|
|
706
|
+
for (const element of component.elements) {
|
|
707
|
+
measuredComponents.fixed.add(element.id);
|
|
708
|
+
}
|
|
709
|
+
if (component.appearanceRescued) {
|
|
710
|
+
for (const element of component.elements) {
|
|
711
|
+
if (element.type === "text") {
|
|
712
|
+
measuredComponents.omitted.add(element.id);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
(result.kpiAppearanceRescues ??= []).push({
|
|
716
|
+
slideId: slide!.id,
|
|
717
|
+
elementIds: component.elements.filter((element) =>
|
|
718
|
+
element.type === "text"
|
|
719
|
+
).map((element) => element.id),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
for (const element of component.elements) {
|
|
723
|
+
add(element, component.origins[element.id]);
|
|
724
|
+
}
|
|
725
|
+
Object.assign(result.design![slide!.id] ??= {}, component.fields);
|
|
726
|
+
// A rescued source baseline keeps the authoritative component frame.
|
|
727
|
+
// Its unpainted default role heights must not move subsequent content.
|
|
728
|
+
y = component.appearanceRescued
|
|
729
|
+
? Number(component.elements[0].y) +
|
|
730
|
+
Number(component.elements[0].h) + 20
|
|
731
|
+
: Math.max(
|
|
732
|
+
...component.elements.map((element) =>
|
|
733
|
+
Number(element.y) + Number(element.h)
|
|
734
|
+
),
|
|
735
|
+
) + 20;
|
|
736
|
+
if (groups) groups.push(slide!.elements.slice(groupStart));
|
|
737
|
+
contentFrame = previousFrame;
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (
|
|
741
|
+
declaration.component === "diagram" ||
|
|
742
|
+
declaration.component === "timeline"
|
|
743
|
+
) {
|
|
744
|
+
if (!source || computed || inComponent || parentId) {
|
|
745
|
+
throw new DesignError(
|
|
746
|
+
"UNMAPPED_GRAPH_SOURCE",
|
|
747
|
+
"Declare original graphs directly under a slide, outside execution and other components.",
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
if (!slide) startSlide("qb-introduction");
|
|
751
|
+
const projected = await projectGraph(source, node, {
|
|
752
|
+
x: 80,
|
|
753
|
+
y,
|
|
754
|
+
w: 1120,
|
|
755
|
+
h: Math.min(470, 680 - y),
|
|
756
|
+
}, defaults);
|
|
757
|
+
for (const element of projected.elements) {
|
|
758
|
+
add(element, projected.origins[element.id]);
|
|
759
|
+
}
|
|
760
|
+
Object.assign(result.design![slide!.id] ??= {}, projected.fields);
|
|
761
|
+
y = projected.frame.y + projected.frame.h + 20;
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (
|
|
765
|
+
declaration.component === "card" || declaration.component === "layout"
|
|
766
|
+
) {
|
|
767
|
+
if (
|
|
768
|
+
computed ||
|
|
769
|
+
attr[1].some((c) => c === "cell" || c.startsWith("cell-output"))
|
|
770
|
+
) {
|
|
771
|
+
throw new DesignError(
|
|
772
|
+
"COMPUTED_COMPONENT",
|
|
773
|
+
"Declare source components outside execution output.",
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
if (!slide) startSlide("qb-introduction");
|
|
777
|
+
if (
|
|
778
|
+
declaration.component === "layout" &&
|
|
779
|
+
children.some((child) =>
|
|
780
|
+
child.t !== "Div" || !(child.c as [Attr, Node[]])[0][0]
|
|
781
|
+
)
|
|
782
|
+
) {
|
|
783
|
+
throw new DesignError(
|
|
784
|
+
"LAYOUT_CHILDREN",
|
|
785
|
+
"Named layouts require identified direct fenced-div children.",
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
const index = slide!.elements.length, componentY = y;
|
|
789
|
+
const childGroups: Element[][] = [];
|
|
790
|
+
const base = retainedFrames.get(attr[0]) ?? contentFrame ??
|
|
791
|
+
{
|
|
792
|
+
x: 80,
|
|
793
|
+
y: componentY,
|
|
794
|
+
w: 1120,
|
|
795
|
+
h: Math.min(470, 680 - componentY),
|
|
796
|
+
};
|
|
797
|
+
const semantic = children.filter((child) => child.t !== "RawBlock");
|
|
798
|
+
// Measure simple text/code rows at their final width; images receive
|
|
799
|
+
// the remaining space. No layout rectangle is fitted a second time.
|
|
800
|
+
const frameWidth = Number(declaration.properties.w ?? base.w) - 48;
|
|
801
|
+
const lines = (html: string, size: number): number => {
|
|
802
|
+
const plain = html.replace(/<br\s*\/?>/gi, "\n").replace(
|
|
803
|
+
/<[^>]*>/g,
|
|
804
|
+
"",
|
|
805
|
+
);
|
|
806
|
+
return plain.split("\n").reduce(
|
|
807
|
+
(sum, row) =>
|
|
808
|
+
sum + Math.max(
|
|
809
|
+
1,
|
|
810
|
+
Math.ceil(
|
|
811
|
+
[...row].reduce(
|
|
812
|
+
(width, ch) =>
|
|
813
|
+
width + (ch.codePointAt(0)! > 255 ? size : size * .65),
|
|
814
|
+
0,
|
|
815
|
+
) / frameWidth,
|
|
816
|
+
),
|
|
817
|
+
),
|
|
818
|
+
0,
|
|
819
|
+
);
|
|
820
|
+
};
|
|
821
|
+
const rowHeight = (child: Node): number => {
|
|
822
|
+
if (
|
|
823
|
+
child.t === "Div" &&
|
|
824
|
+
(child.c as [Attr, Node[]])[0][1].includes("bento-kpi")
|
|
825
|
+
) return -1;
|
|
826
|
+
// A chart fills the remaining card height, then validates its axes,
|
|
827
|
+
// labels and legend at that exact frame. Wrapper siblings cannot be
|
|
828
|
+
// measured as one chart and silently squeezed into the same slot.
|
|
829
|
+
const containsChart = (node: Node): boolean =>
|
|
830
|
+
node.t === "Div" &&
|
|
831
|
+
((node.c as [Attr, Node[]])[0][1].includes("bento-chart") ||
|
|
832
|
+
(node.c as [Attr, Node[]])[1].some(containsChart));
|
|
833
|
+
if (containsChart(child)) {
|
|
834
|
+
let candidate = child;
|
|
835
|
+
while (candidate.t === "Div") {
|
|
836
|
+
const [attributes, nested] = candidate.c as [Attr, Node[]];
|
|
837
|
+
if (attributes[1].includes("bento-chart")) return -1;
|
|
838
|
+
const semantic = nested.filter((n) => n.t !== "RawBlock");
|
|
839
|
+
if (semantic.length !== 1) {
|
|
840
|
+
throw new DesignError(
|
|
841
|
+
"COMPONENT_CHART_SUBTREE",
|
|
842
|
+
"Put chart wrapper siblings in separate card children.",
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
candidate = semantic[0];
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// A table can be wrapped by source and execution divs. Inspect the
|
|
849
|
+
// whole subtree; never select one descendant and drop its siblings.
|
|
850
|
+
const tableHeight = (value: Node): number | undefined => {
|
|
851
|
+
if (value.t === "Table") {
|
|
852
|
+
return Number(
|
|
853
|
+
projectTable(value, "measurement", 0, {
|
|
854
|
+
x: 0,
|
|
855
|
+
y: 0,
|
|
856
|
+
w: frameWidth,
|
|
857
|
+
h: 720,
|
|
858
|
+
}).h,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
if (value.t !== "Div") return undefined;
|
|
862
|
+
const [attributes, nested] = value.c as [Attr, Node[]];
|
|
863
|
+
if (attributes[1].includes("bento-chart")) return undefined;
|
|
864
|
+
const semantic = nested.filter((n) => n.t !== "RawBlock");
|
|
865
|
+
const measured = semantic.map(tableHeight);
|
|
866
|
+
if (measured.every((h) => h === undefined)) return undefined;
|
|
867
|
+
if (semantic.length !== 1) {
|
|
868
|
+
throw new DesignError(
|
|
869
|
+
"COMPONENT_TABLE_SUBTREE",
|
|
870
|
+
"A table wrapper must contain only the table and transparent execution wrappers; move sibling text/code into separate card children.",
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
return measured[0];
|
|
874
|
+
};
|
|
875
|
+
const measuredTable = tableHeight(child);
|
|
876
|
+
if (measuredTable !== undefined) return measuredTable;
|
|
877
|
+
if (images([child]).length) return -1;
|
|
878
|
+
const explicitSize = Number(declaration.properties.fontSize);
|
|
879
|
+
if (child.t === "Header") {
|
|
880
|
+
const [, , content] = child.c as [number, Attr, Node[]];
|
|
881
|
+
const size = explicitSize || 32;
|
|
882
|
+
return lines(inline(content), size) * size * 1.25 + 4;
|
|
883
|
+
}
|
|
884
|
+
if (child.t === "Para" || child.t === "Plain") {
|
|
885
|
+
const size = explicitSize || 26;
|
|
886
|
+
return lines(inline(child.c as Node[]), size) * size * 1.25 + 5;
|
|
887
|
+
}
|
|
888
|
+
if (child.t === "CodeBlock") {
|
|
889
|
+
const size = explicitSize || 24;
|
|
890
|
+
return Math.max(
|
|
891
|
+
90,
|
|
892
|
+
((child.c as [Attr, string])[1].split("\n").length + 1) * size *
|
|
893
|
+
1.25,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
return 90;
|
|
897
|
+
};
|
|
898
|
+
const placement = componentFrames(
|
|
899
|
+
declaration,
|
|
900
|
+
base,
|
|
901
|
+
semantic.length,
|
|
902
|
+
defaults,
|
|
903
|
+
declaration.component === "card"
|
|
904
|
+
? semantic.map(rowHeight)
|
|
905
|
+
: undefined,
|
|
906
|
+
);
|
|
907
|
+
await walk(
|
|
908
|
+
children,
|
|
909
|
+
false,
|
|
910
|
+
attr[0],
|
|
911
|
+
true,
|
|
912
|
+
childGroups,
|
|
913
|
+
placement.children,
|
|
914
|
+
);
|
|
915
|
+
const fields = (result.design![slide!.id] ??= {});
|
|
916
|
+
const component = projectComponent(
|
|
917
|
+
childGroups.filter((group) => group.length),
|
|
918
|
+
declaration,
|
|
919
|
+
placement.frame,
|
|
920
|
+
fields,
|
|
921
|
+
defaults,
|
|
922
|
+
measuredComponents,
|
|
923
|
+
);
|
|
924
|
+
for (const id of component.decorativeIds) {
|
|
925
|
+
if (slide!.elements.slice(0, index).some((e) => e.id === id)) {
|
|
926
|
+
throw new Error(`DUPLICATE_ID: ${id}`);
|
|
927
|
+
}
|
|
928
|
+
result.origins[slide!.id][id] = {
|
|
929
|
+
origin: "authored",
|
|
930
|
+
sourceId: attr[0],
|
|
931
|
+
designOnly: true,
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
slide!.elements.splice(
|
|
935
|
+
index,
|
|
936
|
+
slide!.elements.length - index,
|
|
937
|
+
...component.elements,
|
|
938
|
+
);
|
|
939
|
+
Object.assign(fields, component.fields);
|
|
940
|
+
y = Math.max(
|
|
941
|
+
...component.elements.filter((element) =>
|
|
942
|
+
!measuredComponents.omitted.has(element.id)
|
|
943
|
+
).map((e) => Number(e.y) + Number(e.h)),
|
|
944
|
+
) + 20;
|
|
945
|
+
if (groups) {
|
|
946
|
+
groups.push(slide!.elements.slice(groupStart));
|
|
947
|
+
}
|
|
948
|
+
contentFrame = previousFrame;
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
if (
|
|
952
|
+
declaration.component === "code" &&
|
|
953
|
+
(children.length !== 1 || children[0].t !== "CodeBlock")
|
|
954
|
+
) {
|
|
955
|
+
throw new DesignError(
|
|
956
|
+
"CODE_COMPONENT_CONTENT",
|
|
957
|
+
"A .bento-code wrapper needs exactly one displayed, non-executable code fence.",
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
const beforeSlide = slide, beforeCount = slide?.elements.length ?? 0;
|
|
961
|
+
if (attr[1].includes("bento-chart")) {
|
|
962
|
+
const declaration = parseDesignAttributes(attr);
|
|
963
|
+
if (!source || computed) {
|
|
964
|
+
throw new Error(
|
|
965
|
+
"UNMAPPED_CHART_SOURCE: declare chart intent in an original QMD wrapper, not generated output",
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
const binding = chartSource(source, attr[0]);
|
|
969
|
+
if (consumedCharts.has(attr[0])) {
|
|
970
|
+
throw new Error(
|
|
971
|
+
"UNMAPPED_CHART_SOURCE: original chart projected more than once",
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
consumedCharts.add(attr[0]);
|
|
975
|
+
if (
|
|
976
|
+
binding.wrapper.parentId !== parentId ||
|
|
977
|
+
JSON.stringify([...attr[1]].sort()) !==
|
|
978
|
+
JSON.stringify([...binding.attr[1]].sort()) ||
|
|
979
|
+
JSON.stringify([...attr[2]].sort()) !==
|
|
980
|
+
JSON.stringify([...binding.attr[2]].sort())
|
|
981
|
+
) {
|
|
982
|
+
throw new Error(
|
|
983
|
+
"UNMAPPED_CHART_SOURCE: executed chart attributes or ancestry differ from original source",
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
const executed = executedChartTable(
|
|
987
|
+
binding.computed
|
|
988
|
+
? producerOutputChildren(
|
|
989
|
+
ast,
|
|
990
|
+
children,
|
|
991
|
+
source.hash,
|
|
992
|
+
binding.block.id,
|
|
993
|
+
)
|
|
994
|
+
: children,
|
|
995
|
+
);
|
|
996
|
+
if (
|
|
997
|
+
binding.kind !== declaration.chartKind ||
|
|
998
|
+
binding.computed !== executed.computed
|
|
999
|
+
) {
|
|
1000
|
+
throw new Error(
|
|
1001
|
+
"UNMAPPED_CHART_SOURCE: original chart intent or producer disagrees with execution",
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
const element = projectChart(
|
|
1005
|
+
executed.table,
|
|
1006
|
+
binding.kind,
|
|
1007
|
+
`${attr[0]}/chart`,
|
|
1008
|
+
y,
|
|
1009
|
+
contentFrame,
|
|
1010
|
+
);
|
|
1011
|
+
if (!binding.computed) {
|
|
1012
|
+
validateAuthoredChart(source, binding.block.id, element);
|
|
1013
|
+
}
|
|
1014
|
+
add(element, {
|
|
1015
|
+
origin: binding.computed ? "computed" : "authored",
|
|
1016
|
+
sourceId: binding.block.id,
|
|
1017
|
+
...(binding.computed ? { producer: binding.block.id } : {}),
|
|
1018
|
+
});
|
|
1019
|
+
design([
|
|
1020
|
+
attr[0],
|
|
1021
|
+
attr[1].filter((c) => c !== "bento-chart"),
|
|
1022
|
+
attr[2].filter(([k]) => k !== "bento-chart-kind"),
|
|
1023
|
+
], [element]);
|
|
1024
|
+
y += Number(element.h) + 20;
|
|
1025
|
+
if (groups) groups.push(slide!.elements.slice(groupStart));
|
|
1026
|
+
contentFrame = previousFrame;
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
const containsTable = (value: unknown): boolean =>
|
|
1030
|
+
Array.isArray(value)
|
|
1031
|
+
? value.some(containsTable)
|
|
1032
|
+
: !!value && typeof value === "object" &&
|
|
1033
|
+
((value as Node).t === "Table" ||
|
|
1034
|
+
containsTable((value as Node).c));
|
|
1035
|
+
if (attr[1].includes("quarto-float") && containsTable(children)) {
|
|
1036
|
+
throw new Error(
|
|
1037
|
+
"UNSUPPORTED_TABLE: captions and table floats need a faithful associated caption mapping; use an uncaptioned table with a non-tbl producer label for this slice",
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
const token = attr[2].find(([key]) => key === "qbento-producer-proof")
|
|
1041
|
+
?.[1];
|
|
1042
|
+
if (attr[1].includes("qbento-execution") || token !== undefined) {
|
|
1043
|
+
if (!source || token === undefined) {
|
|
1044
|
+
throw new Error(
|
|
1045
|
+
"INVALID_PRODUCER_RECEIPT: missing original source or token",
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
const proof = producerReceipt(ast, source.hash, token);
|
|
1049
|
+
const producer = source.blocks.find((block) =>
|
|
1050
|
+
block.id === proof.id && block.kind === "chunk"
|
|
1051
|
+
);
|
|
1052
|
+
if (!producer || producer.slideId !== proof.slideId) {
|
|
1053
|
+
throw new Error(
|
|
1054
|
+
"INVALID_PRODUCER_RECEIPT: original producer scope changed",
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
const previousProducer = verifiedTableProducer;
|
|
1058
|
+
verifiedTableProducer = proof.id;
|
|
1059
|
+
try {
|
|
1060
|
+
await walk(children, true, proof.id, inComponent);
|
|
1061
|
+
} finally {
|
|
1062
|
+
verifiedTableProducer = previousProducer;
|
|
1063
|
+
}
|
|
1064
|
+
contentFrame = previousFrame;
|
|
1065
|
+
if (groups && slide === groupSlide) {
|
|
1066
|
+
groups.push(slide!.elements.slice(groupStart));
|
|
1067
|
+
}
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
// Unlabelled execution wrappers never infer producers from displayed code.
|
|
1071
|
+
const sourceParent = computed && parentId
|
|
1072
|
+
? parentId
|
|
1073
|
+
: attr[0] || parentId;
|
|
1074
|
+
const isComputed = computed ||
|
|
1075
|
+
attr[1].some((c) => c === "cell" || c.startsWith("cell-output"));
|
|
1076
|
+
const nativeId = attr[2].find(([name]) => name === "bento-element-id")
|
|
1077
|
+
?.[1];
|
|
1078
|
+
if (nativeId !== undefined) {
|
|
1079
|
+
if (attr[1].includes("notes")) {
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
"UNSUPPORTED_NOTES: native element bindings do not apply to slide notes",
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
if (!isNativeIdentity(nativeId) || !attr[0]) {
|
|
1085
|
+
throw new Error(
|
|
1086
|
+
"INVALID_ID: bento-element-id needs safe visual and explicit source identities",
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
if (
|
|
1090
|
+
isComputed || children.length !== 1 ||
|
|
1091
|
+
![
|
|
1092
|
+
"Para",
|
|
1093
|
+
"Plain",
|
|
1094
|
+
"BulletList",
|
|
1095
|
+
"OrderedList",
|
|
1096
|
+
"CodeBlock",
|
|
1097
|
+
"Table",
|
|
1098
|
+
]
|
|
1099
|
+
.includes(
|
|
1100
|
+
children[0].t,
|
|
1101
|
+
) ||
|
|
1102
|
+
images(children).length
|
|
1103
|
+
) {
|
|
1104
|
+
throw new Error(
|
|
1105
|
+
"AMBIGUOUS_VISUAL_BINDING: bento-element-id requires exactly one authored paragraph, list, display equation or displayed code",
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
const content = children[0].c as Node[];
|
|
1109
|
+
const isList = ["BulletList", "OrderedList"].includes(children[0].t);
|
|
1110
|
+
const formula =
|
|
1111
|
+
isList || ["CodeBlock", "Table"].includes(children[0].t)
|
|
1112
|
+
? undefined
|
|
1113
|
+
: displayFormula(content);
|
|
1114
|
+
if (children[0].t === "Table") {
|
|
1115
|
+
if (!source || tableNativeIdentity(source, attr[0]) !== nativeId) {
|
|
1116
|
+
throw new Error(
|
|
1117
|
+
"UNMAPPED_TABLE_SOURCE: native table identity differs from the original wrapper declaration",
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
await walk(children, false, attr[0]);
|
|
1121
|
+
const element = slide!.elements.at(-1)!;
|
|
1122
|
+
const previousId = element.id;
|
|
1123
|
+
element.id = nativeId;
|
|
1124
|
+
result.origins[slide!.id][nativeId] =
|
|
1125
|
+
result.origins[slide!.id][previousId];
|
|
1126
|
+
delete result.origins[slide!.id][previousId];
|
|
1127
|
+
} else if (children[0].t === "CodeBlock") {
|
|
1128
|
+
const code = structuredClone(children[0]);
|
|
1129
|
+
const codeAttr = (code.c as [Attr, string])[0];
|
|
1130
|
+
if (codeAttr[0]) {
|
|
1131
|
+
throw new Error(
|
|
1132
|
+
"AMBIGUOUS_VISUAL_BINDING: displayed code has its own identity",
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
codeAttr[0] = nativeId;
|
|
1136
|
+
await walk([code], false, attr[0]);
|
|
1137
|
+
result.origins[slide!.id][nativeId] = {
|
|
1138
|
+
origin: "authored",
|
|
1139
|
+
sourceId: `${attr[0]}/code:1`,
|
|
1140
|
+
};
|
|
1141
|
+
} else if (isList) {
|
|
1142
|
+
text(nativeId, renderList(children[0]), false, {
|
|
1143
|
+
origin: "authored",
|
|
1144
|
+
sourceId: `${attr[0]}/list:1`,
|
|
1145
|
+
});
|
|
1146
|
+
} else if (formula !== undefined) {
|
|
1147
|
+
math(nativeId, formula, {
|
|
1148
|
+
origin: "authored",
|
|
1149
|
+
sourceId: `${attr[0]}/math:1`,
|
|
1150
|
+
});
|
|
1151
|
+
} else {text(nativeId, inline(content), false, {
|
|
1152
|
+
origin: "authored",
|
|
1153
|
+
sourceId: `${attr[0]}/paragraph:1`,
|
|
1154
|
+
});}
|
|
1155
|
+
} else if (attr[1].includes("notes")) {
|
|
1156
|
+
if (isComputed) {
|
|
1157
|
+
throw new Error(
|
|
1158
|
+
"COMPUTED_NOTES_UNSUPPORTED: generated speaker notes need explicit computed slide-property provenance; refusing authored classification",
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
const original = source && slide && attr[0]
|
|
1162
|
+
? sourceNotes(source, attr[0], slide.id)
|
|
1163
|
+
: undefined;
|
|
1164
|
+
const plain = renderPlainNotes(children);
|
|
1165
|
+
if (
|
|
1166
|
+
!slide || !attr[0] || attr[1].length !== 1 || attr[2].length ||
|
|
1167
|
+
parentId
|
|
1168
|
+
) {
|
|
1169
|
+
throw new Error(
|
|
1170
|
+
"UNMAPPED_NOTES: use a single top-level identified .notes div under an explicit slide",
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
if (result.notes?.[slide.id]) {
|
|
1174
|
+
throw new Error("AMBIGUOUS_NOTES: use one notes block per slide");
|
|
1175
|
+
}
|
|
1176
|
+
if (original) {
|
|
1177
|
+
if (original.text !== plain) {
|
|
1178
|
+
throw new Error(
|
|
1179
|
+
"UNSUPPORTED_NOTES: executed notes differ from literal original plain text; transformations and computed content need an explicit adapter",
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
slide.notes = plain;
|
|
1184
|
+
(result.notes ??= {})[slide.id] = {
|
|
1185
|
+
sourceId: attr[0],
|
|
1186
|
+
codec: NOTES_CODEC_VERSION,
|
|
1187
|
+
};
|
|
1188
|
+
} else if (
|
|
1189
|
+
attr[1].includes("quarto-float") && images(children).length
|
|
1190
|
+
) {
|
|
1191
|
+
for (const img of images(children)) {
|
|
1192
|
+
await figure(img, isComputed, attr[0], sourceParent);
|
|
1193
|
+
}
|
|
1194
|
+
} else await walk(children, isComputed, sourceParent, inComponent);
|
|
1195
|
+
design(
|
|
1196
|
+
attr,
|
|
1197
|
+
slide && (!beforeSlide || slide === beforeSlide)
|
|
1198
|
+
? slide.elements.slice(beforeCount)
|
|
1199
|
+
: [],
|
|
1200
|
+
);
|
|
1201
|
+
} else if (node.t === "Para" || node.t === "Plain") {
|
|
1202
|
+
const content = node.c as Node[];
|
|
1203
|
+
const imgs = images(content);
|
|
1204
|
+
const formula = displayFormula(content);
|
|
1205
|
+
if (formula !== undefined) {
|
|
1206
|
+
mathIndex++;
|
|
1207
|
+
const sourceId = sourceMarker ??
|
|
1208
|
+
(parentId ? `${parentId}/math:${mathIndex}` : undefined);
|
|
1209
|
+
sourceMarker = undefined;
|
|
1210
|
+
math(sourceId ?? `qb-math-${++counter}`, formula, {
|
|
1211
|
+
origin: computed ? "computed" : "authored",
|
|
1212
|
+
...(sourceId ? { sourceId } : {}),
|
|
1213
|
+
...(computed && parentId ? { producer: parentId } : {}),
|
|
1214
|
+
});
|
|
1215
|
+
} else if (imgs.length) {
|
|
1216
|
+
if (source && !computed) {
|
|
1217
|
+
await authoredFigure(node, parentId);
|
|
1218
|
+
if (groups) groups.push(slide!.elements.slice(groupStart));
|
|
1219
|
+
contentFrame = previousFrame;
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
if (content.some((n) => n.t !== "Image" && n.t !== "Space")) {
|
|
1223
|
+
throw new Error("MIXED_IMAGE_TEXT: split figure and paragraph");
|
|
1224
|
+
}
|
|
1225
|
+
for (const img of imgs) await figure(img, computed, parentId);
|
|
1226
|
+
} else {
|
|
1227
|
+
const id = sourceMarker ??
|
|
1228
|
+
(parentId
|
|
1229
|
+
? `${parentId}/paragraph:${++paragraphIndex}`
|
|
1230
|
+
: `qb-paragraph-${++counter}`);
|
|
1231
|
+
const explicitMarker = sourceMarker;
|
|
1232
|
+
sourceMarker = undefined;
|
|
1233
|
+
text(id, inline(content), false, {
|
|
1234
|
+
origin: computed ? "computed" : "authored",
|
|
1235
|
+
sourceId: explicitMarker ?? (parentId ? id : undefined),
|
|
1236
|
+
...(computed && parentId ? { producer: parentId } : {}),
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
} else if (["BulletList", "OrderedList"].includes(node.t)) {
|
|
1240
|
+
// Marked lists still consume their source-kind ordinal.
|
|
1241
|
+
listIndex++;
|
|
1242
|
+
const id = sourceMarker ??
|
|
1243
|
+
(parentId ? `${parentId}/list:${listIndex}` : `qb-list-${++counter}`);
|
|
1244
|
+
const explicitMarker = sourceMarker;
|
|
1245
|
+
sourceMarker = undefined;
|
|
1246
|
+
text(id, renderList(node), false, {
|
|
1247
|
+
origin: computed ? "computed" : "authored",
|
|
1248
|
+
sourceId: explicitMarker ?? (parentId ? id : undefined),
|
|
1249
|
+
...(computed && parentId ? { producer: parentId } : {}),
|
|
1250
|
+
});
|
|
1251
|
+
} else if (node.t === "Table") {
|
|
1252
|
+
if (!source || computed && !parentId) {
|
|
1253
|
+
throw new Error(
|
|
1254
|
+
"UNMAPPED_TABLE_SOURCE: tables need original source evidence and computed tables need an identified producer",
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
const identifiedBlock = computed
|
|
1258
|
+
? tableProducer(source, parentId!, verifiedTableProducer)
|
|
1259
|
+
: parentId
|
|
1260
|
+
? authoredTableBlock(source, parentId)
|
|
1261
|
+
: undefined;
|
|
1262
|
+
const provisional = projectTable(
|
|
1263
|
+
node,
|
|
1264
|
+
"pending-table",
|
|
1265
|
+
y,
|
|
1266
|
+
contentFrame,
|
|
1267
|
+
);
|
|
1268
|
+
const block = identifiedBlock ?? matchBareTable(source, provisional);
|
|
1269
|
+
if (tableSources.has(block.id)) {
|
|
1270
|
+
throw new Error(
|
|
1271
|
+
"UNSUPPORTED_TABLE: multiple tables cannot claim one original source fragment",
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
tableSources.add(block.id);
|
|
1275
|
+
if (!parentId && sourceMarker && block.explicitId !== sourceMarker) {
|
|
1276
|
+
throw new Error(
|
|
1277
|
+
"UNMAPPED_TABLE_SOURCE: a table marker must exist in its exact original source fragment",
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
const id = computed
|
|
1281
|
+
? `${block.id}/table:1`
|
|
1282
|
+
: parentId
|
|
1283
|
+
? `${parentId}/table`
|
|
1284
|
+
: sourceMarker ?? `qb-table-${++counter}`;
|
|
1285
|
+
sourceMarker = undefined;
|
|
1286
|
+
const element: Element = { ...provisional, id };
|
|
1287
|
+
if (!computed) validateAuthoredTable(source, block.id, element);
|
|
1288
|
+
add(element, {
|
|
1289
|
+
origin: computed ? "computed" : "authored",
|
|
1290
|
+
sourceId: block.id,
|
|
1291
|
+
...(computed ? { producer: block.id } : {}),
|
|
1292
|
+
});
|
|
1293
|
+
y += Number(element.h) + 20;
|
|
1294
|
+
} else if (node.t === "CodeBlock") {
|
|
1295
|
+
const [attr, content] = node.c as [Attr, string];
|
|
1296
|
+
let sourceId = attr[0] || sourceMarker;
|
|
1297
|
+
if (!sourceId && parentId) {
|
|
1298
|
+
if (computed) {
|
|
1299
|
+
const key = JSON.stringify([slide?.id, parentId]);
|
|
1300
|
+
const index = (computedCodeIndexes.get(key) ?? 0) + 1;
|
|
1301
|
+
computedCodeIndexes.set(key, index);
|
|
1302
|
+
sourceId = `${parentId}/code:${index}`;
|
|
1303
|
+
} else sourceId = `${parentId}/code:${++codeIndex}`;
|
|
1304
|
+
}
|
|
1305
|
+
sourceMarker = undefined;
|
|
1306
|
+
const languages = attr[1].filter((name) =>
|
|
1307
|
+
!["sourceCode", "cell-code", "bento-code"].includes(name)
|
|
1308
|
+
);
|
|
1309
|
+
if (languages.length > 1) {
|
|
1310
|
+
throw new Error(
|
|
1311
|
+
"UNSUPPORTED_CODE_CLASSES: displayed code supports one language class plus native visual attributes",
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
const height = contentFrame
|
|
1315
|
+
? Math.max(90, (content.split("\n").length + 1) * 30)
|
|
1316
|
+
: Math.min(470, Math.max(90, (content.split("\n").length + 1) * 30));
|
|
1317
|
+
const element: Element = {
|
|
1318
|
+
id: sourceId ?? `qb-code-${++counter}`,
|
|
1319
|
+
type: "code",
|
|
1320
|
+
content,
|
|
1321
|
+
x: contentFrame?.x ?? 80,
|
|
1322
|
+
y,
|
|
1323
|
+
w: contentFrame?.w ?? 1120,
|
|
1324
|
+
h: height,
|
|
1325
|
+
rotation: 0,
|
|
1326
|
+
opacity: 1,
|
|
1327
|
+
fontSize: 24,
|
|
1328
|
+
fontFamily: "ui-monospace, monospace",
|
|
1329
|
+
color: theme.color,
|
|
1330
|
+
align: "left",
|
|
1331
|
+
valign: "top",
|
|
1332
|
+
lineHeight: 1.25,
|
|
1333
|
+
...(languages[0] ? { grammarName: languages[0] } : {}),
|
|
1334
|
+
};
|
|
1335
|
+
add(element, {
|
|
1336
|
+
origin: computed ? "computed" : "authored",
|
|
1337
|
+
...(sourceId ? { sourceId } : {}),
|
|
1338
|
+
...(computed && parentId ? { producer: parentId } : {}),
|
|
1339
|
+
});
|
|
1340
|
+
design(attr, [element]);
|
|
1341
|
+
y += height + 20;
|
|
1342
|
+
} else if (node.t === "RawBlock") {
|
|
1343
|
+
const [format, raw] = node.c as [string, string];
|
|
1344
|
+
const marker = format === "html"
|
|
1345
|
+
? raw.match(/^\s*<!--\s*qb:([^\s]+)\s*-->\s*$/)
|
|
1346
|
+
: null;
|
|
1347
|
+
sourceMarker = marker && isSourceIdentity(marker[1])
|
|
1348
|
+
? marker[1]
|
|
1349
|
+
: undefined;
|
|
1350
|
+
if (
|
|
1351
|
+
format !== "html" ||
|
|
1352
|
+
!/^\s*(?:<\/?(?:div|section|figure|figcaption)[^>]*>\s*|<!--[\s\S]*?-->\s*)*$/
|
|
1353
|
+
.test(raw)
|
|
1354
|
+
) {
|
|
1355
|
+
throw new Error(
|
|
1356
|
+
"UNSUPPORTED_RAW: raw output needs a faithful, isolated fallback",
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
} else {throw new Error(
|
|
1360
|
+
`UNSUPPORTED_BLOCK: ${node.t}; no faithful projection implemented`,
|
|
1361
|
+
);}
|
|
1362
|
+
if (groups) {
|
|
1363
|
+
if (slide !== groupSlide && groupSlide) {
|
|
1364
|
+
throw new DesignError(
|
|
1365
|
+
"COMPONENT_SLIDE",
|
|
1366
|
+
"Component content cannot create slides.",
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
groups.push(slide?.elements.slice(groupStart) ?? []);
|
|
1370
|
+
}
|
|
1371
|
+
contentFrame = previousFrame;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
await walk(ast.blocks);
|
|
1375
|
+
if (
|
|
1376
|
+
source &&
|
|
1377
|
+
consumedImages.size !==
|
|
1378
|
+
source.blocks.filter((block) => block.kind === "image").length
|
|
1379
|
+
) {
|
|
1380
|
+
throw new Error(
|
|
1381
|
+
"UNMAPPED_AUTHORED_IMAGE: every original source image must be projected once with its caption",
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
if (consumedCharts.size !== expectedCharts.size) {
|
|
1385
|
+
throw new Error(
|
|
1386
|
+
"UNMAPPED_CHART_SOURCE: every original chart must be projected exactly once",
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
if (consumedRichTables.size !== richTables.size) {
|
|
1390
|
+
throw new Error(
|
|
1391
|
+
"RICH_TABLE_PROJECTION_MISMATCH: not every checked rich table was consumed exactly once",
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
if (consumedWidgets.size !== widgets.size) {
|
|
1395
|
+
throw new Error(
|
|
1396
|
+
"WIDGET_PROJECTION_MISMATCH: not every verified widget was consumed exactly once",
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
if (!document.slides.length) {
|
|
1400
|
+
throw new Error("EMPTY_DECK: source contains no supported content");
|
|
1401
|
+
}
|
|
1402
|
+
result.diagnostics = refreshOverflowDiagnostics(document, result.diagnostics);
|
|
1403
|
+
return result;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** Execution modes that erase source/output boundaries cannot be reverse-mapped. */
|
|
1407
|
+
export function assertSupportedExecutionSource(source: SourceDocument): void {
|
|
1408
|
+
assertCompatibleComponentSource(source);
|
|
1409
|
+
for (
|
|
1410
|
+
const block of source.blocks.filter((block) =>
|
|
1411
|
+
block.kind === "div" && sourceComponentClass(block.raw) === ".bento-kpi"
|
|
1412
|
+
)
|
|
1413
|
+
) {
|
|
1414
|
+
kpiSource(source, block.id);
|
|
1415
|
+
}
|
|
1416
|
+
sourceGraphs(source);
|
|
1417
|
+
for (const block of source.blocks.filter((b) => b.kind === "chunk")) {
|
|
1418
|
+
if (
|
|
1419
|
+
/(?:#\|\s*results\s*:\s*|results\s*=\s*)["']?asis\b|\b(?:knitr::)?asis_output\s*\(/i
|
|
1420
|
+
.test(block.raw)
|
|
1421
|
+
) {
|
|
1422
|
+
throw new Error(
|
|
1423
|
+
`UNMAPPED_PROVENANCE: chunk ${block.id} uses as-is output, which can erase execution-origin boundaries; use a supported wrapped output or preserve it outside editable projection`,
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
/** Bind executed projection to exact original blocks; never guess unknown output is authored. */
|
|
1429
|
+
export function validateProjectionOrigins(
|
|
1430
|
+
source: SourceDocument,
|
|
1431
|
+
projection: Projection,
|
|
1432
|
+
sourceInline?: ReadonlyMap<string, SourceInlineProof>,
|
|
1433
|
+
): Projection {
|
|
1434
|
+
assertSupportedExecutionSource(source);
|
|
1435
|
+
if (source.diagnostics.length) {
|
|
1436
|
+
throw new Error(
|
|
1437
|
+
"UNMAPPED_PROVENANCE: resolve original source identity/structure diagnostics first",
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
const origins: Projection["origins"] = {};
|
|
1441
|
+
const used = new Set<string>();
|
|
1442
|
+
const originalHtml = (
|
|
1443
|
+
block: SourceDocument["blocks"][number],
|
|
1444
|
+
): string | undefined => {
|
|
1445
|
+
if (!sourceInline) return block.content;
|
|
1446
|
+
const proof = sourceInline.get(block.id);
|
|
1447
|
+
return proof?.sourceHash === source.hash && proof.blockId === block.id &&
|
|
1448
|
+
proof.kind === block.kind
|
|
1449
|
+
? proof.html
|
|
1450
|
+
: undefined;
|
|
1451
|
+
};
|
|
1452
|
+
// Automatic heading IDs and authored source IDs are different until a
|
|
1453
|
+
// reviewed sync pins them. Establish each slide from its exact original
|
|
1454
|
+
// heading before considering any unlabelled paragraph beneath it.
|
|
1455
|
+
const originalSlides = new Map<string, string>();
|
|
1456
|
+
for (const slide of projection.document.slides) {
|
|
1457
|
+
const exact = source.slides.filter((candidate) =>
|
|
1458
|
+
candidate.id === slide.id && candidate.stable
|
|
1459
|
+
);
|
|
1460
|
+
if (exact.length === 1) {
|
|
1461
|
+
originalSlides.set(slide.id, exact[0].id);
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
const title = slide.elements.find((element) =>
|
|
1465
|
+
projection.origins[slide.id]?.[element.id]?.sourceId === slide.id &&
|
|
1466
|
+
element.type === "text" && typeof element.html === "string"
|
|
1467
|
+
);
|
|
1468
|
+
const matches = title
|
|
1469
|
+
? source.slides.filter((candidate) => {
|
|
1470
|
+
const heading = source.blocks.find((block) =>
|
|
1471
|
+
block.id === candidate.headingId
|
|
1472
|
+
)!;
|
|
1473
|
+
return heading.origin === "authored" && !heading.stable &&
|
|
1474
|
+
originalHtml(heading) === title.html &&
|
|
1475
|
+
(!sourceInline ||
|
|
1476
|
+
sourceInline.get(heading.id)?.readerId === slide.id);
|
|
1477
|
+
})
|
|
1478
|
+
: [];
|
|
1479
|
+
if (matches.length === 1) originalSlides.set(slide.id, matches[0].id);
|
|
1480
|
+
}
|
|
1481
|
+
for (const [slideId, entries] of Object.entries(projection.origins)) {
|
|
1482
|
+
origins[slideId] = {};
|
|
1483
|
+
const slide = projection.document.slides.find((s) => s.id === slideId);
|
|
1484
|
+
for (const [elementId, original] of Object.entries(entries)) {
|
|
1485
|
+
const element = slide?.elements.find((e) => e.id === elementId);
|
|
1486
|
+
if (!element) {
|
|
1487
|
+
throw new Error(
|
|
1488
|
+
"UNMAPPED_PROVENANCE: source origin has no projected element",
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
let candidates = original.sourceId
|
|
1492
|
+
? source.blocks.filter((b) => b.id === original.sourceId)
|
|
1493
|
+
: [];
|
|
1494
|
+
if (original.graph) {
|
|
1495
|
+
const sourceSlideId = originalSlides.get(slideId);
|
|
1496
|
+
if (!sourceSlideId) {
|
|
1497
|
+
throw new DesignError(
|
|
1498
|
+
"UNMAPPED_GRAPH_PROVENANCE",
|
|
1499
|
+
"Graph slide needs its exact original heading proof before projection.",
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
validateGraphOrigin(source, original, element, slideId, sourceSlideId);
|
|
1503
|
+
origins[slideId][elementId] = {
|
|
1504
|
+
...original,
|
|
1505
|
+
graph: {
|
|
1506
|
+
...(original.graph as { [key: string]: Json }),
|
|
1507
|
+
nativeSlideId: slideId,
|
|
1508
|
+
sourceSlideId,
|
|
1509
|
+
},
|
|
1510
|
+
};
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
if (original.designOnly) {
|
|
1514
|
+
const container = candidates[0];
|
|
1515
|
+
if (
|
|
1516
|
+
candidates.length !== 1 || container.kind !== "div" ||
|
|
1517
|
+
!container.stable ||
|
|
1518
|
+
element.type !== "shape" || element.shape !== "rect" ||
|
|
1519
|
+
!(sourceComponentClass(container.raw) === ".bento-card" &&
|
|
1520
|
+
elementId === `${container.id}/qb-card-background` ||
|
|
1521
|
+
sourceComponentClass(container.raw) === ".bento-kpi" &&
|
|
1522
|
+
elementId === `${container.id}/qb-kpi-background` &&
|
|
1523
|
+
element.groupId === kpiSource(source, container.id).groupId)
|
|
1524
|
+
) {
|
|
1525
|
+
throw new Error(
|
|
1526
|
+
"UNMAPPED_COMPONENT_DESIGN: generated component background lacks original container proof",
|
|
1527
|
+
);
|
|
1528
|
+
}
|
|
1529
|
+
if (sourceComponentClass(container.raw) === ".bento-kpi") {
|
|
1530
|
+
const binding = kpiSource(source, container.id);
|
|
1531
|
+
if (binding.wrapper.slideId !== originalSlides.get(slideId)) {
|
|
1532
|
+
throw new Error(
|
|
1533
|
+
"UNMAPPED_COMPONENT_DESIGN: KPI background moved outside its verified source slide",
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
origins[slideId][elementId] = original;
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
if (element.type === "chart") {
|
|
1541
|
+
const block = candidates.length === 1 ? candidates[0] : undefined;
|
|
1542
|
+
if (!block?.parentId) {
|
|
1543
|
+
throw new Error(
|
|
1544
|
+
"UNMAPPED_CHART_SOURCE: chart lacks one original data source",
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
const binding = chartSource(source, block.parentId);
|
|
1548
|
+
if (
|
|
1549
|
+
binding.block.id !== block.id ||
|
|
1550
|
+
element.id !== `${binding.wrapper.id}/chart` ||
|
|
1551
|
+
binding.wrapper.slideId !== originalSlides.get(slideId) ||
|
|
1552
|
+
element.groupId !== binding.groupId ||
|
|
1553
|
+
(original.origin === "computed") !== binding.computed ||
|
|
1554
|
+
(binding.computed && original.producer !== binding.block.id)
|
|
1555
|
+
) {
|
|
1556
|
+
throw new Error(
|
|
1557
|
+
"UNMAPPED_CHART_SOURCE: chart identity, group, slide or producer differs from original source",
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
if (original.origin === "computed") {
|
|
1562
|
+
const producer = original.producer ??
|
|
1563
|
+
(candidates[0]?.kind === "chunk" ? candidates[0].id : undefined);
|
|
1564
|
+
const producerBlock = source.blocks.find((b) =>
|
|
1565
|
+
b.id === producer && b.kind === "chunk"
|
|
1566
|
+
);
|
|
1567
|
+
if (
|
|
1568
|
+
!producer || !producerBlock || !originalSlides.has(slideId) ||
|
|
1569
|
+
producerBlock.slideId !== originalSlides.get(slideId)
|
|
1570
|
+
) {
|
|
1571
|
+
throw new Error(
|
|
1572
|
+
`UNMAPPED_PROVENANCE: computed element ${elementId} has no verified original producer on its source slide; capture a fresh original execution receipt through qbento render or the guarded Quarto integration`,
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
origins[slideId][elementId] = { ...original, producer };
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
if (
|
|
1579
|
+
!candidates.length && element.type === "text" &&
|
|
1580
|
+
typeof element.html === "string"
|
|
1581
|
+
) {
|
|
1582
|
+
// Only faithful original authored inline HTML can establish this
|
|
1583
|
+
// binding, uniquely within the same verified original slide.
|
|
1584
|
+
const kind = /^<(ul|ol)>/.test(element.html)
|
|
1585
|
+
? "list"
|
|
1586
|
+
: original.sourceId
|
|
1587
|
+
? "heading"
|
|
1588
|
+
: "paragraph";
|
|
1589
|
+
candidates = source.blocks.filter((b) =>
|
|
1590
|
+
b.kind === kind && b.origin === "authored" && !b.stable &&
|
|
1591
|
+
!b.parentId && b.slideId === originalSlides.get(slideId) &&
|
|
1592
|
+
originalSlides.has(slideId) && originalHtml(b) === element.html &&
|
|
1593
|
+
(kind !== "heading" || !sourceInline ||
|
|
1594
|
+
sourceInline.get(b.id)?.readerId === original.sourceId)
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
if (
|
|
1598
|
+
!candidates.length && !original.sourceId && element.type === "text" &&
|
|
1599
|
+
typeof element.html === "string" && element.html.startsWith("$$")
|
|
1600
|
+
) {
|
|
1601
|
+
candidates = source.blocks.filter((block) =>
|
|
1602
|
+
block.kind === "math" && block.origin === "authored" &&
|
|
1603
|
+
originalSlides.has(slideId) &&
|
|
1604
|
+
block.slideId === originalSlides.get(slideId) &&
|
|
1605
|
+
mathToNativeHtml(block.content) === element.html
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
if (!candidates.length && !original.sourceId && element.type === "code") {
|
|
1609
|
+
candidates = source.blocks.filter((block) => {
|
|
1610
|
+
const proof = sourceInline?.get(block.id);
|
|
1611
|
+
return block.kind === "code" && block.origin === "authored" &&
|
|
1612
|
+
!block.stable && !block.parentId &&
|
|
1613
|
+
originalSlides.has(slideId) &&
|
|
1614
|
+
block.slideId === originalSlides.get(slideId) &&
|
|
1615
|
+
proof?.sourceHash === source.hash &&
|
|
1616
|
+
proof.code?.content === element.content &&
|
|
1617
|
+
proof.code?.grammarName === (element.grammarName ?? null);
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
if (candidates.length !== 1) {
|
|
1621
|
+
throw new Error(
|
|
1622
|
+
`UNMAPPED_PROVENANCE: element ${elementId} is not uniquely linked to original source; explicitly pin authored identity or use a supported computed output`,
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
const block = candidates[0];
|
|
1626
|
+
if (
|
|
1627
|
+
!originalSlides.has(slideId) ||
|
|
1628
|
+
block.slideId !== originalSlides.get(slideId)
|
|
1629
|
+
) {
|
|
1630
|
+
throw new Error(
|
|
1631
|
+
"UNMAPPED_PROVENANCE: authored object moved outside its verified original source slide",
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
if (block.kind === "image") {
|
|
1635
|
+
const proof = sourceInline?.get(block.id),
|
|
1636
|
+
metadata = original.image as Record<string, Json> | undefined;
|
|
1637
|
+
const ids = imageNativeIdentities(source, block);
|
|
1638
|
+
const role = metadata?.role;
|
|
1639
|
+
if (
|
|
1640
|
+
!proof?.image?.data || !metadata ||
|
|
1641
|
+
!["asset", "caption"].includes(String(role)) ||
|
|
1642
|
+
metadata.target !== proof.image.target ||
|
|
1643
|
+
metadata.assetDigest !== hashSource(proof.image.data) ||
|
|
1644
|
+
metadata.imageId !== ids.imageId ||
|
|
1645
|
+
metadata.captionId !== ids.captionId ||
|
|
1646
|
+
element.groupId !== ids.groupId ||
|
|
1647
|
+
block.slideId !== originalSlides.get(slideId) || (role === "asset"
|
|
1648
|
+
? element.type !== "image" || element.id !== ids.imageId ||
|
|
1649
|
+
element.src !== proof.image.data
|
|
1650
|
+
: element.type !== "text" || element.id !== ids.captionId ||
|
|
1651
|
+
element.html !== proof.html) ||
|
|
1652
|
+
used.has(`${block.id}:${role}`)
|
|
1653
|
+
) {
|
|
1654
|
+
throw new Error(
|
|
1655
|
+
"UNMAPPED_AUTHORED_IMAGE: image/caption pair, bytes, group or slide differs from the original source proof",
|
|
1656
|
+
);
|
|
1657
|
+
}
|
|
1658
|
+
used.add(`${block.id}:${role}`);
|
|
1659
|
+
origins[slideId][elementId] = {
|
|
1660
|
+
...original,
|
|
1661
|
+
sourceId: block.id,
|
|
1662
|
+
origin: "authored",
|
|
1663
|
+
};
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
if (used.has(block.id)) {
|
|
1667
|
+
throw new Error(
|
|
1668
|
+
`UNMAPPED_PROVENANCE: multiple executed objects claim authored source block ${block.id}`,
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
if (block.kind === "paragraph" && block.parentId) {
|
|
1672
|
+
const role = source.blocks.find((candidate) =>
|
|
1673
|
+
candidate.id === block.parentId
|
|
1674
|
+
);
|
|
1675
|
+
const wrapper = role?.parentId &&
|
|
1676
|
+
source.blocks.find((candidate) => candidate.id === role.parentId);
|
|
1677
|
+
if (wrapper && sourceComponentClass(wrapper.raw) === ".bento-kpi") {
|
|
1678
|
+
const binding = kpiSource(source, wrapper.id);
|
|
1679
|
+
if (
|
|
1680
|
+
!binding.slots.some((slot) => slot.paragraph.id === block.id) ||
|
|
1681
|
+
element.groupId !== binding.groupId ||
|
|
1682
|
+
binding.wrapper.slideId !== originalSlides.get(slideId)
|
|
1683
|
+
) {
|
|
1684
|
+
throw new Error(
|
|
1685
|
+
"UNMAPPED_COMPONENT_DESIGN: KPI role does not match its verified source group and slide",
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
if (element.type === "chart") {
|
|
1691
|
+
validateAuthoredChart(source, block.id, element);
|
|
1692
|
+
} else if (element.type === "table") {
|
|
1693
|
+
validateAuthoredTable(source, block.id, element);
|
|
1694
|
+
if (
|
|
1695
|
+
block.slideId !== originalSlides.get(slideId) ||
|
|
1696
|
+
!block.parentId && element.groupId !== undefined
|
|
1697
|
+
) {
|
|
1698
|
+
throw new Error(
|
|
1699
|
+
"UNMAPPED_TABLE_SOURCE: authored table moved outside its original source slide or into an invented group",
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
} else if (block.kind === "unsupported" || block.kind === "div") {
|
|
1703
|
+
throw new Error(
|
|
1704
|
+
`UNMAPPED_PROVENANCE: element ${elementId} points to an unsupported source container`,
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
if (
|
|
1708
|
+
element.type === "text" && typeof element.html === "string" &&
|
|
1709
|
+
element.html.startsWith("$$") && block.kind !== "math"
|
|
1710
|
+
) {
|
|
1711
|
+
throw new Error(
|
|
1712
|
+
"UNMAPPED_PROVENANCE: a display formula needs a separately mapped original math block",
|
|
1713
|
+
);
|
|
1714
|
+
}
|
|
1715
|
+
if (
|
|
1716
|
+
block.kind === "math" &&
|
|
1717
|
+
(element.type !== "text" || typeof element.html !== "string" ||
|
|
1718
|
+
mathToNativeHtml(block.content) !== element.html)
|
|
1719
|
+
) {
|
|
1720
|
+
throw new Error(
|
|
1721
|
+
"UNMAPPED_PROVENANCE: executed math differs from the exact authored source formula",
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
if (block.kind === "code" && block.origin === "authored") {
|
|
1725
|
+
const proof = sourceInline?.get(block.id);
|
|
1726
|
+
if (
|
|
1727
|
+
element.type !== "code" || !proof?.code ||
|
|
1728
|
+
proof.sourceHash !== source.hash ||
|
|
1729
|
+
proof.code.content !== element.content ||
|
|
1730
|
+
proof.code.grammarName !== (element.grammarName ?? null)
|
|
1731
|
+
) {
|
|
1732
|
+
throw new Error(
|
|
1733
|
+
"UNMAPPED_PROVENANCE: displayed code differs from its exact authored fence and language",
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
if (
|
|
1738
|
+
block.kind === "list" && block.origin === "authored" &&
|
|
1739
|
+
originalHtml(block) !== element.html
|
|
1740
|
+
) {
|
|
1741
|
+
throw new Error(
|
|
1742
|
+
"UNMAPPED_PROVENANCE: executed list differs from its exact authored source fragment",
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
used.add(block.id);
|
|
1746
|
+
origins[slideId][elementId] = {
|
|
1747
|
+
...original,
|
|
1748
|
+
sourceId: block.id,
|
|
1749
|
+
origin: block.origin,
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
for (const block of source.blocks.filter((block) => block.kind === "image")) {
|
|
1754
|
+
if (!used.has(`${block.id}:asset`) || !used.has(`${block.id}:caption`)) {
|
|
1755
|
+
throw new Error(
|
|
1756
|
+
"UNMAPPED_AUTHORED_IMAGE: original image/caption pair is incomplete",
|
|
1757
|
+
);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
for (const block of source.blocks) {
|
|
1761
|
+
if (
|
|
1762
|
+
block.kind !== "div" || !isSourceChart(block)
|
|
1763
|
+
) continue;
|
|
1764
|
+
const matching = projection.document.slides.flatMap((slide) =>
|
|
1765
|
+
slide.elements
|
|
1766
|
+
).filter((element) =>
|
|
1767
|
+
element.id === `${block.id}/chart` && element.type === "chart"
|
|
1768
|
+
);
|
|
1769
|
+
if (matching.length !== 1) {
|
|
1770
|
+
throw new Error(
|
|
1771
|
+
"UNMAPPED_CHART_SOURCE: every original chart needs one native chart identity",
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
for (const block of literalTableBlocks(source)) {
|
|
1776
|
+
if (!used.has(block.id)) {
|
|
1777
|
+
throw new Error(
|
|
1778
|
+
"UNMAPPED_TABLE_SOURCE: an original literal table was removed or lost its exact source binding",
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
// A reader/filter may remove a whole authored list without emitting an
|
|
1783
|
+
// object to validate above. Source coverage is part of fidelity too.
|
|
1784
|
+
for (const block of source.blocks) {
|
|
1785
|
+
if (
|
|
1786
|
+
block.kind === "list" && block.origin === "authored" && block.slideId &&
|
|
1787
|
+
originalHtml(block) !== undefined && !used.has(block.id)
|
|
1788
|
+
) {
|
|
1789
|
+
throw new Error(
|
|
1790
|
+
"UNMAPPED_PROVENANCE: an authored source list disappeared from executed output; source-transforming filters need an explicit adapter",
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
for (const [slideId, binding] of Object.entries(projection.notes ?? {})) {
|
|
1795
|
+
const original = sourceNotes(source, binding.sourceId, slideId);
|
|
1796
|
+
const slide = projection.document.slides.find((s) => s.id === slideId);
|
|
1797
|
+
if (
|
|
1798
|
+
binding.codec !== NOTES_CODEC_VERSION || slide?.notes !== original.text
|
|
1799
|
+
) {
|
|
1800
|
+
throw new Error(
|
|
1801
|
+
"UNMAPPED_NOTES: speaker notes lack verified original plain-text provenance",
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
return { ...projection, origins };
|
|
1806
|
+
}
|