@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/index.js
CHANGED
|
@@ -1,1583 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
validatePresentation
|
|
4
|
-
} from "@openpresentation/opf";
|
|
5
|
-
|
|
6
|
-
export const packageName = "@openpresentation/opf-render";
|
|
7
|
-
|
|
8
|
-
export const releaseLane = Object.freeze({
|
|
9
|
-
githubRepository: "OpenPresentation/opf-render",
|
|
10
|
-
npmPackage: "@openpresentation/opf-render",
|
|
11
|
-
compatibilityPackage: "@openpresentation/opf"
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
export const runtimePolicy = Object.freeze({
|
|
15
|
-
hostedServiceInCriticalPath: false,
|
|
16
|
-
telemetry: false,
|
|
17
|
-
commercialSdkInCriticalPath: false,
|
|
18
|
-
requiredNetworkCalls: false,
|
|
19
|
-
deterministicLocalExecution: true
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
export const engineDefaults = Object.freeze({
|
|
23
|
-
catalogs: Object.freeze({
|
|
24
|
-
narratives: Object.freeze({ source: "https://www.pptx.gallery/narratives" }),
|
|
25
|
-
themes: Object.freeze({ source: "https://www.pptx.gallery/themes" }),
|
|
26
|
-
colorSchemes: Object.freeze({ source: "https://www.pptx.gallery/color-schemes" }),
|
|
27
|
-
fontSchemes: Object.freeze({ source: "https://www.pptx.gallery/font-schemes" }),
|
|
28
|
-
languages: Object.freeze({ source: "https://www.pptx.gallery/languages" }),
|
|
29
|
-
layouts: Object.freeze({ source: "https://www.pptx.gallery/layouts" }),
|
|
30
|
-
chartTypes: Object.freeze({ source: "https://www.pptx.gallery/chart-types" }),
|
|
31
|
-
tones: Object.freeze({ source: "https://www.pptx.gallery/tones" }),
|
|
32
|
-
audiences: Object.freeze({ source: "https://www.pptx.gallery/audiences" }),
|
|
33
|
-
socialPlatforms: Object.freeze({ source: "https://www.pptx.gallery/social-platforms" })
|
|
34
|
-
}),
|
|
35
|
-
theme: "minimal",
|
|
36
|
-
colorScheme: "cool-horizon",
|
|
37
|
-
language: "english",
|
|
38
|
-
narrative: "classic-story",
|
|
39
|
-
tone: "formal",
|
|
40
|
-
audience: "executives",
|
|
41
|
-
fontScheme: Object.freeze({
|
|
42
|
-
pptx: Object.freeze({ latin: "aptos", ea: "microsoft-yahei", cs: "nirmala-ui" }),
|
|
43
|
-
google: Object.freeze({ latin: "roboto", ea: "noto-sans-sc", cs: "noto-sans" })
|
|
44
|
-
}),
|
|
45
|
-
chartTypes: Object.freeze([
|
|
46
|
-
"stacked-column-3x",
|
|
47
|
-
"stacked-area-3x",
|
|
48
|
-
"line-with-markers-3x"
|
|
49
|
-
])
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
export class OPFRenderError extends Error {
|
|
53
|
-
constructor(code, message, details = {}) {
|
|
54
|
-
super(message);
|
|
55
|
-
this.name = "OPFRenderError";
|
|
56
|
-
this.code = code;
|
|
57
|
-
this.details = details;
|
|
58
|
-
if (details.issues) this.issues = details.issues;
|
|
59
|
-
if (details.path) this.path = details.path;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const ROOT_PAYLOAD_FIELDS = [
|
|
64
|
-
"text",
|
|
65
|
-
"items",
|
|
66
|
-
"bullets",
|
|
67
|
-
"image",
|
|
68
|
-
"video",
|
|
69
|
-
"chart",
|
|
70
|
-
"table",
|
|
71
|
-
"code",
|
|
72
|
-
"metric",
|
|
73
|
-
"quote",
|
|
74
|
-
"timeline"
|
|
75
|
-
];
|
|
76
|
-
|
|
77
|
-
const PROMOTED_REGION_KEYS = [
|
|
78
|
-
"left",
|
|
79
|
-
"center",
|
|
80
|
-
"right",
|
|
81
|
-
"left+center",
|
|
82
|
-
"center+right",
|
|
83
|
-
"left+center+right",
|
|
84
|
-
"top",
|
|
85
|
-
"middle",
|
|
86
|
-
"bottom",
|
|
87
|
-
"top+middle",
|
|
88
|
-
"middle+bottom",
|
|
89
|
-
"top+middle+bottom",
|
|
90
|
-
"top:left",
|
|
91
|
-
"top:center",
|
|
92
|
-
"top:right",
|
|
93
|
-
"top:left+center",
|
|
94
|
-
"top:center+right",
|
|
95
|
-
"top:left+center+right",
|
|
96
|
-
"middle:left",
|
|
97
|
-
"middle:center",
|
|
98
|
-
"middle:right",
|
|
99
|
-
"middle:left+center",
|
|
100
|
-
"middle:center+right",
|
|
101
|
-
"middle:left+center+right",
|
|
102
|
-
"bottom:left",
|
|
103
|
-
"bottom:center",
|
|
104
|
-
"bottom:right",
|
|
105
|
-
"bottom:left+center",
|
|
106
|
-
"bottom:center+right",
|
|
107
|
-
"bottom:left+center+right",
|
|
108
|
-
"top+middle:left",
|
|
109
|
-
"top+middle:center",
|
|
110
|
-
"top+middle:right",
|
|
111
|
-
"top+middle:left+center",
|
|
112
|
-
"top+middle:center+right",
|
|
113
|
-
"top+middle:left+center+right",
|
|
114
|
-
"middle+bottom:left",
|
|
115
|
-
"middle+bottom:center",
|
|
116
|
-
"middle+bottom:right",
|
|
117
|
-
"middle+bottom:left+center",
|
|
118
|
-
"middle+bottom:center+right",
|
|
119
|
-
"middle+bottom:left+center+right",
|
|
120
|
-
"top+middle+bottom:left",
|
|
121
|
-
"top+middle+bottom:center",
|
|
122
|
-
"top+middle+bottom:right",
|
|
123
|
-
"top+middle+bottom:left+center",
|
|
124
|
-
"top+middle+bottom:center+right",
|
|
125
|
-
"top+middle+bottom:left+center+right"
|
|
126
|
-
];
|
|
127
|
-
|
|
128
|
-
const TITLE_PLACEHOLDERS = new Set(["title", "subtitle", "tag"]);
|
|
129
|
-
|
|
130
|
-
const FIELD_TYPE = Object.freeze({
|
|
131
|
-
text: "text",
|
|
132
|
-
items: "list",
|
|
133
|
-
bullets: "list",
|
|
134
|
-
image: "image",
|
|
135
|
-
video: "video",
|
|
136
|
-
chart: "chart",
|
|
137
|
-
table: "table",
|
|
138
|
-
code: "code",
|
|
139
|
-
metric: "metric",
|
|
140
|
-
quote: "quote",
|
|
141
|
-
timeline: "timeline"
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
const PLACEHOLDER_COMPATIBILITY = Object.freeze({
|
|
145
|
-
text: ["text", "body"],
|
|
146
|
-
list: ["list", "body", "text"],
|
|
147
|
-
image: ["picture", "image", "body"],
|
|
148
|
-
video: ["media", "video", "body"],
|
|
149
|
-
chart: ["chart", "body"],
|
|
150
|
-
table: ["table", "body"],
|
|
151
|
-
code: ["code", "body", "text"],
|
|
152
|
-
metric: ["body", "text", "metric"],
|
|
153
|
-
quote: ["body", "text", "quote"],
|
|
154
|
-
timeline: ["body", "text", "timeline"]
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
const DIMENSIONS = Object.freeze({
|
|
158
|
-
widescreen: Object.freeze({ width: 1280, height: 720 }),
|
|
159
|
-
"16:9": Object.freeze({ width: 1280, height: 720 }),
|
|
160
|
-
"16:10": Object.freeze({ width: 1280, height: 800 }),
|
|
161
|
-
standard: Object.freeze({ width: 1024, height: 768 }),
|
|
162
|
-
"4:3": Object.freeze({ width: 1024, height: 768 })
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
const DEFAULT_DIMENSIONS = DIMENSIONS.widescreen;
|
|
166
|
-
const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
|
|
167
|
-
const DEFAULT_RASTER_SCALE = 1;
|
|
168
|
-
const DEFAULT_RASTER_BACKGROUND = "#FFFFFF";
|
|
169
|
-
let bundledFontFilesCache = null;
|
|
170
|
-
|
|
171
|
-
function parseInput(input) {
|
|
172
|
-
if (typeof input === "string") {
|
|
173
|
-
try {
|
|
174
|
-
return JSON.parse(input);
|
|
175
|
-
} catch (error) {
|
|
176
|
-
throw new OPFRenderError("invalid-json", "OPF input is not valid JSON.", {
|
|
177
|
-
cause: error instanceof Error ? error.message : String(error)
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (input instanceof Uint8Array) {
|
|
183
|
-
return parseInput(new TextDecoder().decode(input));
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (input && typeof input === "object") {
|
|
187
|
-
return input;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
throw new OPFRenderError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function assertValidBoundary(presentation) {
|
|
194
|
-
const result = validatePresentation(presentation);
|
|
195
|
-
if (!result.valid) {
|
|
196
|
-
throw new OPFRenderError("invalid-opf", "OPF validation failed.", {
|
|
197
|
-
issues: result.errors,
|
|
198
|
-
result
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function stableJson(value) {
|
|
204
|
-
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
205
|
-
if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
|
206
|
-
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function cloneWithSortedKeys(value) {
|
|
210
|
-
if (Array.isArray(value)) return value.map((item) => cloneWithSortedKeys(item));
|
|
211
|
-
if (!value || typeof value !== "object") return value;
|
|
212
|
-
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, cloneWithSortedKeys(value[key])]));
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function isPlainObject(value) {
|
|
216
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function referenceId(reference) {
|
|
220
|
-
if (typeof reference === "string") return reference;
|
|
221
|
-
if (isPlainObject(reference) && typeof reference.id === "string") return reference.id;
|
|
222
|
-
return null;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function normalizeSourceRecords(value) {
|
|
226
|
-
if (!value) return [];
|
|
227
|
-
if (Array.isArray(value)) return value;
|
|
228
|
-
if (Array.isArray(value.records)) return value.records;
|
|
229
|
-
return [];
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function findById(records, id) {
|
|
233
|
-
return records.find((record) => record && record.id === id) ?? null;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function defaultCatalogFor(kind) {
|
|
237
|
-
return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function sourceRecordsFor(kind, source, options) {
|
|
241
|
-
if (!source) return [];
|
|
242
|
-
const bySource = options.catalogSources?.[source];
|
|
243
|
-
if (bySource) return normalizeSourceRecords(bySource);
|
|
244
|
-
if (source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/")) {
|
|
245
|
-
return defaultCatalogFor(kind);
|
|
246
|
-
}
|
|
247
|
-
return [];
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function engineDefaultId(kind) {
|
|
251
|
-
if (kind === "fontSchemes") return engineDefaults.fontScheme.google.latin;
|
|
252
|
-
if (kind === "chartTypes") return engineDefaults.chartTypes[0];
|
|
253
|
-
const key = kind.endsWith("s") ? kind.slice(0, -1) : kind;
|
|
254
|
-
return engineDefaults[key];
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function resolveCatalogRecord(kind, reference, context, path, fallbackId = engineDefaultId(kind)) {
|
|
258
|
-
const id = referenceId(reference) ?? fallbackId;
|
|
259
|
-
const documentCatalog = context.presentation.catalogs?.[kind];
|
|
260
|
-
const documentRecords = normalizeSourceRecords(documentCatalog);
|
|
261
|
-
const injectedRecords = normalizeSourceRecords(context.options.catalogs?.[kind]);
|
|
262
|
-
const documentSource = documentCatalog?.source;
|
|
263
|
-
const sourceRecords = sourceRecordsFor(kind, documentSource, context.options);
|
|
264
|
-
const defaultSource = engineDefaults.catalogs[kind]?.source;
|
|
265
|
-
const engineSourceRecords = sourceRecordsFor(kind, defaultSource, context.options);
|
|
266
|
-
const defaultRecords = defaultCatalogFor(kind);
|
|
267
|
-
|
|
268
|
-
const record =
|
|
269
|
-
(id ? findById(documentRecords, id) : null) ??
|
|
270
|
-
(id ? findById(sourceRecords, id) : null) ??
|
|
271
|
-
(id ? findById(injectedRecords, id) : null) ??
|
|
272
|
-
(id ? findById(engineSourceRecords, id) : null) ??
|
|
273
|
-
(id ? findById(defaultRecords, id) : null);
|
|
274
|
-
|
|
275
|
-
if (record) {
|
|
276
|
-
return isPlainObject(reference)
|
|
277
|
-
? cloneWithSortedKeys({ ...record, ...reference })
|
|
278
|
-
: cloneWithSortedKeys(record);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (isPlainObject(reference)) {
|
|
282
|
-
return cloneWithSortedKeys(reference);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
throw new OPFRenderError("catalog-resolution-failed", `Could not resolve ${kind} reference '${id}'.`, {
|
|
286
|
-
kind,
|
|
287
|
-
id,
|
|
288
|
-
path,
|
|
289
|
-
source: documentSource ?? defaultSource ?? null
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function resolveDimensions(value) {
|
|
294
|
-
if (typeof value === "string") return DIMENSIONS[value] ?? DEFAULT_DIMENSIONS;
|
|
295
|
-
if (isPlainObject(value)) {
|
|
296
|
-
if (typeof value.preset === "string" && DIMENSIONS[value.preset]) return DIMENSIONS[value.preset];
|
|
297
|
-
if (typeof value.width === "number" && typeof value.height === "number") {
|
|
298
|
-
return {
|
|
299
|
-
width: Math.max(1, Math.round(value.width)),
|
|
300
|
-
height: Math.max(1, Math.round(value.height))
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
return DEFAULT_DIMENSIONS;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function colorFromScheme(scheme, slot, fallback) {
|
|
308
|
-
if (!slot) return fallback;
|
|
309
|
-
if (typeof slot === "string") {
|
|
310
|
-
if (slot.startsWith("#")) return normalizeColor(slot, fallback);
|
|
311
|
-
return normalizeColor(scheme[slot], fallback);
|
|
312
|
-
}
|
|
313
|
-
if (isPlainObject(slot)) {
|
|
314
|
-
if (slot.type === "theme") return normalizeColor(scheme[slot.slot], fallback);
|
|
315
|
-
if (slot.type === "solid") return normalizeColor(slot.color, fallback);
|
|
316
|
-
}
|
|
317
|
-
return fallback;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function normalizeColor(value, fallback) {
|
|
321
|
-
if (typeof value !== "string") return fallback;
|
|
322
|
-
const trimmed = value.trim();
|
|
323
|
-
if (/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(trimmed)) return trimmed.toUpperCase();
|
|
324
|
-
return fallback;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function resolveBackground(background, colorScheme) {
|
|
328
|
-
if (!background) return colorFromScheme(colorScheme, "light1", "#FFFFFF");
|
|
329
|
-
if (typeof background === "string") return colorFromScheme(colorScheme, background, "#FFFFFF");
|
|
330
|
-
if (background.type === "theme") return colorFromScheme(colorScheme, background.slot, "#FFFFFF");
|
|
331
|
-
if (background.type === "solid") return normalizeColor(background.color, "#FFFFFF");
|
|
332
|
-
if (background.type === "gradient") return null;
|
|
333
|
-
if (background.type === "pattern") return normalizeColor(background.pattern?.backgroundColor, "#FFFFFF");
|
|
334
|
-
return colorFromScheme(colorScheme, "light1", "#FFFFFF");
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function fontFamily(fontScheme, role) {
|
|
338
|
-
const roleValue = fontScheme?.[role];
|
|
339
|
-
if (isPlainObject(roleValue) && typeof roleValue.family === "string") return roleValue.family;
|
|
340
|
-
if (role === "heading" && typeof fontScheme?.major === "string") return fontScheme.major;
|
|
341
|
-
if (role === "code" && typeof fontScheme?.code === "string") return fontScheme.code;
|
|
342
|
-
if (role === "code") return "Roboto Mono";
|
|
343
|
-
if (typeof fontScheme?.minor === "string") return fontScheme.minor;
|
|
344
|
-
if (typeof fontScheme?.major === "string") return fontScheme.major;
|
|
345
|
-
if (typeof fontScheme?.name === "string") return fontScheme.name;
|
|
346
|
-
return "Roboto";
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function resolveDesign(presentation, slide, context) {
|
|
350
|
-
const deckDesign = presentation.design ?? {};
|
|
351
|
-
const slideDesign = slide.design ?? {};
|
|
352
|
-
|
|
353
|
-
const theme = resolveCatalogRecord(
|
|
354
|
-
"themes",
|
|
355
|
-
slideDesign.theme ?? deckDesign.theme ?? engineDefaults.theme,
|
|
356
|
-
context,
|
|
357
|
-
"design.theme"
|
|
358
|
-
);
|
|
359
|
-
const colorScheme = resolveCatalogRecord(
|
|
360
|
-
"colorSchemes",
|
|
361
|
-
slideDesign.colorScheme ?? deckDesign.colorScheme ?? theme.colorScheme ?? engineDefaults.colorScheme,
|
|
362
|
-
context,
|
|
363
|
-
"design.colorScheme"
|
|
364
|
-
);
|
|
365
|
-
const fontScheme = resolveCatalogRecord(
|
|
366
|
-
"fontSchemes",
|
|
367
|
-
slideDesign.fontScheme ?? deckDesign.fontScheme ?? theme.fontScheme ?? engineDefaults.fontScheme.google.latin,
|
|
368
|
-
context,
|
|
369
|
-
"design.fontScheme"
|
|
370
|
-
);
|
|
371
|
-
const dimensions = resolveDimensions(slideDesign.dimensions ?? deckDesign.dimensions ?? theme.dimensions);
|
|
372
|
-
const backgroundDefinition = slideDesign.background ?? deckDesign.background ?? theme.background;
|
|
373
|
-
|
|
374
|
-
return {
|
|
375
|
-
theme,
|
|
376
|
-
colorScheme,
|
|
377
|
-
fontScheme,
|
|
378
|
-
dimensions,
|
|
379
|
-
background: backgroundDefinition,
|
|
380
|
-
backgroundColor: resolveBackground(backgroundDefinition, colorScheme),
|
|
381
|
-
colors: {
|
|
382
|
-
background: colorFromScheme(colorScheme, "light1", "#FFFFFF"),
|
|
383
|
-
surface: colorFromScheme(colorScheme, "light2", "#F8FAFC"),
|
|
384
|
-
text: colorFromScheme(colorScheme, "dark1", "#111827"),
|
|
385
|
-
mutedText: colorFromScheme(colorScheme, "dark2", "#334155"),
|
|
386
|
-
primary: normalizeColor(colorScheme.primary, null) ?? colorFromScheme(colorScheme, "accent1", "#2563EB"),
|
|
387
|
-
secondary: normalizeColor(colorScheme.secondary, null) ?? colorFromScheme(colorScheme, "accent2", "#0F766E"),
|
|
388
|
-
accent: normalizeColor(colorScheme.accent, null) ?? colorFromScheme(colorScheme, "accent3", "#F59E0B"),
|
|
389
|
-
border: colorFromScheme(colorScheme, "accent5", "#CBD5E1")
|
|
390
|
-
},
|
|
391
|
-
fonts: {
|
|
392
|
-
heading: fontFamily(fontScheme, "heading"),
|
|
393
|
-
body: fontFamily(fontScheme, "body"),
|
|
394
|
-
code: fontFamily(fontScheme, "code")
|
|
395
|
-
}
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function inferLayoutId(slide) {
|
|
400
|
-
if (slide.layout) return slide.layout;
|
|
401
|
-
if (slide.blocks?.length) return "blank";
|
|
402
|
-
if (PROMOTED_REGION_KEYS.some((key) => key in slide)) return "blank";
|
|
403
|
-
if (slide.chart) return "chart-1x";
|
|
404
|
-
if (slide.table) return "table-1x";
|
|
405
|
-
if (slide.image) return "image-1x";
|
|
406
|
-
if (slide.video) return "media-1x";
|
|
407
|
-
if (slide.code) return "code-1x";
|
|
408
|
-
if (slide.items || slide.bullets) return "list-1x";
|
|
409
|
-
if (slide.text || slide.metric || slide.quote || slide.timeline) return "text-1x";
|
|
410
|
-
if (slide.subtitle) return "title-subtitle";
|
|
411
|
-
return "title";
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function resolveLayout(slide, context, index) {
|
|
415
|
-
const layoutId = inferLayoutId(slide);
|
|
416
|
-
try {
|
|
417
|
-
return resolveCatalogRecord("layouts", layoutId, context, `slides.${index}.layout`);
|
|
418
|
-
} catch (error) {
|
|
419
|
-
if (error instanceof OPFRenderError && isPlainObject(slide.layout)) return slide.layout;
|
|
420
|
-
throw error;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function contentPayloadFromHost(host, path, slot) {
|
|
425
|
-
if (!isPlainObject(host)) {
|
|
426
|
-
return { type: "text", field: "text", value: host, slot: slot ?? "text", path };
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
for (const field of ROOT_PAYLOAD_FIELDS) {
|
|
430
|
-
if (host[field] !== undefined) {
|
|
431
|
-
return {
|
|
432
|
-
type: host.type ?? FIELD_TYPE[field],
|
|
433
|
-
field,
|
|
434
|
-
value: host[field],
|
|
435
|
-
slot: host.slot ?? slot ?? host.type ?? FIELD_TYPE[field],
|
|
436
|
-
path: `${path}.${field}`
|
|
437
|
-
};
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
return {
|
|
442
|
-
type: "text",
|
|
443
|
-
field: "text",
|
|
444
|
-
value: stableJson(host),
|
|
445
|
-
slot: slot ?? "text",
|
|
446
|
-
path
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function slideRootContent(slide, slidePath) {
|
|
451
|
-
const items = [];
|
|
452
|
-
for (const field of ROOT_PAYLOAD_FIELDS) {
|
|
453
|
-
if (slide[field] !== undefined) {
|
|
454
|
-
items.push({
|
|
455
|
-
type: slide.type ?? FIELD_TYPE[field],
|
|
456
|
-
field,
|
|
457
|
-
value: slide[field],
|
|
458
|
-
slot: slide.type ?? FIELD_TYPE[field],
|
|
459
|
-
path: `${slidePath}.${field}`
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
return items;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function promotedRegionContent(slide, slidePath) {
|
|
467
|
-
const items = [];
|
|
468
|
-
for (const key of PROMOTED_REGION_KEYS) {
|
|
469
|
-
if (slide[key] !== undefined) {
|
|
470
|
-
const payload = contentPayloadFromHost(slide[key], `${slidePath}.${key}`, key);
|
|
471
|
-
items.push({ ...payload, slot: key, regionKey: key });
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
return items;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function blockContent(slide, slidePath) {
|
|
478
|
-
if (!Array.isArray(slide.blocks)) return [];
|
|
479
|
-
return slide.blocks.map((block, index) => contentPayloadFromHost(block, `${slidePath}.blocks.${index}`, "body"));
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
function normalizeFutureContent(slide, slidePath) {
|
|
483
|
-
if (!Array.isArray(slide.content)) return [];
|
|
484
|
-
return slide.content.map((item, index) => {
|
|
485
|
-
const payload = contentPayloadFromHost(item, `${slidePath}.content.${index}`, item.slot);
|
|
486
|
-
return { ...payload, slot: item.slot ?? payload.slot, path: `${slidePath}.content.${index}` };
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function bindSlide(presentation, slide, layout, index, context) {
|
|
491
|
-
const slidePath = `slides.${index}`;
|
|
492
|
-
const placeholders = Array.isArray(layout.placeholders) ? layout.placeholders : [];
|
|
493
|
-
const titleBindings = [];
|
|
494
|
-
|
|
495
|
-
for (let i = 0; i < placeholders.length; i += 1) {
|
|
496
|
-
const type = placeholders[i]?.type;
|
|
497
|
-
if (!TITLE_PLACEHOLDERS.has(type)) continue;
|
|
498
|
-
const value = slide[type] ?? presentation[type] ?? "";
|
|
499
|
-
if (value) {
|
|
500
|
-
titleBindings.push({
|
|
501
|
-
type: "text",
|
|
502
|
-
field: type,
|
|
503
|
-
slot: type,
|
|
504
|
-
value,
|
|
505
|
-
placeholderIndex: i,
|
|
506
|
-
path: `${slidePath}.${type}`
|
|
507
|
-
});
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
const contentItems = [
|
|
512
|
-
...normalizeFutureContent(slide, slidePath),
|
|
513
|
-
...slideRootContent(slide, slidePath),
|
|
514
|
-
...promotedRegionContent(slide, slidePath)
|
|
515
|
-
];
|
|
516
|
-
const blocks = blockContent(slide, slidePath);
|
|
517
|
-
|
|
518
|
-
return {
|
|
519
|
-
index,
|
|
520
|
-
path: slidePath,
|
|
521
|
-
slide,
|
|
522
|
-
layout,
|
|
523
|
-
design: resolveDesign(presentation, slide, context),
|
|
524
|
-
titleBindings,
|
|
525
|
-
contentItems,
|
|
526
|
-
blocks
|
|
527
|
-
};
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
export function resolvePresentation(input, options = {}) {
|
|
531
|
-
const presentation = parseInput(input);
|
|
532
|
-
assertValidBoundary(presentation);
|
|
533
|
-
|
|
534
|
-
const context = { presentation, options };
|
|
535
|
-
const slides = presentation.slides.map((slide, index) => {
|
|
536
|
-
const layout = resolveLayout(slide, context, index);
|
|
537
|
-
return bindSlide(presentation, slide, layout, index, context);
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
return {
|
|
541
|
-
presentation,
|
|
542
|
-
engineDefaults,
|
|
543
|
-
slides
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
export function renderSvg(input, options = {}) {
|
|
548
|
-
const resolved = resolvePresentation(input, options);
|
|
549
|
-
const slideIndex = options.slideIndex ?? 0;
|
|
550
|
-
if (!Number.isInteger(slideIndex) || slideIndex < 0 || slideIndex >= resolved.slides.length) {
|
|
551
|
-
throw new OPFRenderError("slide-index-out-of-range", `Slide index ${slideIndex} is out of range.`, {
|
|
552
|
-
slideIndex,
|
|
553
|
-
slideCount: resolved.slides.length
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
return renderResolvedSlide(resolved, slideIndex, options);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
export function renderSvgDeck(input, options = {}) {
|
|
560
|
-
const resolved = resolvePresentation(input, options);
|
|
561
|
-
return resolved.slides.map((_, index) => renderResolvedSlide(resolved, index, options));
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
export async function svgToPng(svg, options = {}) {
|
|
565
|
-
const rendered = await rasterizeSvg(svg, options);
|
|
566
|
-
return rendered.png;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
export async function svgToPdf(svgs, options = {}) {
|
|
570
|
-
const pdfInputs = normalizeSvgList(svgs);
|
|
571
|
-
if (!pdfInputs.length) {
|
|
572
|
-
throw new OPFRenderError("empty-pdf", "svgToPdf requires at least one SVG slide.");
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
const { PDFDocument } = await loadPdfLib();
|
|
576
|
-
const pdf = await PDFDocument.create({ updateMetadata: false });
|
|
577
|
-
pdf.setCreator(packageName);
|
|
578
|
-
pdf.setProducer(packageName);
|
|
579
|
-
|
|
580
|
-
for (const input of pdfInputs) {
|
|
581
|
-
const svg = normalizeSvgInput(input);
|
|
582
|
-
const pageSize = svgPageSize(svg);
|
|
583
|
-
const rendered = await rasterizeSvg(svg, options);
|
|
584
|
-
const image = await pdf.embedPng(rendered.png);
|
|
585
|
-
const page = pdf.addPage([pageSize.width, pageSize.height]);
|
|
586
|
-
page.drawImage(image, {
|
|
587
|
-
x: 0,
|
|
588
|
-
y: 0,
|
|
589
|
-
width: pageSize.width,
|
|
590
|
-
height: pageSize.height
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
return pdf.save({ addDefaultPage: false, useObjectStreams: false });
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
function renderResolvedSlide(resolved, slideIndex, options) {
|
|
598
|
-
const bound = resolved.slides[slideIndex];
|
|
599
|
-
const { width, height } = bound.design.dimensions;
|
|
600
|
-
const title = bound.slide.title ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
|
|
601
|
-
const children = [
|
|
602
|
-
renderBackground(bound, width, height, options),
|
|
603
|
-
...renderSlideContent(bound, width, height, options),
|
|
604
|
-
renderFooter(bound, resolved.presentation, width, height, options)
|
|
605
|
-
].filter(Boolean);
|
|
606
|
-
|
|
607
|
-
return tag(
|
|
608
|
-
"svg",
|
|
609
|
-
{
|
|
610
|
-
xmlns: "http://www.w3.org/2000/svg",
|
|
611
|
-
role: "img",
|
|
612
|
-
"aria-label": title,
|
|
613
|
-
viewBox: `0 0 ${width} ${height}`,
|
|
614
|
-
width,
|
|
615
|
-
height,
|
|
616
|
-
...traceAttrs(options, bound.path)
|
|
617
|
-
},
|
|
618
|
-
`\n${children.join("\n")}\n`
|
|
619
|
-
);
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
function renderBackground(bound, width, height, options) {
|
|
623
|
-
const background = bound.design.background;
|
|
624
|
-
if (isPlainObject(background) && background.type === "gradient") {
|
|
625
|
-
const id = `opf-s${bound.index + 1}-background`;
|
|
626
|
-
const stops = Array.isArray(background.gradient?.stops) ? background.gradient.stops : [];
|
|
627
|
-
const stopTags = stops.map((stop, index) => tag("stop", {
|
|
628
|
-
offset: stableNumber((stop.position ?? index / Math.max(1, stops.length - 1)) * 100) + "%",
|
|
629
|
-
"stop-color": normalizeColor(stop.color, bound.design.colors.background)
|
|
630
|
-
}));
|
|
631
|
-
return [
|
|
632
|
-
tag("defs", traceAttrs(options, `${bound.path}.design.background`), tag("linearGradient", {
|
|
633
|
-
id,
|
|
634
|
-
x1: "0%",
|
|
635
|
-
x2: "100%",
|
|
636
|
-
y1: "0%",
|
|
637
|
-
y2: "100%"
|
|
638
|
-
}, stopTags.join(""))),
|
|
639
|
-
tag("rect", {
|
|
640
|
-
x: 0,
|
|
641
|
-
y: 0,
|
|
642
|
-
width,
|
|
643
|
-
height,
|
|
644
|
-
fill: `url(#${id})`,
|
|
645
|
-
...traceAttrs(options, `${bound.path}.design.background`)
|
|
646
|
-
})
|
|
647
|
-
].join("\n");
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
return tag("rect", {
|
|
651
|
-
x: 0,
|
|
652
|
-
y: 0,
|
|
653
|
-
width,
|
|
654
|
-
height,
|
|
655
|
-
fill: bound.design.backgroundColor ?? bound.design.colors.background,
|
|
656
|
-
...traceAttrs(options, `${bound.path}.design.background`)
|
|
657
|
-
});
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
function renderSlideContent(bound, width, height, options) {
|
|
661
|
-
const titleArea = titleAreas(bound, width, height);
|
|
662
|
-
const contentArea = contentBounds(titleArea, width, height);
|
|
663
|
-
const parts = [];
|
|
664
|
-
|
|
665
|
-
for (const binding of bound.titleBindings) {
|
|
666
|
-
const box = titleArea[binding.slot];
|
|
667
|
-
if (box) parts.push(renderPayload(binding, box, bound, options));
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const regionItems = bound.contentItems.filter((item) => item.regionKey);
|
|
671
|
-
const normalItems = bound.contentItems.filter((item) => !item.regionKey);
|
|
672
|
-
|
|
673
|
-
for (const item of regionItems) {
|
|
674
|
-
parts.push(renderPayload(item, regionBox(item.regionKey, contentArea), bound, options));
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
if (bound.blocks.length) {
|
|
678
|
-
parts.push(...renderGridItems(bound.blocks, contentArea, bound, options));
|
|
679
|
-
} else {
|
|
680
|
-
parts.push(...renderBoundPlaceholders(normalItems, bound, contentArea, options));
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
if (!parts.length) {
|
|
684
|
-
const fallback = {
|
|
685
|
-
type: "text",
|
|
686
|
-
field: "title",
|
|
687
|
-
value: bound.slide.title ?? "",
|
|
688
|
-
path: `${bound.path}.title`
|
|
689
|
-
};
|
|
690
|
-
if (fallback.value) parts.push(renderPayload(fallback, contentArea, bound, options));
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
return parts;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function titleAreas(bound, width, height) {
|
|
697
|
-
const placeholders = bound.layout.placeholders ?? [];
|
|
698
|
-
const hasTitle = placeholders.some((placeholder) => placeholder?.type === "title") || Boolean(bound.slide.title);
|
|
699
|
-
const hasSubtitle = placeholders.some((placeholder) => placeholder?.type === "subtitle") || Boolean(bound.slide.subtitle);
|
|
700
|
-
const marginX = Math.round(width * 0.07);
|
|
701
|
-
const top = Math.round(height * 0.075);
|
|
702
|
-
const titleHeight = hasSubtitle ? Math.round(height * 0.12) : Math.round(height * 0.15);
|
|
703
|
-
const subtitleHeight = Math.round(height * 0.07);
|
|
704
|
-
|
|
705
|
-
return {
|
|
706
|
-
tag: { x: marginX, y: top - 28, width: width - marginX * 2, height: 24 },
|
|
707
|
-
title: { x: marginX, y: top, width: width - marginX * 2, height: hasTitle ? titleHeight : 0 },
|
|
708
|
-
subtitle: {
|
|
709
|
-
x: marginX,
|
|
710
|
-
y: top + titleHeight,
|
|
711
|
-
width: width - marginX * 2,
|
|
712
|
-
height: hasSubtitle ? subtitleHeight : 0
|
|
713
|
-
}
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
function contentBounds(titleArea, width, height) {
|
|
718
|
-
const marginX = Math.round(width * 0.07);
|
|
719
|
-
const marginBottom = Math.round(height * 0.08);
|
|
720
|
-
const titleBottom = Math.max(
|
|
721
|
-
titleArea.title.y + titleArea.title.height,
|
|
722
|
-
titleArea.subtitle.y + titleArea.subtitle.height
|
|
723
|
-
);
|
|
724
|
-
const y = titleBottom + Math.round(height * 0.035);
|
|
725
|
-
return {
|
|
726
|
-
x: marginX,
|
|
727
|
-
y,
|
|
728
|
-
width: width - marginX * 2,
|
|
729
|
-
height: Math.max(80, height - y - marginBottom)
|
|
730
|
-
};
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
function renderBoundPlaceholders(items, bound, contentArea, options) {
|
|
734
|
-
if (!items.length) return [];
|
|
735
|
-
const placeholders = (bound.layout.placeholders ?? [])
|
|
736
|
-
.map((placeholder, index) => ({ type: placeholder.type, index }))
|
|
737
|
-
.filter((placeholder) => !TITLE_PLACEHOLDERS.has(placeholder.type));
|
|
738
|
-
|
|
739
|
-
if (!placeholders.length) {
|
|
740
|
-
return renderGridItems(items, contentArea, bound, options);
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
const boxes = placeholderBoxes(placeholders, contentArea);
|
|
744
|
-
const remaining = new Map(placeholders.map((placeholder) => [placeholder.index, placeholder]));
|
|
745
|
-
const rendered = [];
|
|
746
|
-
const unbound = [];
|
|
747
|
-
|
|
748
|
-
for (const item of items) {
|
|
749
|
-
const placeholder = takeCompatiblePlaceholder(item, remaining);
|
|
750
|
-
if (!placeholder) {
|
|
751
|
-
unbound.push(item);
|
|
752
|
-
continue;
|
|
753
|
-
}
|
|
754
|
-
rendered.push(renderPayload(item, boxes.get(placeholder.index), bound, options));
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
if (unbound.length) {
|
|
758
|
-
rendered.push(...renderGridItems(unbound, contentArea, bound, options));
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
return rendered;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
function takeCompatiblePlaceholder(item, remaining) {
|
|
765
|
-
const wanted = [item.slot, item.type, ...(PLACEHOLDER_COMPATIBILITY[item.type] ?? [])].filter(Boolean);
|
|
766
|
-
for (const slot of wanted) {
|
|
767
|
-
for (const [index, placeholder] of remaining) {
|
|
768
|
-
if (placeholder.type === slot) {
|
|
769
|
-
remaining.delete(index);
|
|
770
|
-
return placeholder;
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
const first = remaining.entries().next().value;
|
|
775
|
-
if (!first) return null;
|
|
776
|
-
remaining.delete(first[0]);
|
|
777
|
-
return first[1];
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
function placeholderBoxes(placeholders, area) {
|
|
781
|
-
const map = new Map();
|
|
782
|
-
const boxes = gridBoxes(placeholders.length, area);
|
|
783
|
-
placeholders.forEach((placeholder, index) => map.set(placeholder.index, boxes[index]));
|
|
784
|
-
return map;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
function renderGridItems(items, area, bound, options) {
|
|
788
|
-
const boxes = gridBoxes(items.length, area);
|
|
789
|
-
return items.map((item, index) => renderPayload(item, boxes[index], bound, options));
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
function gridBoxes(count, area) {
|
|
793
|
-
if (count <= 0) return [];
|
|
794
|
-
const columns = count <= 3 ? count : Math.ceil(Math.sqrt(count));
|
|
795
|
-
const rows = Math.ceil(count / columns);
|
|
796
|
-
const gap = 24;
|
|
797
|
-
const cellWidth = (area.width - gap * (columns - 1)) / columns;
|
|
798
|
-
const cellHeight = (area.height - gap * (rows - 1)) / rows;
|
|
799
|
-
const boxes = [];
|
|
800
|
-
|
|
801
|
-
for (let index = 0; index < count; index += 1) {
|
|
802
|
-
const column = index % columns;
|
|
803
|
-
const row = Math.floor(index / columns);
|
|
804
|
-
boxes.push({
|
|
805
|
-
x: Math.round(area.x + column * (cellWidth + gap)),
|
|
806
|
-
y: Math.round(area.y + row * (cellHeight + gap)),
|
|
807
|
-
width: Math.round(cellWidth),
|
|
808
|
-
height: Math.round(cellHeight)
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
return boxes;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
function regionBox(regionKey, area) {
|
|
816
|
-
const [rowKey, columnKey] = regionKey.includes(":") ? regionKey.split(":") : [null, regionKey];
|
|
817
|
-
const rowSpan = span(rowKey, ["top", "middle", "bottom"]);
|
|
818
|
-
const columnSpan = span(columnKey, ["left", "center", "right"]);
|
|
819
|
-
const gap = 20;
|
|
820
|
-
const colWidth = (area.width - gap * 2) / 3;
|
|
821
|
-
const rowHeight = (area.height - gap * 2) / 3;
|
|
822
|
-
const firstColumn = columnSpan[0];
|
|
823
|
-
const lastColumn = columnSpan[columnSpan.length - 1];
|
|
824
|
-
const firstRow = rowSpan[0];
|
|
825
|
-
const lastRow = rowSpan[rowSpan.length - 1];
|
|
826
|
-
|
|
827
|
-
return {
|
|
828
|
-
x: Math.round(area.x + firstColumn * (colWidth + gap)),
|
|
829
|
-
y: Math.round(area.y + firstRow * (rowHeight + gap)),
|
|
830
|
-
width: Math.round((lastColumn - firstColumn + 1) * colWidth + (lastColumn - firstColumn) * gap),
|
|
831
|
-
height: Math.round((lastRow - firstRow + 1) * rowHeight + (lastRow - firstRow) * gap)
|
|
832
|
-
};
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
function span(key, values) {
|
|
836
|
-
if (!key) return [0, 1, 2];
|
|
837
|
-
const pieces = key.split("+");
|
|
838
|
-
const indexes = pieces.map((piece) => values.indexOf(piece)).filter((index) => index >= 0);
|
|
839
|
-
return indexes.length ? indexes : [0, 1, 2];
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
function renderPayload(item, box, bound, options) {
|
|
843
|
-
if (!box) return "";
|
|
844
|
-
switch (item.type) {
|
|
845
|
-
case "chart":
|
|
846
|
-
return renderChart(item, box, bound, options);
|
|
847
|
-
case "table":
|
|
848
|
-
return renderTable(item, box, bound, options);
|
|
849
|
-
case "image":
|
|
850
|
-
return renderImage(item, box, bound, options);
|
|
851
|
-
case "video":
|
|
852
|
-
return renderMedia(item, box, bound, options);
|
|
853
|
-
case "code":
|
|
854
|
-
return renderCode(item, box, bound, options);
|
|
855
|
-
case "metric":
|
|
856
|
-
return renderMetric(item, box, bound, options);
|
|
857
|
-
case "quote":
|
|
858
|
-
return renderQuote(item, box, bound, options);
|
|
859
|
-
case "timeline":
|
|
860
|
-
return renderTimeline(item, box, bound, options);
|
|
861
|
-
case "list":
|
|
862
|
-
return renderList(item, box, bound, options);
|
|
863
|
-
default:
|
|
864
|
-
return renderTextPayload(item, box, bound, options);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
function renderTextPayload(item, box, bound, options) {
|
|
869
|
-
return renderTextBox(flattenText(item.value), box, bound, {
|
|
870
|
-
path: item.path,
|
|
871
|
-
fontSize: item.field === "title" ? 54 : item.field === "subtitle" ? 25 : 25,
|
|
872
|
-
fontFamily: item.field === "title" ? bound.design.fonts.heading : bound.design.fonts.body,
|
|
873
|
-
fontWeight: item.field === "title" ? 700 : 400,
|
|
874
|
-
fill: bound.design.colors.text,
|
|
875
|
-
options
|
|
876
|
-
});
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
function renderList(item, box, bound, options) {
|
|
880
|
-
const values = normalizeList(item.value);
|
|
881
|
-
if (!values.length) return "";
|
|
882
|
-
const lines = values.flatMap((entry) => {
|
|
883
|
-
const marker = entry.level > 0 ? "-" : "*";
|
|
884
|
-
const text = `${" ".repeat(Math.min(3, entry.level))}${marker} ${entry.text}`;
|
|
885
|
-
return entry.description ? [text, ` ${entry.description}`] : [text];
|
|
886
|
-
});
|
|
887
|
-
return renderTextBox(lines.join("\n"), box, bound, {
|
|
888
|
-
path: item.path,
|
|
889
|
-
fontSize: 23,
|
|
890
|
-
fontFamily: bound.design.fonts.body,
|
|
891
|
-
fontWeight: 400,
|
|
892
|
-
fill: bound.design.colors.text,
|
|
893
|
-
options
|
|
894
|
-
});
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
function renderImage(item, box, bound, options) {
|
|
898
|
-
const asset = normalizeAsset(item.value);
|
|
899
|
-
const fill = bound.design.colors.surface;
|
|
900
|
-
const children = [
|
|
901
|
-
tag("rect", {
|
|
902
|
-
x: box.x,
|
|
903
|
-
y: box.y,
|
|
904
|
-
width: box.width,
|
|
905
|
-
height: box.height,
|
|
906
|
-
rx: 0,
|
|
907
|
-
fill,
|
|
908
|
-
stroke: bound.design.colors.border,
|
|
909
|
-
"stroke-width": 1,
|
|
910
|
-
...traceAttrs(options, item.path)
|
|
911
|
-
}),
|
|
912
|
-
renderTextBox(asset.alt || asset.title || asset.src || "Image", inset(box, 24), bound, {
|
|
913
|
-
path: item.path,
|
|
914
|
-
fontSize: 20,
|
|
915
|
-
fontFamily: bound.design.fonts.body,
|
|
916
|
-
fontWeight: 600,
|
|
917
|
-
fill: bound.design.colors.mutedText,
|
|
918
|
-
options,
|
|
919
|
-
align: "center",
|
|
920
|
-
verticalAlign: "middle"
|
|
921
|
-
})
|
|
922
|
-
];
|
|
923
|
-
|
|
924
|
-
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
function renderMedia(item, box, bound, options) {
|
|
928
|
-
const asset = normalizeAsset(item.value);
|
|
929
|
-
const iconBox = centeredBox(box, 72, 72);
|
|
930
|
-
const children = [
|
|
931
|
-
tag("rect", {
|
|
932
|
-
x: box.x,
|
|
933
|
-
y: box.y,
|
|
934
|
-
width: box.width,
|
|
935
|
-
height: box.height,
|
|
936
|
-
fill: bound.design.colors.surface,
|
|
937
|
-
stroke: bound.design.colors.border,
|
|
938
|
-
"stroke-width": 1,
|
|
939
|
-
...traceAttrs(options, item.path)
|
|
940
|
-
}),
|
|
941
|
-
tag("circle", {
|
|
942
|
-
cx: iconBox.x + iconBox.width / 2,
|
|
943
|
-
cy: iconBox.y + iconBox.height / 2,
|
|
944
|
-
r: 36,
|
|
945
|
-
fill: bound.design.colors.primary,
|
|
946
|
-
...traceAttrs(options, item.path)
|
|
947
|
-
}),
|
|
948
|
-
tag("path", {
|
|
949
|
-
d: trianglePath(iconBox.x + 28, iconBox.y + 22, 28, 28),
|
|
950
|
-
fill: "#FFFFFF",
|
|
951
|
-
...traceAttrs(options, item.path)
|
|
952
|
-
}),
|
|
953
|
-
renderTextBox(asset.title || asset.src || "Media", { ...box, y: iconBox.y + iconBox.height + 20, height: 50 }, bound, {
|
|
954
|
-
path: item.path,
|
|
955
|
-
fontSize: 18,
|
|
956
|
-
fontFamily: bound.design.fonts.body,
|
|
957
|
-
fontWeight: 600,
|
|
958
|
-
fill: bound.design.colors.mutedText,
|
|
959
|
-
options,
|
|
960
|
-
align: "center"
|
|
961
|
-
})
|
|
962
|
-
];
|
|
963
|
-
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
function renderCode(item, box, bound, options) {
|
|
967
|
-
const code = typeof item.value === "string" ? item.value : item.value?.source ?? stableJson(item.value);
|
|
968
|
-
const label = typeof item.value === "object" && item.value?.language ? String(item.value.language) : "code";
|
|
969
|
-
const children = [
|
|
970
|
-
tag("rect", {
|
|
971
|
-
x: box.x,
|
|
972
|
-
y: box.y,
|
|
973
|
-
width: box.width,
|
|
974
|
-
height: box.height,
|
|
975
|
-
fill: "#111827",
|
|
976
|
-
stroke: "#334155",
|
|
977
|
-
"stroke-width": 1,
|
|
978
|
-
...traceAttrs(options, item.path)
|
|
979
|
-
}),
|
|
980
|
-
tag("text", {
|
|
981
|
-
x: box.x + 18,
|
|
982
|
-
y: box.y + 28,
|
|
983
|
-
fill: "#93C5FD",
|
|
984
|
-
"font-family": bound.design.fonts.code,
|
|
985
|
-
"font-size": 14,
|
|
986
|
-
"font-weight": 700,
|
|
987
|
-
...traceAttrs(options, item.path)
|
|
988
|
-
}, escapeText(label.toUpperCase())),
|
|
989
|
-
renderTextBox(code, { x: box.x + 18, y: box.y + 46, width: box.width - 36, height: box.height - 64 }, bound, {
|
|
990
|
-
path: item.path,
|
|
991
|
-
fontSize: 18,
|
|
992
|
-
fontFamily: bound.design.fonts.code,
|
|
993
|
-
fontWeight: 400,
|
|
994
|
-
fill: "#E5E7EB",
|
|
995
|
-
options,
|
|
996
|
-
preserveNewlines: true
|
|
997
|
-
})
|
|
998
|
-
];
|
|
999
|
-
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
function renderMetric(item, box, bound, options) {
|
|
1003
|
-
const metric = isPlainObject(item.value) ? item.value : { value: item.value };
|
|
1004
|
-
const children = [
|
|
1005
|
-
renderTextBox(String(metric.value ?? ""), { ...box, height: box.height * 0.45 }, bound, {
|
|
1006
|
-
path: `${item.path}.value`,
|
|
1007
|
-
fontSize: Math.min(76, box.height * 0.28),
|
|
1008
|
-
fontFamily: bound.design.fonts.heading,
|
|
1009
|
-
fontWeight: 800,
|
|
1010
|
-
fill: bound.design.colors.primary,
|
|
1011
|
-
options
|
|
1012
|
-
}),
|
|
1013
|
-
renderTextBox([metric.label, metric.description, metric.delta].filter(Boolean).join("\n"), {
|
|
1014
|
-
x: box.x,
|
|
1015
|
-
y: box.y + box.height * 0.45,
|
|
1016
|
-
width: box.width,
|
|
1017
|
-
height: box.height * 0.55
|
|
1018
|
-
}, bound, {
|
|
1019
|
-
path: item.path,
|
|
1020
|
-
fontSize: 23,
|
|
1021
|
-
fontFamily: bound.design.fonts.body,
|
|
1022
|
-
fontWeight: 500,
|
|
1023
|
-
fill: bound.design.colors.text,
|
|
1024
|
-
options
|
|
1025
|
-
})
|
|
1026
|
-
];
|
|
1027
|
-
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
function renderQuote(item, box, bound, options) {
|
|
1031
|
-
const quote = isPlainObject(item.value) ? item.value : { text: item.value };
|
|
1032
|
-
const children = [
|
|
1033
|
-
renderTextBox(`"${quote.text ?? ""}"`, inset(box, 18), bound, {
|
|
1034
|
-
path: `${item.path}.text`,
|
|
1035
|
-
fontSize: 28,
|
|
1036
|
-
fontFamily: bound.design.fonts.heading,
|
|
1037
|
-
fontWeight: 600,
|
|
1038
|
-
fill: bound.design.colors.text,
|
|
1039
|
-
options
|
|
1040
|
-
}),
|
|
1041
|
-
renderTextBox([quote.attribution, quote.source].filter(Boolean).join(" - "), {
|
|
1042
|
-
x: box.x + 18,
|
|
1043
|
-
y: box.y + box.height - 58,
|
|
1044
|
-
width: box.width - 36,
|
|
1045
|
-
height: 40
|
|
1046
|
-
}, bound, {
|
|
1047
|
-
path: item.path,
|
|
1048
|
-
fontSize: 17,
|
|
1049
|
-
fontFamily: bound.design.fonts.body,
|
|
1050
|
-
fontWeight: 500,
|
|
1051
|
-
fill: bound.design.colors.mutedText,
|
|
1052
|
-
options
|
|
1053
|
-
})
|
|
1054
|
-
];
|
|
1055
|
-
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
function renderTimeline(item, box, bound, options) {
|
|
1059
|
-
const timeline = Array.isArray(item.value) ? { events: item.value } : item.value;
|
|
1060
|
-
const events = Array.isArray(timeline?.events) ? timeline.events : [];
|
|
1061
|
-
if (!events.length) return "";
|
|
1062
|
-
const gap = box.width / Math.max(1, events.length - 1);
|
|
1063
|
-
const y = box.y + box.height * 0.46;
|
|
1064
|
-
const children = [
|
|
1065
|
-
tag("line", {
|
|
1066
|
-
x1: box.x + 20,
|
|
1067
|
-
x2: box.x + box.width - 20,
|
|
1068
|
-
y1: y,
|
|
1069
|
-
y2: y,
|
|
1070
|
-
stroke: bound.design.colors.border,
|
|
1071
|
-
"stroke-width": 3,
|
|
1072
|
-
...traceAttrs(options, item.path)
|
|
1073
|
-
})
|
|
1074
|
-
];
|
|
1075
|
-
|
|
1076
|
-
events.forEach((event, index) => {
|
|
1077
|
-
const x = events.length === 1 ? box.x + box.width / 2 : box.x + index * gap;
|
|
1078
|
-
const eventPath = `${item.path}.events.${index}`;
|
|
1079
|
-
children.push(tag("circle", {
|
|
1080
|
-
cx: x,
|
|
1081
|
-
cy: y,
|
|
1082
|
-
r: 9,
|
|
1083
|
-
fill: bound.design.colors.primary,
|
|
1084
|
-
...traceAttrs(options, eventPath)
|
|
1085
|
-
}));
|
|
1086
|
-
children.push(renderTextBox([event.when, event.what, event.description].filter(Boolean).join("\n"), {
|
|
1087
|
-
x: x - box.width / Math.max(2, events.length) / 2,
|
|
1088
|
-
y: index % 2 === 0 ? box.y : y + 24,
|
|
1089
|
-
width: box.width / Math.max(2, events.length),
|
|
1090
|
-
height: box.height * 0.38
|
|
1091
|
-
}, bound, {
|
|
1092
|
-
path: eventPath,
|
|
1093
|
-
fontSize: 16,
|
|
1094
|
-
fontFamily: bound.design.fonts.body,
|
|
1095
|
-
fontWeight: 500,
|
|
1096
|
-
fill: bound.design.colors.text,
|
|
1097
|
-
options,
|
|
1098
|
-
align: "center"
|
|
1099
|
-
}));
|
|
1100
|
-
});
|
|
1101
|
-
|
|
1102
|
-
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
function renderTable(item, box, bound, options) {
|
|
1106
|
-
const table = item.value ?? {};
|
|
1107
|
-
const columns = Array.isArray(table.columns) ? table.columns : [];
|
|
1108
|
-
const rows = Array.isArray(table.rows) ? table.rows : [];
|
|
1109
|
-
const allRows = columns.length ? [columns, ...rows] : rows;
|
|
1110
|
-
const columnCount = Math.max(1, ...allRows.map((row) => Array.isArray(row) ? row.length : 1));
|
|
1111
|
-
const rowCount = Math.max(1, allRows.length);
|
|
1112
|
-
const cellWidth = box.width / columnCount;
|
|
1113
|
-
const cellHeight = Math.min(54, box.height / rowCount);
|
|
1114
|
-
const children = [];
|
|
1115
|
-
|
|
1116
|
-
allRows.forEach((row, rowIndex) => {
|
|
1117
|
-
const cells = Array.isArray(row) ? row : [row];
|
|
1118
|
-
for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
|
|
1119
|
-
const x = box.x + columnIndex * cellWidth;
|
|
1120
|
-
const y = box.y + rowIndex * cellHeight;
|
|
1121
|
-
const cellPath = `${item.path}.rows.${Math.max(0, rowIndex - (columns.length ? 1 : 0))}.${columnIndex}`;
|
|
1122
|
-
children.push(tag("rect", {
|
|
1123
|
-
x: stableNumber(x),
|
|
1124
|
-
y: stableNumber(y),
|
|
1125
|
-
width: stableNumber(cellWidth),
|
|
1126
|
-
height: stableNumber(cellHeight),
|
|
1127
|
-
fill: rowIndex === 0 && columns.length ? bound.design.colors.primary : "#FFFFFF",
|
|
1128
|
-
stroke: bound.design.colors.border,
|
|
1129
|
-
"stroke-width": 1,
|
|
1130
|
-
...traceAttrs(options, cellPath)
|
|
1131
|
-
}));
|
|
1132
|
-
children.push(renderTextBox(flattenText(cells[columnIndex] ?? ""), {
|
|
1133
|
-
x: x + 10,
|
|
1134
|
-
y: y + 8,
|
|
1135
|
-
width: cellWidth - 20,
|
|
1136
|
-
height: cellHeight - 12
|
|
1137
|
-
}, bound, {
|
|
1138
|
-
path: cellPath,
|
|
1139
|
-
fontSize: 15,
|
|
1140
|
-
fontFamily: bound.design.fonts.body,
|
|
1141
|
-
fontWeight: rowIndex === 0 && columns.length ? 700 : 400,
|
|
1142
|
-
fill: rowIndex === 0 && columns.length ? "#FFFFFF" : bound.design.colors.text,
|
|
1143
|
-
options
|
|
1144
|
-
}));
|
|
1145
|
-
}
|
|
1146
|
-
});
|
|
1147
|
-
|
|
1148
|
-
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
function renderChart(item, box, bound, options) {
|
|
1152
|
-
const chart = item.value ?? {};
|
|
1153
|
-
const chartType = chart.type ?? engineDefaults.chartTypes[0];
|
|
1154
|
-
const data = inlineChartRows(chart.data);
|
|
1155
|
-
const plot = inset(box, 28);
|
|
1156
|
-
const max = Math.max(1, ...data.map((row) => Math.abs(row.value)));
|
|
1157
|
-
const children = [
|
|
1158
|
-
tag("rect", {
|
|
1159
|
-
x: box.x,
|
|
1160
|
-
y: box.y,
|
|
1161
|
-
width: box.width,
|
|
1162
|
-
height: box.height,
|
|
1163
|
-
fill: "#FFFFFF",
|
|
1164
|
-
stroke: bound.design.colors.border,
|
|
1165
|
-
"stroke-width": 1,
|
|
1166
|
-
...traceAttrs(options, item.path)
|
|
1167
|
-
})
|
|
1168
|
-
];
|
|
1169
|
-
|
|
1170
|
-
if (!data.length) {
|
|
1171
|
-
children.push(renderTextBox("No chart data", plot, bound, {
|
|
1172
|
-
path: item.path,
|
|
1173
|
-
fontSize: 20,
|
|
1174
|
-
fontFamily: bound.design.fonts.body,
|
|
1175
|
-
fontWeight: 500,
|
|
1176
|
-
fill: bound.design.colors.mutedText,
|
|
1177
|
-
options,
|
|
1178
|
-
align: "center",
|
|
1179
|
-
verticalAlign: "middle"
|
|
1180
|
-
}));
|
|
1181
|
-
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
if (chartType.includes("line") || chartType.includes("area")) {
|
|
1185
|
-
const points = data.map((row, index) => {
|
|
1186
|
-
const x = plot.x + (data.length === 1 ? plot.width / 2 : (plot.width / (data.length - 1)) * index);
|
|
1187
|
-
const y = plot.y + plot.height - (Math.abs(row.value) / max) * plot.height;
|
|
1188
|
-
return [x, y];
|
|
1189
|
-
});
|
|
1190
|
-
children.push(tag("polyline", {
|
|
1191
|
-
points: points.map(([x, y]) => `${stableNumber(x)},${stableNumber(y)}`).join(" "),
|
|
1192
|
-
fill: "none",
|
|
1193
|
-
stroke: bound.design.colors.primary,
|
|
1194
|
-
"stroke-width": 4,
|
|
1195
|
-
...traceAttrs(options, `${item.path}.data`)
|
|
1196
|
-
}));
|
|
1197
|
-
points.forEach(([x, y], index) => children.push(tag("circle", {
|
|
1198
|
-
cx: stableNumber(x),
|
|
1199
|
-
cy: stableNumber(y),
|
|
1200
|
-
r: 5,
|
|
1201
|
-
fill: bound.design.colors.primary,
|
|
1202
|
-
...traceAttrs(options, `${item.path}.data.rows.${index}`)
|
|
1203
|
-
})));
|
|
1204
|
-
} else {
|
|
1205
|
-
const gap = 10;
|
|
1206
|
-
const barWidth = Math.max(6, (plot.width - gap * (data.length - 1)) / data.length);
|
|
1207
|
-
data.forEach((row, index) => {
|
|
1208
|
-
const barHeight = (Math.abs(row.value) / max) * plot.height;
|
|
1209
|
-
const x = plot.x + index * (barWidth + gap);
|
|
1210
|
-
const y = plot.y + plot.height - barHeight;
|
|
1211
|
-
children.push(tag("rect", {
|
|
1212
|
-
x: stableNumber(x),
|
|
1213
|
-
y: stableNumber(y),
|
|
1214
|
-
width: stableNumber(barWidth),
|
|
1215
|
-
height: stableNumber(barHeight),
|
|
1216
|
-
fill: index % 2 === 0 ? bound.design.colors.primary : bound.design.colors.secondary,
|
|
1217
|
-
...traceAttrs(options, `${item.path}.data.rows.${index}`)
|
|
1218
|
-
}));
|
|
1219
|
-
});
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
children.push(renderTextBox(data.map((row) => row.label).join(" "), {
|
|
1223
|
-
x: plot.x,
|
|
1224
|
-
y: plot.y + plot.height + 8,
|
|
1225
|
-
width: plot.width,
|
|
1226
|
-
height: 24
|
|
1227
|
-
}, bound, {
|
|
1228
|
-
path: `${item.path}.data`,
|
|
1229
|
-
fontSize: 12,
|
|
1230
|
-
fontFamily: bound.design.fonts.body,
|
|
1231
|
-
fontWeight: 400,
|
|
1232
|
-
fill: bound.design.colors.mutedText,
|
|
1233
|
-
options,
|
|
1234
|
-
align: "center"
|
|
1235
|
-
}));
|
|
1236
|
-
|
|
1237
|
-
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
function inlineChartRows(data) {
|
|
1241
|
-
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
|
1242
|
-
return rows.map((row, index) => {
|
|
1243
|
-
const cells = Array.isArray(row) ? row : [row];
|
|
1244
|
-
const value = cells.find((cell) => typeof cell === "number") ?? Number(cells.find((cell) => Number.isFinite(Number(cell))) ?? 0);
|
|
1245
|
-
return {
|
|
1246
|
-
label: flattenText(cells.find((cell) => typeof cell === "string") ?? `Row ${index + 1}`),
|
|
1247
|
-
value: Number.isFinite(value) ? value : 0
|
|
1248
|
-
};
|
|
1249
|
-
});
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
function renderFooter(bound, presentation, width, height, options) {
|
|
1253
|
-
const footer = bound.slide.design?.footer ?? presentation.design?.footer;
|
|
1254
|
-
if (!footer) return "";
|
|
1255
|
-
const pieces = [];
|
|
1256
|
-
if (footer.left?.text) pieces.push({ text: footer.left.text, x: width * 0.07, anchor: "start", path: `${bound.path}.design.footer.left` });
|
|
1257
|
-
if (footer.center?.text) pieces.push({ text: footer.center.text, x: width / 2, anchor: "middle", path: `${bound.path}.design.footer.center` });
|
|
1258
|
-
if (footer.right?.text || footer.right?.slideNumber) {
|
|
1259
|
-
pieces.push({
|
|
1260
|
-
text: footer.right?.slideNumber ? String(bound.index + 1) : footer.right.text,
|
|
1261
|
-
x: width * 0.93,
|
|
1262
|
-
anchor: "end",
|
|
1263
|
-
path: `${bound.path}.design.footer.right`
|
|
1264
|
-
});
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
return tag("g", traceAttrs(options, `${bound.path}.design.footer`), pieces.map((piece) => tag("text", {
|
|
1268
|
-
x: stableNumber(piece.x),
|
|
1269
|
-
y: height - 28,
|
|
1270
|
-
"text-anchor": piece.anchor,
|
|
1271
|
-
"font-family": bound.design.fonts.body,
|
|
1272
|
-
"font-size": 13,
|
|
1273
|
-
fill: bound.design.colors.mutedText,
|
|
1274
|
-
...traceAttrs(options, piece.path)
|
|
1275
|
-
}, escapeText(flattenText(piece.text)))).join("\n"));
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
function renderTextBox(text, box, bound, config) {
|
|
1279
|
-
const rawLines = config.preserveNewlines
|
|
1280
|
-
? String(text ?? "").split(/\r?\n/)
|
|
1281
|
-
: flattenText(text).replace(/\s+/g, " ").trim().split(/\r?\n/);
|
|
1282
|
-
const size = fitFontSize(rawLines, box, config.fontSize, config.fontFamily, config.fontWeight);
|
|
1283
|
-
const wrapped = rawLines.flatMap((line) => wrapLine(line, box.width, size));
|
|
1284
|
-
const lineHeight = size * 1.22;
|
|
1285
|
-
const totalHeight = wrapped.length * lineHeight;
|
|
1286
|
-
const startY = config.verticalAlign === "middle"
|
|
1287
|
-
? box.y + Math.max(0, (box.height - totalHeight) / 2) + size
|
|
1288
|
-
: box.y + size;
|
|
1289
|
-
const anchor = config.align === "center" ? "middle" : "start";
|
|
1290
|
-
const x = config.align === "center" ? box.x + box.width / 2 : box.x;
|
|
1291
|
-
|
|
1292
|
-
const lines = wrapped.slice(0, Math.floor(box.height / lineHeight) || 1).map((line, index) => tag("text", {
|
|
1293
|
-
x: stableNumber(x),
|
|
1294
|
-
y: stableNumber(startY + index * lineHeight),
|
|
1295
|
-
"text-anchor": anchor,
|
|
1296
|
-
"font-family": config.fontFamily,
|
|
1297
|
-
"font-size": stableNumber(size),
|
|
1298
|
-
"font-weight": config.fontWeight,
|
|
1299
|
-
fill: config.fill,
|
|
1300
|
-
...traceAttrs(config.options, config.path)
|
|
1301
|
-
}, escapeText(line)));
|
|
1302
|
-
|
|
1303
|
-
return tag("g", traceAttrs(config.options, config.path), lines.join("\n"));
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
function fitFontSize(lines, box, requestedSize) {
|
|
1307
|
-
let size = requestedSize;
|
|
1308
|
-
while (size > 10) {
|
|
1309
|
-
const wrapped = lines.flatMap((line) => wrapLine(line, box.width, size));
|
|
1310
|
-
if (wrapped.length * size * 1.22 <= box.height + 0.1) return size;
|
|
1311
|
-
size -= 1;
|
|
1312
|
-
}
|
|
1313
|
-
return size;
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
function wrapLine(line, width, fontSize) {
|
|
1317
|
-
const words = String(line ?? "").split(/\s+/).filter(Boolean);
|
|
1318
|
-
if (!words.length) return [""];
|
|
1319
|
-
const lines = [];
|
|
1320
|
-
let current = "";
|
|
1321
|
-
|
|
1322
|
-
for (const word of words) {
|
|
1323
|
-
const next = current ? `${current} ${word}` : word;
|
|
1324
|
-
if (measureText(next, fontSize) <= width || !current) {
|
|
1325
|
-
current = next;
|
|
1326
|
-
} else {
|
|
1327
|
-
lines.push(current);
|
|
1328
|
-
current = word;
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
if (current) lines.push(current);
|
|
1333
|
-
return lines;
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
function measureText(text, fontSize) {
|
|
1337
|
-
let units = 0;
|
|
1338
|
-
for (const character of String(text)) {
|
|
1339
|
-
if (character === " ") units += 0.32;
|
|
1340
|
-
else if (/[A-Z0-9]/.test(character)) units += 0.62;
|
|
1341
|
-
else if (character.charCodeAt(0) > 0x2e80) units += 1;
|
|
1342
|
-
else units += 0.54;
|
|
1343
|
-
}
|
|
1344
|
-
return units * fontSize;
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
function normalizeList(value) {
|
|
1348
|
-
if (!Array.isArray(value)) return [{ text: flattenText(value), level: 0 }];
|
|
1349
|
-
return value.map((item) => {
|
|
1350
|
-
if (Array.isArray(item)) return { text: flattenText(item), level: 0 };
|
|
1351
|
-
if (isPlainObject(item)) {
|
|
1352
|
-
return {
|
|
1353
|
-
text: flattenText(item.text ?? item.value ?? ""),
|
|
1354
|
-
description: item.description ? flattenText(item.description) : "",
|
|
1355
|
-
level: Number.isInteger(item.level) ? item.level : 0
|
|
1356
|
-
};
|
|
1357
|
-
}
|
|
1358
|
-
return { text: flattenText(item), level: 0 };
|
|
1359
|
-
});
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
function flattenText(value) {
|
|
1363
|
-
if (value === null || value === undefined) return "";
|
|
1364
|
-
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
1365
|
-
if (Array.isArray(value)) return value.map((item) => flattenText(item)).join("");
|
|
1366
|
-
if (isPlainObject(value)) {
|
|
1367
|
-
if (value.text !== undefined) return flattenText(value.text);
|
|
1368
|
-
if (value.value !== undefined) return flattenText(value.value);
|
|
1369
|
-
if (value.source !== undefined) return flattenText(value.source);
|
|
1370
|
-
}
|
|
1371
|
-
return stableJson(value);
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
function normalizeAsset(value) {
|
|
1375
|
-
if (typeof value === "string") return { src: value };
|
|
1376
|
-
if (isPlainObject(value)) return value;
|
|
1377
|
-
return { src: "" };
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
function inset(box, padding) {
|
|
1381
|
-
return {
|
|
1382
|
-
x: box.x + padding,
|
|
1383
|
-
y: box.y + padding,
|
|
1384
|
-
width: Math.max(1, box.width - padding * 2),
|
|
1385
|
-
height: Math.max(1, box.height - padding * 2)
|
|
1386
|
-
};
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
function centeredBox(box, width, height) {
|
|
1390
|
-
return {
|
|
1391
|
-
x: box.x + (box.width - width) / 2,
|
|
1392
|
-
y: box.y + (box.height - height) / 2,
|
|
1393
|
-
width,
|
|
1394
|
-
height
|
|
1395
|
-
};
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
function trianglePath(x, y, width, height) {
|
|
1399
|
-
return `M ${stableNumber(x)} ${stableNumber(y)} L ${stableNumber(x)} ${stableNumber(y + height)} L ${stableNumber(x + width)} ${stableNumber(y + height / 2)} Z`;
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
function traceAttrs(options, path) {
|
|
1403
|
-
return options.trace ? { "data-opf-path": path } : {};
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
function stableNumber(value) {
|
|
1407
|
-
if (Number.isInteger(value)) return String(value);
|
|
1408
|
-
return Number(value).toFixed(3).replace(/\.?0+$/, "");
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
function escapeText(value) {
|
|
1412
|
-
return String(value)
|
|
1413
|
-
.replaceAll("&", "&")
|
|
1414
|
-
.replaceAll("<", "<")
|
|
1415
|
-
.replaceAll(">", ">");
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
function escapeAttr(value) {
|
|
1419
|
-
return escapeText(value).replaceAll('"', """);
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
function tag(name, attrs = {}, children = "") {
|
|
1423
|
-
const serializedAttrs = Object.keys(attrs)
|
|
1424
|
-
.filter((key) => attrs[key] !== undefined && attrs[key] !== null && attrs[key] !== false)
|
|
1425
|
-
.sort()
|
|
1426
|
-
.map((key) => ` ${key}="${escapeAttr(attrs[key])}"`)
|
|
1427
|
-
.join("");
|
|
1428
|
-
if (children === "") return `<${name}${serializedAttrs}/>`;
|
|
1429
|
-
return `<${name}${serializedAttrs}>${children}</${name}>`;
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
async function rasterizeSvg(svgInput, options) {
|
|
1433
|
-
const { Resvg } = await loadResvg();
|
|
1434
|
-
const svg = normalizeSvgInput(svgInput);
|
|
1435
|
-
const scale = positiveNumber(options.scale, "scale", DEFAULT_RASTER_SCALE);
|
|
1436
|
-
const fontFiles = [
|
|
1437
|
-
...(options.useBundledFonts === false ? [] : await bundledFontFiles()),
|
|
1438
|
-
...stringArray(options.fontFiles)
|
|
1439
|
-
];
|
|
1440
|
-
const renderOptions = {
|
|
1441
|
-
fitTo: { mode: "zoom", value: scale },
|
|
1442
|
-
background: options.background ?? DEFAULT_RASTER_BACKGROUND,
|
|
1443
|
-
font: {
|
|
1444
|
-
loadSystemFonts: options.loadSystemFonts === true,
|
|
1445
|
-
fontFiles,
|
|
1446
|
-
fontDirs: stringArray(options.fontDirs),
|
|
1447
|
-
defaultFontFamily: options.defaultFontFamily ?? "Roboto",
|
|
1448
|
-
sansSerifFamily: options.sansSerifFamily ?? options.defaultFontFamily ?? "Roboto",
|
|
1449
|
-
monospaceFamily: options.monospaceFamily ?? "Roboto Mono"
|
|
1450
|
-
},
|
|
1451
|
-
logLevel: "off"
|
|
1452
|
-
};
|
|
1453
|
-
|
|
1454
|
-
if (options.dpi !== undefined) renderOptions.dpi = positiveNumber(options.dpi, "dpi", 96);
|
|
1455
|
-
|
|
1456
|
-
try {
|
|
1457
|
-
const image = new Resvg(svg, renderOptions).render();
|
|
1458
|
-
return {
|
|
1459
|
-
png: new Uint8Array(image.asPng()),
|
|
1460
|
-
width: image.width,
|
|
1461
|
-
height: image.height
|
|
1462
|
-
};
|
|
1463
|
-
} catch (error) {
|
|
1464
|
-
throw new OPFRenderError("png-render-failed", "SVG to PNG conversion failed.", {
|
|
1465
|
-
cause: error instanceof Error ? error.message : String(error)
|
|
1466
|
-
});
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
async function loadResvg() {
|
|
1471
|
-
try {
|
|
1472
|
-
return await import("@resvg/resvg-js");
|
|
1473
|
-
} catch (error) {
|
|
1474
|
-
throw new OPFRenderError("png-renderer-unavailable", "PNG/PDF conversion requires the Node runtime dependency @resvg/resvg-js.", {
|
|
1475
|
-
cause: error instanceof Error ? error.message : String(error)
|
|
1476
|
-
});
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
async function loadPdfLib() {
|
|
1481
|
-
try {
|
|
1482
|
-
return await import("pdf-lib");
|
|
1483
|
-
} catch (error) {
|
|
1484
|
-
throw new OPFRenderError("pdf-writer-unavailable", "PDF conversion requires the runtime dependency pdf-lib.", {
|
|
1485
|
-
cause: error instanceof Error ? error.message : String(error)
|
|
1486
|
-
});
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
async function bundledFontFiles() {
|
|
1491
|
-
if (!bundledFontFilesCache) {
|
|
1492
|
-
bundledFontFilesCache = resolveBundledFontFiles();
|
|
1493
|
-
}
|
|
1494
|
-
return bundledFontFilesCache;
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
async function resolveBundledFontFiles() {
|
|
1498
|
-
try {
|
|
1499
|
-
const { createRequire } = await import("node:module");
|
|
1500
|
-
const path = await import("node:path");
|
|
1501
|
-
const require = createRequire(import.meta.url);
|
|
1502
|
-
const roboto = path.dirname(require.resolve("@expo-google-fonts/roboto/package.json"));
|
|
1503
|
-
const robotoMono = path.dirname(require.resolve("@expo-google-fonts/roboto-mono/package.json"));
|
|
1504
|
-
return [
|
|
1505
|
-
path.join(roboto, "400Regular/Roboto_400Regular.ttf"),
|
|
1506
|
-
path.join(roboto, "500Medium/Roboto_500Medium.ttf"),
|
|
1507
|
-
path.join(roboto, "700Bold/Roboto_700Bold.ttf"),
|
|
1508
|
-
path.join(roboto, "800ExtraBold/Roboto_800ExtraBold.ttf"),
|
|
1509
|
-
path.join(robotoMono, "400Regular/RobotoMono_400Regular.ttf"),
|
|
1510
|
-
path.join(robotoMono, "700Bold/RobotoMono_700Bold.ttf")
|
|
1511
|
-
];
|
|
1512
|
-
} catch {
|
|
1513
|
-
return [];
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
function normalizeSvgList(value) {
|
|
1518
|
-
if (Array.isArray(value)) return value;
|
|
1519
|
-
return [value];
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
function normalizeSvgInput(value) {
|
|
1523
|
-
if (typeof value === "string") return value;
|
|
1524
|
-
if (value instanceof Uint8Array) return new TextDecoder().decode(value);
|
|
1525
|
-
throw new OPFRenderError("invalid-svg-input", "SVG input must be a string or Uint8Array.");
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
function positiveNumber(value, name, fallback) {
|
|
1529
|
-
if (value === undefined || value === null) return fallback;
|
|
1530
|
-
const number = Number(value);
|
|
1531
|
-
if (Number.isFinite(number) && number > 0) return number;
|
|
1532
|
-
throw new OPFRenderError("invalid-conversion-option", `${name} must be a positive finite number.`, {
|
|
1533
|
-
option: name,
|
|
1534
|
-
value
|
|
1535
|
-
});
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
function stringArray(value) {
|
|
1539
|
-
if (!Array.isArray(value)) return [];
|
|
1540
|
-
return value.filter((item) => typeof item === "string");
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
function svgPageSize(svg) {
|
|
1544
|
-
const attrs = svgRootAttrs(svg);
|
|
1545
|
-
const viewBox = parseViewBox(attrs.viewBox);
|
|
1546
|
-
return {
|
|
1547
|
-
width: positiveDimension(parseSvgLength(attrs.width) ?? viewBox?.width, DEFAULT_DIMENSIONS.width),
|
|
1548
|
-
height: positiveDimension(parseSvgLength(attrs.height) ?? viewBox?.height, DEFAULT_DIMENSIONS.height)
|
|
1549
|
-
};
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
function svgRootAttrs(svg) {
|
|
1553
|
-
const match = svg.match(/<svg\s+([^>]*?)>/i);
|
|
1554
|
-
if (!match) {
|
|
1555
|
-
throw new OPFRenderError("invalid-svg", "SVG input must contain a root <svg> element.");
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
const attrs = {};
|
|
1559
|
-
const attrPattern = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
1560
|
-
for (const attr of match[1].matchAll(attrPattern)) {
|
|
1561
|
-
attrs[attr[1]] = attr[2] ?? attr[3] ?? "";
|
|
1562
|
-
}
|
|
1563
|
-
return attrs;
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
function parseViewBox(value) {
|
|
1567
|
-
if (typeof value !== "string") return null;
|
|
1568
|
-
const parts = value.trim().split(/[\s,]+/).map(Number);
|
|
1569
|
-
if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) return null;
|
|
1570
|
-
return { width: parts[2], height: parts[3] };
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
function parseSvgLength(value) {
|
|
1574
|
-
if (typeof value !== "string" && typeof value !== "number") return null;
|
|
1575
|
-
const match = String(value).trim().match(/^-?\d+(?:\.\d+)?/);
|
|
1576
|
-
if (!match) return null;
|
|
1577
|
-
const number = Number(match[0]);
|
|
1578
|
-
return Number.isFinite(number) ? number : null;
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
function positiveDimension(value, fallback) {
|
|
1582
|
-
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
1583
|
-
}
|
|
1
|
+
export * from './svg.js';
|
|
2
|
+
export {svgToPng,svgToPdf} from './raster.js';
|