@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,205 @@
1
+ /** Previously verified source roles can supply visual typography, never meaning. */
2
+ import { clone, type ProjectState, targetKey } from "../state/mod.ts";
3
+ import type { SourceDocument } from "../source/mod.ts";
4
+ import { verifiedSourceSlide } from "./source-scope.ts";
5
+ import { StateError } from "../state/types.ts";
6
+ import { kpiSource } from "../source/kpi.ts";
7
+ import { sourceComponentClass } from "../render/components.ts";
8
+ import type { KpiAppearance } from "../render/kpi.ts";
9
+ import type { BentoDoc, Element } from "../render/types.ts";
10
+ export type KpiAppearances = ReadonlyMap<string, KpiAppearance>;
11
+ /** A globally identified source wrapper must have exactly one native receipt. */
12
+ function sourceBackedScope(
13
+ state: ProjectState,
14
+ source: SourceDocument,
15
+ sourceSlideId: string,
16
+ componentId: string,
17
+ ): string | undefined {
18
+ const backgroundId = `${componentId}/qb-kpi-background`;
19
+ const groupId = kpiSource(source, componentId).groupId;
20
+ const candidates = Object.entries(state.provenance).flatMap(
21
+ ([key, proof]) => {
22
+ let target: unknown;
23
+ try {
24
+ target = JSON.parse(key);
25
+ } catch {
26
+ return [];
27
+ }
28
+ if (
29
+ !Array.isArray(target) || target.length !== 2 ||
30
+ typeof target[0] !== "string" || !target[0] ||
31
+ target[1] !== backgroundId ||
32
+ proof.origin !== "authored" || proof.semanticId !== componentId ||
33
+ proof.source?.kind !== "div" || proof.source.blockId !== componentId ||
34
+ proof.source.stable !== true
35
+ ) return [];
36
+ const baseline = state.generatedBaseline.slides.find((slide) =>
37
+ slide.id === target[0]
38
+ )
39
+ ?.elements.find((element) => element.id === backgroundId);
40
+ return baseline?.type === "shape" && baseline.shape === "rect" &&
41
+ baseline.groupId === groupId
42
+ ? [target[0]]
43
+ : [];
44
+ },
45
+ );
46
+ if (candidates.length !== 1) return;
47
+ try {
48
+ const slideId = verifiedSourceSlide(
49
+ source,
50
+ state,
51
+ sourceSlideId,
52
+ candidates[0],
53
+ );
54
+ // A weak heading's receipt is valid only for this exact execution snapshot.
55
+ const heading = source.slides.find((slide) => slide.id === sourceSlideId)!;
56
+ if (
57
+ !heading.stable &&
58
+ state.provenance[targetKey({ slideId, elementId: backgroundId })].source
59
+ ?.hash !== source.hash
60
+ ) return;
61
+ return slideId;
62
+ } catch (error) {
63
+ if (error instanceof StateError && error.code === "UNMAPPED_SOURCE_SLIDE") {
64
+ return;
65
+ }
66
+ throw error;
67
+ }
68
+ }
69
+ export function retainedKpiAppearances(
70
+ state: ProjectState | null | undefined,
71
+ source: SourceDocument,
72
+ ): KpiAppearances {
73
+ const appearances = new Map<string, KpiAppearance>();
74
+ if (!state) return appearances;
75
+ for (
76
+ const block of source.blocks.filter((block) =>
77
+ block.kind === "div" && block.stable &&
78
+ sourceComponentClass(block.raw) === ".bento-kpi"
79
+ )
80
+ ) {
81
+ const binding = kpiSource(source, block.id), sourceSlideId = block.slideId;
82
+ if (!sourceSlideId) continue;
83
+ // Pending roles have explicit membership; source-backed roles require the
84
+ // unique original background receipt before any native slide is considered.
85
+ const intent = state.componentCreations?.find((intent) =>
86
+ intent.kind === "kpi" && intent.id === block.id
87
+ );
88
+ let slideId: string | undefined;
89
+ if (intent) {
90
+ try {
91
+ slideId = verifiedSourceSlide(
92
+ source,
93
+ state,
94
+ sourceSlideId,
95
+ intent.slideId,
96
+ );
97
+ } catch (error) {
98
+ if (
99
+ !(error instanceof StateError) ||
100
+ error.code !== "UNMAPPED_SOURCE_SLIDE"
101
+ ) throw error;
102
+ }
103
+ } else {
104
+ slideId = sourceBackedScope(state, source, sourceSlideId, block.id);
105
+ }
106
+ if (!slideId) continue;
107
+ const elements = new Map<string, Element>();
108
+ const backgroundId = `${block.id}/qb-kpi-background`;
109
+ const backgroundProof =
110
+ state.provenance[targetKey({ slideId, elementId: backgroundId })];
111
+ const baselineBackground = state.generatedBaseline.slides.find((slide) =>
112
+ slide.id === slideId
113
+ )
114
+ ?.elements.find((element) => element.id === backgroundId);
115
+ const sourceBacked = backgroundProof?.origin === "authored" &&
116
+ backgroundProof.semanticId === block.id &&
117
+ backgroundProof.source?.kind === "div" &&
118
+ backgroundProof.source?.blockId === block.id &&
119
+ baselineBackground?.type === "shape" &&
120
+ baselineBackground.shape === "rect" &&
121
+ baselineBackground.groupId === binding.groupId;
122
+ for (const slot of binding.slots) {
123
+ const nativeId = slot.paragraph.id;
124
+ const member = intent?.members.find((member) =>
125
+ member.nativeId === nativeId &&
126
+ member.sourceId === slot.wrapper.id &&
127
+ member.role === slot.role
128
+ );
129
+ if (member) {
130
+ elements.set(nativeId, clone(member.snapshot));
131
+ continue;
132
+ }
133
+ if (!sourceBacked) continue;
134
+ const proof =
135
+ state.provenance[targetKey({ slideId, elementId: nativeId })];
136
+ const current = state.emitted.slides.find((slide) => slide.id === slideId)
137
+ ?.elements.find((element) => element.id === nativeId);
138
+ if (
139
+ current?.type === "text" &&
140
+ !!proof && ["authored", "computed"].includes(proof.origin) &&
141
+ proof.semanticId === nativeId && proof.source?.kind === "paragraph" &&
142
+ proof.source?.blockId === nativeId &&
143
+ proof.source?.parentId === slot.wrapper.id &&
144
+ proof.source?.siblings === 1 &&
145
+ proof.source?.hash === backgroundProof.source?.hash
146
+ ) {
147
+ elements.set(nativeId, clone(current));
148
+ }
149
+ }
150
+ if (elements.size) {
151
+ const previous = appearances.get(slideId);
152
+ const combined = new Map(previous?.elements ?? []);
153
+ for (const [id, element] of elements) combined.set(id, element);
154
+ appearances.set(slideId, {
155
+ elements: combined,
156
+ resources: state.emitted as unknown as BentoDoc,
157
+ });
158
+ }
159
+ }
160
+ return appearances;
161
+ }
162
+ /** Rescue keeps the actual saved boxes as visual overlays, not source baseline fields. */
163
+ export function preserveRescuedKpiGeometry(
164
+ state: ProjectState,
165
+ appearances: KpiAppearances,
166
+ rescues: { slideId: string; elementIds: string[] }[],
167
+ ownership: ProjectState["ownership"],
168
+ ): void {
169
+ for (const rescue of rescues) {
170
+ for (const elementId of rescue.elementIds) {
171
+ const native = appearances.get(rescue.slideId)?.elements.get(elementId);
172
+ const current = state.emitted.slides.find((slide) =>
173
+ slide.id === rescue.slideId
174
+ )
175
+ ?.elements.find((element) => element.id === elementId);
176
+ if (!native || current?.type !== "text") continue;
177
+ const target = { slideId: rescue.slideId, elementId },
178
+ key = targetKey(target);
179
+ for (const field of ["x", "y", "w", "h"]) {
180
+ if (
181
+ typeof native[field] !== "number" || !Number.isFinite(native[field])
182
+ ) continue;
183
+ if (
184
+ ownership[key]?.some((path) =>
185
+ path === field || field.startsWith(path + "/")
186
+ )
187
+ ) continue;
188
+ let overlay = state.overrides.find((overlay) =>
189
+ targetKey(overlay.target) === key
190
+ );
191
+ if (!overlay) {
192
+ overlay = { target, fields: [] };
193
+ state.overrides.push(overlay);
194
+ }
195
+ overlay.fields = overlay.fields.filter((item) =>
196
+ item.path.length !== 1 || item.path[0] !== field
197
+ );
198
+ overlay.fields.push({
199
+ path: [field],
200
+ value: { present: true, value: native[field] },
201
+ });
202
+ }
203
+ }
204
+ }
205
+ }
@@ -0,0 +1,245 @@
1
+ /** Explicit, design-only application of reusable layout definitions to saved objects. */
2
+ import { withProject } from "./project.ts";
3
+ import { hashSource, scanSource } from "../source/mod.ts";
4
+ import { builtinLayouts, type Frame } from "../render/design.ts";
5
+ import {
6
+ assertDesignRegistryUnchanged,
7
+ readDesignRegistry,
8
+ } from "../render/registry.ts";
9
+ import { nativeLayoutFrame } from "../render/native-layout.ts";
10
+ import { assertProjectDocument } from "../render/validate.ts";
11
+ import {
12
+ assertOutputUnchanged,
13
+ captureEdits,
14
+ equal,
15
+ type ProjectState,
16
+ StateError,
17
+ targetKey,
18
+ } from "../state/mod.ts";
19
+ export interface LayoutRequest {
20
+ expectedDocId: string;
21
+ expectedRevision: number;
22
+ expectedSourceHash: string;
23
+ expectedRegistryHash: string | null;
24
+ slideId: string;
25
+ layout: string;
26
+ /** Ordered explicit native IDs per column; no automatic membership matching. */
27
+ columns: string[][];
28
+ }
29
+ function fail(code: string, message: string): never {
30
+ throw new StateError(code, message);
31
+ }
32
+ function requestShape(request: LayoutRequest) {
33
+ if (
34
+ !request || typeof request !== "object" || Array.isArray(request) ||
35
+ Object.keys(request).sort().join(",") !==
36
+ "columns,expectedDocId,expectedRegistryHash,expectedRevision,expectedSourceHash,layout,slideId" ||
37
+ typeof request.expectedDocId !== "string" || !request.expectedDocId ||
38
+ !Number.isSafeInteger(request.expectedRevision) ||
39
+ request.expectedRevision < 0 ||
40
+ typeof request.expectedSourceHash !== "string" ||
41
+ !/^[a-f0-9]{64}$/.test(request.expectedSourceHash) ||
42
+ !(request.expectedRegistryHash === null ||
43
+ typeof request.expectedRegistryHash === "string" &&
44
+ /^[a-f0-9]{64}$/.test(request.expectedRegistryHash)) ||
45
+ typeof request.slideId !== "string" || !request.slideId ||
46
+ typeof request.layout !== "string" ||
47
+ !Array.isArray(request.columns) || request.columns.length < 1 ||
48
+ request.columns.length > 6 ||
49
+ request.columns.some((ids) =>
50
+ !Array.isArray(ids) || !ids.length || ids.length > 200 ||
51
+ ids.some((id) => typeof id !== "string" || !id)
52
+ )
53
+ ) {
54
+ fail(
55
+ "INVALID_LAYOUT_REQUEST",
56
+ "Select a saved slide, layout and explicit objects for every column.",
57
+ );
58
+ }
59
+ }
60
+ function catalog(
61
+ state: ProjectState,
62
+ text: string,
63
+ registry: Awaited<ReturnType<typeof readDesignRegistry>>,
64
+ ) {
65
+ const source = scanSource(text),
66
+ headings = new Set(source.slides.map((slide) => slide.headingId));
67
+ // Weak source IDs include offsets. A newer source cannot safely classify the
68
+ // saved objects (especially slide titles) before a fresh projection.
69
+ if (
70
+ Object.values(state.provenance).some((proof) =>
71
+ typeof proof.source?.hash === "string" &&
72
+ proof.source.hash !== source.hash
73
+ )
74
+ ) {
75
+ fail(
76
+ "STALE_SOURCE",
77
+ "QMD changed since the saved projection. Recalculate before applying a layout; saved and pending edits remain recoverable.",
78
+ );
79
+ }
80
+ const size = state.emitted.size as { width: number; height: number };
81
+ // Existing projected title region is reserved. Explicitly moved source titles
82
+ // can increase it, but are never moved or selected by this action.
83
+ const slides = state.emitted.slides.map((slide) => {
84
+ const titles = slide.elements.filter((element) =>
85
+ element.role === "title" ||
86
+ headings.has(
87
+ String(
88
+ state
89
+ .provenance[targetKey({ slideId: slide.id, elementId: element.id })]
90
+ ?.semanticId,
91
+ ),
92
+ )
93
+ );
94
+ const top = Math.max(
95
+ size.height * 166 / 720,
96
+ ...titles.map((e) => Number(e.y) + Number(e.h) + 24),
97
+ );
98
+ const frame: Frame = {
99
+ x: size.width / 16,
100
+ y: top,
101
+ w: size.width * 7 / 8,
102
+ h: size.height - top - size.height * 84 / 720,
103
+ };
104
+ return {
105
+ id: slide.id,
106
+ label: String(slide.title ?? slide.id),
107
+ frame,
108
+ elements: slide.elements.filter((e) =>
109
+ !titles.includes(e) &&
110
+ ["text", "code", "image", "table", "chart"].includes(e.type)
111
+ ).map((element) => ({
112
+ id: element.id,
113
+ type: element.type,
114
+ label: String(
115
+ element.html ?? element.content ?? element.alt ?? element.id,
116
+ ).replace(/<[^>]*>/g, "").slice(0, 120),
117
+ })),
118
+ };
119
+ });
120
+ return {
121
+ docId: state.docId,
122
+ revision: state.revision,
123
+ sourceHash: source.hash,
124
+ registryHash: registry.hash,
125
+ layouts: { ...builtinLayouts, ...registry.registry.layouts },
126
+ slides,
127
+ };
128
+ }
129
+ export async function readLayouts(source: string) {
130
+ return await withProject(source, async (paths, store) => {
131
+ const state = await store.load();
132
+ if (!state) fail("NO_BASELINE", "Render the project first.");
133
+ return catalog(
134
+ state,
135
+ await Deno.readTextFile(paths.source),
136
+ await readDesignRegistry(paths.directory),
137
+ );
138
+ });
139
+ }
140
+ async function operation(
141
+ source: string,
142
+ request: LayoutRequest,
143
+ save: boolean,
144
+ ) {
145
+ requestShape(request);
146
+ return await withProject(source, async (paths, store) => {
147
+ const state = await store.load();
148
+ if (!state) fail("NO_BASELINE", "Render the project first.");
149
+ if (state.docId !== request.expectedDocId) {
150
+ fail("DOC_ID_CHANGED", "Reload the project before applying a layout.");
151
+ }
152
+ if (state.revision !== request.expectedRevision) {
153
+ fail("STALE_REVISION", "Saved objects changed; reopen Apply layout.");
154
+ }
155
+ const text = await Deno.readTextFile(paths.source);
156
+ if (hashSource(text) !== request.expectedSourceHash) {
157
+ fail("STALE_SOURCE", "QMD changed; reopen Apply layout.");
158
+ }
159
+ const registry = await readDesignRegistry(paths.directory);
160
+ if (registry.hash !== request.expectedRegistryHash) {
161
+ fail(
162
+ "DESIGN_REGISTRY_CHANGED",
163
+ "Layout definitions changed; reopen Apply layout.",
164
+ );
165
+ }
166
+ const choices = catalog(state, text, registry),
167
+ slide = choices.slides.find((s) => s.id === request.slideId);
168
+ if (!slide) fail("LAYOUT_SLIDE_SCOPE", "Choose an existing saved slide.");
169
+ if (!Object.hasOwn(choices.layouts, request.layout)) {
170
+ fail("UNKNOWN_LAYOUT", "Choose a declared layout.");
171
+ }
172
+ const layout = choices.layouts[request.layout];
173
+ if (request.columns.length !== layout.columns) {
174
+ fail(
175
+ "LAYOUT_COLUMN_COUNT",
176
+ "Assign objects explicitly to every column of this layout.",
177
+ );
178
+ }
179
+ const ids = request.columns.flat();
180
+ if (
181
+ new Set(ids).size !== ids.length ||
182
+ ids.some((id) => !slide.elements.some((e) => e.id === id))
183
+ ) {
184
+ fail(
185
+ "LAYOUT_ELEMENT_SCOPE",
186
+ "Every selected object must occur once on this slide; titles stay outside the content layout.",
187
+ );
188
+ }
189
+ const width =
190
+ (slide.frame.w - 2 * layout.padding - (layout.columns - 1) * layout.gap) /
191
+ layout.columns;
192
+ const height = slide.frame.h - 2 * layout.padding;
193
+ const doc = structuredClone(state.emitted),
194
+ native = doc.slides.find((s) => s.id === slide.id)!;
195
+ const placements = request.columns.flatMap((members, column) => {
196
+ const h = (height - (members.length - 1) * layout.gap) / members.length;
197
+ if (width <= 0 || h <= 0) {
198
+ fail(
199
+ "LAYOUT_CONTENT_OVERFLOW",
200
+ "The selected layout has no usable content area.",
201
+ );
202
+ }
203
+ return members.map((id, row) => {
204
+ const element = native.elements.find((e) => e.id === id)!;
205
+ const frame = nativeLayoutFrame(element, {
206
+ x: slide.frame.x + layout.padding + column * (width + layout.gap),
207
+ y: slide.frame.y + layout.padding + row * (h + layout.gap),
208
+ w: width,
209
+ h,
210
+ });
211
+ Object.assign(element, frame);
212
+ return { id, ...frame };
213
+ });
214
+ });
215
+ assertProjectDocument(doc);
216
+ const next = equal(doc, state.emitted) ? state : captureEdits(state, doc);
217
+ const preview = {
218
+ ...choices,
219
+ slideId: slide.id,
220
+ layout: request.layout,
221
+ placements,
222
+ pending: next.pending.length,
223
+ };
224
+ // Recheck all external authorities after adaptation and before the single
225
+ // state write. Generated HTML is never replaced by this action.
226
+ await assertOutputUnchanged(state, paths.output);
227
+ if (
228
+ hashSource(await Deno.readTextFile(paths.source)) !==
229
+ request.expectedSourceHash
230
+ ) fail("STALE_SOURCE", "QMD changed while preparing layout.");
231
+ await assertDesignRegistryUnchanged(
232
+ paths.directory,
233
+ request.expectedRegistryHash,
234
+ );
235
+ if (!save) return preview;
236
+ const saved = next === state ? state : await store.save(next);
237
+ return { ...preview, revision: saved.revision };
238
+ });
239
+ }
240
+ export async function previewLayout(source: string, request: LayoutRequest) {
241
+ return await operation(source, request, false);
242
+ }
243
+ export async function applyLayout(source: string, request: LayoutRequest) {
244
+ return await operation(source, request, true);
245
+ }