@openpresentation/opf-render 0.0.2 → 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/README.md +32 -3
- package/dist/font-compatibility.js +48 -0
- package/dist/fonts-browser.d.ts +17 -0
- package/dist/fonts-browser.js +61 -0
- package/dist/fonts-node.d.ts +4 -0
- package/dist/fonts-node.js +44 -0
- package/dist/fonts.d.ts +21 -0
- package/dist/fonts.js +111 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +2 -1583
- package/dist/raster.js +191 -0
- package/dist/svg.d.ts +112 -0
- package/dist/svg.js +1308 -0
- package/package.json +32 -5
package/dist/svg.js
ADDED
|
@@ -0,0 +1,1308 @@
|
|
|
1
|
+
import { fitList, fitRichText, composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textWidthMeasurer, fitText } from "@openpresentation/opf/composition";
|
|
2
|
+
import {
|
|
3
|
+
catalogs as bundledCatalogs,
|
|
4
|
+
validatePresentation
|
|
5
|
+
} from "@openpresentation/opf";
|
|
6
|
+
|
|
7
|
+
export const packageName = "@openpresentation/opf-render";
|
|
8
|
+
|
|
9
|
+
export const releaseLane = Object.freeze({
|
|
10
|
+
githubRepository: "OpenPresentation/opf-render",
|
|
11
|
+
npmPackage: "@openpresentation/opf-render",
|
|
12
|
+
compatibilityPackage: "@openpresentation/opf"
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const runtimePolicy = Object.freeze({
|
|
16
|
+
hostedServiceInCriticalPath: false,
|
|
17
|
+
telemetry: false,
|
|
18
|
+
commercialSdkInCriticalPath: false,
|
|
19
|
+
requiredNetworkCalls: false,
|
|
20
|
+
deterministicLocalExecution: true
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const engineDefaults = Object.freeze({
|
|
24
|
+
catalogs: Object.freeze({
|
|
25
|
+
narratives: Object.freeze({ source: "https://www.pptx.gallery/narratives" }),
|
|
26
|
+
themes: Object.freeze({ source: "https://www.pptx.gallery/themes" }),
|
|
27
|
+
colorSchemes: Object.freeze({ source: "https://www.pptx.gallery/color-schemes" }),
|
|
28
|
+
fontSchemes: Object.freeze({ source: "https://www.pptx.gallery/font-schemes" }),
|
|
29
|
+
languages: Object.freeze({ source: "https://www.pptx.gallery/languages" }),
|
|
30
|
+
layouts: Object.freeze({ source: "https://www.pptx.gallery/layouts" }),
|
|
31
|
+
chartTypes: Object.freeze({ source: "https://www.pptx.gallery/chart-types" }),
|
|
32
|
+
tones: Object.freeze({ source: "https://www.pptx.gallery/tones" }),
|
|
33
|
+
audiences: Object.freeze({ source: "https://www.pptx.gallery/audiences" }),
|
|
34
|
+
socialPlatforms: Object.freeze({ source: "https://www.pptx.gallery/social-platforms" })
|
|
35
|
+
}),
|
|
36
|
+
theme: "minimal",
|
|
37
|
+
colorScheme: "cool-horizon",
|
|
38
|
+
language: "english",
|
|
39
|
+
narrative: "classic-story",
|
|
40
|
+
tone: "formal",
|
|
41
|
+
audience: "executives",
|
|
42
|
+
fontScheme: Object.freeze({
|
|
43
|
+
pptx: Object.freeze({ latin: "aptos", ea: "microsoft-yahei", cs: "nirmala-ui" }),
|
|
44
|
+
google: Object.freeze({ latin: "roboto", ea: "noto-sans-sc", cs: "noto-sans" })
|
|
45
|
+
}),
|
|
46
|
+
chartTypes: Object.freeze([
|
|
47
|
+
"stacked-column-3x",
|
|
48
|
+
"stacked-area-3x",
|
|
49
|
+
"line-with-markers-3x"
|
|
50
|
+
])
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export class OPFRenderError extends Error {
|
|
54
|
+
constructor(code, message, details = {}) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "OPFRenderError";
|
|
57
|
+
this.code = code;
|
|
58
|
+
this.details = details;
|
|
59
|
+
if (details.issues) this.issues = details.issues;
|
|
60
|
+
if (details.path) this.path = details.path;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ROOT_PAYLOAD_FIELDS = [
|
|
65
|
+
"text",
|
|
66
|
+
"items",
|
|
67
|
+
"bullets",
|
|
68
|
+
"image",
|
|
69
|
+
"video",
|
|
70
|
+
"chart",
|
|
71
|
+
"table",
|
|
72
|
+
"code",
|
|
73
|
+
"metric",
|
|
74
|
+
"quote",
|
|
75
|
+
"timeline"
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const PROMOTED_REGION_KEYS = [
|
|
79
|
+
"left",
|
|
80
|
+
"center",
|
|
81
|
+
"right",
|
|
82
|
+
"left+center",
|
|
83
|
+
"center+right",
|
|
84
|
+
"left+center+right",
|
|
85
|
+
"top",
|
|
86
|
+
"middle",
|
|
87
|
+
"bottom",
|
|
88
|
+
"top+middle",
|
|
89
|
+
"middle+bottom",
|
|
90
|
+
"top+middle+bottom",
|
|
91
|
+
"top:left",
|
|
92
|
+
"top:center",
|
|
93
|
+
"top:right",
|
|
94
|
+
"top:left+center",
|
|
95
|
+
"top:center+right",
|
|
96
|
+
"top:left+center+right",
|
|
97
|
+
"middle:left",
|
|
98
|
+
"middle:center",
|
|
99
|
+
"middle:right",
|
|
100
|
+
"middle:left+center",
|
|
101
|
+
"middle:center+right",
|
|
102
|
+
"middle:left+center+right",
|
|
103
|
+
"bottom:left",
|
|
104
|
+
"bottom:center",
|
|
105
|
+
"bottom:right",
|
|
106
|
+
"bottom:left+center",
|
|
107
|
+
"bottom:center+right",
|
|
108
|
+
"bottom:left+center+right",
|
|
109
|
+
"top+middle:left",
|
|
110
|
+
"top+middle:center",
|
|
111
|
+
"top+middle:right",
|
|
112
|
+
"top+middle:left+center",
|
|
113
|
+
"top+middle:center+right",
|
|
114
|
+
"top+middle:left+center+right",
|
|
115
|
+
"middle+bottom:left",
|
|
116
|
+
"middle+bottom:center",
|
|
117
|
+
"middle+bottom:right",
|
|
118
|
+
"middle+bottom:left+center",
|
|
119
|
+
"middle+bottom:center+right",
|
|
120
|
+
"middle+bottom:left+center+right",
|
|
121
|
+
"top+middle+bottom:left",
|
|
122
|
+
"top+middle+bottom:center",
|
|
123
|
+
"top+middle+bottom:right",
|
|
124
|
+
"top+middle+bottom:left+center",
|
|
125
|
+
"top+middle+bottom:center+right",
|
|
126
|
+
"top+middle+bottom:left+center+right"
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const TITLE_PLACEHOLDERS = new Set(["title", "subtitle", "tag"]);
|
|
130
|
+
|
|
131
|
+
const FIELD_TYPE = Object.freeze({
|
|
132
|
+
text: "text",
|
|
133
|
+
items: "list",
|
|
134
|
+
bullets: "list",
|
|
135
|
+
image: "image",
|
|
136
|
+
video: "video",
|
|
137
|
+
chart: "chart",
|
|
138
|
+
table: "table",
|
|
139
|
+
code: "code",
|
|
140
|
+
metric: "metric",
|
|
141
|
+
quote: "quote",
|
|
142
|
+
timeline: "timeline"
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
|
|
146
|
+
const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
function parseInput(input) {
|
|
150
|
+
if (typeof input === "string") {
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(input);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw new OPFRenderError("invalid-json", "OPF input is not valid JSON.", {
|
|
155
|
+
cause: error instanceof Error ? error.message : String(error)
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (input instanceof Uint8Array) {
|
|
161
|
+
return parseInput(new TextDecoder().decode(input));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (input && typeof input === "object") {
|
|
165
|
+
return input;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw new OPFRenderError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function assertValidBoundary(presentation) {
|
|
172
|
+
const result = validatePresentation(presentation);
|
|
173
|
+
if (!result.valid) {
|
|
174
|
+
throw new OPFRenderError("invalid-opf", "OPF validation failed.", {
|
|
175
|
+
issues: result.errors,
|
|
176
|
+
result
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function stableJson(value) {
|
|
182
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
183
|
+
if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
|
184
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function cloneWithSortedKeys(value) {
|
|
188
|
+
if (Array.isArray(value)) return value.map((item) => cloneWithSortedKeys(item));
|
|
189
|
+
if (!value || typeof value !== "object") return value;
|
|
190
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, cloneWithSortedKeys(value[key])]));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isPlainObject(value) {
|
|
194
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function referenceId(reference) {
|
|
198
|
+
if (typeof reference === "string") return reference;
|
|
199
|
+
if (isPlainObject(reference) && typeof reference.id === "string") return reference.id;
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeSourceRecords(value) {
|
|
204
|
+
if (!value) return [];
|
|
205
|
+
if (Array.isArray(value)) return value;
|
|
206
|
+
if (Array.isArray(value.records)) return value.records;
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function findById(records, id) {
|
|
211
|
+
return records.find((record) => record && record.id === id) ?? null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function defaultCatalogFor(kind) {
|
|
215
|
+
return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function sourceRecordsFor(kind, source, options) {
|
|
219
|
+
if (!source) return [];
|
|
220
|
+
const bySource = options.catalogSources?.[source];
|
|
221
|
+
if (bySource) return normalizeSourceRecords(bySource);
|
|
222
|
+
if (source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/")) {
|
|
223
|
+
return defaultCatalogFor(kind);
|
|
224
|
+
}
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function engineDefaultId(kind) {
|
|
229
|
+
if (kind === "fontSchemes") return engineDefaults.fontScheme.google.latin;
|
|
230
|
+
if (kind === "chartTypes") return engineDefaults.chartTypes[0];
|
|
231
|
+
const key = kind.endsWith("s") ? kind.slice(0, -1) : kind;
|
|
232
|
+
return engineDefaults[key];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function resolveCatalogRecord(kind, reference, context, path, fallbackId = engineDefaultId(kind)) {
|
|
236
|
+
const id = referenceId(reference) ?? fallbackId;
|
|
237
|
+
const documentCatalog = context.presentation.catalogs?.[kind];
|
|
238
|
+
const documentRecords = normalizeSourceRecords(documentCatalog);
|
|
239
|
+
const injectedRecords = normalizeSourceRecords(context.options.catalogs?.[kind]);
|
|
240
|
+
const documentSource = documentCatalog?.source;
|
|
241
|
+
const sourceRecords = sourceRecordsFor(kind, documentSource, context.options);
|
|
242
|
+
const defaultSource = engineDefaults.catalogs[kind]?.source;
|
|
243
|
+
const engineSourceRecords = sourceRecordsFor(kind, defaultSource, context.options);
|
|
244
|
+
const defaultRecords = defaultCatalogFor(kind);
|
|
245
|
+
|
|
246
|
+
const record =
|
|
247
|
+
(id ? findById(documentRecords, id) : null) ??
|
|
248
|
+
(id ? findById(sourceRecords, id) : null) ??
|
|
249
|
+
(id ? findById(injectedRecords, id) : null) ??
|
|
250
|
+
(id ? findById(engineSourceRecords, id) : null) ??
|
|
251
|
+
(id ? findById(defaultRecords, id) : null);
|
|
252
|
+
|
|
253
|
+
if (record) {
|
|
254
|
+
return isPlainObject(reference)
|
|
255
|
+
? cloneWithSortedKeys({ ...record, ...reference })
|
|
256
|
+
: cloneWithSortedKeys(record);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (isPlainObject(reference)) {
|
|
260
|
+
return cloneWithSortedKeys(reference);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
throw new OPFRenderError("catalog-resolution-failed", `Could not resolve ${kind} reference '${id}'.`, {
|
|
264
|
+
kind,
|
|
265
|
+
id,
|
|
266
|
+
path,
|
|
267
|
+
source: documentSource ?? defaultSource ?? null
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const resolveDimensions = resolveCanvasDimensions;
|
|
272
|
+
|
|
273
|
+
function colorFromScheme(scheme, slot, fallback) {
|
|
274
|
+
if (!slot) return fallback;
|
|
275
|
+
if (typeof slot === "string") {
|
|
276
|
+
if (slot.startsWith("#")) return normalizeColor(slot, fallback);
|
|
277
|
+
return normalizeColor(scheme[slot], fallback);
|
|
278
|
+
}
|
|
279
|
+
if (isPlainObject(slot)) {
|
|
280
|
+
if (slot.type === "theme") return normalizeColor(scheme[slot.slot], fallback);
|
|
281
|
+
if (slot.type === "solid") return normalizeColor(slot.color, fallback);
|
|
282
|
+
}
|
|
283
|
+
return fallback;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function normalizeColor(value, fallback) {
|
|
287
|
+
if (typeof value !== "string") return fallback;
|
|
288
|
+
const trimmed = value.trim();
|
|
289
|
+
if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) return "#" + [...trimmed.slice(1)].map(char => char + char).join("").toUpperCase();
|
|
290
|
+
if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(trimmed)) return trimmed.toUpperCase();
|
|
291
|
+
return fallback;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function resolveBackground(background, colorScheme) {
|
|
295
|
+
if (!background) return colorFromScheme(colorScheme, "light1", "#FFFFFF");
|
|
296
|
+
if (typeof background === "string") return colorFromScheme(colorScheme, background, "#FFFFFF");
|
|
297
|
+
if (background.type === "theme") return colorFromScheme(colorScheme, background.slot, "#FFFFFF");
|
|
298
|
+
if (background.type === "solid") return normalizeColor(background.color, "#FFFFFF");
|
|
299
|
+
if (background.type === "gradient") return null;
|
|
300
|
+
if (background.type === "pattern") return normalizeColor(background.pattern?.backgroundColor, "#FFFFFF");
|
|
301
|
+
return colorFromScheme(colorScheme, "light1", "#FFFFFF");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
function colorLuminance(color) {
|
|
306
|
+
const hex = normalizeColor(color, "#FFFFFF").replace("#", "");
|
|
307
|
+
const channels = [0, 2, 4].map(offset => {
|
|
308
|
+
const value = parseInt(hex.slice(offset, offset + 2), 16) / 255;
|
|
309
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
310
|
+
});
|
|
311
|
+
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function resolveDesign(presentation, slide, context) {
|
|
315
|
+
const deckDesign = presentation.design ?? {};
|
|
316
|
+
const slideDesign = slide.design ?? {};
|
|
317
|
+
|
|
318
|
+
const theme = resolveCatalogRecord(
|
|
319
|
+
"themes",
|
|
320
|
+
slideDesign.theme ?? deckDesign.theme ?? engineDefaults.theme,
|
|
321
|
+
context,
|
|
322
|
+
"design.theme"
|
|
323
|
+
);
|
|
324
|
+
const colorScheme = resolveCatalogRecord(
|
|
325
|
+
"colorSchemes",
|
|
326
|
+
slideDesign.colorScheme ?? deckDesign.colorScheme ?? theme.colorScheme ?? engineDefaults.colorScheme,
|
|
327
|
+
context,
|
|
328
|
+
"design.colorScheme"
|
|
329
|
+
);
|
|
330
|
+
const fontScheme = resolveCatalogRecord(
|
|
331
|
+
"fontSchemes",
|
|
332
|
+
slideDesign.fontScheme ?? deckDesign.fontScheme ?? theme.fontScheme ?? engineDefaults.fontScheme.google.latin,
|
|
333
|
+
context,
|
|
334
|
+
"design.fontScheme"
|
|
335
|
+
);
|
|
336
|
+
const dimensions = resolveDimensions(slideDesign.dimensions ?? deckDesign.dimensions ?? theme.dimensions);
|
|
337
|
+
const backgroundDefinition = slideDesign.background ?? deckDesign.background ?? theme.background;
|
|
338
|
+
const backgroundColor = resolveBackground(backgroundDefinition, colorScheme);
|
|
339
|
+
const darkBackground = colorLuminance(backgroundColor ?? "#FFFFFF") < 0.179;
|
|
340
|
+
const textColor = colorFromScheme(colorScheme, darkBackground ? "light1" : "dark1", darkBackground ? "#FFFFFF" : "#111827");
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
...deckDesign,
|
|
344
|
+
...slideDesign,
|
|
345
|
+
theme,
|
|
346
|
+
colorScheme,
|
|
347
|
+
fontScheme,
|
|
348
|
+
dimensions,
|
|
349
|
+
background: backgroundDefinition,
|
|
350
|
+
backgroundColor,
|
|
351
|
+
colors: {
|
|
352
|
+
background: colorFromScheme(colorScheme, "light1", "#FFFFFF"),
|
|
353
|
+
surface: colorFromScheme(colorScheme, darkBackground ? "dark2" : "light2", darkBackground ? "#1E293B" : "#F8FAFC"),
|
|
354
|
+
text: textColor,
|
|
355
|
+
mutedText: colorFromScheme(colorScheme, darkBackground ? "light2" : "dark2", darkBackground ? "#E2E8F0" : "#334155"),
|
|
356
|
+
primary: normalizeColor(colorScheme.primary, null) ?? colorFromScheme(colorScheme, "accent1", "#2563EB"),
|
|
357
|
+
secondary: normalizeColor(colorScheme.secondary, null) ?? colorFromScheme(colorScheme, "accent2", "#0F766E"),
|
|
358
|
+
accent: normalizeColor(colorScheme.accent, null) ?? colorFromScheme(colorScheme, "accent3", "#F59E0B"),
|
|
359
|
+
border: colorFromScheme(colorScheme, "accent5", "#CBD5E1")
|
|
360
|
+
},
|
|
361
|
+
fonts: resolveFontFamilies(fontScheme)
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function inferLayoutId(slide) {
|
|
366
|
+
if (slide.layout) return slide.layout;
|
|
367
|
+
if (slide.blocks?.length) return "blank";
|
|
368
|
+
if (PROMOTED_REGION_KEYS.some((key) => key in slide)) return "blank";
|
|
369
|
+
if (slide.chart) return "chart-1x";
|
|
370
|
+
if (slide.table) return "table-1x";
|
|
371
|
+
if (slide.image) return "image-1x";
|
|
372
|
+
if (slide.video) return "media-1x";
|
|
373
|
+
if (slide.code) return "code-1x";
|
|
374
|
+
if (slide.items || slide.bullets) return "list-1x";
|
|
375
|
+
if (slide.text || slide.metric || slide.quote || slide.timeline) return "text-1x";
|
|
376
|
+
if (slide.subtitle) return "title-subtitle";
|
|
377
|
+
return "title";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function resolveLayout(slide, context, index) {
|
|
381
|
+
const layoutId = inferLayoutId(slide);
|
|
382
|
+
try {
|
|
383
|
+
return resolveCatalogRecord("layouts", layoutId, context, `slides.${index}.layout`);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (error instanceof OPFRenderError && isPlainObject(slide.layout)) return slide.layout;
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function contentPayloadFromHost(host, path, slot) {
|
|
391
|
+
if (!isPlainObject(host)) {
|
|
392
|
+
return { type: "text", field: "text", value: host, slot: slot ?? "text", path };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
for (const field of ROOT_PAYLOAD_FIELDS) {
|
|
396
|
+
if (host[field] !== undefined) {
|
|
397
|
+
return {
|
|
398
|
+
type: host.type ?? FIELD_TYPE[field],
|
|
399
|
+
field,
|
|
400
|
+
value: host[field],
|
|
401
|
+
slot: host.slot ?? slot ?? host.type ?? FIELD_TYPE[field],
|
|
402
|
+
path: `${path}.${field}`
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
type: "text",
|
|
409
|
+
field: "text",
|
|
410
|
+
value: stableJson(host),
|
|
411
|
+
slot: slot ?? "text",
|
|
412
|
+
path
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function slideRootContent(slide, slidePath) {
|
|
417
|
+
const items = [];
|
|
418
|
+
for (const field of ROOT_PAYLOAD_FIELDS) {
|
|
419
|
+
if (slide[field] !== undefined) {
|
|
420
|
+
items.push({
|
|
421
|
+
type: slide.type ?? FIELD_TYPE[field],
|
|
422
|
+
field,
|
|
423
|
+
value: slide[field],
|
|
424
|
+
slot: slide.type ?? FIELD_TYPE[field],
|
|
425
|
+
path: `${slidePath}.${field}`
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return items;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function promotedRegionContent(slide, slidePath) {
|
|
433
|
+
const items = [];
|
|
434
|
+
for (const key of PROMOTED_REGION_KEYS) {
|
|
435
|
+
if (slide[key] !== undefined) {
|
|
436
|
+
const payload = contentPayloadFromHost(slide[key], `${slidePath}.${key}`, key);
|
|
437
|
+
items.push({ ...payload, slot: key, regionKey: key });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return items;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function blockContent(slide, slidePath) {
|
|
444
|
+
if (!Array.isArray(slide.blocks)) return [];
|
|
445
|
+
return slide.blocks.map((block, index) => contentPayloadFromHost(block, `${slidePath}.blocks.${index}`, "body"));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function normalizeFutureContent(slide, slidePath) {
|
|
449
|
+
if (!Array.isArray(slide.content)) return [];
|
|
450
|
+
return slide.content.map((item, index) => {
|
|
451
|
+
const payload = contentPayloadFromHost(item, `${slidePath}.content.${index}`, item.slot);
|
|
452
|
+
return { ...payload, slot: item.slot ?? payload.slot, path: `${slidePath}.content.${index}` };
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function bindSlide(presentation, slide, layout, index, context) {
|
|
457
|
+
const slidePath = `slides.${index}`;
|
|
458
|
+
const placeholders = Array.isArray(layout.placeholders) ? layout.placeholders : [];
|
|
459
|
+
const titleBindings = [];
|
|
460
|
+
|
|
461
|
+
for (let i = 0; i < placeholders.length; i += 1) {
|
|
462
|
+
const type = placeholders[i]?.type;
|
|
463
|
+
if (!TITLE_PLACEHOLDERS.has(type)) continue;
|
|
464
|
+
const value = slide[type] ?? presentation[type] ?? "";
|
|
465
|
+
if (value) {
|
|
466
|
+
titleBindings.push({
|
|
467
|
+
type: "text",
|
|
468
|
+
field: type,
|
|
469
|
+
slot: type,
|
|
470
|
+
value,
|
|
471
|
+
placeholderIndex: i,
|
|
472
|
+
path: `${slidePath}.${type}`
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const contentItems = [
|
|
478
|
+
...normalizeFutureContent(slide, slidePath),
|
|
479
|
+
...slideRootContent(slide, slidePath),
|
|
480
|
+
...promotedRegionContent(slide, slidePath)
|
|
481
|
+
];
|
|
482
|
+
const blocks = blockContent(slide, slidePath);
|
|
483
|
+
|
|
484
|
+
const design = resolveDesign(presentation, slide, context);
|
|
485
|
+
for (const role of ["heading","body","code"]) design.fonts[role] = resolveTextStyle({fontFamily:design.fonts[role],fontWeight:role === "heading" ? 700 : 400},context.options.textMeasurement).fontFamily;
|
|
486
|
+
const geometry = composeSlide(slide, { ...design.dimensions, layout, slideIndex: index, fonts: design.fonts, textMeasurement: context.options.textMeasurement });
|
|
487
|
+
return {
|
|
488
|
+
geometry,
|
|
489
|
+
assets: presentation.assets ?? {},
|
|
490
|
+
index,
|
|
491
|
+
path: slidePath,
|
|
492
|
+
slide,
|
|
493
|
+
layout,
|
|
494
|
+
design,
|
|
495
|
+
titleBindings,
|
|
496
|
+
contentItems,
|
|
497
|
+
blocks
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function resolvePresentation(input, options = {}) {
|
|
502
|
+
const presentation = parseInput(input);
|
|
503
|
+
assertValidBoundary(presentation);
|
|
504
|
+
|
|
505
|
+
const context = { presentation, options };
|
|
506
|
+
const slides = presentation.slides.map((slide, index) => {
|
|
507
|
+
const layout = resolveLayout(slide, context, index);
|
|
508
|
+
return bindSlide(presentation, slide, layout, index, context);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
presentation,
|
|
513
|
+
engineDefaults,
|
|
514
|
+
slides
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function renderSvg(input, options = {}) {
|
|
519
|
+
const resolved = resolvePresentation(input, options);
|
|
520
|
+
const slideIndex = options.slideIndex ?? 0;
|
|
521
|
+
if (!Number.isInteger(slideIndex) || slideIndex < 0 || slideIndex >= resolved.slides.length) {
|
|
522
|
+
throw new OPFRenderError("slide-index-out-of-range", `Slide index ${slideIndex} is out of range.`, {
|
|
523
|
+
slideIndex,
|
|
524
|
+
slideCount: resolved.slides.length
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
return renderResolvedSlide(resolved, slideIndex, options);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function renderSvgDeck(input, options = {}) {
|
|
531
|
+
const resolved = resolvePresentation(input, options);
|
|
532
|
+
return resolved.slides.map((_, index) => renderResolvedSlide(resolved, index, options));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function renderResolvedSlide(resolved, slideIndex, options) {
|
|
536
|
+
options = { ...options, _diagnosticPaths: new Set() };
|
|
537
|
+
const bound = resolved.slides[slideIndex];
|
|
538
|
+
const { width, height } = bound.design.dimensions;
|
|
539
|
+
const title = bound.slide.title ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
|
|
540
|
+
const children = [
|
|
541
|
+
renderEmbeddedFonts(options.embeddedFonts),
|
|
542
|
+
renderBackground(bound, width, height, options),
|
|
543
|
+
renderBranding(bound, resolved.presentation, width, height, options),
|
|
544
|
+
...renderSlideContent(bound, width, height, options),
|
|
545
|
+
renderFurniture(bound, resolved.presentation, width, height, options, "header"),
|
|
546
|
+
renderFurniture(bound, resolved.presentation, width, height, options, "footer")
|
|
547
|
+
].filter(Boolean);
|
|
548
|
+
|
|
549
|
+
return tag(
|
|
550
|
+
"svg",
|
|
551
|
+
{
|
|
552
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
553
|
+
role: "img",
|
|
554
|
+
"aria-label": title,
|
|
555
|
+
viewBox: `0 0 ${width} ${height}`,
|
|
556
|
+
width,
|
|
557
|
+
height,
|
|
558
|
+
...traceAttrs(options, bound.path)
|
|
559
|
+
},
|
|
560
|
+
`\n${children.join("\n")}\n`
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function renderBackground(bound, width, height, options) {
|
|
565
|
+
const background = bound.design.background;
|
|
566
|
+
if (isPlainObject(background) && background.type === "gradient") {
|
|
567
|
+
const id = `opf-s${bound.index + 1}-background`;
|
|
568
|
+
const stops = Array.isArray(background.gradient?.stops) ? background.gradient.stops : [];
|
|
569
|
+
const angle=(Number(background.gradient?.angle??0)*Math.PI)/180;
|
|
570
|
+
const dx=Math.cos(angle)*50,dy=Math.sin(angle)*50;
|
|
571
|
+
const stopTags = stops.map((stop, index) => tag("stop", {
|
|
572
|
+
offset: stableNumber((stop.position ?? index / Math.max(1, stops.length - 1)) * 100) + "%",
|
|
573
|
+
"stop-color": normalizeColor(stop.color, bound.design.colors.background)
|
|
574
|
+
}));
|
|
575
|
+
return [
|
|
576
|
+
tag("defs", traceAttrs(options, `${bound.path}.design.background`), tag("linearGradient", {
|
|
577
|
+
id,
|
|
578
|
+
x1: `${50-dx}%`,
|
|
579
|
+
x2: `${50+dx}%`,
|
|
580
|
+
y1: `${50-dy}%`,
|
|
581
|
+
y2: `${50+dy}%`
|
|
582
|
+
}, stopTags.join(""))),
|
|
583
|
+
tag("rect", {
|
|
584
|
+
x: 0,
|
|
585
|
+
y: 0,
|
|
586
|
+
width,
|
|
587
|
+
height,
|
|
588
|
+
fill: `url(#${id})`,
|
|
589
|
+
opacity: background.opacity ?? 1,
|
|
590
|
+
...traceAttrs(options, `${bound.path}.design.background`)
|
|
591
|
+
})
|
|
592
|
+
].join("\n");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if(isPlainObject(background) && background.type==='image'){
|
|
596
|
+
const imageItem={value:background.image,path:`${bound.path}.design.background.image`};
|
|
597
|
+
if(background.image.fit==='tile'){
|
|
598
|
+
const size=Math.min(width,height)/4,id=`opf-s${bound.index+1}-image-tile`;
|
|
599
|
+
return tag('g',{opacity:background.opacity??1},tag('defs',{},tag('pattern',{id,width:size,height:size,patternUnits:'userSpaceOnUse'},renderImage(imageItem,{x:0,y:0,width:size,height:size},bound,options)))+tag('rect',{width,height,fill:`url(#${id})`}));
|
|
600
|
+
}
|
|
601
|
+
return tag('g',{opacity:background.opacity??1},renderImage(imageItem,{x:0,y:0,width,height},bound,{...options,imageFit:background.image.fit??'cover'}));
|
|
602
|
+
}
|
|
603
|
+
if(isPlainObject(background) && background.type==='pattern'){
|
|
604
|
+
const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=normalizeColor(pattern.foregroundColor,bound.design.colors.text),preset=pattern.preset;
|
|
605
|
+
const mark=preset==='ltHorz'?tag('path',{d:'M0 4H8',stroke:color,'stroke-width':1}):preset==='diagStripe'?tag('path',{d:'M-2 2L2 -2M0 8L8 0M6 10L10 6',stroke:color,'stroke-width':2}):preset==='pct5'?tag('circle',{cx:2,cy:2,r:.8,fill:color}):'';
|
|
606
|
+
if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
|
|
607
|
+
return tag('g',{opacity:background.opacity??1},tag('rect',{width,height,fill:normalizeColor(pattern.backgroundColor,'#FFFFFF')})+tag('defs',{},tag('pattern',{id,width:8,height:8,patternUnits:'userSpaceOnUse'},mark))+tag('rect',{width,height,fill:`url(#${id})`}));
|
|
608
|
+
}
|
|
609
|
+
return tag("rect", {
|
|
610
|
+
x: 0,
|
|
611
|
+
y: 0,
|
|
612
|
+
width,
|
|
613
|
+
height,
|
|
614
|
+
opacity: typeof background==='object'?background?.opacity??1:1,
|
|
615
|
+
fill: bound.design.backgroundColor ?? bound.design.colors.background,
|
|
616
|
+
...traceAttrs(options, `${bound.path}.design.background`)
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function renderSlideContent(bound, width, height, options) {
|
|
621
|
+
for (const diagnostic of bound.geometry.diagnostics) reportDiagnostic(diagnostic, options);
|
|
622
|
+
return bound.geometry.items.map(item => {
|
|
623
|
+
const surface=bound.design.contentBox && !['title','subtitle','tag'].includes(item.field) ? tag('rect',{x:item.box.x,y:item.box.y,width:item.box.width,height:item.box.height,rx:8,fill:bound.design.colors.surface,stroke:bound.design.colors.border}) : '';
|
|
624
|
+
return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function reportDiagnostic(diagnostic, options) {
|
|
629
|
+
const key = `${diagnostic.code}:${diagnostic.path}`;
|
|
630
|
+
if (options._diagnosticPaths.has(key)) return;
|
|
631
|
+
options._diagnosticPaths.add(key);
|
|
632
|
+
options.onDiagnostic?.(diagnostic);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function renderPayload(item, box, bound, options) {
|
|
636
|
+
if (!box) return "";
|
|
637
|
+
if (item.field === "items" || item.field === "bullets") return renderList(item, box, bound, options);
|
|
638
|
+
switch (item.type) {
|
|
639
|
+
case "chart":
|
|
640
|
+
return renderChart(item, box, bound, options);
|
|
641
|
+
case "table":
|
|
642
|
+
return renderTable(item, box, bound, options);
|
|
643
|
+
case "image":
|
|
644
|
+
return renderImage(item, box, bound, options);
|
|
645
|
+
case "video":
|
|
646
|
+
return renderMedia(item, box, bound, options);
|
|
647
|
+
case "code":
|
|
648
|
+
return renderCode(item, box, bound, options);
|
|
649
|
+
case "metric":
|
|
650
|
+
return renderMetric(item, box, bound, options);
|
|
651
|
+
case "quote":
|
|
652
|
+
return renderQuote(item, box, bound, options);
|
|
653
|
+
case "timeline":
|
|
654
|
+
return renderTimeline(item, box, bound, options);
|
|
655
|
+
case "list":
|
|
656
|
+
return renderList(item, box, bound, options);
|
|
657
|
+
default:
|
|
658
|
+
return renderTextPayload(item, box, bound, options);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function renderTextPayload(item, box, bound, options) {
|
|
663
|
+
return (Array.isArray(item.value) ? renderRichTextBox : renderTextBox)(Array.isArray(item.value) ? item.value : flattenText(item.value), box, bound, {
|
|
664
|
+
path: item.path,
|
|
665
|
+
align: item.field === "title" ? bound.design.titleAlignment : bound.design.contentAlignment,
|
|
666
|
+
fontSize: item.field === "title" ? 54 : item.field === "tag" ? 16 : 25,
|
|
667
|
+
fontFamily: item.field === "title" ? bound.design.fonts.heading : bound.design.fonts.body,
|
|
668
|
+
fontWeight: item.field === "title" ? 700 : 400,
|
|
669
|
+
fill: bound.design.colors.text,
|
|
670
|
+
options
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function renderList(item, box, bound, options) {
|
|
675
|
+
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
676
|
+
const fit=item.text?.listEntries?item.text:fitList(item.value,box,25*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,{style:{fontFamily:bound.design.fonts.body,fontWeight:400,path:item.path},textMeasurement:options.textMeasurement});
|
|
677
|
+
const children=[];
|
|
678
|
+
for(const entry of fit.listEntries){
|
|
679
|
+
children.push(tag('text',{x:stableNumber(entry.marker.x),y:stableNumber(entry.marker.y),'font-family':fontStack(entry.marker.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(entry.marker.fontSize),fill:bound.design.colors.text,'aria-hidden':'true'},escapeText(entry.marker.text)));
|
|
680
|
+
const config={path:entry.textPath,align:'left',fill:bound.design.colors.text,rich:Array.isArray(entry.value),options};
|
|
681
|
+
children.push(renderRichLines(typeof entry.value==='string'?[entry.value]:entry.value,entry.text,entry.textBox,bound,config));
|
|
682
|
+
if(entry.description)children.push(renderRichLines(typeof entry.descriptionValue==='string'?[entry.descriptionValue]:entry.descriptionValue,entry.description,entry.descriptionBox,bound,{...config,path:entry.descriptionPath,rich:Array.isArray(entry.descriptionValue),fill:bound.design.colors.mutedText}));
|
|
683
|
+
}
|
|
684
|
+
return tag('g',{...traceAttrs(options,item.path),...(fit.overflow?{'data-opf-overflow':'true'}:{})},children.join('\n'));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function renderImage(item, box, bound, options) {
|
|
688
|
+
let asset = normalizeAsset(item.value);
|
|
689
|
+
const seen = new Set();
|
|
690
|
+
while (asset.src?.startsWith("asset:")) {
|
|
691
|
+
const id = asset.src.slice(6);
|
|
692
|
+
if (seen.has(id)) throw new OPFRenderError("invalid-asset-reference", "Circular asset reference.", { path: item.path });
|
|
693
|
+
seen.add(id);
|
|
694
|
+
asset = normalizeAsset(bound.assets[id]);
|
|
695
|
+
}
|
|
696
|
+
const source = options.imageResolver?.(asset.src, { asset, path: item.path }) ?? asset.src;
|
|
697
|
+
if (typeof source === "string" && /^data:image\/(png|jpeg|gif|webp);base64,/i.test(source)) {
|
|
698
|
+
return tag("image", { x: box.x, y: box.y, width: box.width, height: box.height,
|
|
699
|
+
href: source, preserveAspectRatio: (options.imageFit ?? (bound.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? "Image",
|
|
700
|
+
...traceAttrs(options, item.path) });
|
|
701
|
+
}
|
|
702
|
+
if (options.strictAssets) throw new OPFRenderError("unresolved-asset", "Image requires an embedded raster data URI or a host imageResolver.", { path: item.path });
|
|
703
|
+
const fill = bound.design.colors.surface;
|
|
704
|
+
const children = [
|
|
705
|
+
tag("rect", {
|
|
706
|
+
x: box.x,
|
|
707
|
+
y: box.y,
|
|
708
|
+
width: box.width,
|
|
709
|
+
height: box.height,
|
|
710
|
+
rx: 0,
|
|
711
|
+
fill,
|
|
712
|
+
stroke: bound.design.colors.border,
|
|
713
|
+
"stroke-width": 1,
|
|
714
|
+
...traceAttrs(options, item.path)
|
|
715
|
+
}),
|
|
716
|
+
renderTextBox(asset.alt || asset.title || asset.src || "Image", inset(box, 24), bound, {
|
|
717
|
+
path: item.path,
|
|
718
|
+
fontSize: 20,
|
|
719
|
+
fontFamily: bound.design.fonts.body,
|
|
720
|
+
fontWeight: 600,
|
|
721
|
+
fill: bound.design.colors.mutedText,
|
|
722
|
+
options,
|
|
723
|
+
align: "center",
|
|
724
|
+
verticalAlign: "middle"
|
|
725
|
+
})
|
|
726
|
+
];
|
|
727
|
+
|
|
728
|
+
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function renderMedia(item, box, bound, options) {
|
|
732
|
+
const asset = normalizeAsset(item.value);
|
|
733
|
+
const iconBox = centeredBox(box, 72, 72);
|
|
734
|
+
const children = [
|
|
735
|
+
tag("rect", {
|
|
736
|
+
x: box.x,
|
|
737
|
+
y: box.y,
|
|
738
|
+
width: box.width,
|
|
739
|
+
height: box.height,
|
|
740
|
+
fill: bound.design.colors.surface,
|
|
741
|
+
stroke: bound.design.colors.border,
|
|
742
|
+
"stroke-width": 1,
|
|
743
|
+
...traceAttrs(options, item.path)
|
|
744
|
+
}),
|
|
745
|
+
tag("circle", {
|
|
746
|
+
cx: iconBox.x + iconBox.width / 2,
|
|
747
|
+
cy: iconBox.y + iconBox.height / 2,
|
|
748
|
+
r: 36,
|
|
749
|
+
fill: bound.design.colors.primary,
|
|
750
|
+
...traceAttrs(options, item.path)
|
|
751
|
+
}),
|
|
752
|
+
tag("path", {
|
|
753
|
+
d: trianglePath(iconBox.x + 28, iconBox.y + 22, 28, 28),
|
|
754
|
+
fill: "#FFFFFF",
|
|
755
|
+
...traceAttrs(options, item.path)
|
|
756
|
+
}),
|
|
757
|
+
renderTextBox(asset.title || asset.src || "Media", { ...box, y: iconBox.y + iconBox.height + 20, height: 50 }, bound, {
|
|
758
|
+
path: item.path,
|
|
759
|
+
fontSize: 18,
|
|
760
|
+
fontFamily: bound.design.fonts.body,
|
|
761
|
+
fontWeight: 600,
|
|
762
|
+
fill: bound.design.colors.mutedText,
|
|
763
|
+
options,
|
|
764
|
+
align: "center"
|
|
765
|
+
})
|
|
766
|
+
];
|
|
767
|
+
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function renderCode(item, box, bound, options) {
|
|
771
|
+
const code = typeof item.value === "string" ? item.value : item.value?.source ?? stableJson(item.value);
|
|
772
|
+
const label = typeof item.value === "object" && item.value?.language ? String(item.value.language) : "code";
|
|
773
|
+
const children = [
|
|
774
|
+
tag("rect", {
|
|
775
|
+
x: box.x,
|
|
776
|
+
y: box.y,
|
|
777
|
+
width: box.width,
|
|
778
|
+
height: box.height,
|
|
779
|
+
fill: "#111827",
|
|
780
|
+
stroke: "#334155",
|
|
781
|
+
"stroke-width": 1,
|
|
782
|
+
...traceAttrs(options, item.path)
|
|
783
|
+
}),
|
|
784
|
+
tag("text", {
|
|
785
|
+
x: box.x + 18,
|
|
786
|
+
y: box.y + 28,
|
|
787
|
+
fill: "#93C5FD",
|
|
788
|
+
"font-family": fontStack(bound.design.fonts.code, "monospace"),
|
|
789
|
+
"font-size": 14,
|
|
790
|
+
"font-weight": 700,
|
|
791
|
+
...traceAttrs(options, item.path)
|
|
792
|
+
}, escapeText(label.toUpperCase())),
|
|
793
|
+
renderTextBox(code, { x: box.x + 18, y: box.y + 46, width: box.width - 36, height: box.height - 64 }, bound, {
|
|
794
|
+
path: item.path,
|
|
795
|
+
fontSize: 18,
|
|
796
|
+
fontFamily: bound.design.fonts.code,
|
|
797
|
+
fontWeight: 400,
|
|
798
|
+
fill: "#E5E7EB",
|
|
799
|
+
options,
|
|
800
|
+
preserveNewlines: true
|
|
801
|
+
})
|
|
802
|
+
];
|
|
803
|
+
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function renderMetric(item, box, bound, options) {
|
|
807
|
+
const metric = isPlainObject(item.value) ? item.value : { value: item.value };
|
|
808
|
+
const children = [
|
|
809
|
+
renderTextBox(String(metric.value ?? ""), { ...box, height: box.height * 0.45 }, bound, {
|
|
810
|
+
path: `${item.path}.value`,
|
|
811
|
+
fontSize: Math.min(76, box.height * 0.28),
|
|
812
|
+
fontFamily: bound.design.fonts.heading,
|
|
813
|
+
fontWeight: 800,
|
|
814
|
+
fill: bound.design.colors.primary,
|
|
815
|
+
options
|
|
816
|
+
}),
|
|
817
|
+
renderTextBox([metric.label, metric.description, metric.delta].filter(Boolean).join("\n"), {
|
|
818
|
+
x: box.x,
|
|
819
|
+
y: box.y + box.height * 0.45,
|
|
820
|
+
width: box.width,
|
|
821
|
+
height: box.height * 0.55
|
|
822
|
+
}, bound, {
|
|
823
|
+
path: item.path,
|
|
824
|
+
fontSize: 23,
|
|
825
|
+
fontFamily: bound.design.fonts.body,
|
|
826
|
+
fontWeight: 500,
|
|
827
|
+
fill: bound.design.colors.text,
|
|
828
|
+
options
|
|
829
|
+
})
|
|
830
|
+
];
|
|
831
|
+
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function renderQuote(item, box, bound, options) {
|
|
835
|
+
const quote = isPlainObject(item.value) ? item.value : { text: item.value };
|
|
836
|
+
const children = [
|
|
837
|
+
renderTextBox(`"${quote.text ?? ""}"`, inset(box, 18), bound, {
|
|
838
|
+
path: `${item.path}.text`,
|
|
839
|
+
fontSize: 28,
|
|
840
|
+
fontFamily: bound.design.fonts.heading,
|
|
841
|
+
fontWeight: 600,
|
|
842
|
+
fill: bound.design.colors.text,
|
|
843
|
+
options
|
|
844
|
+
}),
|
|
845
|
+
renderTextBox([quote.attribution, quote.source].filter(Boolean).join(" - "), {
|
|
846
|
+
x: box.x + 18,
|
|
847
|
+
y: box.y + box.height - 58,
|
|
848
|
+
width: box.width - 36,
|
|
849
|
+
height: 40
|
|
850
|
+
}, bound, {
|
|
851
|
+
path: item.path,
|
|
852
|
+
fontSize: 17,
|
|
853
|
+
fontFamily: bound.design.fonts.body,
|
|
854
|
+
fontWeight: 500,
|
|
855
|
+
fill: bound.design.colors.mutedText,
|
|
856
|
+
options
|
|
857
|
+
})
|
|
858
|
+
];
|
|
859
|
+
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function renderTimeline(item, box, bound, options) {
|
|
863
|
+
const timeline = Array.isArray(item.value) ? { events: item.value } : item.value;
|
|
864
|
+
const events = Array.isArray(timeline?.events) ? timeline.events : [];
|
|
865
|
+
if (!events.length) return "";
|
|
866
|
+
const labelWidth = box.width / Math.max(2, events.length);
|
|
867
|
+
const gap = (box.width - labelWidth) / Math.max(1, events.length - 1);
|
|
868
|
+
const start = events.length === 1 ? box.x + box.width / 2 : box.x + labelWidth / 2;
|
|
869
|
+
const end = events.length === 1 ? start : box.x + box.width - labelWidth / 2;
|
|
870
|
+
const y = box.y + box.height * 0.46;
|
|
871
|
+
const children = [
|
|
872
|
+
tag("line", {
|
|
873
|
+
x1: start,
|
|
874
|
+
x2: end,
|
|
875
|
+
y1: y,
|
|
876
|
+
y2: y,
|
|
877
|
+
stroke: bound.design.colors.border,
|
|
878
|
+
"stroke-width": 3,
|
|
879
|
+
...traceAttrs(options, item.path)
|
|
880
|
+
})
|
|
881
|
+
];
|
|
882
|
+
|
|
883
|
+
events.forEach((event, index) => {
|
|
884
|
+
const x = start + index * gap;
|
|
885
|
+
const eventPath = `${item.path}.events.${index}`;
|
|
886
|
+
children.push(tag("circle", {
|
|
887
|
+
cx: x,
|
|
888
|
+
cy: y,
|
|
889
|
+
r: Math.min(9, labelWidth / 5, box.height * 0.04),
|
|
890
|
+
fill: bound.design.colors.primary,
|
|
891
|
+
...traceAttrs(options, eventPath)
|
|
892
|
+
}));
|
|
893
|
+
children.push(renderTextBox([event.when, event.what, event.description].filter(Boolean).join("\n"), {
|
|
894
|
+
x: x - labelWidth / 2,
|
|
895
|
+
y: index % 2 === 0 ? box.y : y + 24,
|
|
896
|
+
width: labelWidth,
|
|
897
|
+
height: box.height * 0.38
|
|
898
|
+
}, bound, {
|
|
899
|
+
path: eventPath,
|
|
900
|
+
fontSize: 16,
|
|
901
|
+
fontFamily: bound.design.fonts.body,
|
|
902
|
+
fontWeight: 500,
|
|
903
|
+
fill: bound.design.colors.text,
|
|
904
|
+
options,
|
|
905
|
+
align: "center"
|
|
906
|
+
}));
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function renderTable(item, box, bound, options) {
|
|
913
|
+
const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
914
|
+
const table = item.value ?? {};
|
|
915
|
+
const columns = Array.isArray(table.columns) ? table.columns : [];
|
|
916
|
+
const rows = Array.isArray(table.rows) ? table.rows : [];
|
|
917
|
+
const allRows = columns.length ? [columns, ...rows] : rows;
|
|
918
|
+
const columnCount = Math.max(1, ...allRows.map((row) => Array.isArray(row) ? row.length : 1));
|
|
919
|
+
const rowCount = Math.max(1, allRows.length);
|
|
920
|
+
const cellWidth = box.width / columnCount;
|
|
921
|
+
const cellHeight = Math.min(54 * scale, box.height / rowCount);
|
|
922
|
+
const children = [];
|
|
923
|
+
|
|
924
|
+
allRows.forEach((row, rowIndex) => {
|
|
925
|
+
const cells = Array.isArray(row) ? row : [row];
|
|
926
|
+
for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
|
|
927
|
+
const x = box.x + columnIndex * cellWidth;
|
|
928
|
+
const y = box.y + rowIndex * cellHeight;
|
|
929
|
+
const cellPath = rowIndex === 0 && columns.length ? `${item.path}.columns.${columnIndex}` : `${item.path}.rows.${rowIndex - (columns.length ? 1 : 0)}.${columnIndex}`;
|
|
930
|
+
children.push(tag("rect", {
|
|
931
|
+
x: stableNumber(x),
|
|
932
|
+
y: stableNumber(y),
|
|
933
|
+
width: stableNumber(cellWidth),
|
|
934
|
+
height: stableNumber(cellHeight),
|
|
935
|
+
fill: rowIndex === 0 && columns.length ? bound.design.colors.primary : bound.design.colors.surface,
|
|
936
|
+
stroke: bound.design.colors.border,
|
|
937
|
+
"stroke-width": 1,
|
|
938
|
+
...traceAttrs(options, cellPath)
|
|
939
|
+
}));
|
|
940
|
+
children.push(renderTextBox(flattenText(cells[columnIndex] ?? ""), {
|
|
941
|
+
x: x + 10 * scale,
|
|
942
|
+
y: y + 8 * scale,
|
|
943
|
+
width: Math.max(scale, cellWidth - 20 * scale),
|
|
944
|
+
height: Math.max(scale, cellHeight - 12 * scale)
|
|
945
|
+
}, bound, {
|
|
946
|
+
path: cellPath,
|
|
947
|
+
fontSize: 15,
|
|
948
|
+
fontFamily: bound.design.fonts.body,
|
|
949
|
+
fontWeight: rowIndex === 0 && columns.length ? 700 : 400,
|
|
950
|
+
fill: rowIndex === 0 && columns.length ? "#FFFFFF" : bound.design.colors.text,
|
|
951
|
+
options
|
|
952
|
+
}));
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function renderImportedChart(item, box, bound, options) {
|
|
960
|
+
const chart=item.value, rows=chart.data?.rows??[], columns=chart.data?.columns??[];
|
|
961
|
+
if(!rows.length||columns.length<2)return null;
|
|
962
|
+
const kind=chart.type, circular=['pie','donut','doughnut'].includes(kind);
|
|
963
|
+
const colors=[bound.design.colors.primary,bound.design.colors.secondary,'#9B6BCC','#D98944','#429B85','#CB5D79'];
|
|
964
|
+
const children=[];
|
|
965
|
+
const text=(value,rect,path,size=16,align='center')=>renderTextBox(String(value),rect,bound,{path,fontSize:size,fontFamily:bound.design.fonts.body,fontWeight:400,fill:bound.design.colors.text,options,align,verticalAlign:'middle'});
|
|
966
|
+
const number=value=>typeof value==='number'&&Number.isFinite(value)?value:typeof value==='string'&&value.trim()&&Number.isFinite(Number(value))?Number(value):null;
|
|
967
|
+
const series=columns.slice(1).map((name,j)=>({name,values:rows.map(row=>number(row[j+1]))}));
|
|
968
|
+
children.push(tag('rect',{x:box.x,y:box.y,width:box.width,height:box.height,fill:bound.design.colors.surface,stroke:bound.design.colors.border,...traceAttrs(options,item.path)}));
|
|
969
|
+
if(circular){
|
|
970
|
+
if(series.length!==1||series[0].values.some(v=>v!==null&&v<0)){
|
|
971
|
+
children.push(text('Pie charts need one nonnegative series',box,item.path,18));return tag('g',traceAttrs(options,item.path),children.join('\n'));
|
|
972
|
+
}
|
|
973
|
+
const values=series[0].values,total=values.reduce((sum,v)=>sum+(v??0),0),cx=box.x+box.width*.35,cy=box.y+box.height*.5,r=Math.max(1,Math.min(box.width*.3,box.height*.42));
|
|
974
|
+
if(!total){children.push(text('No positive chart values',box,item.path,18));return tag('g',traceAttrs(options,item.path),children.join('\n'));}
|
|
975
|
+
let angle=-Math.PI/2;
|
|
976
|
+
values.forEach((v,i)=>{
|
|
977
|
+
const delta=(v??0)/total*Math.PI*2,end=angle+delta,color=colors[i%colors.length];
|
|
978
|
+
if(delta>=Math.PI*2-1e-8)children.push(tag('circle',{cx,cy,r,fill:color,...traceAttrs(options,`${item.path}.data.rows.${i}.1`)}));
|
|
979
|
+
else if(delta>0)children.push(tag('path',{d:`M ${cx} ${cy} L ${cx+r*Math.cos(angle)} ${cy+r*Math.sin(angle)} A ${r} ${r} 0 ${delta>Math.PI?1:0} 1 ${cx+r*Math.cos(end)} ${cy+r*Math.sin(end)} Z`,fill:color,...traceAttrs(options,`${item.path}.data.rows.${i}.1`)}));
|
|
980
|
+
angle=end;
|
|
981
|
+
const y=box.y+15+i*Math.min(30,(box.height-30)/rows.length);
|
|
982
|
+
children.push(tag('rect',{x:box.x+box.width*.69,y:y+6,width:12,height:12,fill:color}));
|
|
983
|
+
children.push(text(`${rows[i][0]}: ${v??'—'}`,{x:box.x+box.width*.69+20,y,width:box.width*.29-20,height:28},`${item.path}.data.rows.${i}`,14,'left'));
|
|
984
|
+
});
|
|
985
|
+
if(kind!=='pie')children.push(tag('circle',{cx,cy,r:r*.57,fill:bound.design.colors.surface}));
|
|
986
|
+
}else{
|
|
987
|
+
const horizontal=kind==='bar',legendHeight=series.length>1?34:8;
|
|
988
|
+
const plot={x:box.x+(horizontal?100:64),y:box.y+legendHeight+14,width:Math.max(1,box.width-(horizontal?124:84)),height:Math.max(1,box.height-legendHeight-62)};
|
|
989
|
+
const values=series.flatMap(s=>s.values).filter(v=>v!==null),min=Math.min(0,...values),rawMax=Math.max(0,...values),max=rawMax===min?min+1:rawMax,range=max-min;
|
|
990
|
+
const xValue=v=>plot.x+(v-min)/range*plot.width,yValue=v=>plot.y+(max-v)/range*plot.height;
|
|
991
|
+
const zero=horizontal?xValue(0):yValue(0);
|
|
992
|
+
for(let tick=0;tick<=4;tick++){
|
|
993
|
+
const value=min+range*tick/4,at=horizontal?xValue(value):yValue(value),label=Number(value.toPrecision(4));
|
|
994
|
+
children.push(tag('line',{x1:horizontal?at:plot.x,x2:horizontal?at:plot.x+plot.width,y1:horizontal?plot.y:at,y2:horizontal?plot.y+plot.height:at,stroke:bound.design.colors.border,'stroke-width':1}));
|
|
995
|
+
children.push(text(label,horizontal?{x:at-32,y:plot.y+plot.height+6,width:64,height:26}:{x:box.x+4,y:at-12,width:52,height:24},item.path,13,horizontal?'center':'right'));
|
|
996
|
+
}
|
|
997
|
+
if(series.length>1)series.forEach((s,j)=>{
|
|
998
|
+
const width=(box.width-24)/series.length,x=box.x+12+j*width;
|
|
999
|
+
children.push(tag('rect',{x,y:box.y+12,width:12,height:12,fill:colors[j%colors.length]}));
|
|
1000
|
+
children.push(text(s.name,{x:x+18,y:box.y+5,width:Math.max(1,width-22),height:28},`${item.path}.data.columns.${j+1}`,14,'left'));
|
|
1001
|
+
});
|
|
1002
|
+
const categorySize=(horizontal?plot.height:plot.width)/rows.length;
|
|
1003
|
+
rows.forEach((row,i)=>children.push(text(row[0],horizontal?{x:box.x+4,y:plot.y+i*categorySize,width:88,height:categorySize}:{x:plot.x+i*categorySize,y:plot.y+plot.height+7,width:categorySize,height:30},`${item.path}.data.rows.${i}.0`,14,horizontal?'right':'center')));
|
|
1004
|
+
series.forEach((s,j)=>{
|
|
1005
|
+
const color=colors[j%colors.length];
|
|
1006
|
+
if(kind==='line'||kind==='area'){
|
|
1007
|
+
let points=[];
|
|
1008
|
+
const flush=()=>{if(!points.length)return;const pairs=points.map(p=>p.join(',')).join(' ');if(kind==='area')children.push(tag('polygon',{points:`${points[0][0]},${zero} ${pairs} ${points.at(-1)[0]},${zero}`,fill:color,'fill-opacity':.18}));children.push(tag('polyline',{points:pairs,fill:'none',stroke:color,'stroke-width':3,...traceAttrs(options,`${item.path}.data`)}));points=[];};
|
|
1009
|
+
s.values.forEach((v,i)=>{if(v===null){flush();return;}const x=plot.x+(i+.5)*categorySize,y=yValue(v);points.push([x,y]);children.push(tag('circle',{cx:x,cy:y,r:4,fill:color,...traceAttrs(options,`${item.path}.data.rows.${i}.${j+1}`)}));});flush();
|
|
1010
|
+
}else{
|
|
1011
|
+
const bar=categorySize*.8/series.length;
|
|
1012
|
+
s.values.forEach((v,i)=>{if(v===null)return;const at=(horizontal?plot.y:plot.x)+i*categorySize+categorySize*.1+j*bar;
|
|
1013
|
+
children.push(tag('rect',horizontal?{x:Math.min(zero,xValue(v)),y:at,width:Math.abs(xValue(v)-zero),height:Math.max(.1,bar*.9),fill:color,...traceAttrs(options,`${item.path}.data.rows.${i}.${j+1}`)}:{x:at,y:Math.min(zero,yValue(v)),width:Math.max(.1,bar*.9),height:Math.abs(yValue(v)-zero),fill:color,...traceAttrs(options,`${item.path}.data.rows.${i}.${j+1}`)}));
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
children.push(tag('line',{x1:horizontal?zero:plot.x,x2:horizontal?zero:plot.x+plot.width,y1:horizontal?plot.y:zero,y2:horizontal?plot.y+plot.height:zero,stroke:bound.design.colors.text,'stroke-width':1}));
|
|
1018
|
+
}
|
|
1019
|
+
return tag('g',traceAttrs(options,item.path),children.join('\n'));
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function renderChart(item, box, bound, options) {
|
|
1023
|
+
if (["column","bar","line","area","pie","donut","doughnut"].includes(item.value?.type)) {
|
|
1024
|
+
const rendered = renderImportedChart(item, box, bound, options);
|
|
1025
|
+
if (rendered) return rendered;
|
|
1026
|
+
}
|
|
1027
|
+
const chart = item.value ?? {};
|
|
1028
|
+
const chartType = chart.type ?? engineDefaults.chartTypes[0];
|
|
1029
|
+
const data = inlineChartRows(chart.data);
|
|
1030
|
+
const plot = inset(box, 28);
|
|
1031
|
+
const max = Math.max(1, ...data.map((row) => Math.abs(row.value)));
|
|
1032
|
+
const children = [
|
|
1033
|
+
tag("rect", {
|
|
1034
|
+
x: box.x,
|
|
1035
|
+
y: box.y,
|
|
1036
|
+
width: box.width,
|
|
1037
|
+
height: box.height,
|
|
1038
|
+
fill: "#FFFFFF",
|
|
1039
|
+
stroke: bound.design.colors.border,
|
|
1040
|
+
"stroke-width": 1,
|
|
1041
|
+
...traceAttrs(options, item.path)
|
|
1042
|
+
})
|
|
1043
|
+
];
|
|
1044
|
+
|
|
1045
|
+
if (!data.length) {
|
|
1046
|
+
children.push(renderTextBox("No chart data", plot, bound, {
|
|
1047
|
+
path: item.path,
|
|
1048
|
+
fontSize: 20,
|
|
1049
|
+
fontFamily: bound.design.fonts.body,
|
|
1050
|
+
fontWeight: 500,
|
|
1051
|
+
fill: bound.design.colors.mutedText,
|
|
1052
|
+
options,
|
|
1053
|
+
align: "center",
|
|
1054
|
+
verticalAlign: "middle"
|
|
1055
|
+
}));
|
|
1056
|
+
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if (chartType.includes("line") || chartType.includes("area")) {
|
|
1060
|
+
const points = data.map((row, index) => {
|
|
1061
|
+
const x = plot.x + (data.length === 1 ? plot.width / 2 : (plot.width / (data.length - 1)) * index);
|
|
1062
|
+
const y = plot.y + plot.height - (Math.abs(row.value) / max) * plot.height;
|
|
1063
|
+
return [x, y];
|
|
1064
|
+
});
|
|
1065
|
+
children.push(tag("polyline", {
|
|
1066
|
+
points: points.map(([x, y]) => `${stableNumber(x)},${stableNumber(y)}`).join(" "),
|
|
1067
|
+
fill: "none",
|
|
1068
|
+
stroke: bound.design.colors.primary,
|
|
1069
|
+
"stroke-width": 4,
|
|
1070
|
+
...traceAttrs(options, `${item.path}.data`)
|
|
1071
|
+
}));
|
|
1072
|
+
points.forEach(([x, y], index) => children.push(tag("circle", {
|
|
1073
|
+
cx: stableNumber(x),
|
|
1074
|
+
cy: stableNumber(y),
|
|
1075
|
+
r: 5,
|
|
1076
|
+
fill: bound.design.colors.primary,
|
|
1077
|
+
...traceAttrs(options, `${item.path}.data.rows.${index}`)
|
|
1078
|
+
})));
|
|
1079
|
+
} else {
|
|
1080
|
+
const gap = 10;
|
|
1081
|
+
const barWidth = Math.max(6, (plot.width - gap * (data.length - 1)) / data.length);
|
|
1082
|
+
data.forEach((row, index) => {
|
|
1083
|
+
const barHeight = (Math.abs(row.value) / max) * plot.height;
|
|
1084
|
+
const x = plot.x + index * (barWidth + gap);
|
|
1085
|
+
const y = plot.y + plot.height - barHeight;
|
|
1086
|
+
children.push(tag("rect", {
|
|
1087
|
+
x: stableNumber(x),
|
|
1088
|
+
y: stableNumber(y),
|
|
1089
|
+
width: stableNumber(barWidth),
|
|
1090
|
+
height: stableNumber(barHeight),
|
|
1091
|
+
fill: index % 2 === 0 ? bound.design.colors.primary : bound.design.colors.secondary,
|
|
1092
|
+
...traceAttrs(options, `${item.path}.data.rows.${index}`)
|
|
1093
|
+
}));
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
children.push(renderTextBox(data.map((row) => row.label).join(" "), {
|
|
1098
|
+
x: plot.x,
|
|
1099
|
+
y: plot.y + plot.height + 8,
|
|
1100
|
+
width: plot.width,
|
|
1101
|
+
height: 24
|
|
1102
|
+
}, bound, {
|
|
1103
|
+
path: `${item.path}.data`,
|
|
1104
|
+
fontSize: 12,
|
|
1105
|
+
fontFamily: bound.design.fonts.body,
|
|
1106
|
+
fontWeight: 400,
|
|
1107
|
+
fill: bound.design.colors.mutedText,
|
|
1108
|
+
options,
|
|
1109
|
+
align: "center"
|
|
1110
|
+
}));
|
|
1111
|
+
|
|
1112
|
+
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function inlineChartRows(data) {
|
|
1116
|
+
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
|
1117
|
+
return rows.map((row, index) => {
|
|
1118
|
+
const cells = Array.isArray(row) ? row : [row];
|
|
1119
|
+
const value = cells.find((cell) => typeof cell === "number") ?? Number(cells.find((cell) => Number.isFinite(Number(cell))) ?? 0);
|
|
1120
|
+
return {
|
|
1121
|
+
label: flattenText(cells.find((cell) => typeof cell === "string") ?? `Row ${index + 1}`),
|
|
1122
|
+
value: Number.isFinite(value) ? value : 0
|
|
1123
|
+
};
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function renderFurniture(bound, presentation, width, height, options, kind) {
|
|
1128
|
+
const local=bound.slide.design?.[kind] !== undefined;
|
|
1129
|
+
const furniture=local?bound.slide.design[kind]:presentation.design?.[kind];
|
|
1130
|
+
if(!furniture)return "";
|
|
1131
|
+
const root=local?`${bound.path}.design.${kind}`:`design.${kind}`;
|
|
1132
|
+
const organizations=Array.isArray(presentation.organization)?presentation.organization:[presentation.organization];
|
|
1133
|
+
const organization=organizations.find(item=>item?.role==='primary')??organizations.find(Boolean);
|
|
1134
|
+
return tag('g',traceAttrs(options,root),['left','center','right'].map((zone,index)=>{
|
|
1135
|
+
const item=furniture[zone];if(!item)return '';
|
|
1136
|
+
const box={x:width*(.07+index*.3),y:kind==='header'?height*.025:height*.925,width:width*.26,height:height*.05};
|
|
1137
|
+
if(item.image)return renderImage({value:item.image,path:`${root}.${zone}.image`},box,bound,options);
|
|
1138
|
+
const pieces=[item.text,item.organization?organization?.name:null,item.section?bound.slide.section:null,item.slideNumber?String(bound.index+1):null,typeof item.date==='string'?item.date:null].filter(value=>value!==undefined&&value!==null&&value!=='');
|
|
1139
|
+
if(item.date===true)reportDiagnostic({code:'date-needs-value',path:`${root}.${zone}.date`,message:'Use a literal date string for a reproducible preview; the document does not define a presentation date.'},options);
|
|
1140
|
+
const anchor=['start','middle','end'][index],x=index===0?width*.07:index===1?width/2:width*.93;
|
|
1141
|
+
return tag('text',{x:stableNumber(x),y:stableNumber(kind==='header'?height*.05:height-28),'text-anchor':anchor,'font-family':fontStack(bound.design.fonts.body,bound.design.fontScheme.type),'font-size':13,fill:bound.design.colors.mutedText,...traceAttrs(options,`${root}.${zone}`)},escapeText(pieces.join(' · ')));
|
|
1142
|
+
}).join(''));
|
|
1143
|
+
}
|
|
1144
|
+
function renderBranding(bound,presentation,width,height,options) {
|
|
1145
|
+
const design={...presentation.design,...bound.slide.design}, pieces=[];
|
|
1146
|
+
const rootFor=key=>bound.slide.design?.[key]!==undefined?`${bound.path}.design.${key}`:`design.${key}`;
|
|
1147
|
+
if(design.watermark){
|
|
1148
|
+
pieces.push(tag('g',{opacity:typeof design.watermark==='object'?design.watermark.opacity??.08:.08},renderImage({value:design.watermark,path:rootFor('watermark')},{x:width*.3,y:height*.3,width:width*.4,height:height*.4},bound,options)));
|
|
1149
|
+
}
|
|
1150
|
+
return pieces.join('');
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function fontStack(family, type) {
|
|
1154
|
+
const fallback = type === "serif" ? "serif" : type === "monospace" ? "monospace" : "sans-serif";
|
|
1155
|
+
return `${family}, ${fallback}`;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function renderEmbeddedFonts(fonts = []) {
|
|
1159
|
+
if (!fonts.length) return "";
|
|
1160
|
+
const css = fonts.map(font => {
|
|
1161
|
+
if (typeof font.family !== "string" || /[\u0000-\u001f"'\\<>;]/.test(font.family) || !/^data:font\/(ttf|otf|woff|woff2);base64,[A-Za-z0-9+/=]+$/.test(font.dataUrl) || !Number.isInteger(font.weight) || font.weight < 1 || font.weight > 1000) throw new OPFRenderError("invalid-embedded-font", "Embedded fonts require a plain family name, valid weight, and a font data URI.");
|
|
1162
|
+
return `@font-face{font-family:"${font.family}";font-weight:${font.weight};font-style:${font.italic ? "italic" : "normal"};src:url("${font.dataUrl}")}`;
|
|
1163
|
+
}).join("\n");
|
|
1164
|
+
const licenses = [...new Set(fonts.map(font=>font.license).filter(Boolean))];
|
|
1165
|
+
return tag("style",{},css) + (licenses.length ? tag("metadata",{},escapeText(licenses.join("\n\n"))) : "");
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function renderRichTextBox(value, box, bound, config) {
|
|
1169
|
+
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
1170
|
+
const fit=fitRichText(value,box,config.fontSize*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,{style:{fontFamily:config.fontFamily,fontWeight:config.fontWeight??400,path:config.path},textMeasurement:config.options.textMeasurement});
|
|
1171
|
+
if(fit.overflow){const diagnostic={code:'text-overflow',path:config.path,message:'Mixed-style text exceeds its cell at the minimum font size.'};reportDiagnostic(diagnostic,config.options);if((bound.composition??bound.geometry.composition).overflow==='error')throw new OPFRenderError('layout-overflow',diagnostic.message,{issues:[diagnostic]});}
|
|
1172
|
+
return renderRichLines(value,fit,box,bound,config);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function renderRichLines(value,fit,box,bound,config) {
|
|
1176
|
+
const alignment=config.align??bound.design.contentAlignment;
|
|
1177
|
+
let textOffset=0;
|
|
1178
|
+
const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
|
|
1179
|
+
const content=fit.richLines.flatMap(line=>line.fragments.map(fragment=>{
|
|
1180
|
+
const run=fragment.run,offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
|
|
1181
|
+
const rendered=tag('text',{...(config.options.trace?{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end}:{}),x:stableNumber(box.x+offset+fragment.x),y:stableNumber(box.y+line.baseline+fragment.baselineShift),'xml:space':'preserve','font-family':fontStack(fragment.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(fragment.fontSize),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':undefined,'text-decoration':[run.underline?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(run.color??'')?run.color:config.fill},escapeText(fragment.text));
|
|
1182
|
+
if(run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
|
|
1183
|
+
return rendered;
|
|
1184
|
+
}));
|
|
1185
|
+
let cursor=0;
|
|
1186
|
+
const whole=value.map(run=>typeof run==='string'?run:run.text).join('');
|
|
1187
|
+
const lineTrace=config.options.trace?fit.richLines.map((line,index)=>{
|
|
1188
|
+
if(index){const newline=/^(\r\n|\r|\n)/.exec(whole.slice(cursor));if(newline)cursor+=newline[0].length;}
|
|
1189
|
+
const start=cursor;cursor+=(fit.lines[index]??'').length;
|
|
1190
|
+
const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
|
|
1191
|
+
return {start,end:cursor,x:box.x+offset,y:box.y+line.y,height:line.height};
|
|
1192
|
+
}):undefined;
|
|
1193
|
+
return tag('g',{...traceAttrs(config.options,config.path),...(config.options.trace?{'data-opf-box-width':box.width,'data-opf-rich-text':config.rich===false?undefined:'true','data-opf-rich-lines':JSON.stringify(lineTrace)}:{}),...(fit.overflow?{'data-opf-overflow':'true'}:{})},content.join('\n'));
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function renderTextBox(text, box, bound, config) {
|
|
1197
|
+
const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
1198
|
+
const style = resolveTextStyle({fontFamily:config.fontFamily,fontWeight:config.fontWeight ?? 400,italic:config.italic ?? false,path:config.path},config.options.textMeasurement);
|
|
1199
|
+
const fit = fitText(String(text ?? ""), box, config.fontSize * scale,
|
|
1200
|
+
((bound.composition ?? bound.geometry.composition).minFontSize ?? 16) * scale, textWidthMeasurer(style,config.options.textMeasurement));
|
|
1201
|
+
if (fit.overflow) {
|
|
1202
|
+
const diagnostic = { code: "text-overflow", path: config.path,
|
|
1203
|
+
message: "Text exceeds its cell at the minimum font size; shorten it, increase its space, or split the slide." };
|
|
1204
|
+
reportDiagnostic(diagnostic, config.options);
|
|
1205
|
+
if ((bound.composition ?? bound.geometry.composition).overflow === "error") throw new OPFRenderError("layout-overflow", diagnostic.message, { path: diagnostic.path, issues: [diagnostic] });
|
|
1206
|
+
}
|
|
1207
|
+
const size = fit.fontSize;
|
|
1208
|
+
const totalHeight = fit.lines.length * fit.lineHeight;
|
|
1209
|
+
const startY = config.verticalAlign === "middle"
|
|
1210
|
+
? box.y + Math.max(0, (box.height - totalHeight) / 2) + size : box.y + size;
|
|
1211
|
+
const alignment=config.align??bound.design.contentAlignment;
|
|
1212
|
+
const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
|
|
1213
|
+
const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
|
|
1214
|
+
const lines = fit.lines.map((line, index) => tag("text", {
|
|
1215
|
+
x: stableNumber(x), y: stableNumber(startY + index * fit.lineHeight),
|
|
1216
|
+
"text-anchor": anchor, "font-family": fontStack(style.fontFamily, config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type),
|
|
1217
|
+
"font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
|
|
1218
|
+
...traceAttrs(config.options, config.path)
|
|
1219
|
+
}, escapeText(line)));
|
|
1220
|
+
return tag("g", { ...traceAttrs(config.options, config.path),
|
|
1221
|
+
...(fit.overflow ? { "data-opf-overflow": "true" } : {}) }, lines.join("\n"));
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function normalizeList(value) {
|
|
1225
|
+
if (!Array.isArray(value)) return [{ text: flattenText(value), level: 0 }];
|
|
1226
|
+
return value.map((item) => {
|
|
1227
|
+
if (Array.isArray(item)) return { text: flattenText(item), level: 0 };
|
|
1228
|
+
if (isPlainObject(item)) {
|
|
1229
|
+
return {
|
|
1230
|
+
text: flattenText(item.text ?? item.value ?? ""),
|
|
1231
|
+
description: item.description ? flattenText(item.description) : "",
|
|
1232
|
+
level: Number.isInteger(item.level) ? item.level : 0
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
return { text: flattenText(item), level: 0 };
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function flattenText(value) {
|
|
1240
|
+
if (value === null || value === undefined) return "";
|
|
1241
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
1242
|
+
if (Array.isArray(value)) return value.map((item) => flattenText(item)).join("");
|
|
1243
|
+
if (isPlainObject(value)) {
|
|
1244
|
+
if (value.text !== undefined) return flattenText(value.text);
|
|
1245
|
+
if (value.value !== undefined) return flattenText(value.value);
|
|
1246
|
+
if (value.source !== undefined) return flattenText(value.source);
|
|
1247
|
+
}
|
|
1248
|
+
return stableJson(value);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function normalizeAsset(value) {
|
|
1252
|
+
if (typeof value === "string") return { src: value };
|
|
1253
|
+
if (isPlainObject(value)) return value;
|
|
1254
|
+
return { src: "" };
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function inset(box, padding) {
|
|
1258
|
+
return {
|
|
1259
|
+
x: box.x + padding,
|
|
1260
|
+
y: box.y + padding,
|
|
1261
|
+
width: Math.max(1, box.width - padding * 2),
|
|
1262
|
+
height: Math.max(1, box.height - padding * 2)
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function centeredBox(box, width, height) {
|
|
1267
|
+
return {
|
|
1268
|
+
x: box.x + (box.width - width) / 2,
|
|
1269
|
+
y: box.y + (box.height - height) / 2,
|
|
1270
|
+
width,
|
|
1271
|
+
height
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function trianglePath(x, y, width, height) {
|
|
1276
|
+
return `M ${stableNumber(x)} ${stableNumber(y)} L ${stableNumber(x)} ${stableNumber(y + height)} L ${stableNumber(x + width)} ${stableNumber(y + height / 2)} Z`;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function traceAttrs(options, path) {
|
|
1280
|
+
return options.trace ? { "data-opf-path": path } : {};
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function stableNumber(value) {
|
|
1284
|
+
if (Number.isInteger(value)) return String(value);
|
|
1285
|
+
return Number(value).toFixed(3).replace(/\.?0+$/, "");
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function escapeText(value) {
|
|
1289
|
+
return String(value)
|
|
1290
|
+
.replaceAll("&", "&")
|
|
1291
|
+
.replaceAll("<", "<")
|
|
1292
|
+
.replaceAll(">", ">");
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function escapeAttr(value) {
|
|
1296
|
+
return escapeText(value).replaceAll('"', """);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function tag(name, attrs = {}, children = "") {
|
|
1300
|
+
const serializedAttrs = Object.keys(attrs)
|
|
1301
|
+
.filter((key) => attrs[key] !== undefined && attrs[key] !== null && attrs[key] !== false)
|
|
1302
|
+
.sort()
|
|
1303
|
+
.map((key) => ` ${key}="${escapeAttr(attrs[key])}"`)
|
|
1304
|
+
.join("");
|
|
1305
|
+
if (children === "") return `<${name}${serializedAttrs}/>`;
|
|
1306
|
+
return `<${name}${serializedAttrs}>${children}</${name}>`;
|
|
1307
|
+
}
|
|
1308
|
+
|