@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.
- package/LICENSE +21 -0
- package/README.md +86 -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 +219 -0
- package/distribution/runtime/src/core/agent.ts +565 -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 +759 -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 +2423 -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 +790 -0
- package/distribution/runtime/src/studio/components.js +188 -0
- package/distribution/runtime/src/studio/index.html +278 -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 +433 -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,385 @@
|
|
|
1
|
+
import { measureNativeText, type NativeTextMeasurement } from "./measure.ts";
|
|
2
|
+
import { sourceComponentClass } from "./components.ts";
|
|
3
|
+
/** Native KPI text/rectangle composition, with no numeric conversion or count-up. */
|
|
4
|
+
import { type KpiSource, kpiSource } from "../source/kpi.ts";
|
|
5
|
+
import type { SourceDocument } from "../source/mod.ts";
|
|
6
|
+
import { renderInline } from "./inline.ts";
|
|
7
|
+
import {
|
|
8
|
+
applyElementDesign,
|
|
9
|
+
type DesignDefaults,
|
|
10
|
+
DesignError,
|
|
11
|
+
designTheme,
|
|
12
|
+
type Frame,
|
|
13
|
+
type PandocAttr,
|
|
14
|
+
} from "./design.ts";
|
|
15
|
+
import type {
|
|
16
|
+
BentoDoc,
|
|
17
|
+
Diagnostic,
|
|
18
|
+
Element,
|
|
19
|
+
Node,
|
|
20
|
+
Origin,
|
|
21
|
+
Projection,
|
|
22
|
+
} from "./types.ts";
|
|
23
|
+
|
|
24
|
+
type Fields = NonNullable<Projection["design"]>[string];
|
|
25
|
+
const geometry = new Set(["x", "y", "w", "h"]);
|
|
26
|
+
const shape = new Set(["fill", "stroke", "strokeWidth", "radius"]);
|
|
27
|
+
const generic = new Set(["rotation", "opacity"]);
|
|
28
|
+
const typography = [
|
|
29
|
+
"fontSize",
|
|
30
|
+
"fontFamily",
|
|
31
|
+
"fontWeight",
|
|
32
|
+
"lineHeight",
|
|
33
|
+
"letterSpacing",
|
|
34
|
+
"align",
|
|
35
|
+
"valign",
|
|
36
|
+
];
|
|
37
|
+
/** Source/slide provenance is verified by the caller before admitting appearance. */
|
|
38
|
+
export interface KpiAppearance {
|
|
39
|
+
elements: ReadonlyMap<string, Element>;
|
|
40
|
+
resources?: BentoDoc;
|
|
41
|
+
}
|
|
42
|
+
function fail(code: string, message: string): never {
|
|
43
|
+
throw new DesignError(code, message);
|
|
44
|
+
}
|
|
45
|
+
function sameAttr(actual: PandocAttr, expected: PandocAttr): boolean {
|
|
46
|
+
return actual[0] === expected[0] &&
|
|
47
|
+
JSON.stringify([...actual[1]].sort()) ===
|
|
48
|
+
JSON.stringify([...expected[1]].sort()) &&
|
|
49
|
+
JSON.stringify([...actual[2]].sort()) ===
|
|
50
|
+
JSON.stringify([...expected[2]].sort());
|
|
51
|
+
}
|
|
52
|
+
async function originalParagraphs(binding: KpiSource): Promise<Node[][]> {
|
|
53
|
+
const output = await new Deno.Command("quarto", {
|
|
54
|
+
args: ["pandoc", "--from", "markdown", "--to", "json"],
|
|
55
|
+
stdin: "piped",
|
|
56
|
+
stdout: "piped",
|
|
57
|
+
stderr: "piped",
|
|
58
|
+
}).spawn();
|
|
59
|
+
const writer = output.stdin.getWriter();
|
|
60
|
+
await writer.write(
|
|
61
|
+
new TextEncoder().encode(
|
|
62
|
+
binding.slots.map((slot) => slot.paragraph.content).join("\n\n"),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
await writer.close();
|
|
66
|
+
const result = await output.output();
|
|
67
|
+
if (!result.success) {
|
|
68
|
+
fail("KPI_SOURCE_INLINE", new TextDecoder().decode(result.stderr));
|
|
69
|
+
}
|
|
70
|
+
const ast = JSON.parse(new TextDecoder().decode(result.stdout));
|
|
71
|
+
if (
|
|
72
|
+
ast.blocks.length !== binding.slots.length ||
|
|
73
|
+
ast.blocks.some((node: Node) => node.t !== "Para")
|
|
74
|
+
) {
|
|
75
|
+
fail(
|
|
76
|
+
"KPI_SOURCE_INLINE",
|
|
77
|
+
"Original KPI slots must parse as exactly one paragraph each.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return ast.blocks.map((node: Node) => node.c as Node[]);
|
|
81
|
+
}
|
|
82
|
+
/** Outer height cannot correct glyph overhang on a fixed native edge, or
|
|
83
|
+
* internally overlapping lines. Centered single-line text can use measured slack.
|
|
84
|
+
*/
|
|
85
|
+
function unsupportedTypography(
|
|
86
|
+
element: Element,
|
|
87
|
+
measurement: NativeTextMeasurement,
|
|
88
|
+
): boolean {
|
|
89
|
+
const valign = element.valign ?? "top";
|
|
90
|
+
return (valign === "top" && measurement.overhangTop > .1) ||
|
|
91
|
+
(valign === "bottom" && measurement.overhangBottom > .1) ||
|
|
92
|
+
(measurement.lineCount > 1 &&
|
|
93
|
+
Math.max(measurement.overhangTop, measurement.overhangBottom) > .1);
|
|
94
|
+
}
|
|
95
|
+
export async function projectKpi(
|
|
96
|
+
source: SourceDocument,
|
|
97
|
+
attr: PandocAttr,
|
|
98
|
+
children: Node[],
|
|
99
|
+
base: Frame,
|
|
100
|
+
defaults: DesignDefaults = {},
|
|
101
|
+
appearance?: KpiAppearance,
|
|
102
|
+
/** An enclosing QMD frame cannot adopt saved role boxes as visual overlays. */
|
|
103
|
+
sourceFrameOwned = false,
|
|
104
|
+
): Promise<
|
|
105
|
+
{
|
|
106
|
+
elements: Element[];
|
|
107
|
+
origins: Record<string, Origin>;
|
|
108
|
+
fields: Fields;
|
|
109
|
+
/** The consumer retains unowned member geometry when saved typography rescues projection. */
|
|
110
|
+
appearanceRescued: boolean;
|
|
111
|
+
}
|
|
112
|
+
> {
|
|
113
|
+
const binding = kpiSource(source, attr[0]), declaration = binding.design;
|
|
114
|
+
if (
|
|
115
|
+
!sameAttr(attr, binding.attr) || children.length !== binding.slots.length
|
|
116
|
+
) {
|
|
117
|
+
fail(
|
|
118
|
+
"KPI_EXECUTION_MISMATCH",
|
|
119
|
+
"Executed KPI declaration must match its original identified roles and design.",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const frame = { ...base };
|
|
123
|
+
for (const key of geometry) {
|
|
124
|
+
if (key in declaration.properties) {
|
|
125
|
+
frame[key as keyof Frame] = Number(declaration.properties[key]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if ("rotation" in declaration.properties) {
|
|
129
|
+
fail(
|
|
130
|
+
"COMPONENT_ROTATION_UNSUPPORTED",
|
|
131
|
+
"Rotate individual KPI objects; no coupled parent rotation is inferred.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const theme = designTheme(defaults),
|
|
135
|
+
fields: Fields = {},
|
|
136
|
+
origins: Record<string, Origin> = {};
|
|
137
|
+
const background: Element = {
|
|
138
|
+
id: `${attr[0]}/qb-kpi-background`,
|
|
139
|
+
type: "shape",
|
|
140
|
+
shape: "rect",
|
|
141
|
+
...frame,
|
|
142
|
+
rotation: 0,
|
|
143
|
+
opacity: 1,
|
|
144
|
+
fill: theme.background,
|
|
145
|
+
stroke: theme.accent,
|
|
146
|
+
strokeWidth: 1,
|
|
147
|
+
radius: 16,
|
|
148
|
+
groupId: attr[0],
|
|
149
|
+
};
|
|
150
|
+
const elements = [background];
|
|
151
|
+
fields[background.id] = { ownedPaths: [], bindings: {} };
|
|
152
|
+
origins[background.id] = {
|
|
153
|
+
origin: "authored",
|
|
154
|
+
sourceId: attr[0],
|
|
155
|
+
designOnly: true,
|
|
156
|
+
};
|
|
157
|
+
for (const [field, value] of Object.entries(declaration.properties)) {
|
|
158
|
+
if (shape.has(field)) {
|
|
159
|
+
background[field] = value;
|
|
160
|
+
fields[background.id].ownedPaths.push(field);
|
|
161
|
+
fields[background.id].bindings[field] = {
|
|
162
|
+
sourceId: attr[0],
|
|
163
|
+
attribute: declaration.bindings[field],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const paragraphs = await originalParagraphs(binding);
|
|
168
|
+
let y = frame.y + 24;
|
|
169
|
+
for (const [index, slot] of binding.slots.entries()) {
|
|
170
|
+
const child = children[index];
|
|
171
|
+
if (child.t !== "Div") {
|
|
172
|
+
fail(
|
|
173
|
+
"KPI_EXECUTION_MISMATCH",
|
|
174
|
+
"Executed KPI roles must remain direct div children.",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const [childAttr, blocks] = child.c as [PandocAttr, Node[]];
|
|
178
|
+
if (
|
|
179
|
+
!sameAttr(childAttr, slot.attr) || blocks.length !== 1 ||
|
|
180
|
+
blocks[0].t !== "Para"
|
|
181
|
+
) {
|
|
182
|
+
fail(
|
|
183
|
+
"KPI_EXECUTION_MISMATCH",
|
|
184
|
+
"Executed KPI role, identity and paragraph must match original source.",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const html = renderInline(blocks[0].c as Node[]);
|
|
188
|
+
if (
|
|
189
|
+
slot.paragraph.origin !== "computed" &&
|
|
190
|
+
renderInline(paragraphs[index]) !== html
|
|
191
|
+
) {
|
|
192
|
+
fail(
|
|
193
|
+
"KPI_EXECUTION_MISMATCH",
|
|
194
|
+
"Executed literal KPI text differs from its original paragraph.",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
// Parsing original inline nodes also rejects unsupported source formatting.
|
|
198
|
+
if (slot.paragraph.origin === "computed") renderInline(paragraphs[index]);
|
|
199
|
+
let element: Element = {
|
|
200
|
+
id: slot.paragraph.id,
|
|
201
|
+
type: "text",
|
|
202
|
+
x: frame.x + 24,
|
|
203
|
+
y,
|
|
204
|
+
w: frame.w - 48,
|
|
205
|
+
h: 1,
|
|
206
|
+
rotation: 0,
|
|
207
|
+
opacity: 1,
|
|
208
|
+
html,
|
|
209
|
+
fontSize: [26, 52, 22][index],
|
|
210
|
+
fontFamily: theme.fontFamily,
|
|
211
|
+
fontWeight: index === 1 ? 700 : 400,
|
|
212
|
+
color: theme.color,
|
|
213
|
+
align: "left",
|
|
214
|
+
valign: "top",
|
|
215
|
+
lineHeight: 1.25,
|
|
216
|
+
groupId: attr[0],
|
|
217
|
+
};
|
|
218
|
+
if (
|
|
219
|
+
Object.keys(slot.design.properties).some((field) => geometry.has(field))
|
|
220
|
+
) {
|
|
221
|
+
fail(
|
|
222
|
+
"COMPONENT_CHILD_GEOMETRY",
|
|
223
|
+
"Declare KPI geometry on the outer component; child boxes are measured together.",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const applied = applyElementDesign(element, slot.design);
|
|
227
|
+
element = applied.element;
|
|
228
|
+
fields[element.id] = {
|
|
229
|
+
ownedPaths: applied.ownedPaths,
|
|
230
|
+
bindings: Object.fromEntries(
|
|
231
|
+
Object.entries(applied.bindings).map((
|
|
232
|
+
[field, attribute],
|
|
233
|
+
) => [field, { sourceId: slot.wrapper.id, attribute }]),
|
|
234
|
+
),
|
|
235
|
+
};
|
|
236
|
+
for (const [field, value] of Object.entries(declaration.properties)) {
|
|
237
|
+
if (!geometry.has(field) && !shape.has(field) && !generic.has(field)) {
|
|
238
|
+
if (field in slot.design.properties) {
|
|
239
|
+
fail(
|
|
240
|
+
"AMBIGUOUS_DESIGN_OWNERSHIP",
|
|
241
|
+
"A KPI and role declare the same native visual field.",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
element[field] = value;
|
|
245
|
+
fields[element.id].ownedPaths.push(field);
|
|
246
|
+
// Inherited typography affects several slots, so a child's inverse is ambiguous.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
elements.push(element);
|
|
250
|
+
// Keep the ordinary inline paragraph source path: validation classifies
|
|
251
|
+
// the source-bound rendered value as computed, without a fake chunk.
|
|
252
|
+
origins[element.id] = { origin: "authored", sourceId: slot.paragraph.id };
|
|
253
|
+
}
|
|
254
|
+
// A valid source baseline keeps normal flow. Only a baseline refusal may
|
|
255
|
+
// use retained typography with fresh HTML; unsafe saved styles remain final
|
|
256
|
+
// composed diagnostics rather than new initial-source refusals.
|
|
257
|
+
const geometryOwned = [...geometry].some((field) =>
|
|
258
|
+
field in declaration.properties
|
|
259
|
+
);
|
|
260
|
+
const text = elements.slice(1);
|
|
261
|
+
const measured = await measureNativeText(text, appearance?.resources);
|
|
262
|
+
let appearanceRescued = false;
|
|
263
|
+
const retained = text.map((element) => {
|
|
264
|
+
const saved = appearance?.elements.get(element.id);
|
|
265
|
+
if (!saved || saved.id !== element.id || saved.type !== "text") {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
const effective = { ...element };
|
|
269
|
+
for (const field of typography) {
|
|
270
|
+
if (fields[element.id].ownedPaths.includes(field)) continue;
|
|
271
|
+
if (Object.hasOwn(saved, field)) effective[field] = saved[field];
|
|
272
|
+
else delete effective[field];
|
|
273
|
+
}
|
|
274
|
+
return effective;
|
|
275
|
+
});
|
|
276
|
+
let retainedMeasurements: NativeTextMeasurement[] | undefined;
|
|
277
|
+
async function effectiveMeasurements(): Promise<NativeTextMeasurement[]> {
|
|
278
|
+
return retainedMeasurements ??= await measureNativeText(
|
|
279
|
+
text.map((element, index) => retained[index] ?? element),
|
|
280
|
+
appearance?.resources,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
async function rescue(index: number): Promise<boolean> {
|
|
284
|
+
const effective = retained[index];
|
|
285
|
+
if (!effective) return false;
|
|
286
|
+
const measurement = (await effectiveMeasurements())[index];
|
|
287
|
+
return !unsupportedTypography(effective, measurement) &&
|
|
288
|
+
measurement.width <= Number(effective.w) + 1;
|
|
289
|
+
}
|
|
290
|
+
for (const [index, element] of text.entries()) {
|
|
291
|
+
if (unsupportedTypography(element, measured[index])) {
|
|
292
|
+
if (await rescue(index)) appearanceRescued = true;
|
|
293
|
+
else {fail(
|
|
294
|
+
"KPI_TYPOGRAPHY_UNSUPPORTED",
|
|
295
|
+
"KPI font bounds exceed the native line boxes or overlap adjacent lines; increase explicit line-height, or center a single-line role with enough space. A taller top/bottom-aligned box alone cannot correct glyph overhang.",
|
|
296
|
+
);}
|
|
297
|
+
}
|
|
298
|
+
if (measured[index].width > Number(element.w) + 1) {
|
|
299
|
+
if (await rescue(index)) appearanceRescued = true;
|
|
300
|
+
else {fail(
|
|
301
|
+
"KPI_CONTENT_OVERFLOW",
|
|
302
|
+
"A KPI glyph or inline token exceeds its final width; increase the frame or explicitly change typography.",
|
|
303
|
+
);}
|
|
304
|
+
}
|
|
305
|
+
element.y = y;
|
|
306
|
+
element.h = measured[index].height;
|
|
307
|
+
y += Number(element.h) + 16;
|
|
308
|
+
}
|
|
309
|
+
if (frame.w <= 48 || y - 16 > frame.y + frame.h - 24 + .01) {
|
|
310
|
+
// Unowned frame capacity can use verified saved typography for every role.
|
|
311
|
+
// Source-coupled geometry remains strict, and baseline boxes stay unchanged:
|
|
312
|
+
// the consumer preserves saved geometry and checks the actual composed boxes.
|
|
313
|
+
let fitsRetained = false;
|
|
314
|
+
if (
|
|
315
|
+
!geometryOwned && !sourceFrameOwned && frame.w > 48 &&
|
|
316
|
+
retained.every(Boolean)
|
|
317
|
+
) {
|
|
318
|
+
const measurements = await effectiveMeasurements();
|
|
319
|
+
const height = measurements.reduce((sum, result) =>
|
|
320
|
+
sum + result.height, 0) +
|
|
321
|
+
48 + 16 * (text.length - 1);
|
|
322
|
+
fitsRetained = height <= frame.h + .01 &&
|
|
323
|
+
retained.every((element, index) =>
|
|
324
|
+
element && !unsupportedTypography(element, measurements[index]) &&
|
|
325
|
+
measurements[index].width <= Number(element.w) + 1
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
if (fitsRetained) appearanceRescued = true;
|
|
329
|
+
else {fail(
|
|
330
|
+
"KPI_CONTENT_OVERFLOW",
|
|
331
|
+
"KPI text exceeds the frame; increase the dimensions or explicitly change typography. Automatic shrink and clipping refuse.",
|
|
332
|
+
);}
|
|
333
|
+
}
|
|
334
|
+
for (const element of elements) {
|
|
335
|
+
if (geometryOwned) fields[element.id].ownedPaths.push(...geometry);
|
|
336
|
+
for (const field of generic) {
|
|
337
|
+
if (field in declaration.properties) {
|
|
338
|
+
element[field] = declaration.properties[field];
|
|
339
|
+
fields[element.id].ownedPaths.push(field);
|
|
340
|
+
// Parent effects are coupled and deliberately have no single-child binding.
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { elements, origins, fields, appearanceRescued };
|
|
345
|
+
}
|
|
346
|
+
/** Diagnose the actual composed text, preserving the owner's saved geometry. */
|
|
347
|
+
export async function kpiReadabilityDiagnostics(
|
|
348
|
+
source: SourceDocument,
|
|
349
|
+
document: BentoDoc,
|
|
350
|
+
): Promise<Diagnostic[]> {
|
|
351
|
+
const targets: { element: Element; slideId: string }[] = [];
|
|
352
|
+
for (
|
|
353
|
+
const block of source.blocks.filter((block) =>
|
|
354
|
+
block.kind === "div" && sourceComponentClass(block.raw) === ".bento-kpi"
|
|
355
|
+
)
|
|
356
|
+
) {
|
|
357
|
+
const binding = kpiSource(source, block.id);
|
|
358
|
+
for (const slot of binding.slots) {
|
|
359
|
+
for (const slide of document.slides) {
|
|
360
|
+
const element = slide.elements.find((element) =>
|
|
361
|
+
element.id === slot.paragraph.id
|
|
362
|
+
);
|
|
363
|
+
if (element) targets.push({ element, slideId: slide.id });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const measurements = await measureNativeText(
|
|
368
|
+
targets.map((target) => target.element),
|
|
369
|
+
document,
|
|
370
|
+
);
|
|
371
|
+
return targets.flatMap(({ element, slideId }, index) =>
|
|
372
|
+
element.type !== "text" ||
|
|
373
|
+
unsupportedTypography(element, measurements[index]) ||
|
|
374
|
+
measurements[index].height > Number(element.h) + .01 ||
|
|
375
|
+
measurements[index].width > Number(element.w) + 1
|
|
376
|
+
? [{
|
|
377
|
+
code: "KPI_CONTENT_OVERFLOW",
|
|
378
|
+
message:
|
|
379
|
+
`Saved KPI text ${element.id} exceeds its native box or line metrics; increase dimensions or line-height, or use suitable single-line alignment. Saved geometry is not adjusted automatically. Saved design and pending content remain retained.`,
|
|
380
|
+
elementId: element.id,
|
|
381
|
+
slideId,
|
|
382
|
+
}]
|
|
383
|
+
: []
|
|
384
|
+
);
|
|
385
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/** Native attribute-free list vocabulary supported by pinned Bento. */
|
|
2
|
+
import { StateError } from "../state/mod.ts";
|
|
3
|
+
import {
|
|
4
|
+
canonicalInlineHtml,
|
|
5
|
+
inlineHtmlToMarkdown,
|
|
6
|
+
renderInline,
|
|
7
|
+
} from "./inline.ts";
|
|
8
|
+
import type { Node } from "./types.ts";
|
|
9
|
+
export const LIST_CODEC_VERSION = 1;
|
|
10
|
+
const fail = (message: string): never => {
|
|
11
|
+
throw new StateError("UNSUPPORTED_LIST", message);
|
|
12
|
+
};
|
|
13
|
+
export function renderList(node: Node, depth = 0): string {
|
|
14
|
+
if (depth > 8) fail("List nesting exceeds eight levels.");
|
|
15
|
+
let items: Node[][], tag: string;
|
|
16
|
+
if (node.t === "BulletList") {
|
|
17
|
+
items = node.c as Node[][];
|
|
18
|
+
tag = "ul";
|
|
19
|
+
} else if (node.t === "OrderedList") {
|
|
20
|
+
const [attributes, children] = node.c as [
|
|
21
|
+
[number, { t: string }, { t: string }],
|
|
22
|
+
Node[][],
|
|
23
|
+
];
|
|
24
|
+
if (
|
|
25
|
+
attributes[0] !== 1 ||
|
|
26
|
+
!["Decimal", "DefaultStyle"].includes(attributes[1].t) ||
|
|
27
|
+
!["Period", "DefaultDelim"].includes(attributes[2].t)
|
|
28
|
+
) {
|
|
29
|
+
fail(
|
|
30
|
+
"Native Bento preserves decimal lists starting at 1 with period markers only; its sanitizer removes start/type attributes.",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
items = children;
|
|
34
|
+
tag = "ol";
|
|
35
|
+
} else return fail("Expected a bullet or ordered list.");
|
|
36
|
+
if (!items.length) fail("Empty lists require explicit deletion.");
|
|
37
|
+
return `<${tag}>` + items.map((blocks) => {
|
|
38
|
+
if (!blocks.length || !["Plain", "Para"].includes(blocks[0].t)) {
|
|
39
|
+
fail(
|
|
40
|
+
"List items need initial authored text; empty items and block-only items require a separate projection.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return `<li>` + blocks.map((block) => {
|
|
44
|
+
if (["BulletList", "OrderedList"].includes(block.t)) {
|
|
45
|
+
return renderList(block, depth + 1);
|
|
46
|
+
}
|
|
47
|
+
if (!["Plain", "Para"].includes(block.t)) {
|
|
48
|
+
fail(
|
|
49
|
+
"List code, images and other non-text blocks need an explicit projection.",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const content = renderInline(block.c as Node[]);
|
|
53
|
+
if (!content.trim()) fail("Empty list paragraphs are unsupported.");
|
|
54
|
+
return block.t === "Para" ? `<p>${content}</p>` : content;
|
|
55
|
+
}).join("") + `</li>`;
|
|
56
|
+
}).join("") + `</${tag}>`;
|
|
57
|
+
}
|
|
58
|
+
interface Leaf {
|
|
59
|
+
html: string;
|
|
60
|
+
markdown: string;
|
|
61
|
+
paragraph: boolean;
|
|
62
|
+
}
|
|
63
|
+
interface List {
|
|
64
|
+
tag: "ul" | "ol";
|
|
65
|
+
items: (Leaf | List)[][];
|
|
66
|
+
}
|
|
67
|
+
/** Strict structural parser; inline leaves use the existing independently checked codec. */
|
|
68
|
+
export function parseNativeList(
|
|
69
|
+
html: string,
|
|
70
|
+
): { html: string; markdown: string } {
|
|
71
|
+
if (html.length > 1_000_000 || html.includes("\0")) {
|
|
72
|
+
fail("Invalid or excessive list fragment.");
|
|
73
|
+
}
|
|
74
|
+
const tokens = html.trim().match(/<[^>]*>|[^<]+|</g) ?? [];
|
|
75
|
+
let index = 0;
|
|
76
|
+
const space = () => {
|
|
77
|
+
while (/^\s+$/.test(tokens[index] ?? "")) index++;
|
|
78
|
+
};
|
|
79
|
+
const parse = (depth: number): List => {
|
|
80
|
+
if (depth > 8) fail("List nesting exceeds eight levels.");
|
|
81
|
+
space();
|
|
82
|
+
const opener = /^<(ul|ol)>$/i.exec(tokens[index++] ?? "");
|
|
83
|
+
if (!opener) fail("Lists must be attribute-free ul or ol elements.");
|
|
84
|
+
const tag = opener![1].toLowerCase() as "ul" | "ol",
|
|
85
|
+
items: List["items"] = [];
|
|
86
|
+
space();
|
|
87
|
+
while (/^<li>$/i.test(tokens[index] ?? "")) {
|
|
88
|
+
index++;
|
|
89
|
+
const parts: (Leaf | List)[] = [];
|
|
90
|
+
while (index < tokens.length && !/^<\/li>$/i.test(tokens[index])) {
|
|
91
|
+
if (/^<(?:ul|ol)>$/i.test(tokens[index])) {
|
|
92
|
+
if (!parts.length) {
|
|
93
|
+
fail("A list item needs text before nested lists.");
|
|
94
|
+
}
|
|
95
|
+
parts.push(parse(depth + 1));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const paragraph = /^<p>$/i.test(tokens[index]);
|
|
99
|
+
let leaf = "";
|
|
100
|
+
if (paragraph) {
|
|
101
|
+
index++;
|
|
102
|
+
while (index < tokens.length && !/^<\/p>$/i.test(tokens[index])) {
|
|
103
|
+
leaf += tokens[index++];
|
|
104
|
+
}
|
|
105
|
+
if (!/^<\/p>$/i.test(tokens[index++] ?? "")) {
|
|
106
|
+
fail("Unclosed list paragraph.");
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
if (parts.length) {
|
|
110
|
+
fail("Text after nested lists needs a native paragraph boundary.");
|
|
111
|
+
}
|
|
112
|
+
while (
|
|
113
|
+
index < tokens.length &&
|
|
114
|
+
!/^<(?:ul|ol|p)>$|^<\/li>$/i.test(tokens[index])
|
|
115
|
+
) leaf += tokens[index++];
|
|
116
|
+
}
|
|
117
|
+
parts.push({
|
|
118
|
+
html: canonicalInlineHtml(leaf),
|
|
119
|
+
markdown: inlineHtmlToMarkdown(leaf),
|
|
120
|
+
paragraph,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (!parts.length || !/^<\/li>$/i.test(tokens[index++] ?? "")) {
|
|
124
|
+
fail("Malformed or empty list item.");
|
|
125
|
+
}
|
|
126
|
+
items.push(parts);
|
|
127
|
+
space();
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
!items.length ||
|
|
131
|
+
!new RegExp(`^</${tag}>$`, "i").test(tokens[index++] ?? "")
|
|
132
|
+
) fail("Malformed or empty list.");
|
|
133
|
+
if (
|
|
134
|
+
items.flat().some((i) => "paragraph" in i && i.paragraph) &&
|
|
135
|
+
items.flat().some((i) => "paragraph" in i && !i.paragraph)
|
|
136
|
+
) {
|
|
137
|
+
fail("Mixed tight and loose items need explicit source review.");
|
|
138
|
+
}
|
|
139
|
+
return { tag, items };
|
|
140
|
+
};
|
|
141
|
+
const root = parse(0);
|
|
142
|
+
space();
|
|
143
|
+
if (index !== tokens.length) {
|
|
144
|
+
fail("A list object must contain exactly one list.");
|
|
145
|
+
}
|
|
146
|
+
const emitHtml = (list: List): string =>
|
|
147
|
+
`<${list.tag}>` + list.items.map((parts) =>
|
|
148
|
+
`<li>` + parts.map((part) =>
|
|
149
|
+
"tag" in part
|
|
150
|
+
? emitHtml(part)
|
|
151
|
+
: part.paragraph
|
|
152
|
+
? `<p>${part.html}</p>`
|
|
153
|
+
: part.html
|
|
154
|
+
).join("") + `</li>`
|
|
155
|
+
).join("") + `</${list.tag}>`;
|
|
156
|
+
const emitMarkdown = (list: List): string => {
|
|
157
|
+
const loose = list.items.flat().some((part) =>
|
|
158
|
+
"paragraph" in part && part.paragraph
|
|
159
|
+
);
|
|
160
|
+
return list.items.map((parts, n) => {
|
|
161
|
+
const marker = list.tag === "ul" ? "- " : `${n + 1}. `,
|
|
162
|
+
indent = " ".repeat(marker.length);
|
|
163
|
+
const body = parts.map((part) =>
|
|
164
|
+
"tag" in part ? emitMarkdown(part) : part.markdown
|
|
165
|
+
).join(loose ? "\n\n" : "\n");
|
|
166
|
+
return marker + body.replaceAll("\n", "\n" + indent);
|
|
167
|
+
}).join(loose ? "\n\n" : "\n");
|
|
168
|
+
};
|
|
169
|
+
return { html: emitHtml(root), markdown: emitMarkdown(root) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One standalone native body object, never a concatenation of unrelated blocks. */
|
|
173
|
+
export function authoredText(
|
|
174
|
+
html: string,
|
|
175
|
+
): { kind: "list" | "paragraph"; html: string; markdown: string } {
|
|
176
|
+
return /^<(?:ul|ol)>/i.test(html.trim())
|
|
177
|
+
? { kind: "list", ...parseNativeList(html) }
|
|
178
|
+
: {
|
|
179
|
+
kind: "paragraph",
|
|
180
|
+
html: canonicalInlineHtml(html),
|
|
181
|
+
markdown: inlineHtmlToMarkdown(html),
|
|
182
|
+
};
|
|
183
|
+
}
|