@openpresentation/opf-render 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -76
- package/dist/chart-options.js +26 -21
- package/dist/charts.js +204 -117
- package/dist/deck-runtime.js +21 -34
- package/dist/element.d.ts +11 -6
- package/dist/element.js +28 -18
- package/dist/export-browser.d.ts +7 -1
- package/dist/export-browser.js +7 -6
- package/dist/font-compatibility.js +4 -4
- package/dist/font-registry.js +324 -0
- package/dist/fonts-browser.d.ts +25 -3
- package/dist/fonts-browser.js +54 -3
- package/dist/fonts-node.d.ts +32 -16
- package/dist/fonts-node.js +110 -50
- package/dist/fonts.d.ts +48 -27
- package/dist/fonts.js +7 -320
- package/dist/index.d.ts +36 -16
- package/dist/lazy-font-list.js +1 -1
- package/dist/lazy-fonts.js +7 -7
- package/dist/pdf-fonts.js +1 -1
- package/dist/player.d.ts +4 -4
- package/dist/player.js +5 -5
- package/dist/preview-fonts.d.ts +4 -4
- package/dist/preview-fonts.js +6 -5
- package/dist/raster.js +24 -9
- package/dist/script-font-pack.js +19 -26
- package/dist/script-fonts.js +6 -17
- package/dist/svg.d.ts +30 -14
- package/dist/svg.js +286 -266
- package/dist/symbol-fonts.js +22 -55
- package/package.json +2 -2
- package/dist/font-policy.js +0 -2734
- package/dist/symbol-encodings.js +0 -7
package/dist/svg.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
// The layout engine lives in core's `/composition` entry. Every name is a static import, so a core without one fails to
|
|
2
|
+
// load instead of switching a feature (script fonts, right-to-left, patterns, code colours, trend arrows) off.
|
|
3
|
+
import {
|
|
4
|
+
layoutTable, fitList, fitRichText, composeSlide, resolveTextStyle, textWidthMeasurer, fitText, textColorForFill, chartColorForFill,
|
|
5
|
+
resolveColorRef as resolveCoreColorRef, resolveScriptFonts, paragraphDirection, physicalAlignment, patternRuns,
|
|
6
|
+
metricTrendMark as coreMetricTrendMark, tokenizeCode, codeLineRuns, codeSyntaxPaletteForScheme, resolveChartData,
|
|
7
|
+
resolveColorRoles, defaultSlideBackground, timelineMarkerShapes, timelineTextColor, layoutWatermark,
|
|
8
|
+
codeHighlightLines, codeLineNumbers, codeHighlightBands, codeHighlightColors
|
|
9
|
+
} from "@openpresentation/opf/composition";
|
|
2
10
|
import {
|
|
3
11
|
catalogs as bundledCatalogs,
|
|
4
|
-
|
|
5
|
-
|
|
12
|
+
hasContentVariables,
|
|
13
|
+
isTemplate,
|
|
14
|
+
resolveSlideContext,
|
|
15
|
+
resolveVariables,
|
|
16
|
+
validate
|
|
6
17
|
} from "@openpresentation/opf";
|
|
7
|
-
// Optional core exports are read from the namespace so an older published core
|
|
8
|
-
// still loads; resolveScriptFonts ships with core FF-18.
|
|
9
|
-
import * as opfCore from "@openpresentation/opf";
|
|
10
18
|
import { adjustedFontSize, baselineShift, createScriptFonts } from "./script-fonts.js";
|
|
11
19
|
import { disabledFeaturesStyle } from "./font-compatibility.js";
|
|
12
|
-
import { fontPolicyFor } from "
|
|
20
|
+
import { fontPolicyFor } from "@openpresentation/opf/font-policy";
|
|
13
21
|
import { isDatasetChart, isDatasetTable, renderCatalogChart } from "./charts.js";
|
|
14
22
|
import { renderCaption, renderFootnotes } from "./annotations.js";
|
|
15
23
|
|
|
@@ -53,25 +61,23 @@ export const engineDefaults = Object.freeze({
|
|
|
53
61
|
google: Object.freeze({ latin: "roboto", ea: "noto-sans-sc", cs: "noto-sans" })
|
|
54
62
|
}),
|
|
55
63
|
chartTypes: Object.freeze([
|
|
56
|
-
"stacked-column
|
|
57
|
-
"stacked-area
|
|
58
|
-
"line-with-markers
|
|
64
|
+
"stacked-column",
|
|
65
|
+
"stacked-area",
|
|
66
|
+
"line-with-markers"
|
|
59
67
|
])
|
|
60
68
|
});
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const DEFAULT_FONT_SCHEME = engineDefaults.fontScheme.pptx.latin;
|
|
67
|
-
|
|
70
|
+
/**
|
|
71
|
+
* `details.findings` (`invalid-opf`) are the error findings of core `validate`; `details.diagnostics` hold the
|
|
72
|
+
* layout or variable diagnostics that explain the other codes. `findings` and `path` are also set on the error.
|
|
73
|
+
*/
|
|
68
74
|
export class OPFRenderError extends Error {
|
|
69
75
|
constructor(code, message, details = {}) {
|
|
70
76
|
super(message);
|
|
71
77
|
this.name = "OPFRenderError";
|
|
72
78
|
this.code = code;
|
|
73
79
|
this.details = details;
|
|
74
|
-
if (details.
|
|
80
|
+
if (details.findings) this.findings = details.findings;
|
|
75
81
|
if (details.path) this.path = details.path;
|
|
76
82
|
}
|
|
77
83
|
}
|
|
@@ -186,39 +192,40 @@ function parseInput(input) {
|
|
|
186
192
|
// Template variables (core resolveVariables, RR-32): a deck that uses content variables, or a template, is resolved
|
|
187
193
|
// to a concrete deck before composition, so the preview and the PPTX exporter see the same text, numbers, dates and
|
|
188
194
|
// images. A template previews with each unfilled variable's example; a normal deck with an unfilled required
|
|
189
|
-
// variable is refused. Decks without content variables are returned untouched.
|
|
190
|
-
// published core still loads.
|
|
195
|
+
// variable is refused. Decks without content variables are returned untouched.
|
|
191
196
|
function resolveTemplateInput(presentation, options) {
|
|
192
|
-
if (
|
|
197
|
+
if (!isPlainObject(presentation)) return presentation;
|
|
193
198
|
const values = options.variables;
|
|
194
199
|
// variables: false draws the document as authored, tokens and var: references included (the template editing view).
|
|
195
200
|
if (values === false) return presentation;
|
|
196
201
|
if (values !== undefined && !isPlainObject(values)) {
|
|
197
202
|
throw new OPFRenderError("invalid-variables", "The variables option must be an object keyed by variable id.", { path: "options.variables" });
|
|
198
203
|
}
|
|
199
|
-
const template =
|
|
200
|
-
if (!template && !
|
|
201
|
-
const result =
|
|
204
|
+
const template = isTemplate(presentation);
|
|
205
|
+
if (!template && !hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
|
|
206
|
+
const result = resolveVariables(presentation, values ?? {}, { examples: template });
|
|
202
207
|
const errors = result.diagnostics.filter((entry) => entry.severity === "error");
|
|
203
208
|
if (errors.length) {
|
|
204
209
|
const unfilled = errors.some((entry) => entry.code === "variable-unfilled");
|
|
205
210
|
throw new OPFRenderError(unfilled ? "unfilled-variables" : "invalid-variables", errors[0].message, {
|
|
206
|
-
|
|
211
|
+
diagnostics: errors,
|
|
207
212
|
path: errors[0].path
|
|
208
213
|
});
|
|
209
214
|
}
|
|
210
215
|
for (const entry of result.diagnostics) {
|
|
211
|
-
if (entry.code === "variable-example-used") options.onDiagnostic?.({ code:
|
|
216
|
+
if (entry.code === "variable-example-used" || entry.code === "variable-builtin-missing") options.onDiagnostic?.({ code: entry.code, path: entry.path, message: entry.message, id: entry.id });
|
|
212
217
|
}
|
|
213
218
|
return result.presentation;
|
|
214
219
|
}
|
|
215
220
|
|
|
221
|
+
// The boundary check is the format check only (schema and the cross-field rules a schema cannot say): layout, accessibility
|
|
222
|
+
// and content findings are `validate`'s job, and drawing a slide reports what it cannot fit.
|
|
216
223
|
function assertValidBoundary(presentation) {
|
|
217
|
-
const
|
|
218
|
-
if (!
|
|
224
|
+
const report = validate(presentation, { only: ["format"] });
|
|
225
|
+
if (!report.valid) {
|
|
219
226
|
throw new OPFRenderError("invalid-opf", "OPF validation failed.", {
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
findings: report.findings.filter((finding) => finding.severity === "error"),
|
|
228
|
+
report
|
|
222
229
|
});
|
|
223
230
|
}
|
|
224
231
|
}
|
|
@@ -239,12 +246,6 @@ function isPlainObject(value) {
|
|
|
239
246
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
240
247
|
}
|
|
241
248
|
|
|
242
|
-
function referenceId(reference) {
|
|
243
|
-
if (typeof reference === "string") return reference;
|
|
244
|
-
if (isPlainObject(reference) && typeof reference.id === "string") return reference.id;
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
249
|
function normalizeSourceRecords(value) {
|
|
249
250
|
if (!value) return [];
|
|
250
251
|
if (Array.isArray(value)) return value;
|
|
@@ -252,10 +253,6 @@ function normalizeSourceRecords(value) {
|
|
|
252
253
|
return [];
|
|
253
254
|
}
|
|
254
255
|
|
|
255
|
-
function findById(records, id) {
|
|
256
|
-
return records.find((record) => record && record.id === id) ?? null;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
256
|
function defaultCatalogFor(kind) {
|
|
260
257
|
return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
|
|
261
258
|
}
|
|
@@ -276,86 +273,36 @@ function sourceRecordsFor(kind, source, options) {
|
|
|
276
273
|
return [];
|
|
277
274
|
}
|
|
278
275
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// Font schemes follow the shared core rule (resolveFontSchemeReference in
|
|
297
|
-
// @openpresentation/opf): an id that matches no record reports one
|
|
298
|
-
// `unresolved-font-scheme` diagnostic and uses the DEFAULT_FONT_SCHEME record as the
|
|
299
|
-
// base, with sibling overrides on top, so preview and PPTX export use the same fonts.
|
|
300
|
-
function resolveFontSchemeRecord(reference, context, path) {
|
|
301
|
-
const id = referenceId(reference);
|
|
302
|
-
const found = findCatalogRecord("fontSchemes", id, context);
|
|
303
|
-
const base = found ?? findCatalogRecord("fontSchemes", DEFAULT_FONT_SCHEME, context) ?? {};
|
|
304
|
-
const scheme = cloneWithSortedKeys(isPlainObject(reference) ? { ...base, ...reference } : base);
|
|
305
|
-
if (!id || found) return { scheme };
|
|
306
|
-
return { scheme, diagnostic: { code: "unresolved-font-scheme", path, id, fallback: DEFAULT_FONT_SCHEME, message: `Font scheme '${id}' is not in the inline or bundled catalogs; using the default font scheme '${DEFAULT_FONT_SCHEME}'.` } };
|
|
276
|
+
// The records a host adds to core's `resolveSlideContext`, which looks a layout, theme, colour scheme or font scheme up in the
|
|
277
|
+
// deck's inline records, then these, then its bundled catalogs. In the renderer's order they are the records a
|
|
278
|
+
// `catalogs.<kind>.source` names (a bundled source, or `options.catalogSources`), then `options.catalogs`.
|
|
279
|
+
const CONTEXT_CATALOG_KINDS = ["themes", "colorSchemes", "fontSchemes", "layouts"];
|
|
280
|
+
function contextCatalogs(context) {
|
|
281
|
+
if (context.contextCatalogs) return context.contextCatalogs;
|
|
282
|
+
const out = {};
|
|
283
|
+
for (const kind of CONTEXT_CATALOG_KINDS) {
|
|
284
|
+
const records = [
|
|
285
|
+
...sourceRecordsFor(kind, context.presentation.catalogs?.[kind]?.source, context.options),
|
|
286
|
+
...normalizeSourceRecords(context.options.catalogs?.[kind]),
|
|
287
|
+
...sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options)
|
|
288
|
+
];
|
|
289
|
+
if (records.length) out[kind] = records;
|
|
290
|
+
}
|
|
291
|
+
return (context.contextCatalogs = out);
|
|
307
292
|
}
|
|
308
293
|
|
|
309
294
|
// Generated socials furniture formats handles in core. Core applies inline
|
|
310
|
-
// document records first; the host supplies the rest in resolution order.
|
|
311
|
-
function socialPlatformRecords(context) {
|
|
295
|
+
// document records first; the host supplies the rest in resolution order, ahead of the bundled ones.
|
|
296
|
+
function socialPlatformRecords(context, bundled) {
|
|
312
297
|
const kind = "socialPlatforms";
|
|
313
298
|
return [
|
|
314
299
|
...sourceRecordsFor(kind, context.presentation.catalogs?.[kind]?.source, context.options),
|
|
315
300
|
...normalizeSourceRecords(context.options.catalogs?.[kind]),
|
|
316
301
|
...sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options),
|
|
317
|
-
...
|
|
302
|
+
...bundled
|
|
318
303
|
];
|
|
319
304
|
}
|
|
320
305
|
|
|
321
|
-
function resolveCatalogRecord(kind, reference, context, path, fallbackId = engineDefaultId(kind)) {
|
|
322
|
-
const id = referenceId(reference) ?? fallbackId;
|
|
323
|
-
const documentCatalog = context.presentation.catalogs?.[kind];
|
|
324
|
-
const documentRecords = normalizeSourceRecords(documentCatalog);
|
|
325
|
-
const injectedRecords = normalizeSourceRecords(context.options.catalogs?.[kind]);
|
|
326
|
-
const documentSource = documentCatalog?.source;
|
|
327
|
-
const sourceRecords = sourceRecordsFor(kind, documentSource, context.options);
|
|
328
|
-
const defaultSource = engineDefaults.catalogs[kind]?.source;
|
|
329
|
-
const engineSourceRecords = sourceRecordsFor(kind, defaultSource, context.options);
|
|
330
|
-
const defaultRecords = defaultCatalogFor(kind);
|
|
331
|
-
|
|
332
|
-
const record =
|
|
333
|
-
(id ? findById(documentRecords, id) : null) ??
|
|
334
|
-
(id ? findById(sourceRecords, id) : null) ??
|
|
335
|
-
(id ? findById(injectedRecords, id) : null) ??
|
|
336
|
-
(id ? findById(engineSourceRecords, id) : null) ??
|
|
337
|
-
(id ? findById(defaultRecords, id) : null);
|
|
338
|
-
|
|
339
|
-
if (record) {
|
|
340
|
-
return isPlainObject(reference)
|
|
341
|
-
? cloneWithSortedKeys({ ...record, ...reference })
|
|
342
|
-
: cloneWithSortedKeys(record);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
if (isPlainObject(reference)) {
|
|
346
|
-
return cloneWithSortedKeys(reference);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
throw new OPFRenderError("catalog-resolution-failed", `Could not resolve ${kind} reference '${id}'.`, {
|
|
350
|
-
kind,
|
|
351
|
-
id,
|
|
352
|
-
path,
|
|
353
|
-
source: documentSource ?? defaultSource ?? null
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const resolveDimensions = resolveCanvasDimensions;
|
|
358
|
-
|
|
359
306
|
function colorFromScheme(scheme, slot, fallback) {
|
|
360
307
|
if (!slot) return fallback;
|
|
361
308
|
if (typeof slot === "string") {
|
|
@@ -421,14 +368,17 @@ function resolveBackgroundColor(value, design, fallback) {
|
|
|
421
368
|
return resolveColorRefIn(value, { colorScheme: design.colorScheme, colors: { primary, secondary, accent }, variables: design.variables }, fallback);
|
|
422
369
|
}
|
|
423
370
|
|
|
371
|
+
// The slide's single background color, or null when there is none (a gradient, or no color the engine can read).
|
|
372
|
+
// With no definition, or a picture, the scheme's default slide background (core defaultSlideBackground) is the canvas.
|
|
424
373
|
function resolveBackground(background, colorScheme, design) {
|
|
425
|
-
|
|
426
|
-
if (
|
|
427
|
-
if (background
|
|
374
|
+
const canvas = defaultSlideBackground(colorScheme);
|
|
375
|
+
if (!background) return canvas;
|
|
376
|
+
if (typeof background === "string") return colorFromScheme(colorScheme, background, canvas);
|
|
377
|
+
if (background.type === "theme") return colorFromScheme(colorScheme, background.slot, canvas);
|
|
428
378
|
if (background.type === "solid") return resolveBackgroundColor(background.color, design, "#FFFFFF");
|
|
429
379
|
if (background.type === "gradient") return null;
|
|
430
380
|
if (background.type === "pattern") return resolveBackgroundColor(background.pattern?.backgroundColor, design, "#FFFFFF");
|
|
431
|
-
return
|
|
381
|
+
return canvas;
|
|
432
382
|
}
|
|
433
383
|
|
|
434
384
|
|
|
@@ -456,40 +406,31 @@ function tagFill(design) {
|
|
|
456
406
|
return contrastRatio(design.colors.primary, background) < TAG_MIN_CONTRAST ? design.colors.text : design.colors.primary;
|
|
457
407
|
}
|
|
458
408
|
|
|
459
|
-
|
|
409
|
+
const LINK_URL = /^(https?:|mailto:|tel:)/i;
|
|
410
|
+
|
|
411
|
+
// What the renderer paints with, from the records core's `resolveSlideContext` resolved (slide design, then deck design, then
|
|
412
|
+
// theme, then the engine default, per field: the one order every engine uses). The theme and the colour scheme keep their
|
|
413
|
+
// sorted keys, so the output does not depend on the order a catalog lists a field in. An id no catalog has never throws: the
|
|
414
|
+
// context falls back (a theme to `minimal`, a colour scheme to `cool-horizon`, a font scheme to `aptos`, a layout to none) and
|
|
415
|
+
// reports `unresolved-theme`, `unresolved-color-scheme`, `unresolved-font-scheme` or `unresolved-layout`, which are forwarded
|
|
416
|
+
// as render diagnostics.
|
|
417
|
+
function resolveDesign(presentation, slide, slideContext) {
|
|
460
418
|
const deckDesign = presentation.design ?? {};
|
|
461
419
|
const slideDesign = slide.design ?? {};
|
|
462
|
-
|
|
463
|
-
const theme =
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
"design.theme"
|
|
468
|
-
);
|
|
469
|
-
const colorScheme = resolveCatalogRecord(
|
|
470
|
-
"colorSchemes",
|
|
471
|
-
slideDesign.colorScheme ?? deckDesign.colorScheme ?? theme.colorScheme ?? engineDefaults.colorScheme,
|
|
472
|
-
context,
|
|
473
|
-
"design.colorScheme"
|
|
474
|
-
);
|
|
475
|
-
const fontPath = slideDesign.fontScheme !== undefined ? `slides.${index}.design.fontScheme`
|
|
476
|
-
: deckDesign.fontScheme !== undefined ? "design.fontScheme"
|
|
477
|
-
: slideDesign.theme !== undefined ? `slides.${index}.design.theme` : "design.theme";
|
|
478
|
-
const { scheme: fontScheme, diagnostic: fontSchemeDiagnostic } = resolveFontSchemeRecord(
|
|
479
|
-
slideDesign.fontScheme ?? deckDesign.fontScheme ?? theme.fontScheme ?? DEFAULT_FONT_SCHEME,
|
|
480
|
-
context,
|
|
481
|
-
fontPath
|
|
482
|
-
);
|
|
483
|
-
const dimensions = resolveDimensions(slideDesign.dimensions ?? deckDesign.dimensions ?? theme.dimensions);
|
|
420
|
+
const { options: composeOptions, diagnostics, resolved } = slideContext;
|
|
421
|
+
const theme = cloneWithSortedKeys(resolved.theme);
|
|
422
|
+
const colorScheme = cloneWithSortedKeys(resolved.colorScheme);
|
|
423
|
+
const fontScheme = cloneWithSortedKeys(resolved.fontScheme);
|
|
424
|
+
const dimensions = { width: composeOptions.width, height: composeOptions.height };
|
|
484
425
|
const backgroundDefinition = slideDesign.background ?? deckDesign.background ?? theme.background;
|
|
485
|
-
const
|
|
486
|
-
const secondary = normalizeColor(colorScheme.secondary, null) ?? colorFromScheme(colorScheme, "accent2", "#0F766E");
|
|
487
|
-
const accent = normalizeColor(colorScheme.accent, null) ?? colorFromScheme(colorScheme, "accent3", "#F59E0B");
|
|
426
|
+
const { primary, secondary, accent } = resolveColorRoles(colorScheme);
|
|
488
427
|
const variables = presentation.variables ?? {};
|
|
489
428
|
// background/surface/text roles depend on the background itself, so a background reference sees only the scheme and the three accent roles.
|
|
490
429
|
const backgroundColor = resolveBackground(backgroundDefinition, colorScheme, { colorScheme, colors: { primary, secondary, accent }, variables });
|
|
491
|
-
|
|
492
|
-
|
|
430
|
+
// One resolution for every role (core resolveColorRoles), shared with the PPTX export and the validator. Whether the background is dark
|
|
431
|
+
// (relative luminance under 0.179) is core's decision in the slide context, the one composition uses for logos and bullets.
|
|
432
|
+
const roles = resolveColorRoles(colorScheme, { background: backgroundColor });
|
|
433
|
+
const darkBackground = composeOptions.darkBackground === true;
|
|
493
434
|
|
|
494
435
|
return {
|
|
495
436
|
...deckDesign,
|
|
@@ -502,18 +443,20 @@ function resolveDesign(presentation, slide, context, index) {
|
|
|
502
443
|
background: backgroundDefinition,
|
|
503
444
|
backgroundColor,
|
|
504
445
|
colors: {
|
|
505
|
-
background:
|
|
506
|
-
surface:
|
|
507
|
-
text:
|
|
508
|
-
mutedText:
|
|
446
|
+
background: roles.background,
|
|
447
|
+
surface: roles.surface,
|
|
448
|
+
text: roles.text,
|
|
449
|
+
mutedText: roles.textSecondary,
|
|
509
450
|
primary,
|
|
510
451
|
secondary,
|
|
511
452
|
accent,
|
|
453
|
+
hyperlink: roles.hyperlink,
|
|
454
|
+
followedHyperlink: roles.followedHyperlink,
|
|
512
455
|
border: colorFromScheme(colorScheme, "accent5", "#CBD5E1")
|
|
513
456
|
},
|
|
514
|
-
fonts:
|
|
457
|
+
fonts: { ...composeOptions.fontFamilies },
|
|
515
458
|
darkBackground,
|
|
516
|
-
diagnostics:
|
|
459
|
+
diagnostics: diagnostics.map((entry) => ({ ...entry }))
|
|
517
460
|
};
|
|
518
461
|
}
|
|
519
462
|
|
|
@@ -521,23 +464,14 @@ function resolveDesign(presentation, slide, context, index) {
|
|
|
521
464
|
* Script font slots, language tags and direction for one slide from core
|
|
522
465
|
* `resolveScriptFonts` (FF-18). The latin slot is always the renderer's design
|
|
523
466
|
* font; a script slot that core fills from the latin family follows it too.
|
|
524
|
-
*
|
|
467
|
+
* A language core cannot resolve is reported (`language-preview-unresolved`) and every slot repeats the latin family.
|
|
525
468
|
*/
|
|
526
|
-
function scriptProfile(presentation, index, design, context) {
|
|
469
|
+
function scriptProfile(presentation, index, design, context, language) {
|
|
527
470
|
let resolved;
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
message: "Script fonts could not be resolved (" + (error instanceof Error ? error.message : String(error)) + "), so the preview uses the design font for every script, sets no lang and lays out every paragraph left to right." });
|
|
533
|
-
}
|
|
534
|
-
} else if (presentation.language !== undefined) {
|
|
535
|
-
reportLanguageDiagnostic(context, { code: "language-preview-unavailable", path: "language",
|
|
536
|
-
message: "The installed @openpresentation/opf has no resolveScriptFonts (FF-18), so the preview uses the design font for every script, sets no lang and lays out every paragraph left to right. Use a core release with the language model." });
|
|
537
|
-
}
|
|
538
|
-
if (resolved?.rtl === true && typeof opfCore.paragraphDirection !== "function") {
|
|
539
|
-
reportLanguageDiagnostic(context, { code: "paragraph-direction-unavailable", path: "language",
|
|
540
|
-
message: "The installed @openpresentation/opf has no paragraphDirection, so the preview lays out every paragraph of this right-to-left deck left to right, as the PPTX export does. Use a core release that exports it." });
|
|
471
|
+
try { resolved = resolveScriptFonts(presentation, { slideIndex: index, ...(language === undefined ? {} : { language }) }); }
|
|
472
|
+
catch (error) {
|
|
473
|
+
reportLanguageDiagnostic(context, { code: "language-preview-unresolved", path: "language",
|
|
474
|
+
message: "Script fonts could not be resolved (" + (error instanceof Error ? error.message : String(error)) + "), so the preview uses the design font for every script, sets no lang and lays out every paragraph left to right." });
|
|
541
475
|
}
|
|
542
476
|
const slots = role => {
|
|
543
477
|
const latin = design.fonts[role];
|
|
@@ -594,33 +528,6 @@ function reportGlyphFallback(context, note) {
|
|
|
594
528
|
});
|
|
595
529
|
}
|
|
596
530
|
|
|
597
|
-
function inferLayoutId(slide) {
|
|
598
|
-
if (slide.layout) return slide.layout;
|
|
599
|
-
if (slide.blocks?.length) return "blank";
|
|
600
|
-
if (PROMOTED_REGION_KEYS.some((key) => key in slide)) return "blank";
|
|
601
|
-
if (slide.chart) return "chart-1x";
|
|
602
|
-
if (slide.table) return "table-1x";
|
|
603
|
-
if (slide.image) return "image-1x";
|
|
604
|
-
if (slide.video) return "media-1x";
|
|
605
|
-
// Unspecified code layout uses the same automatic flow as core pagination
|
|
606
|
-
// and PPTX export. An explicit code-1x preset still reserves its own slots.
|
|
607
|
-
if (slide.code !== undefined) return "blank";
|
|
608
|
-
if (slide.items || slide.bullets) return "list-1x";
|
|
609
|
-
if (slide.text || slide.metric || slide.quote || slide.timeline) return "text-1x";
|
|
610
|
-
if (slide.subtitle) return "title-subtitle";
|
|
611
|
-
return "title";
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function resolveLayout(slide, context, index) {
|
|
615
|
-
const layoutId = inferLayoutId(slide);
|
|
616
|
-
try {
|
|
617
|
-
return resolveCatalogRecord("layouts", layoutId, context, `slides.${index}.layout`);
|
|
618
|
-
} catch (error) {
|
|
619
|
-
if (error instanceof OPFRenderError && isPlainObject(slide.layout)) return slide.layout;
|
|
620
|
-
throw error;
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
|
|
624
531
|
function contentPayloadFromHost(host, path, slot) {
|
|
625
532
|
if (!isPlainObject(host)) {
|
|
626
533
|
return { type: "text", field: "text", value: host, slot: slot ?? "text", path };
|
|
@@ -687,9 +594,18 @@ function normalizeFutureContent(slide, slidePath) {
|
|
|
687
594
|
});
|
|
688
595
|
}
|
|
689
596
|
|
|
690
|
-
function bindSlide(presentation, slide,
|
|
597
|
+
function bindSlide(presentation, slide, index, context) {
|
|
691
598
|
const slidePath = `slides.${index}`;
|
|
692
|
-
|
|
599
|
+
// Core resolves the slide's canvas, layout, theme, colour scheme, font scheme and family names the one way every engine must
|
|
600
|
+
// (slide design, deck design, theme, default); `fonts` carries the measurement the host loaded. A slide with no `layout`, or one
|
|
601
|
+
// that names an id no catalog has, composes with no layout record.
|
|
602
|
+
const slideContext = resolveSlideContext(presentation, index, {
|
|
603
|
+
fonts: { textMeasurement: context.options.textMeasurement },
|
|
604
|
+
catalogs: contextCatalogs(context),
|
|
605
|
+
date: context.options.date
|
|
606
|
+
});
|
|
607
|
+
const layout = slideContext.options.layout;
|
|
608
|
+
const placeholders = Array.isArray(layout?.placeholders) ? layout.placeholders : [];
|
|
693
609
|
const titleBindings = [];
|
|
694
610
|
|
|
695
611
|
for (let i = 0; i < placeholders.length; i += 1) {
|
|
@@ -715,15 +631,38 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
715
631
|
];
|
|
716
632
|
const blocks = blockContent(slide, slidePath);
|
|
717
633
|
|
|
718
|
-
const design = resolveDesign(presentation, slide,
|
|
634
|
+
const design = resolveDesign(presentation, slide, slideContext);
|
|
719
635
|
// Script fonts (FF-19): each text run is measured and drawn with its script
|
|
720
636
|
// slot's face; the latin slot stays the design font scheme's family.
|
|
721
637
|
const scriptFonts = createScriptFonts(scriptProfile(presentation, index, design, context), context.options.textMeasurement, {
|
|
722
638
|
glyphFallback: context.options.glyphFallback,
|
|
723
639
|
onFallback: note => reportGlyphFallback(context, note)
|
|
724
640
|
});
|
|
725
|
-
|
|
726
|
-
//
|
|
641
|
+
// FA-13: a run with its own language (TextRun.lang, which core puts on the fragment style as `lang`) is measured and drawn with the
|
|
642
|
+
// script fonts of that language (the Japanese or the Simplified Chinese face for Han text); every other run uses the deck's.
|
|
643
|
+
const languageScripts = new Map();
|
|
644
|
+
const scriptFontsFor = lang => {
|
|
645
|
+
if (typeof lang !== "string" || !lang) return undefined;
|
|
646
|
+
if (!languageScripts.has(lang)) {
|
|
647
|
+
let own;
|
|
648
|
+
try {
|
|
649
|
+
const profile = scriptProfile(presentation, index, design, context, lang);
|
|
650
|
+
own = profile.languageSource === "option" && profile.bcp47?.toLowerCase() !== scriptFonts.profile?.bcp47?.toLowerCase()
|
|
651
|
+
? createScriptFonts(profile, context.options.textMeasurement, { glyphFallback: context.options.glyphFallback, onFallback: note => reportGlyphFallback(context, note) })
|
|
652
|
+
: undefined;
|
|
653
|
+
} catch { own = undefined; }
|
|
654
|
+
languageScripts.set(lang, own);
|
|
655
|
+
}
|
|
656
|
+
return languageScripts.get(lang);
|
|
657
|
+
};
|
|
658
|
+
let textMeasurement = scriptFonts.textMeasurement ?? context.options.textMeasurement;
|
|
659
|
+
if (scriptFonts.textMeasurement) {
|
|
660
|
+
const deckMeasurement = scriptFonts.textMeasurement;
|
|
661
|
+
const pick = style => scriptFontsFor(style?.lang)?.textMeasurement ?? deckMeasurement;
|
|
662
|
+
textMeasurement = { ...deckMeasurement, measure: (text, size, style) => pick(style).measure(text, size, style) };
|
|
663
|
+
if (typeof deckMeasurement.outlineBounds === "function") textMeasurement.outlineBounds = (text, size, style) => (pick(style).outlineBounds ?? deckMeasurement.outlineBounds)(text, size, style);
|
|
664
|
+
}
|
|
665
|
+
// fontScheme.accent (the tag and quote text) takes the same look-alike policy.
|
|
727
666
|
// RR-17 (FF-41): a family that names its weight (Arial Black, Segoe UI Semibold and Light) keeps its own name through composition, so each run
|
|
728
667
|
// resolves it again and draws the replacement's encoded weight (Montserrat 900, Red Hat Display 600 and 300); resolving the role to the
|
|
729
668
|
// replacement family first would draw every run at 400 or 700 and measure it that way. Every other family resolves once, here.
|
|
@@ -731,9 +670,10 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
731
670
|
const named = design.fonts[role], resolved = resolveTextStyle({fontFamily:named,fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
|
|
732
671
|
design.fonts[role] = fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
|
|
733
672
|
}
|
|
734
|
-
const geometry = composeSlide(slide, { ...
|
|
673
|
+
const geometry = composeSlide(slide, { ...slideContext.options, fontFamilies: design.fonts, textRasterPadding:context.options.textRasterPadding, textMeasurement, socialPlatforms: socialPlatformRecords(context, slideContext.options.socialPlatforms) });
|
|
735
674
|
return {
|
|
736
675
|
scriptFonts,
|
|
676
|
+
scriptFontsFor,
|
|
737
677
|
textMeasurement,
|
|
738
678
|
geometry,
|
|
739
679
|
assets: presentation.assets ?? {},
|
|
@@ -750,17 +690,29 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
750
690
|
};
|
|
751
691
|
}
|
|
752
692
|
|
|
693
|
+
// `options.fonts` is the handle `loadFonts()` returns (or any object with a `textMeasurement` and, to embed faces in the SVG, an
|
|
694
|
+
// `embeddedFonts` list). The renderer reads the two as plain options from here on.
|
|
695
|
+
function normalizeOptions(options) {
|
|
696
|
+
const { fonts, ...rest } = options ?? {};
|
|
697
|
+
return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: fonts?.embeddedFonts };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Resolve a presentation for drawing: template variables filled, the boundary check passed, and every slide bound to its
|
|
702
|
+
* layout, design, script fonts and composed geometry. `renderSvg` and `renderSlideSvg` draw from this.
|
|
703
|
+
*/
|
|
753
704
|
export function resolvePresentation(input, options = {}) {
|
|
705
|
+
return resolveDeck(input, normalizeOptions(options));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function resolveDeck(input, options) {
|
|
754
709
|
const presentation = resolveTemplateInput(parseInput(input), options);
|
|
755
710
|
if (options.validate !== false) {
|
|
756
711
|
assertValidBoundary(presentation);
|
|
757
712
|
}
|
|
758
713
|
|
|
759
714
|
const context = { presentation, options };
|
|
760
|
-
const slides = presentation.slides.map((slide, index) =>
|
|
761
|
-
const layout = resolveLayout(slide, context, index);
|
|
762
|
-
return bindSlide(presentation, slide, layout, index, context);
|
|
763
|
-
});
|
|
715
|
+
const slides = presentation.slides.map((slide, index) => bindSlide(presentation, slide, index, context));
|
|
764
716
|
|
|
765
717
|
return {
|
|
766
718
|
presentation,
|
|
@@ -769,21 +721,28 @@ export function resolvePresentation(input, options = {}) {
|
|
|
769
721
|
};
|
|
770
722
|
}
|
|
771
723
|
|
|
724
|
+
/**
|
|
725
|
+
* The SVG of every slide of the deck, in order. `skipHidden: true` leaves out slides marked `hidden` (the sequence the player
|
|
726
|
+
* presents); the result is then shorter than the deck, so an index no longer names the slide at that index.
|
|
727
|
+
*/
|
|
772
728
|
export function renderSvg(input, options = {}) {
|
|
773
|
-
const
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
729
|
+
const drawn = normalizeOptions(options);
|
|
730
|
+
const resolved = resolveDeck(input, drawn);
|
|
731
|
+
const indexes = resolved.slides.map((_, index) => index).filter(index => !(options.skipHidden === true && resolved.presentation.slides[index]?.hidden === true));
|
|
732
|
+
return indexes.map(index => renderResolvedSlide(resolved, index, drawn));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** The SVG of one slide of the deck (a zero-based index). */
|
|
736
|
+
export function renderSlideSvg(input, index, options = {}) {
|
|
737
|
+
const drawn = normalizeOptions(options);
|
|
738
|
+
const resolved = resolveDeck(input, drawn);
|
|
739
|
+
if (!Number.isInteger(index) || index < 0 || index >= resolved.slides.length) {
|
|
740
|
+
throw new OPFRenderError("slide-index-out-of-range", `Slide index ${index} is out of range.`, {
|
|
741
|
+
slideIndex: index,
|
|
778
742
|
slideCount: resolved.slides.length
|
|
779
743
|
});
|
|
780
744
|
}
|
|
781
|
-
return renderResolvedSlide(resolved,
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
export function renderSvgDeck(input, options = {}) {
|
|
785
|
-
const resolved = resolvePresentation(input, options);
|
|
786
|
-
return resolved.slides.map((_, index) => renderResolvedSlide(resolved, index, options));
|
|
745
|
+
return renderResolvedSlide(resolved, index, drawn);
|
|
787
746
|
}
|
|
788
747
|
|
|
789
748
|
function renderResolvedSlide(resolved, slideIndex, options) {
|
|
@@ -794,7 +753,8 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
794
753
|
// Only a language the document names sets lang; the renderer default does not.
|
|
795
754
|
const lang = script?.languageSource === "document" || script?.languageSource === "option" ? script.bcp47 : undefined;
|
|
796
755
|
const { width, height } = bound.design.dimensions;
|
|
797
|
-
|
|
756
|
+
// The title may be TextRun[] (FA-10): the accessible name is its plain text.
|
|
757
|
+
const title = (Array.isArray(bound.slide.title) ? flattenText(bound.slide.title) : bound.slide.title) ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
|
|
798
758
|
const content = [
|
|
799
759
|
renderBackground(bound, width, height, options),
|
|
800
760
|
renderSlideImage(bound, options),
|
|
@@ -872,14 +832,10 @@ function renderBackground(bound, width, height, options) {
|
|
|
872
832
|
}
|
|
873
833
|
if(isPlainObject(background) && background.type==='pattern'){
|
|
874
834
|
const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=resolveBackgroundColor(pattern.foregroundColor,bound.design,bound.design.colors.text),preset=pattern.preset;
|
|
875
|
-
// Without core's pattern table (an older core) five presets keep their hand-drawn approximation. diagStripe is the
|
|
876
|
-
// engine id that PPTX export writes as wdUpDiag, so both draw the same rising stripe.
|
|
877
|
-
const stroke=(d,width=1)=>tag('path',{d,fill:'none',stroke:color,'stroke-width':width});
|
|
878
835
|
// RR-07: every DrawingML preset (core PATTERN_PRESETS) is an 8x8 bitmap, one unit per 1/96 inch, anchored at the slide origin.
|
|
879
|
-
|
|
880
|
-
const
|
|
881
|
-
const
|
|
882
|
-
const mark=bitmap||legacy;
|
|
836
|
+
// diagStripe is the engine id that PPTX export writes as wdUpDiag, so both draw the same rising stripe.
|
|
837
|
+
const runs=patternRuns(preset);
|
|
838
|
+
const mark=runs?tag('path',{d:runs.map(run=>`M${run.x} ${run.y}h${run.width}v1h-${run.width}z`).join(''),fill:color,'shape-rendering':'crispEdges'}):'';
|
|
883
839
|
if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
|
|
884
840
|
return tag('g',{opacity:background.opacity??1},tag('rect',{width,height,fill:bound.design.backgroundColor??'#FFFFFF'})+tag('defs',{},tag('pattern',{id,width:8,height:8,patternUnits:'userSpaceOnUse'},mark))+tag('rect',{width,height,fill:`url(#${id})`}));
|
|
885
841
|
}
|
|
@@ -970,12 +926,19 @@ function reportDiagnostic(diagnostic, options) {
|
|
|
970
926
|
options.onDiagnostic?.(diagnostic);
|
|
971
927
|
}
|
|
972
928
|
|
|
929
|
+
// FA-09: Chart.alt is the chart's accessible name: a role="img" group with aria-label, which also makes the drawn marks and labels
|
|
930
|
+
// inside presentational. An empty alt marks the chart decorative (aria-hidden). Without alt the SVG is unchanged.
|
|
931
|
+
function chartText(chart, alt) {
|
|
932
|
+
if (typeof alt !== "string" || !chart) return chart;
|
|
933
|
+
return alt === "" ? tag("g", { "aria-hidden": "true" }, chart) : tag("g", { role: "img", "aria-label": alt }, chart);
|
|
934
|
+
}
|
|
935
|
+
|
|
973
936
|
function renderPayload(item, box, bound, options) {
|
|
974
937
|
if (!box) return "";
|
|
975
938
|
if (item.field === "items" || item.field === "bullets") return renderList(item, box, bound, options);
|
|
976
939
|
switch (item.type) {
|
|
977
940
|
case "chart":
|
|
978
|
-
return renderChart(item, box, bound, options);
|
|
941
|
+
return chartText(renderChart(item, box, bound, options), item.value?.alt);
|
|
979
942
|
case "table":
|
|
980
943
|
return renderTable(item, box, bound, options);
|
|
981
944
|
case "image":
|
|
@@ -1000,10 +963,9 @@ function renderPayload(item, box, bound, options) {
|
|
|
1000
963
|
function renderTextPayload(item, box, bound, options) {
|
|
1001
964
|
return (Array.isArray(item.value) ? renderRichTextBox : renderTextBox)(Array.isArray(item.value) ? item.value : flattenText(item.value), box, bound, {
|
|
1002
965
|
path: item.path,
|
|
1003
|
-
// Core resolves one alignment per composed item for every engine
|
|
1004
|
-
//
|
|
1005
|
-
|
|
1006
|
-
align: item.alignment ?? (item.field === "title" ? bound.design.titleAlignment ?? "left" : bound.design.contentAlignment),
|
|
966
|
+
// Core resolves one alignment per composed item for every engine, from the slide's design, the deck's design
|
|
967
|
+
// and the layout record's design (SlideComposition.design).
|
|
968
|
+
align: item.alignment,
|
|
1007
969
|
fontSize: item.field === "title" ? 54 : item.field === "tag" ? 16 : 25,
|
|
1008
970
|
// fontScheme.accent draws the tag; core's textStyle (item.textStyle) already carries it, this is the fallback family.
|
|
1009
971
|
fontFamily: item.field === "title" ? bound.design.fonts.heading : item.field === "tag" ? bound.design.fonts.accent ?? bound.design.fonts.body : bound.design.fonts.body,
|
|
@@ -1021,7 +983,7 @@ function renderTextPayload(item, box, bound, options) {
|
|
|
1021
983
|
|
|
1022
984
|
function renderList(item, box, bound, options) {
|
|
1023
985
|
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
1024
|
-
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,...(item.payload?.numbering!==undefined?{numbering:item.payload.numbering}:{})});
|
|
986
|
+
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},...(bound.design.fonts.code?{codeFontFamily:bound.design.fonts.code}:{}),textMeasurement:options.textMeasurement,...(item.payload?.numbering!==undefined?{numbering:item.payload.numbering}:{})});
|
|
1025
987
|
const children=[];
|
|
1026
988
|
// design.listBullet=image: core attaches the icon logo as item.bulletImage and its box (entry.bulletBox: 0.65 em, as PowerPoint draws a:buBlip). An icon that cannot be drawn keeps the glyph marker.
|
|
1027
989
|
const bullet=item.bulletImage?resolveBulletImage(item.bulletImage,bound,options):undefined;
|
|
@@ -1213,7 +1175,7 @@ function renderImage(item, box, bound, options) {
|
|
|
1213
1175
|
const { asset, source, drawable, missingReference } = resolveImageSource(item, bound, options);
|
|
1214
1176
|
if (drawable) {
|
|
1215
1177
|
return tag("image", { x: box.x, y: box.y, width: box.width, height: box.height,
|
|
1216
|
-
href: source, preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : options.imageAnchor === "right" ? "xMaxYMid meet" : (options.imageFit ?? (bound.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image",
|
|
1178
|
+
href: source, preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : options.imageAnchor === "right" ? "xMaxYMid meet" : (options.imageFit ?? (bound.geometry.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image",
|
|
1217
1179
|
...traceAttrs(options, item.path), ...generatedAttrs(options) });
|
|
1218
1180
|
}
|
|
1219
1181
|
const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
|
|
@@ -1311,6 +1273,7 @@ function renderCode(item, box, bound, options) {
|
|
|
1311
1273
|
if (invalid) throw new OPFRenderError('invalid-code-text', `Code text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before rendering.`, {path:part.path});
|
|
1312
1274
|
}
|
|
1313
1275
|
const syntax = codeSyntax(item, layout, bound, options);
|
|
1276
|
+
const highlight = codeHighlight(item, layout, bound);
|
|
1314
1277
|
const children = [
|
|
1315
1278
|
tag("rect", {
|
|
1316
1279
|
x: box.x,
|
|
@@ -1323,13 +1286,27 @@ function renderCode(item, box, bound, options) {
|
|
|
1323
1286
|
...traceAttrs(options, item.path)
|
|
1324
1287
|
})
|
|
1325
1288
|
];
|
|
1289
|
+
// FA-13: code.highlight is one band per run of marked lines, behind the line text; the marked lines keep the syntax colours and
|
|
1290
|
+
// the others are dimmed (core's codeHighlight* helpers, the same ones the PPTX export calls).
|
|
1291
|
+
if (highlight) {
|
|
1292
|
+
const body = layout.parts.find(part => part.role === "body");
|
|
1293
|
+
for (const band of highlight.bands) {
|
|
1294
|
+
children.push(tag("rect", {
|
|
1295
|
+
x: stableNumber(box.x + 1), y: stableNumber(body.box.y + band.first * body.fit.lineHeight), width: stableNumber(box.width - 2),
|
|
1296
|
+
height: stableNumber((band.last - band.first + 1) * body.fit.lineHeight), fill: highlight.colors.band,
|
|
1297
|
+
...(options.trace ? { "data-opf-code-highlight": "true" } : {})
|
|
1298
|
+
}));
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1326
1301
|
for (const part of layout.parts) {
|
|
1327
|
-
if (!part.fit) throw new OPFRenderError('layout-overflow', 'Code content has no usable internal space; increase its cell size before rendering.', {path:part.path,
|
|
1328
|
-
const lines=part.fit.sourceLines.map((line,index)=>
|
|
1302
|
+
if (!part.fit) throw new OPFRenderError('layout-overflow', 'Code content has no usable internal space; increase its cell size before rendering.', {path:part.path,diagnostics:layout.diagnostics});
|
|
1303
|
+
const lines=part.fit.sourceLines.map((line,index)=>{
|
|
1304
|
+
const lineColors=highlight&&part.role==='body'?(highlight.marked.has(highlight.numbers[index])?highlight.colors.lit:highlight.colors.dim):undefined;
|
|
1305
|
+
return tag('text',{
|
|
1329
1306
|
x:stableNumber(part.box.x),y:stableNumber(part.box.y+part.fit.fontSize+index*part.fit.lineHeight),
|
|
1330
1307
|
'text-anchor':'start','font-family':fontStack(part.style.fontFamily,'monospace'),
|
|
1331
1308
|
'font-size':stableNumber(part.fit.fontSize),'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
|
|
1332
|
-
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='body'?'#E5E7EB':'#93C5FD',
|
|
1309
|
+
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='body'?(lineColors?.plain??'#E5E7EB'):'#93C5FD',
|
|
1333
1310
|
...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
|
|
1334
1311
|
'data-opf-text-start':line.start,'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
|
|
1335
1312
|
},line.segments.map(segment=>segmentSpan({
|
|
@@ -1338,7 +1315,8 @@ function renderCode(item, box, bound, options) {
|
|
|
1338
1315
|
...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
|
|
1339
1316
|
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
1340
1317
|
// Code stays left to right; script runs still take their slot fonts.
|
|
1341
|
-
},segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false,fontSize:part.fit.fontSize},part.role==='body'?syntax:undefined)).join(''))
|
|
1318
|
+
},segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false,fontSize:part.fit.fontSize},part.role==='body'?(syntax&&lineColors?{...syntax,palette:lineColors}:syntax):undefined)).join(''));
|
|
1319
|
+
});
|
|
1342
1320
|
children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
|
|
1343
1321
|
'data-opf-box-x':part.box.x,'data-opf-box-y':part.box.y,'data-opf-box-width':part.box.width,'data-opf-box-height':part.box.height}:{}),
|
|
1344
1322
|
...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
|
|
@@ -1357,7 +1335,7 @@ function renderMetric(item, box, bound, options) {
|
|
|
1357
1335
|
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
1358
1336
|
if (invalid) throw new OPFRenderError('invalid-metric-text',`Metric text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before rendering.`,{path:part.path});
|
|
1359
1337
|
if (!part.visible) continue;
|
|
1360
|
-
if (!part.fit||part.linePositions?.length!==part.fit.sourceLines.length) throw new OPFRenderError('layout-overflow','Metric content has no accepted internal line positions; increase its cell size or coordinate package versions.',{path:part.path,
|
|
1338
|
+
if (!part.fit||part.linePositions?.length!==part.fit.sourceLines.length) throw new OPFRenderError('layout-overflow','Metric content has no accepted internal line positions; increase its cell size or coordinate package versions.',{path:part.path,diagnostics:layout.diagnostics});
|
|
1361
1339
|
const partRtl=paragraphRtl(bound,part.text);
|
|
1362
1340
|
// Anchor untabbed lines at the accepted alignment edge, as PPTX export does,
|
|
1363
1341
|
// so a shaper whose advance differs from the accepted width keeps the edge.
|
|
@@ -1393,37 +1371,58 @@ function renderMetric(item, box, bound, options) {
|
|
|
1393
1371
|
return tag('g',{...traceAttrs(options,item.path),...(options.trace?{'data-opf-metric-container':'true'}:{})},children.join('\n'));
|
|
1394
1372
|
}
|
|
1395
1373
|
|
|
1396
|
-
// The trend arrow and colour for a laid-out metric (core metricTrendMark); undefined without a trend
|
|
1374
|
+
// The trend arrow and colour for a laid-out metric (core metricTrendMark); undefined without a trend.
|
|
1397
1375
|
function metricTrendMark(layout,bound,options) {
|
|
1398
|
-
|
|
1399
|
-
return opfCore.metricTrendMark(layout,{background:bound.design.backgroundColor??bound.design.colors.background});
|
|
1376
|
+
return coreMetricTrendMark(layout,{background:bound.design.backgroundColor??bound.design.colors.background});
|
|
1400
1377
|
}
|
|
1401
1378
|
|
|
1402
1379
|
function renderQuote(item, box, bound, options) {
|
|
1403
1380
|
const layout = item.quoteLayout;
|
|
1404
1381
|
if (!layout) throw new OPFRenderError('missing-quote-layout', 'Quote rendering requires a coordinated core build with shared quote geometry.', {path:item.path});
|
|
1405
1382
|
const children = layout.parts.map(part => {
|
|
1406
|
-
if (!part.fit) throw new OPFRenderError('layout-overflow', 'Quote content has no usable internal space; increase its cell size before rendering.', {path:part.path,
|
|
1383
|
+
if (!part.fit) throw new OPFRenderError('layout-overflow', 'Quote content has no usable internal space; increase its cell size before rendering.', {path:part.path,diagnostics:layout.diagnostics});
|
|
1384
|
+
const fill=part.role==='footer'?bound.design.colors.mutedText:bound.design.colors.text;
|
|
1385
|
+
// FA-10: a TextRun[] quote body is laid out by core's rich-text layouter; its runs (quotation marks joined to the first and last) draw like body runs.
|
|
1386
|
+
if(part.runs&&part.fit.richLines)return renderRichLines(part.runs,part.fit,part.box,bound,{path:part.path,fill,fontFamily:part.requestedStyle.fontFamily,diagnosticsHandled:true,textOffset:-1,options});
|
|
1407
1387
|
return renderTextBox(part.text,part.box,bound,{
|
|
1408
1388
|
path:part.path,fit:part.fit,textStyle:part.style,fontFamily:part.requestedStyle.fontFamily,
|
|
1409
|
-
fill
|
|
1389
|
+
fill,
|
|
1410
1390
|
diagnosticsHandled:true,options,
|
|
1411
1391
|
});
|
|
1412
1392
|
});
|
|
1393
|
+
// FA-12: the attributed person's headshot, cropped to fill the core circle frame (the clip is the outline the native ellipse picture uses).
|
|
1394
|
+
if (layout.photo) children.push(renderQuotePhoto(layout.photo, item, bound, options));
|
|
1413
1395
|
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
1414
1396
|
}
|
|
1415
1397
|
|
|
1398
|
+
function renderQuotePhoto(photo, item, bound, options) {
|
|
1399
|
+
const picture = renderImage({ value: photo.value, path: photo.path }, photo.box, bound, { ...options, imageFit: 'cover' });
|
|
1400
|
+
// An unresolved source keeps the ordinary placeholder, unmasked, like the export.
|
|
1401
|
+
if (!picture.startsWith('<image')) return picture;
|
|
1402
|
+
const id = `opf-s${bound.index + 1}-quote-photo-${item.path.replace(/[^A-Za-z0-9]+/g, '-')}`;
|
|
1403
|
+
return tag('g', {}, tag('defs', {}, tag('clipPath', { id }, tag('path', { d: photo.shape.path }))) + tag('g', { 'clip-path': `url(#${id})` }, picture));
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1416
1406
|
function renderTimeline(item, box, bound, options) {
|
|
1417
1407
|
const layout=item.timelineLayout;
|
|
1418
1408
|
if(!layout)throw new OPFRenderError('missing-timeline-layout','Timeline rendering requires a coordinated core build with shared timeline geometry.',{path:item.path});
|
|
1419
1409
|
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
1420
1410
|
const children=[tag('line',{...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
|
|
1421
|
-
|
|
1411
|
+
// FA-11: status colors come from the deck (core's timelineMarkerShapes / timelineTextColor); a marker with no status draws the plain primary circle.
|
|
1412
|
+
const background=bound.design.backgroundColor??bound.design.colors.background;
|
|
1413
|
+
const statusColors={background,primary:bound.design.colors.primary,text:bound.design.colors.text,mutedText:bound.design.colors.mutedText};
|
|
1414
|
+
for(const marker of layout.markers){
|
|
1415
|
+
if(!marker.status){
|
|
1416
|
+
children.push(tag('circle',{cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
for(const shape of timelineMarkerShapes(marker,statusColors))children.push(tag('circle',{cx:shape.cx,cy:shape.cy,r:shape.radius,fill:shape.fill??'none',...(shape.stroke?{stroke:shape.stroke.color,'stroke-width':shape.stroke.width}:{}),...traceAttrs(options,marker.path),...(options.trace?{'data-opf-timeline-status':marker.status,'data-opf-timeline-shape':shape.role}:{})}));
|
|
1420
|
+
}
|
|
1422
1421
|
for(const part of layout.parts){
|
|
1423
|
-
if(!part.fit)throw new OPFRenderError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,
|
|
1422
|
+
if(!part.fit)throw new OPFRenderError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,diagnostics:layout.diagnostics});
|
|
1424
1423
|
children.push(tag('g',options.trace?{'data-opf-timeline-role':part.role}:{},renderTextBox(part.text,part.box,bound,{
|
|
1425
1424
|
path:part.path,fit:part.fit,textStyle:part.style,fontFamily:part.requestedStyle.fontFamily,align:part.alignment,
|
|
1426
|
-
fill:bound.design.colors.text,diagnosticsHandled:true,options,
|
|
1425
|
+
fill:part.status?timelineTextColor(part,statusColors):bound.design.colors.text,diagnosticsHandled:true,options,
|
|
1427
1426
|
})));
|
|
1428
1427
|
}
|
|
1429
1428
|
return tag('g',{...traceAttrs(options,item.path),...(options.trace?{'data-opf-timeline-arrangement':layout.arrangement}:{})},children.join('\n'));
|
|
@@ -1431,7 +1430,7 @@ function renderTimeline(item, box, bound, options) {
|
|
|
1431
1430
|
|
|
1432
1431
|
function renderTable(item, box, bound, options) {
|
|
1433
1432
|
const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
1434
|
-
const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, textMeasurement:options.textMeasurement, path:item.path, presentation:bound.presentation, ...(bound.geometry?.direction === "rtl" ? {direction:"rtl"} : {})});
|
|
1433
|
+
const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, ...(bound.design.fonts.code ? {codeFontFamily:bound.design.fonts.code} : {}), textMeasurement:options.textMeasurement, path:item.path, presentation:bound.presentation, ...(bound.geometry?.direction === "rtl" ? {direction:"rtl"} : {})});
|
|
1435
1434
|
const children = [];
|
|
1436
1435
|
// RR-54: a dataset table has no rows or columns of its own to point at; every cell reports the table's authored path.
|
|
1437
1436
|
const own = isDatasetTable(item, bound) ? () => item.path : (path) => path;
|
|
@@ -1512,7 +1511,7 @@ function renderTableBorders(defaultEdges, explicitEdges, scale, bound, options)
|
|
|
1512
1511
|
}
|
|
1513
1512
|
|
|
1514
1513
|
function renderChart(item, box, bound, options) {
|
|
1515
|
-
// Catalog chart types
|
|
1514
|
+
// Catalog chart types preview the native
|
|
1516
1515
|
// construct opf-pptx exports; other ids keep the legacy single-series preview.
|
|
1517
1516
|
const rendered = renderCatalogChart(item, box, bound, options, { tag, traceAttrs, stableNumber, renderTextBox, reportDiagnostic });
|
|
1518
1517
|
if (rendered) return rendered;
|
|
@@ -1611,10 +1610,9 @@ function renderChart(item, box, bound, options) {
|
|
|
1611
1610
|
}
|
|
1612
1611
|
|
|
1613
1612
|
// RR-54: the legacy sketch reads the same resolved rows as the catalog charts (mapping, datasets and strict numbers). Data that does
|
|
1614
|
-
// not resolve
|
|
1613
|
+
// not resolve is read as authored.
|
|
1615
1614
|
function legacyChartData(chart, bound) {
|
|
1616
|
-
|
|
1617
|
-
const resolved = opfCore.resolveChartData(chart, bound.presentation);
|
|
1615
|
+
const resolved = resolveChartData(chart, bound.presentation);
|
|
1618
1616
|
return resolved.ok ? { rows: resolved.rows, resolved: true } : chart.data;
|
|
1619
1617
|
}
|
|
1620
1618
|
|
|
@@ -1648,7 +1646,17 @@ function renderFurniture(bound, presentation, width, height, options, kind) {
|
|
|
1648
1646
|
function renderBranding(bound,presentation,width,height,options) {
|
|
1649
1647
|
const design={...presentation.design,...bound.slide.design}, pieces=[];
|
|
1650
1648
|
const rootFor=key=>bound.slide.design?.[key]!==undefined?`${bound.path}.design.${key}`:`design.${key}`;
|
|
1651
|
-
|
|
1649
|
+
// FA-13: a text watermark is one line of the heading font in the theme text colour, centered and rotated as core's layoutWatermark says.
|
|
1650
|
+
if(design.watermark&&typeof design.watermark==='object'&&typeof design.watermark.text==='string'){
|
|
1651
|
+
const mark=layoutWatermark(design.watermark.text,{width,height},{fontFamily:bound.design.fonts.heading,fontWeight:700,textMeasurement:options.textMeasurement});
|
|
1652
|
+
if(mark){
|
|
1653
|
+
const opacity=typeof design.watermark.opacity==='number'&&Number.isFinite(design.watermark.opacity)?Math.min(1,Math.max(0,design.watermark.opacity)):.08;
|
|
1654
|
+
const cx=width/2,cy=height/2;
|
|
1655
|
+
pieces.push(tag('g',{opacity:stableNumber(opacity),transform:`rotate(${stableNumber(mark.rotation)} ${stableNumber(cx)} ${stableNumber(cy)})`,...traceAttrs(options,rootFor('watermark'))},
|
|
1656
|
+
tag('text',{x:stableNumber(cx),y:stableNumber(cy+mark.fontSize*.35),'text-anchor':'middle','font-family':fontStack(mark.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(mark.fontSize),'font-weight':mark.fontWeight,
|
|
1657
|
+
fill:bound.design.colors.text,'xml:space':'preserve','text-rendering':'geometricPrecision'},escapeText(mark.text))));
|
|
1658
|
+
}
|
|
1659
|
+
} else if(design.watermark){
|
|
1652
1660
|
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,imageFit:'contain'})));
|
|
1653
1661
|
}
|
|
1654
1662
|
// Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
|
|
@@ -1675,20 +1683,16 @@ function fontStack(family, type) {
|
|
|
1675
1683
|
// alignment and measured advances are unchanged.
|
|
1676
1684
|
const RIGHT_TO_LEFT_ISOLATE = "\u2067", POP_DIRECTIONAL_ISOLATE = "\u2069";
|
|
1677
1685
|
// Core owns the rule (paragraphDirection, core #134), so preview and export agree.
|
|
1678
|
-
// Without it (published core 0.11.0) every paragraph is left to right, as in export.
|
|
1679
|
-
const coreParagraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
|
|
1680
1686
|
// RR-05: authored alignment is logical for right-to-left text, so `left` is the start edge (drawn at the right of a
|
|
1681
|
-
// right-to-left line) and `right` the end edge. Core owns
|
|
1682
|
-
// also reports no line directions, so it never flips.
|
|
1683
|
-
const physicalAlignment = typeof opfCore.physicalAlignment === "function" ? opfCore.physicalAlignment : alignment => alignment;
|
|
1687
|
+
// right-to-left line) and `right` the end edge. Core owns that rule too (physicalAlignment, imported above).
|
|
1684
1688
|
/** Maps a source offset of the text to whether its paragraph is right to left. */
|
|
1685
1689
|
function paragraphRtl(bound, text) {
|
|
1686
|
-
if (bound.scriptFonts?.rtl !== true
|
|
1690
|
+
if (bound.scriptFonts?.rtl !== true) return () => false;
|
|
1687
1691
|
const source = String(text ?? ""), spans = [];
|
|
1688
1692
|
let start = 0;
|
|
1689
1693
|
for (const match of source.matchAll(/\r\n|\r|\n/g)) { spans.push([start, match.index]); start = match.index + match[0].length; }
|
|
1690
1694
|
spans.push([start, source.length]);
|
|
1691
|
-
const rtl = spans.map(([from, to]) =>
|
|
1695
|
+
const rtl = spans.map(([from, to]) => paragraphDirection(source.slice(from, to), "rtl") === "rtl");
|
|
1692
1696
|
return offset => { for (let index = spans.length - 1; index >= 0; index--) if (offset >= spans[index][0]) return rtl[index]; return rtl[0]; };
|
|
1693
1697
|
}
|
|
1694
1698
|
|
|
@@ -1710,7 +1714,7 @@ function nestedReset(style, run) {
|
|
|
1710
1714
|
|
|
1711
1715
|
function scriptLine(text, style, bound, type, { rtl = false, placement, trace, fontSize } = {}) {
|
|
1712
1716
|
const value = String(text ?? "");
|
|
1713
|
-
const scripts = bound.scriptFonts;
|
|
1717
|
+
const scripts = (style?.lang && bound.scriptFontsFor?.(style.lang)) || bound.scriptFonts;
|
|
1714
1718
|
rtl = rtl && value !== "";
|
|
1715
1719
|
const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
|
|
1716
1720
|
const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
|
|
@@ -1759,7 +1763,7 @@ function segmentSpan(attrs, segment, text, style, bound, type, options, syntax)
|
|
|
1759
1763
|
// Syntax highlighting (RR-07): a coloured text segment becomes consecutive sibling tspans, one per run, each a
|
|
1760
1764
|
// traced segment of its own source range holding one text node (the editor's caret mapping reads that), the first
|
|
1761
1765
|
// keeping the accepted x and the rest flowing after it. Plain code keeps one tspan per accepted segment.
|
|
1762
|
-
const runs = syntax && segment.kind !== "tab" ?
|
|
1766
|
+
const runs = syntax && segment.kind !== "tab" ? codeLineRuns(syntax.tokens, segment.start, segment.end) : undefined;
|
|
1763
1767
|
if (runs?.some(run => run.kind)) {
|
|
1764
1768
|
return runs.map((run, index) => {
|
|
1765
1769
|
const sizeOf = line => line.sizeAdjust && options?.fontSize > 0 ? stableNumber(adjustedFontSize(options.fontSize, line.sizeAdjust)) : undefined;
|
|
@@ -1773,16 +1777,27 @@ function segmentSpan(attrs, segment, text, style, bound, type, options, syntax)
|
|
|
1773
1777
|
return tag("tspan", { ...attrs, "font-family": scripted.family, "font-size": scripted.sizeAdjust && options?.fontSize > 0 ? stableNumber(adjustedFontSize(options.fontSize, scripted.sizeAdjust)) : undefined }, scripted.content);
|
|
1774
1778
|
}
|
|
1775
1779
|
|
|
1780
|
+
// FA-13: the marked lines of code.highlight, their bands (runs of displayed lines) and the lit/dimmed colours, from core (the PPTX
|
|
1781
|
+
// export calls the same functions); undefined when the code marks no line.
|
|
1782
|
+
function codeHighlight(item, layout, bound) {
|
|
1783
|
+
const highlight = item.value?.highlight;
|
|
1784
|
+
if (!Array.isArray(highlight)) return undefined;
|
|
1785
|
+
const body = layout.parts.find(part => part.role === "body");
|
|
1786
|
+
if (!body?.fit) return undefined;
|
|
1787
|
+
const lines = codeHighlightLines(highlight, body.text).lines;
|
|
1788
|
+
if (!lines.length) return undefined;
|
|
1789
|
+
return { marked: new Set(lines), numbers: codeLineNumbers(body.fit.sourceLines), bands: codeHighlightBands(body.fit.sourceLines, lines), colors: codeHighlightColors(bound.design.colorScheme) };
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1776
1792
|
// Token ranges of the code body and the palette to paint them with; undefined for plain code (an unknown language,
|
|
1777
|
-
// no language
|
|
1793
|
+
// no language). The preview and the PPTX export read the same tables.
|
|
1778
1794
|
function codeSyntax(item, layout, bound, options) {
|
|
1779
1795
|
const body = layout.parts.find(part => part.role === "body");
|
|
1780
1796
|
const language = typeof item.value?.language === "string" ? item.value.language : layout.parts.find(part => part.role === "language")?.text;
|
|
1781
1797
|
if (!body || !language) return undefined;
|
|
1782
|
-
|
|
1783
|
-
const tokens = opfCore.tokenizeCode(body.text, language);
|
|
1798
|
+
const tokens = tokenizeCode(body.text, language);
|
|
1784
1799
|
if (!tokens.length) return undefined;
|
|
1785
|
-
return { tokens, palette:
|
|
1800
|
+
return { tokens, palette: codeSyntaxPaletteForScheme(bound.design.colorScheme) };
|
|
1786
1801
|
}
|
|
1787
1802
|
|
|
1788
1803
|
// Faces flagged embed:"used" (the vendored open, Intos and script-pack faces) are embedded only when the slide's own markup draws
|
|
@@ -1840,8 +1855,8 @@ function renderEmbeddedFonts(fonts = []) {
|
|
|
1840
1855
|
|
|
1841
1856
|
function renderRichTextBox(value, box, bound, config) {
|
|
1842
1857
|
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
1843
|
-
const fit=config.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});
|
|
1844
|
-
if(fit.overflow&&!config.diagnosticsHandled){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,{
|
|
1858
|
+
const fit=config.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},...(bound.design.fonts.code?{codeFontFamily:bound.design.fonts.code}:{}),textMeasurement:config.options.textMeasurement});
|
|
1859
|
+
if(fit.overflow&&!config.diagnosticsHandled){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,{diagnostics:[diagnostic]});}
|
|
1845
1860
|
return renderRichLines(value,fit,box,bound,config);
|
|
1846
1861
|
}
|
|
1847
1862
|
|
|
@@ -1866,10 +1881,11 @@ function trimRichLineEdges(line,textMeasurement) {
|
|
|
1866
1881
|
}
|
|
1867
1882
|
|
|
1868
1883
|
function renderRichLines(value,fit,box,bound,config) {
|
|
1869
|
-
const logicalAlignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1884
|
+
const logicalAlignment=fit.placement?.alignment??config.align??bound.geometry.design.contentAlignment;
|
|
1870
1885
|
// Each line takes its paragraph's direction from core (every wrapped line shares it) and its physical edge from that.
|
|
1871
1886
|
const lineAlignment=index=>fit.placement?.lines[index]?.alignment??physicalAlignment(logicalAlignment,fit.directions?.[index]);
|
|
1872
|
-
|
|
1887
|
+
// A rich quote body draws its quotation marks inside the first and last run; config.textOffset (-1) keeps every traced offset on the authored text.
|
|
1888
|
+
let textOffset=config.textOffset??0;
|
|
1873
1889
|
const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
|
|
1874
1890
|
const richRtl=paragraphRtl(bound,value.map(run=>typeof run==='string'?run:run.text).join(''));
|
|
1875
1891
|
// With no measurement provider, fragment advances are estimates. Let SVG
|
|
@@ -1901,19 +1917,23 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1901
1917
|
// Estimated (natural-flow) lines keep logical order; the browser reorders them.
|
|
1902
1918
|
const fragmentX=rtl&&!naturalFlow?line.width-fragment.x-fragment.width:fragment.x;
|
|
1903
1919
|
const position=asFlow?{'baseline-shift':fragment.baselineShift?stableNumber(-fragment.baselineShift):undefined}:{x:stableNumber(originX+fragmentX),y:stableNumber(baseline+fragment.baselineShift)};
|
|
1920
|
+
// A link run is drawn as PowerPoint draws one: underlined, in the scheme hyperlink color unless the run sets its own color.
|
|
1921
|
+
const linked = fragment.kind !== 'marker' && typeof run.link === 'string' && LINK_URL.test(run.link);
|
|
1904
1922
|
const runFill = run.color == null
|
|
1905
|
-
? config.fill
|
|
1923
|
+
? (linked ? bound.design.colors.hyperlink : config.fill)
|
|
1906
1924
|
: resolveColorRef(run.color, bound, config.fill);
|
|
1907
1925
|
let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
|
|
1908
|
-
|
|
1926
|
+
// FA-13: an inline code run falls back to a monospace face, and a run with its own language declares it.
|
|
1927
|
+
const runLang=fragment.kind!=='tab'&&fragment.style.lang?(bound.scriptFontsFor?.(fragment.style.lang)?.profile.bcp47??fragment.style.lang):undefined;
|
|
1928
|
+
const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(fragment.text,fragment.style,bound,run.code===true?'monospace':bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
|
|
1909
1929
|
placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
|
|
1910
1930
|
if(scripted.positioned)fixedAdvance=false;
|
|
1911
1931
|
const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
|
|
1912
1932
|
// RR-34: a citation/footnote marker is generated text (no source range): it is traced as a marker
|
|
1913
1933
|
// segment without text offsets, so editors never read it as part of the run, and it is not linked.
|
|
1914
1934
|
const marker=fragment.kind==='marker';
|
|
1915
|
-
const rendered=tag(asFlow?'tspan':'text',{...(config.options.trace?marker?{'data-opf-segment':'marker','data-opf-marker':fragment.text}:{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end,'data-opf-segment':fragment.kind}:{}),...position,'xml:space':'preserve','text-rendering':asFlow?undefined:'geometricPrecision',textLength:fixedAdvance?stableNumber(fragment.width):undefined,lengthAdjust:fixedAdvance?'spacingAndGlyphs':undefined,'font-family':scripted.family??fontStack(fragment.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(adjustedFontSize(fragment.fontSize,scripted.sizeAdjust)),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':asFlow?'normal':undefined,'text-decoration':marker?undefined:[run.underline?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
|
|
1916
|
-
if(
|
|
1935
|
+
const rendered=tag(asFlow?'tspan':'text',{...(runLang?{lang:runLang,'xml:lang':runLang}:{}),...(config.options.trace?marker?{'data-opf-segment':'marker','data-opf-marker':fragment.text}:{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end,'data-opf-segment':fragment.kind}:{}),...position,'xml:space':'preserve','text-rendering':asFlow?undefined:'geometricPrecision',textLength:fixedAdvance?stableNumber(fragment.width):undefined,lengthAdjust:fixedAdvance?'spacingAndGlyphs':undefined,'font-family':scripted.family??fontStack(fragment.style.fontFamily,run.code===true?'monospace':bound.design.fontScheme.type),'font-size':stableNumber(adjustedFontSize(fragment.fontSize,scripted.sizeAdjust)),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':asFlow?'normal':undefined,'text-decoration':marker?undefined:[run.underline||linked?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
|
|
1936
|
+
if(linked)return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
|
|
1917
1937
|
return rendered;
|
|
1918
1938
|
};
|
|
1919
1939
|
if(naturalFlow&&lineHasTab) {
|
|
@@ -1958,13 +1978,13 @@ function renderTextBox(text, box, bound, config) {
|
|
|
1958
1978
|
const diagnostic = { code: "text-overflow", path: config.path,
|
|
1959
1979
|
message: "Text exceeds its cell at the minimum font size; shorten it, increase its space, or split the slide." };
|
|
1960
1980
|
reportDiagnostic(diagnostic, config.options);
|
|
1961
|
-
if ((bound.composition ?? bound.geometry.composition).overflow === "error") throw new OPFRenderError("layout-overflow", diagnostic.message, { path: diagnostic.path,
|
|
1981
|
+
if ((bound.composition ?? bound.geometry.composition).overflow === "error") throw new OPFRenderError("layout-overflow", diagnostic.message, { path: diagnostic.path, diagnostics: [diagnostic] });
|
|
1962
1982
|
}
|
|
1963
1983
|
const size = fit.fontSize;
|
|
1964
1984
|
const totalHeight = fit.lines.length * fit.lineHeight;
|
|
1965
1985
|
const startY = config.verticalAlign === "middle"
|
|
1966
1986
|
? box.y + Math.max(0, (box.height - totalHeight) / 2) + size : box.y + size;
|
|
1967
|
-
const logicalAlignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1987
|
+
const logicalAlignment=fit.placement?.alignment??config.align??bound.geometry.design.contentAlignment;
|
|
1968
1988
|
const type = config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type;
|
|
1969
1989
|
const source = String(text ?? ""), boxRtl = paragraphRtl(bound, source);
|
|
1970
1990
|
let cursor = 0;
|