@openpresentation/opf-render 0.13.1 → 0.15.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 +123 -86
- package/dist/chart-options.js +26 -21
- package/dist/charts.js +206 -123
- 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 +45 -40
- 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 +25 -31
- package/dist/script-fonts.js +6 -17
- package/dist/svg.d.ts +39 -38
- package/dist/svg.js +394 -376
- 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,25 @@
|
|
|
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.
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
-
resolveColorRef as resolveCoreColorRef,
|
|
5
|
-
|
|
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, fitImage, intrinsicImageAspect, intrinsicImageSize,
|
|
9
|
+
ENGINE_DEFAULT_THEME, ENGINE_DEFAULT_COLOR_SCHEME, ENGINE_DEFAULT_FONT_SCHEME, ENGINE_DEFAULT_CHART_TYPES
|
|
10
|
+
} from "@openpresentation/opf/composition";
|
|
11
|
+
// The renderer is a library: it never imports a catalog. Hosts register theirs (`options.catalogs`, core `Catalog[]`, for
|
|
12
|
+
// example `defaultCatalog` from `@openpresentation/opf/catalog`) and core resolves every reference against them.
|
|
13
|
+
import {
|
|
14
|
+
hasContentVariables,
|
|
15
|
+
isTemplate,
|
|
16
|
+
resolveSlideContext,
|
|
17
|
+
resolveVariables,
|
|
18
|
+
validate
|
|
6
19
|
} 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
20
|
import { adjustedFontSize, baselineShift, createScriptFonts } from "./script-fonts.js";
|
|
11
21
|
import { disabledFeaturesStyle } from "./font-compatibility.js";
|
|
12
|
-
import { fontPolicyFor } from "
|
|
22
|
+
import { fontPolicyFor } from "@openpresentation/opf/font-policy";
|
|
13
23
|
import { isDatasetChart, isDatasetTable, renderCatalogChart } from "./charts.js";
|
|
14
24
|
import { renderCaption, renderFootnotes } from "./annotations.js";
|
|
15
25
|
|
|
@@ -29,49 +39,26 @@ export const runtimePolicy = Object.freeze({
|
|
|
29
39
|
deterministicLocalExecution: true
|
|
30
40
|
});
|
|
31
41
|
|
|
42
|
+
// What draws when nothing resolves: core's engine defaults (OPF 0.15 ships no catalog records), and the chart type a chart
|
|
43
|
+
// without one previews as.
|
|
32
44
|
export const engineDefaults = Object.freeze({
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
fontSchemes: Object.freeze({ source: "https://www.pptx.gallery/font-schemes" }),
|
|
38
|
-
languages: Object.freeze({ source: "https://www.pptx.gallery/languages" }),
|
|
39
|
-
layouts: Object.freeze({ source: "https://www.pptx.gallery/layouts" }),
|
|
40
|
-
chartTypes: Object.freeze({ source: "https://www.pptx.gallery/chart-types" }),
|
|
41
|
-
tones: Object.freeze({ source: "https://www.pptx.gallery/tones" }),
|
|
42
|
-
audiences: Object.freeze({ source: "https://www.pptx.gallery/audiences" }),
|
|
43
|
-
socialPlatforms: Object.freeze({ source: "https://www.pptx.gallery/social-platforms" })
|
|
44
|
-
}),
|
|
45
|
-
theme: "minimal",
|
|
46
|
-
colorScheme: "cool-horizon",
|
|
47
|
-
language: "english",
|
|
48
|
-
narrative: "classic-story",
|
|
49
|
-
tone: "formal",
|
|
50
|
-
audience: "executives",
|
|
51
|
-
fontScheme: Object.freeze({
|
|
52
|
-
pptx: Object.freeze({ latin: "aptos", ea: "microsoft-yahei", cs: "nirmala-ui" }),
|
|
53
|
-
google: Object.freeze({ latin: "roboto", ea: "noto-sans-sc", cs: "noto-sans" })
|
|
54
|
-
}),
|
|
55
|
-
chartTypes: Object.freeze([
|
|
56
|
-
"stacked-column-3x",
|
|
57
|
-
"stacked-area-3x",
|
|
58
|
-
"line-with-markers-3x"
|
|
59
|
-
])
|
|
45
|
+
theme: ENGINE_DEFAULT_THEME,
|
|
46
|
+
colorScheme: ENGINE_DEFAULT_COLOR_SCHEME,
|
|
47
|
+
fontScheme: ENGINE_DEFAULT_FONT_SCHEME,
|
|
48
|
+
chartType: ENGINE_DEFAULT_CHART_TYPES[0]
|
|
60
49
|
});
|
|
61
50
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const DEFAULT_FONT_SCHEME = engineDefaults.fontScheme.pptx.latin;
|
|
67
|
-
|
|
51
|
+
/**
|
|
52
|
+
* `details.findings` (`invalid-opf`) are the error findings of core `validate`; `details.diagnostics` hold the
|
|
53
|
+
* layout or variable diagnostics that explain the other codes. `findings` and `path` are also set on the error.
|
|
54
|
+
*/
|
|
68
55
|
export class OPFRenderError extends Error {
|
|
69
56
|
constructor(code, message, details = {}) {
|
|
70
57
|
super(message);
|
|
71
58
|
this.name = "OPFRenderError";
|
|
72
59
|
this.code = code;
|
|
73
60
|
this.details = details;
|
|
74
|
-
if (details.
|
|
61
|
+
if (details.findings) this.findings = details.findings;
|
|
75
62
|
if (details.path) this.path = details.path;
|
|
76
63
|
}
|
|
77
64
|
}
|
|
@@ -158,7 +145,6 @@ const FIELD_TYPE = Object.freeze({
|
|
|
158
145
|
});
|
|
159
146
|
|
|
160
147
|
const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
|
|
161
|
-
const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
|
|
162
148
|
|
|
163
149
|
|
|
164
150
|
function parseInput(input) {
|
|
@@ -186,39 +172,40 @@ function parseInput(input) {
|
|
|
186
172
|
// Template variables (core resolveVariables, RR-32): a deck that uses content variables, or a template, is resolved
|
|
187
173
|
// to a concrete deck before composition, so the preview and the PPTX exporter see the same text, numbers, dates and
|
|
188
174
|
// 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.
|
|
175
|
+
// variable is refused. Decks without content variables are returned untouched.
|
|
191
176
|
function resolveTemplateInput(presentation, options) {
|
|
192
|
-
if (
|
|
177
|
+
if (!isPlainObject(presentation)) return presentation;
|
|
193
178
|
const values = options.variables;
|
|
194
179
|
// variables: false draws the document as authored, tokens and var: references included (the template editing view).
|
|
195
180
|
if (values === false) return presentation;
|
|
196
181
|
if (values !== undefined && !isPlainObject(values)) {
|
|
197
182
|
throw new OPFRenderError("invalid-variables", "The variables option must be an object keyed by variable id.", { path: "options.variables" });
|
|
198
183
|
}
|
|
199
|
-
const template =
|
|
200
|
-
if (!template && !
|
|
201
|
-
const result =
|
|
184
|
+
const template = isTemplate(presentation);
|
|
185
|
+
if (!template && !hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
|
|
186
|
+
const result = resolveVariables(presentation, values ?? {}, { examples: template });
|
|
202
187
|
const errors = result.diagnostics.filter((entry) => entry.severity === "error");
|
|
203
188
|
if (errors.length) {
|
|
204
189
|
const unfilled = errors.some((entry) => entry.code === "variable-unfilled");
|
|
205
190
|
throw new OPFRenderError(unfilled ? "unfilled-variables" : "invalid-variables", errors[0].message, {
|
|
206
|
-
|
|
191
|
+
diagnostics: errors,
|
|
207
192
|
path: errors[0].path
|
|
208
193
|
});
|
|
209
194
|
}
|
|
210
195
|
for (const entry of result.diagnostics) {
|
|
211
|
-
if (entry.code === "variable-example-used") options.onDiagnostic?.({ code:
|
|
196
|
+
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
197
|
}
|
|
213
198
|
return result.presentation;
|
|
214
199
|
}
|
|
215
200
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
201
|
+
// The boundary check is the format check only (schema and the cross-field rules a schema cannot say): layout, accessibility
|
|
202
|
+
// and content findings are `validate`'s job, and drawing a slide reports what it cannot fit.
|
|
203
|
+
function assertValidBoundary(presentation, options) {
|
|
204
|
+
const report = validate(presentation, { only: ["format"], ...(options.catalogs !== undefined ? { catalogs: options.catalogs } : {}) });
|
|
205
|
+
if (!report.valid) {
|
|
219
206
|
throw new OPFRenderError("invalid-opf", "OPF validation failed.", {
|
|
220
|
-
|
|
221
|
-
|
|
207
|
+
findings: report.findings.filter((finding) => finding.severity === "error"),
|
|
208
|
+
report
|
|
222
209
|
});
|
|
223
210
|
}
|
|
224
211
|
}
|
|
@@ -239,123 +226,6 @@ function isPlainObject(value) {
|
|
|
239
226
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
240
227
|
}
|
|
241
228
|
|
|
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
|
-
function normalizeSourceRecords(value) {
|
|
249
|
-
if (!value) return [];
|
|
250
|
-
if (Array.isArray(value)) return value;
|
|
251
|
-
if (Array.isArray(value.records)) return value.records;
|
|
252
|
-
return [];
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function findById(records, id) {
|
|
256
|
-
return records.find((record) => record && record.id === id) ?? null;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function defaultCatalogFor(kind) {
|
|
260
|
-
return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// CatalogEntry.source is one source or an ordered search path (first match wins; the
|
|
264
|
-
// default catalog is appended by the callers). An array concatenates each source's
|
|
265
|
-
// records in order, so the findById chain keeps the first match. Non-string entries are
|
|
266
|
-
// ignored. Nothing is fetched: a source resolves from options.catalogSources or, for the
|
|
267
|
-
// bundled default prefixes, from the bundled snapshot.
|
|
268
|
-
function sourceRecordsFor(kind, source, options) {
|
|
269
|
-
if (Array.isArray(source)) return source.flatMap((entry) => sourceRecordsFor(kind, entry, options));
|
|
270
|
-
if (typeof source !== "string" || !source) return [];
|
|
271
|
-
const bySource = options.catalogSources?.[source];
|
|
272
|
-
if (bySource) return normalizeSourceRecords(bySource);
|
|
273
|
-
if (source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/")) {
|
|
274
|
-
return defaultCatalogFor(kind);
|
|
275
|
-
}
|
|
276
|
-
return [];
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function engineDefaultId(kind) {
|
|
280
|
-
if (kind === "fontSchemes") return DEFAULT_FONT_SCHEME;
|
|
281
|
-
if (kind === "chartTypes") return engineDefaults.chartTypes[0];
|
|
282
|
-
const key = kind.endsWith("s") ? kind.slice(0, -1) : kind;
|
|
283
|
-
return engineDefaults[key];
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function findCatalogRecord(kind, id, context) {
|
|
287
|
-
if (!id) return null;
|
|
288
|
-
const documentCatalog = context.presentation.catalogs?.[kind];
|
|
289
|
-
return findById(normalizeSourceRecords(documentCatalog), id) ??
|
|
290
|
-
findById(sourceRecordsFor(kind, documentCatalog?.source, context.options), id) ??
|
|
291
|
-
findById(normalizeSourceRecords(context.options.catalogs?.[kind]), id) ??
|
|
292
|
-
findById(sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options), id) ??
|
|
293
|
-
findById(defaultCatalogFor(kind), id);
|
|
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}'.` } };
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// 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) {
|
|
312
|
-
const kind = "socialPlatforms";
|
|
313
|
-
return [
|
|
314
|
-
...sourceRecordsFor(kind, context.presentation.catalogs?.[kind]?.source, context.options),
|
|
315
|
-
...normalizeSourceRecords(context.options.catalogs?.[kind]),
|
|
316
|
-
...sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options),
|
|
317
|
-
...defaultCatalogFor(kind)
|
|
318
|
-
];
|
|
319
|
-
}
|
|
320
|
-
|
|
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
229
|
function colorFromScheme(scheme, slot, fallback) {
|
|
360
230
|
if (!slot) return fallback;
|
|
361
231
|
if (typeof slot === "string") {
|
|
@@ -421,14 +291,20 @@ function resolveBackgroundColor(value, design, fallback) {
|
|
|
421
291
|
return resolveColorRefIn(value, { colorScheme: design.colorScheme, colors: { primary, secondary, accent }, variables: design.variables }, fallback);
|
|
422
292
|
}
|
|
423
293
|
|
|
294
|
+
// Image sources the background string shorthand accepts (a cover image); any other string is a theme slot or a hex colour.
|
|
295
|
+
const IMAGE_BACKGROUND_SOURCE = /^(asset:|https:\/\/|data:|\.\/|\.\.\/)/;
|
|
296
|
+
|
|
297
|
+
// The slide's single background color, or null when there is none (a gradient, or no color the engine can read).
|
|
298
|
+
// With no definition, or a picture, the scheme's default slide background (core defaultSlideBackground) is the canvas.
|
|
424
299
|
function resolveBackground(background, colorScheme, design) {
|
|
425
|
-
|
|
426
|
-
if (
|
|
427
|
-
if (background
|
|
300
|
+
const canvas = defaultSlideBackground(colorScheme);
|
|
301
|
+
if (!background) return canvas;
|
|
302
|
+
if (typeof background === "string") return IMAGE_BACKGROUND_SOURCE.test(background) ? canvas : colorFromScheme(colorScheme, background, canvas);
|
|
303
|
+
if (background.type === "theme") return colorFromScheme(colorScheme, background.slot, canvas);
|
|
428
304
|
if (background.type === "solid") return resolveBackgroundColor(background.color, design, "#FFFFFF");
|
|
429
305
|
if (background.type === "gradient") return null;
|
|
430
306
|
if (background.type === "pattern") return resolveBackgroundColor(background.pattern?.backgroundColor, design, "#FFFFFF");
|
|
431
|
-
return
|
|
307
|
+
return canvas;
|
|
432
308
|
}
|
|
433
309
|
|
|
434
310
|
|
|
@@ -456,40 +332,30 @@ function tagFill(design) {
|
|
|
456
332
|
return contrastRatio(design.colors.primary, background) < TAG_MIN_CONTRAST ? design.colors.text : design.colors.primary;
|
|
457
333
|
}
|
|
458
334
|
|
|
459
|
-
|
|
335
|
+
const LINK_URL = /^(https?:|mailto:|tel:)/i;
|
|
336
|
+
|
|
337
|
+
// What the renderer paints with, from the records core's `resolveSlideContext` resolved (slide design, then deck design, then
|
|
338
|
+
// theme, then the engine default, per field: the one order every engine uses). The theme and the colour scheme keep their
|
|
339
|
+
// sorted keys, so the output does not depend on the order a catalog lists a field in. A reference that resolves nowhere never
|
|
340
|
+
// throws (unless `strictReferences`): the context falls back to core's engine defaults (ENGINE_DEFAULT_THEME, _COLOR_SCHEME,
|
|
341
|
+
// _FONT_SCHEME; a layout to automatic composition) and reports `unresolved-reference`, which is forwarded as a render diagnostic.
|
|
342
|
+
function resolveDesign(presentation, slide, slideContext) {
|
|
460
343
|
const deckDesign = presentation.design ?? {};
|
|
461
344
|
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);
|
|
345
|
+
const { options: composeOptions, diagnostics, resolved } = slideContext;
|
|
346
|
+
const theme = cloneWithSortedKeys(resolved.theme);
|
|
347
|
+
const colorScheme = cloneWithSortedKeys(resolved.colorScheme);
|
|
348
|
+
const fontScheme = cloneWithSortedKeys(resolved.fontScheme);
|
|
349
|
+
const dimensions = { width: composeOptions.width, height: composeOptions.height };
|
|
484
350
|
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");
|
|
351
|
+
const { primary, secondary, accent } = resolveColorRoles(colorScheme);
|
|
488
352
|
const variables = presentation.variables ?? {};
|
|
489
353
|
// background/surface/text roles depend on the background itself, so a background reference sees only the scheme and the three accent roles.
|
|
490
354
|
const backgroundColor = resolveBackground(backgroundDefinition, colorScheme, { colorScheme, colors: { primary, secondary, accent }, variables });
|
|
491
|
-
|
|
492
|
-
|
|
355
|
+
// One resolution for every role (core resolveColorRoles), shared with the PPTX export and the validator. Whether the background is dark
|
|
356
|
+
// (relative luminance under 0.179) is core's decision in the slide context, the one composition uses for logos and bullets.
|
|
357
|
+
const roles = resolveColorRoles(colorScheme, { background: backgroundColor });
|
|
358
|
+
const darkBackground = composeOptions.darkBackground === true;
|
|
493
359
|
|
|
494
360
|
return {
|
|
495
361
|
...deckDesign,
|
|
@@ -502,18 +368,20 @@ function resolveDesign(presentation, slide, context, index) {
|
|
|
502
368
|
background: backgroundDefinition,
|
|
503
369
|
backgroundColor,
|
|
504
370
|
colors: {
|
|
505
|
-
background:
|
|
506
|
-
surface:
|
|
507
|
-
text:
|
|
508
|
-
mutedText:
|
|
371
|
+
background: roles.background,
|
|
372
|
+
surface: roles.surface,
|
|
373
|
+
text: roles.text,
|
|
374
|
+
mutedText: roles.textSecondary,
|
|
509
375
|
primary,
|
|
510
376
|
secondary,
|
|
511
377
|
accent,
|
|
378
|
+
hyperlink: roles.hyperlink,
|
|
379
|
+
followedHyperlink: roles.followedHyperlink,
|
|
512
380
|
border: colorFromScheme(colorScheme, "accent5", "#CBD5E1")
|
|
513
381
|
},
|
|
514
|
-
fonts:
|
|
382
|
+
fonts: { ...composeOptions.fontFamilies },
|
|
515
383
|
darkBackground,
|
|
516
|
-
diagnostics:
|
|
384
|
+
diagnostics: diagnostics.map((entry) => ({ ...entry }))
|
|
517
385
|
};
|
|
518
386
|
}
|
|
519
387
|
|
|
@@ -521,23 +389,14 @@ function resolveDesign(presentation, slide, context, index) {
|
|
|
521
389
|
* Script font slots, language tags and direction for one slide from core
|
|
522
390
|
* `resolveScriptFonts` (FF-18). The latin slot is always the renderer's design
|
|
523
391
|
* font; a script slot that core fills from the latin family follows it too.
|
|
524
|
-
*
|
|
392
|
+
* A language core cannot resolve is reported (`language-preview-unresolved`) and every slot repeats the latin family.
|
|
525
393
|
*/
|
|
526
|
-
function scriptProfile(presentation, index, design, context) {
|
|
394
|
+
function scriptProfile(presentation, index, design, context, language) {
|
|
527
395
|
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." });
|
|
396
|
+
try { resolved = resolveScriptFonts(presentation, { slideIndex: index, ...(context.options.catalogs !== undefined ? { catalogs: context.options.catalogs } : {}), ...(language === undefined ? {} : { language }) }); }
|
|
397
|
+
catch (error) {
|
|
398
|
+
reportLanguageDiagnostic(context, { code: "language-preview-unresolved", path: "language",
|
|
399
|
+
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
400
|
}
|
|
542
401
|
const slots = role => {
|
|
543
402
|
const latin = design.fonts[role];
|
|
@@ -594,33 +453,6 @@ function reportGlyphFallback(context, note) {
|
|
|
594
453
|
});
|
|
595
454
|
}
|
|
596
455
|
|
|
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
456
|
function contentPayloadFromHost(host, path, slot) {
|
|
625
457
|
if (!isPlainObject(host)) {
|
|
626
458
|
return { type: "text", field: "text", value: host, slot: slot ?? "text", path };
|
|
@@ -687,9 +519,37 @@ function normalizeFutureContent(slide, slidePath) {
|
|
|
687
519
|
});
|
|
688
520
|
}
|
|
689
521
|
|
|
690
|
-
|
|
522
|
+
// The core catalog options every resolution takes: the host's catalogs, unchanged, and its strict reference policy.
|
|
523
|
+
function catalogOptions(options) {
|
|
524
|
+
return { ...(options.catalogs !== undefined ? { catalogs: options.catalogs } : {}), ...(options.strictReferences === true ? { strictReferences: true } : {}) };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function resolveContext(presentation, index, context) {
|
|
528
|
+
try {
|
|
529
|
+
return resolveSlideContext(presentation, index, {
|
|
530
|
+
fonts: { textMeasurement: context.options.textMeasurement },
|
|
531
|
+
...catalogOptions(context.options),
|
|
532
|
+
date: context.options.date
|
|
533
|
+
});
|
|
534
|
+
} catch (error) {
|
|
535
|
+
// Strict references: core refuses a reference that resolves nowhere (OPFUnresolvedReferenceError) instead of falling back.
|
|
536
|
+
if (Array.isArray(error?.diagnostics) && error.diagnostics.some(entry => entry?.code === "unresolved-reference")) {
|
|
537
|
+
throw new OPFRenderError("unresolved-reference", error.message, { diagnostics: error.diagnostics, path: error.diagnostics[0].path, cause: error.message });
|
|
538
|
+
}
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function bindSlide(presentation, slide, index, context) {
|
|
691
544
|
const slidePath = `slides.${index}`;
|
|
692
|
-
|
|
545
|
+
// Core resolves the slide's canvas, layout, theme, colour scheme, font scheme and family names the one way every engine must
|
|
546
|
+
// (slide design, deck design, theme, engine default), from the records the document embeds and the catalogs the host
|
|
547
|
+
// registered (`options.catalogs`, passed unchanged); `fonts` carries the measurement the host loaded. A slide with no
|
|
548
|
+
// `layout` composes automatically; one whose reference resolves nowhere does too, with an `unresolved-reference` diagnostic,
|
|
549
|
+
// or the render fails under `strictReferences`.
|
|
550
|
+
const slideContext = resolveContext(presentation, index, context);
|
|
551
|
+
const layout = slideContext.options.layout;
|
|
552
|
+
const placeholders = Array.isArray(layout?.placeholders) ? layout.placeholders : [];
|
|
693
553
|
const titleBindings = [];
|
|
694
554
|
|
|
695
555
|
for (let i = 0; i < placeholders.length; i += 1) {
|
|
@@ -715,15 +575,38 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
715
575
|
];
|
|
716
576
|
const blocks = blockContent(slide, slidePath);
|
|
717
577
|
|
|
718
|
-
const design = resolveDesign(presentation, slide,
|
|
578
|
+
const design = resolveDesign(presentation, slide, slideContext);
|
|
719
579
|
// Script fonts (FF-19): each text run is measured and drawn with its script
|
|
720
580
|
// slot's face; the latin slot stays the design font scheme's family.
|
|
721
581
|
const scriptFonts = createScriptFonts(scriptProfile(presentation, index, design, context), context.options.textMeasurement, {
|
|
722
582
|
glyphFallback: context.options.glyphFallback,
|
|
723
583
|
onFallback: note => reportGlyphFallback(context, note)
|
|
724
584
|
});
|
|
725
|
-
|
|
726
|
-
//
|
|
585
|
+
// 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
|
|
586
|
+
// script fonts of that language (the Japanese or the Simplified Chinese face for Han text); every other run uses the deck's.
|
|
587
|
+
const languageScripts = new Map();
|
|
588
|
+
const scriptFontsFor = lang => {
|
|
589
|
+
if (typeof lang !== "string" || !lang) return undefined;
|
|
590
|
+
if (!languageScripts.has(lang)) {
|
|
591
|
+
let own;
|
|
592
|
+
try {
|
|
593
|
+
const profile = scriptProfile(presentation, index, design, context, lang);
|
|
594
|
+
own = profile.languageSource === "option" && profile.bcp47?.toLowerCase() !== scriptFonts.profile?.bcp47?.toLowerCase()
|
|
595
|
+
? createScriptFonts(profile, context.options.textMeasurement, { glyphFallback: context.options.glyphFallback, onFallback: note => reportGlyphFallback(context, note) })
|
|
596
|
+
: undefined;
|
|
597
|
+
} catch { own = undefined; }
|
|
598
|
+
languageScripts.set(lang, own);
|
|
599
|
+
}
|
|
600
|
+
return languageScripts.get(lang);
|
|
601
|
+
};
|
|
602
|
+
let textMeasurement = scriptFonts.textMeasurement ?? context.options.textMeasurement;
|
|
603
|
+
if (scriptFonts.textMeasurement) {
|
|
604
|
+
const deckMeasurement = scriptFonts.textMeasurement;
|
|
605
|
+
const pick = style => scriptFontsFor(style?.lang)?.textMeasurement ?? deckMeasurement;
|
|
606
|
+
textMeasurement = { ...deckMeasurement, measure: (text, size, style) => pick(style).measure(text, size, style) };
|
|
607
|
+
if (typeof deckMeasurement.outlineBounds === "function") textMeasurement.outlineBounds = (text, size, style) => (pick(style).outlineBounds ?? deckMeasurement.outlineBounds)(text, size, style);
|
|
608
|
+
}
|
|
609
|
+
// fontScheme.accent (the tag and quote text) takes the same look-alike policy.
|
|
727
610
|
// 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
611
|
// resolves it again and draws the replacement's encoded weight (Montserrat 900, Red Hat Display 600 and 300); resolving the role to the
|
|
729
612
|
// replacement family first would draw every run at 400 or 700 and measure it that way. Every other family resolves once, here.
|
|
@@ -731,9 +614,10 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
731
614
|
const named = design.fonts[role], resolved = resolveTextStyle({fontFamily:named,fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
|
|
732
615
|
design.fonts[role] = fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
|
|
733
616
|
}
|
|
734
|
-
const geometry = composeSlide(slide, { ...
|
|
617
|
+
const geometry = composeSlide(slide, { ...slideContext.options, fontFamilies: design.fonts, textRasterPadding:context.options.textRasterPadding, textMeasurement });
|
|
735
618
|
return {
|
|
736
619
|
scriptFonts,
|
|
620
|
+
scriptFontsFor,
|
|
737
621
|
textMeasurement,
|
|
738
622
|
geometry,
|
|
739
623
|
assets: presentation.assets ?? {},
|
|
@@ -750,17 +634,29 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
750
634
|
};
|
|
751
635
|
}
|
|
752
636
|
|
|
637
|
+
// `options.fonts` is the handle `loadFonts()` returns (or any object with a `textMeasurement` and, to embed faces in the SVG, an
|
|
638
|
+
// `embeddedFonts` list). The renderer reads the two as plain options from here on.
|
|
639
|
+
function normalizeOptions(options) {
|
|
640
|
+
const { fonts, ...rest } = options ?? {};
|
|
641
|
+
return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: fonts?.embeddedFonts };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Resolve a presentation for drawing: template variables filled, the boundary check passed, and every slide bound to its
|
|
646
|
+
* layout, design, script fonts and composed geometry. `renderSvg` and `renderSlideSvg` draw from this.
|
|
647
|
+
*/
|
|
753
648
|
export function resolvePresentation(input, options = {}) {
|
|
649
|
+
return resolveDeck(input, normalizeOptions(options));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function resolveDeck(input, options) {
|
|
754
653
|
const presentation = resolveTemplateInput(parseInput(input), options);
|
|
755
654
|
if (options.validate !== false) {
|
|
756
|
-
assertValidBoundary(presentation);
|
|
655
|
+
assertValidBoundary(presentation, options);
|
|
757
656
|
}
|
|
758
657
|
|
|
759
658
|
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
|
-
});
|
|
659
|
+
const slides = presentation.slides.map((slide, index) => bindSlide(presentation, slide, index, context));
|
|
764
660
|
|
|
765
661
|
return {
|
|
766
662
|
presentation,
|
|
@@ -769,21 +665,28 @@ export function resolvePresentation(input, options = {}) {
|
|
|
769
665
|
};
|
|
770
666
|
}
|
|
771
667
|
|
|
668
|
+
/**
|
|
669
|
+
* The SVG of every slide of the deck, in order. `skipHidden: true` leaves out slides marked `hidden` (the sequence the player
|
|
670
|
+
* presents); the result is then shorter than the deck, so an index no longer names the slide at that index.
|
|
671
|
+
*/
|
|
772
672
|
export function renderSvg(input, options = {}) {
|
|
773
|
-
const
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
673
|
+
const drawn = normalizeOptions(options);
|
|
674
|
+
const resolved = resolveDeck(input, drawn);
|
|
675
|
+
const indexes = resolved.slides.map((_, index) => index).filter(index => !(options.skipHidden === true && resolved.presentation.slides[index]?.hidden === true));
|
|
676
|
+
return indexes.map(index => renderResolvedSlide(resolved, index, drawn));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** The SVG of one slide of the deck (a zero-based index). */
|
|
680
|
+
export function renderSlideSvg(input, index, options = {}) {
|
|
681
|
+
const drawn = normalizeOptions(options);
|
|
682
|
+
const resolved = resolveDeck(input, drawn);
|
|
683
|
+
if (!Number.isInteger(index) || index < 0 || index >= resolved.slides.length) {
|
|
684
|
+
throw new OPFRenderError("slide-index-out-of-range", `Slide index ${index} is out of range.`, {
|
|
685
|
+
slideIndex: index,
|
|
778
686
|
slideCount: resolved.slides.length
|
|
779
687
|
});
|
|
780
688
|
}
|
|
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));
|
|
689
|
+
return renderResolvedSlide(resolved, index, drawn);
|
|
787
690
|
}
|
|
788
691
|
|
|
789
692
|
function renderResolvedSlide(resolved, slideIndex, options) {
|
|
@@ -794,10 +697,10 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
794
697
|
// Only a language the document names sets lang; the renderer default does not.
|
|
795
698
|
const lang = script?.languageSource === "document" || script?.languageSource === "option" ? script.bcp47 : undefined;
|
|
796
699
|
const { width, height } = bound.design.dimensions;
|
|
797
|
-
|
|
700
|
+
// The title may be TextRun[] (FA-10): the accessible name is its plain text.
|
|
701
|
+
const title = (Array.isArray(bound.slide.title) ? flattenText(bound.slide.title) : bound.slide.title) ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
|
|
798
702
|
const content = [
|
|
799
703
|
renderBackground(bound, width, height, options),
|
|
800
|
-
renderSlideImage(bound, options),
|
|
801
704
|
renderBranding(bound, resolved.presentation, width, height, options),
|
|
802
705
|
...renderSlideContent(bound, width, height, options),
|
|
803
706
|
// RR-34: the footnote area core reserved above the footer band, after the content and before the furniture.
|
|
@@ -833,6 +736,8 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
833
736
|
|
|
834
737
|
function renderBackground(bound, width, height, options) {
|
|
835
738
|
const background = bound.design.background;
|
|
739
|
+
// An image background (core SlideComposition.backgroundImage) fills the canvas and moves nothing.
|
|
740
|
+
if (bound.geometry.backgroundImage) return renderBackgroundImage(bound.geometry.backgroundImage, bound, width, height, options);
|
|
836
741
|
if (isPlainObject(background) && background.type === "gradient") {
|
|
837
742
|
const id = `opf-s${bound.index + 1}-background`;
|
|
838
743
|
const stops = Array.isArray(background.gradient?.stops) ? background.gradient.stops : [];
|
|
@@ -861,25 +766,12 @@ function renderBackground(bound, width, height, options) {
|
|
|
861
766
|
})
|
|
862
767
|
].join("\n");
|
|
863
768
|
}
|
|
864
|
-
|
|
865
|
-
if(isPlainObject(background) && background.type==='image'){
|
|
866
|
-
const imageItem={value:background.image,path:`${bound.path}.design.background.image`};
|
|
867
|
-
if(background.image.fit==='tile'){
|
|
868
|
-
const size=Math.min(width,height)/4,id=`opf-s${bound.index+1}-image-tile`;
|
|
869
|
-
return tag('g',{opacity:background.opacity??1},tag('defs',{},tag('pattern',{id,width:size,height:size,patternUnits:'userSpaceOnUse'},renderImage(imageItem,{x:0,y:0,width:size,height:size},bound,options)))+tag('rect',{width,height,fill:`url(#${id})`}));
|
|
870
|
-
}
|
|
871
|
-
return tag('g',{opacity:background.opacity??1},renderImage(imageItem,{x:0,y:0,width,height},bound,{...options,imageFit:background.image.fit??'cover'}));
|
|
872
|
-
}
|
|
873
769
|
if(isPlainObject(background) && background.type==='pattern'){
|
|
874
770
|
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
771
|
// 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;
|
|
772
|
+
// diagStripe is the engine id that PPTX export writes as wdUpDiag, so both draw the same rising stripe.
|
|
773
|
+
const runs=patternRuns(preset);
|
|
774
|
+
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
775
|
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
776
|
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
777
|
}
|
|
@@ -888,43 +780,81 @@ function renderBackground(bound, width, height, options) {
|
|
|
888
780
|
y: 0,
|
|
889
781
|
width,
|
|
890
782
|
height,
|
|
891
|
-
opacity:
|
|
783
|
+
opacity: isPlainObject(background) && background.type !== 'image' ? background.opacity ?? 1 : 1,
|
|
892
784
|
fill: bound.design.backgroundColor ?? bound.design.colors.background,
|
|
893
785
|
...traceAttrs(options, `${bound.path}.design.background`)
|
|
894
786
|
});
|
|
895
787
|
}
|
|
896
788
|
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
// and
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
const
|
|
905
|
-
const
|
|
906
|
-
const picture =
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
789
|
+
// FA-22 image background, in the paint order of the geometry contract: the canvas colour (the colour scheme's default slide
|
|
790
|
+
// background), the picture (recolor and opacity on its pixels only, as for an image block), then the overlay (the whole canvas
|
|
791
|
+
// or an edge band). cover, contain
|
|
792
|
+
// and stretch use the shared fit math with the focus point; tile repeats the picture at its intrinsic size (1 picture px = 1
|
|
793
|
+
// reference px) from the canvas top-left. alt is the picture's accessible name; without alt the picture is decorative.
|
|
794
|
+
function renderBackgroundImage(background, bound, width, height, options) {
|
|
795
|
+
const canvas = tag("rect", { x: 0, y: 0, width, height, fill: bound.design.backgroundColor ?? bound.design.colors.background, ...traceAttrs(options, `${bound.path}.design.background`) });
|
|
796
|
+
const item = { value: background.alt === undefined ? background.src : { src: background.src, alt: background.alt }, path: background.path };
|
|
797
|
+
const drawOptions = { ...options, imageDecorative: background.alt === undefined };
|
|
798
|
+
const picture = background.fit === "tile"
|
|
799
|
+
? renderTiledImage(item, background.box, bound, drawOptions)
|
|
800
|
+
: renderImage(item, background.box, bound, { ...drawOptions, imageFit: background.fit, imageFocus: background.focus, imagePicture: background.picture });
|
|
801
|
+
// An unresolved source draws the ordinary placeholder at the background's opacity (as 0.14 did), without recolor or overlay.
|
|
802
|
+
if (isImagePlaceholder(picture)) return [canvas, background.opacity !== undefined ? tag("g", { opacity: background.opacity }, picture) : picture].join("\n");
|
|
803
|
+
const id = `opf-s${bound.index + 1}-background`, matrix = imageRecolorMatrix(background.recolor, bound);
|
|
804
|
+
const defs = matrix ? tag("defs", {}, tag("filter", { id: `${id}-recolor`, "color-interpolation-filters": "sRGB" }, tag("feColorMatrix", { type: "matrix", values: matrix }))) : "";
|
|
805
|
+
const pixels = matrix || background.opacity !== undefined ? tag("g", { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: background.opacity === undefined ? undefined : preciseNumber(background.opacity) }, picture) : picture;
|
|
806
|
+
return [canvas, defs, pixels, background.overlay ? renderImageOverlay(background.overlay, bound, options) : ""].filter(Boolean).join("\n");
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// tile: a pattern cell of the picture's intrinsic size, anchored at the canvas origin.
|
|
810
|
+
function renderTiledImage(item, box, bound, options) {
|
|
811
|
+
const { source, drawable } = resolveImageSource(item, bound, options);
|
|
812
|
+
if (!drawable) return renderImage(item, box, bound, options);
|
|
813
|
+
const size = intrinsicImageSize(source) ?? { width: Math.min(box.width, box.height) / 4, height: Math.min(box.width, box.height) / 4 };
|
|
814
|
+
const id = `opf-s${bound.index + 1}-background-tile`;
|
|
815
|
+
const cell = tag("image", { x: 0, y: 0, width: stableNumber(size.width), height: stableNumber(size.height), href: source, preserveAspectRatio: "none" });
|
|
816
|
+
return tag("defs", {}, tag("pattern", { id, x: 0, y: 0, width: stableNumber(size.width), height: stableNumber(size.height), patternUnits: "userSpaceOnUse" }, cell))
|
|
817
|
+
+ tag("rect", { x: box.x, y: box.y, width: box.width, height: box.height, fill: `url(#${id})`, ...imageAccessibility(normalizeAsset(item.value), options), ...traceAttrs(options, item.path) });
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// FA-22 image block (core ComposedItem.image): the picture in its frame (image.box) with the block's fit and focus, clipped to
|
|
821
|
+
// the frame's shape unless it is a rectangle, with recolor and opacity on the pixels only; then the line centered on the shape
|
|
822
|
+
// outline; then the overlay (the frame's shape, or an edge band). The outlines are core's imageShape() paths, so the preview
|
|
823
|
+
// draws exactly the native preset geometry, in the paint order the export writes. A placed block (image.placement) arrives
|
|
824
|
+
// with its band as the region and needs nothing else. An unresolved source keeps the ordinary placeholder without treatments.
|
|
825
|
+
function renderImageBlock(item, bound, options) {
|
|
826
|
+
const image = item.image;
|
|
827
|
+
if (!image) throw new OPFRenderError("missing-image-geometry", "Image rendering requires a coordinated core build with image block geometry (ComposedItem.image).", { path: item.path });
|
|
828
|
+
const picture = renderImage(item, image.box, bound, { ...options, imageFit: image.fit, imageFocus: image.focus, imagePicture: image.picture });
|
|
829
|
+
const trace = options.trace && image.placement ? { "data-opf-image-placement": image.placement.edge } : {};
|
|
830
|
+
if (isImagePlaceholder(picture)) return Object.keys(trace).length ? tag("g", trace, picture) : picture;
|
|
831
|
+
const id = `opf-s${bound.index + 1}-image-${item.path.replace(/[^A-Za-z0-9]+/g, "-")}`, shape = image.shape, defs = [];
|
|
832
|
+
const masked = shape && shape.preset !== "rect";
|
|
833
|
+
if (masked) defs.push(tag("clipPath", { id: `${id}-clip` }, tag("path", { d: shape.path })));
|
|
834
|
+
const matrix = imageRecolorMatrix(image.recolor, bound);
|
|
835
|
+
if (matrix) defs.push(tag("filter", { id: `${id}-recolor`, "color-interpolation-filters": "sRGB" }, tag("feColorMatrix", { type: "matrix", values: matrix })));
|
|
836
|
+
let pixels = matrix || image.opacity !== undefined ? tag("g", { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: image.opacity === undefined ? undefined : preciseNumber(image.opacity) }, picture) : picture;
|
|
837
|
+
if (masked) pixels = tag("g", { "clip-path": `url(#${id}-clip)` }, pixels);
|
|
838
|
+
const children = [defs.length ? tag("defs", {}, defs.join("")) : "", pixels];
|
|
917
839
|
if (image.border) {
|
|
918
|
-
const paint =
|
|
919
|
-
children.push(tag(
|
|
920
|
-
}
|
|
921
|
-
if (image.overlay) {
|
|
922
|
-
const paint = slideImagePaint(image.overlay.color, bound, bound.design.colors.text);
|
|
923
|
-
children.push(tag('path', { d: image.overlay.shape?.path ?? rectanglePath(image.overlay.box), fill: paint.color, 'fill-opacity': preciseNumber(paint.alpha * image.overlay.opacity), ...(options.trace ? { 'data-opf-slide-image-overlay': `${image.path}.overlay` } : {}) }));
|
|
840
|
+
const paint = imagePaint(image.border.color, bound, bound.design.colors.border);
|
|
841
|
+
children.push(tag("path", { d: shape?.path ?? rectanglePath(image.box), fill: "none", stroke: paint.color, "stroke-opacity": paint.alpha < 1 ? preciseNumber(paint.alpha) : undefined, "stroke-width": stableNumber(image.border.width), "stroke-linejoin": "miter", "stroke-miterlimit": 8 }));
|
|
924
842
|
}
|
|
925
|
-
|
|
843
|
+
if (image.overlay) children.push(renderImageOverlay(image.overlay, bound, options));
|
|
844
|
+
if (children.length === 2 && !children[0] && !Object.keys(trace).length) return pixels;
|
|
845
|
+
return tag("g", trace, children.filter(Boolean).join(""));
|
|
926
846
|
}
|
|
927
847
|
|
|
848
|
+
// One overlay (shared by image backgrounds and image blocks): its outline filled with the colour, the colour's alpha times the
|
|
849
|
+
// overlay opacity.
|
|
850
|
+
function renderImageOverlay(overlay, bound, options) {
|
|
851
|
+
const paint = imagePaint(overlay.color, bound, bound.design.colors.text);
|
|
852
|
+
return tag("path", { d: overlay.shape?.path ?? rectanglePath(overlay.box), fill: paint.color, "fill-opacity": preciseNumber(paint.alpha * overlay.opacity), ...(options.trace ? { "data-opf-image-overlay": overlay.path } : {}) });
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// The placeholder renderImage draws for a source it cannot resolve.
|
|
856
|
+
const isImagePlaceholder = svg => svg.startsWith("<g") && svg.includes('data-opf-asset-status="unresolved"');
|
|
857
|
+
|
|
928
858
|
// Native alpha and color-matrix values keep 1/100000 precision; three decimals would drift.
|
|
929
859
|
const preciseNumber = value => String(Math.round(value * 1e6) / 1e6);
|
|
930
860
|
|
|
@@ -933,18 +863,18 @@ function rectanglePath(box) {
|
|
|
933
863
|
}
|
|
934
864
|
|
|
935
865
|
// ColorRef -> opaque #RRGGBB plus the AA byte as alpha, as the native srgbClr + alpha.
|
|
936
|
-
function
|
|
866
|
+
function imagePaint(value, bound, fallback) {
|
|
937
867
|
const hex = resolveColorRef(value, bound, fallback) ?? fallback;
|
|
938
868
|
const raw = normalizeColor(hex, fallback).slice(1);
|
|
939
869
|
return { color: `#${raw.slice(0, 6).toUpperCase()}`, alpha: raw.length === 8 ? parseInt(raw.slice(6), 16) / 255 : 1 };
|
|
940
870
|
}
|
|
941
871
|
|
|
942
872
|
// Rec. 601 luminance on sRGB values; duotone maps it linearly from dark to light.
|
|
943
|
-
function
|
|
873
|
+
function imageRecolorMatrix(recolor, bound) {
|
|
944
874
|
if (!recolor) return undefined;
|
|
945
875
|
const weights = [0.299, 0.587, 0.114];
|
|
946
876
|
const channels = recolor.type === 'duotone'
|
|
947
|
-
? [
|
|
877
|
+
? [imagePaint(recolor.dark, bound, '#000000'), imagePaint(recolor.light, bound, '#FFFFFF')].map(paint => [1, 3, 5].map(at => parseInt(paint.color.slice(at, at + 2), 16) / 255))
|
|
948
878
|
: [[0, 0, 0], [1, 1, 1]];
|
|
949
879
|
const [dark, light] = channels;
|
|
950
880
|
const rows = [0, 1, 2].map(channel => [...weights.map(weight => preciseNumber((light[channel] - dark[channel]) * weight)), 0, preciseNumber(dark[channel])].join(' '));
|
|
@@ -970,16 +900,23 @@ function reportDiagnostic(diagnostic, options) {
|
|
|
970
900
|
options.onDiagnostic?.(diagnostic);
|
|
971
901
|
}
|
|
972
902
|
|
|
903
|
+
// 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
|
|
904
|
+
// inside presentational. An empty alt marks the chart decorative (aria-hidden). Without alt the SVG is unchanged.
|
|
905
|
+
function chartText(chart, alt) {
|
|
906
|
+
if (typeof alt !== "string" || !chart) return chart;
|
|
907
|
+
return alt === "" ? tag("g", { "aria-hidden": "true" }, chart) : tag("g", { role: "img", "aria-label": alt }, chart);
|
|
908
|
+
}
|
|
909
|
+
|
|
973
910
|
function renderPayload(item, box, bound, options) {
|
|
974
911
|
if (!box) return "";
|
|
975
912
|
if (item.field === "items" || item.field === "bullets") return renderList(item, box, bound, options);
|
|
976
913
|
switch (item.type) {
|
|
977
914
|
case "chart":
|
|
978
|
-
return renderChart(item, box, bound, options);
|
|
915
|
+
return chartText(renderChart(item, box, bound, options), item.value?.alt);
|
|
979
916
|
case "table":
|
|
980
917
|
return renderTable(item, box, bound, options);
|
|
981
918
|
case "image":
|
|
982
|
-
return
|
|
919
|
+
return renderImageBlock(item, bound, options);
|
|
983
920
|
case "video":
|
|
984
921
|
return renderMedia(item, box, bound, options);
|
|
985
922
|
case "code":
|
|
@@ -1000,10 +937,9 @@ function renderPayload(item, box, bound, options) {
|
|
|
1000
937
|
function renderTextPayload(item, box, bound, options) {
|
|
1001
938
|
return (Array.isArray(item.value) ? renderRichTextBox : renderTextBox)(Array.isArray(item.value) ? item.value : flattenText(item.value), box, bound, {
|
|
1002
939
|
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),
|
|
940
|
+
// Core resolves one alignment per composed item for every engine, from the slide's design, the deck's design
|
|
941
|
+
// and the layout record's design (SlideComposition.design).
|
|
942
|
+
align: item.alignment,
|
|
1007
943
|
fontSize: item.field === "title" ? 54 : item.field === "tag" ? 16 : 25,
|
|
1008
944
|
// fontScheme.accent draws the tag; core's textStyle (item.textStyle) already carries it, this is the fallback family.
|
|
1009
945
|
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 +957,7 @@ function renderTextPayload(item, box, bound, options) {
|
|
|
1021
957
|
|
|
1022
958
|
function renderList(item, box, bound, options) {
|
|
1023
959
|
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}:{})});
|
|
960
|
+
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
961
|
const children=[];
|
|
1026
962
|
// 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
963
|
const bullet=item.bulletImage?resolveBulletImage(item.bulletImage,bound,options):undefined;
|
|
@@ -1063,6 +999,13 @@ function resolveImageSource(item, bound, options) {
|
|
|
1063
999
|
return { asset, source: svg ?? source, drawable, missingReference };
|
|
1064
1000
|
}
|
|
1065
1001
|
|
|
1002
|
+
// The accessible name of a drawn picture: its alt text, else the caller's label; a decorative picture (an image background
|
|
1003
|
+
// without alt) is hidden from assistive technology.
|
|
1004
|
+
function imageAccessibility(asset, options) {
|
|
1005
|
+
if (options.imageDecorative) return { "aria-hidden": "true" };
|
|
1006
|
+
return { role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image" };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1066
1009
|
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
1067
1010
|
|
|
1068
1011
|
// The base64 data URI of a drawable SVG, else null. Drawable: a data URI whose type is image/svg+xml, whose text is
|
|
@@ -1209,12 +1152,29 @@ function resolveBulletImage(bulletImage, bound, options) {
|
|
|
1209
1152
|
return undefined;
|
|
1210
1153
|
}
|
|
1211
1154
|
|
|
1155
|
+
// A drawable picture in `box` with `options.imageFit`: cover (the default for content), contain or stretch. cover takes
|
|
1156
|
+
// `options.imageFocus`: centered, it is the native center crop; anywhere else the picture is placed by core's fit math
|
|
1157
|
+
// (`options.imagePicture`, core's ImageFitPlacement when core read the aspect, else fitImage on core's intrinsicImageAspect
|
|
1158
|
+
// of the resolved source, for pictures a host imageResolver supplies) and
|
|
1159
|
+
// clipped to the box by a nested viewport. A logo keeps its anchor (`options.imageAnchor`).
|
|
1212
1160
|
function renderImage(item, box, bound, options) {
|
|
1213
1161
|
const { asset, source, drawable, missingReference } = resolveImageSource(item, bound, options);
|
|
1214
1162
|
if (drawable) {
|
|
1163
|
+
const fit = options.imageFit ?? "contain", focus = options.imageFocus;
|
|
1164
|
+
const attrs = { href: source, ...imageAccessibility(asset, options), ...traceAttrs(options, item.path), ...generatedAttrs(options) };
|
|
1165
|
+
if (fit === "cover" && focus && !options.imageAnchor && (focus.x !== 0.5 || focus.y !== 0.5)) {
|
|
1166
|
+
// The drawable source (after asset references and the host imageResolver): core reads its size (intrinsicImageAspect).
|
|
1167
|
+
const aspect = options.imagePicture ? undefined : intrinsicImageAspect(source);
|
|
1168
|
+
const placement = options.imagePicture ?? (aspect ? fitImage(box, "cover", aspect, focus) : undefined);
|
|
1169
|
+
if (placement) {
|
|
1170
|
+
const picture = placement.image;
|
|
1171
|
+
return tag("svg", { x: box.x, y: box.y, width: box.width, height: box.height, overflow: "hidden" },
|
|
1172
|
+
tag("image", { x: stableNumber(picture.x - box.x), y: stableNumber(picture.y - box.y), width: stableNumber(picture.width), height: stableNumber(picture.height), preserveAspectRatio: "none", ...attrs }));
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1215
1175
|
return tag("image", { x: box.x, y: box.y, width: box.width, height: box.height,
|
|
1216
|
-
|
|
1217
|
-
...
|
|
1176
|
+
preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : options.imageAnchor === "right" ? "xMaxYMid meet" : fit === "cover" ? "xMidYMid slice" : fit === "stretch" ? "none" : "xMidYMid meet",
|
|
1177
|
+
...attrs });
|
|
1218
1178
|
}
|
|
1219
1179
|
const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
|
|
1220
1180
|
const message=missingReference?`Image asset ${missingReference} is missing; supply the referenced asset or resolve it in the host.`:'Image requires an embedded raster or SVG data URI, or a host imageResolver.';
|
|
@@ -1311,6 +1271,7 @@ function renderCode(item, box, bound, options) {
|
|
|
1311
1271
|
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
1272
|
}
|
|
1313
1273
|
const syntax = codeSyntax(item, layout, bound, options);
|
|
1274
|
+
const highlight = codeHighlight(item, layout, bound);
|
|
1314
1275
|
const children = [
|
|
1315
1276
|
tag("rect", {
|
|
1316
1277
|
x: box.x,
|
|
@@ -1323,13 +1284,27 @@ function renderCode(item, box, bound, options) {
|
|
|
1323
1284
|
...traceAttrs(options, item.path)
|
|
1324
1285
|
})
|
|
1325
1286
|
];
|
|
1287
|
+
// FA-13: code.highlight is one band per run of marked lines, behind the line text; the marked lines keep the syntax colours and
|
|
1288
|
+
// the others are dimmed (core's codeHighlight* helpers, the same ones the PPTX export calls).
|
|
1289
|
+
if (highlight) {
|
|
1290
|
+
const body = layout.parts.find(part => part.role === "body");
|
|
1291
|
+
for (const band of highlight.bands) {
|
|
1292
|
+
children.push(tag("rect", {
|
|
1293
|
+
x: stableNumber(box.x + 1), y: stableNumber(body.box.y + band.first * body.fit.lineHeight), width: stableNumber(box.width - 2),
|
|
1294
|
+
height: stableNumber((band.last - band.first + 1) * body.fit.lineHeight), fill: highlight.colors.band,
|
|
1295
|
+
...(options.trace ? { "data-opf-code-highlight": "true" } : {})
|
|
1296
|
+
}));
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1326
1299
|
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)=>
|
|
1300
|
+
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});
|
|
1301
|
+
const lines=part.fit.sourceLines.map((line,index)=>{
|
|
1302
|
+
const lineColors=highlight&&part.role==='body'?(highlight.marked.has(highlight.numbers[index])?highlight.colors.lit:highlight.colors.dim):undefined;
|
|
1303
|
+
return tag('text',{
|
|
1329
1304
|
x:stableNumber(part.box.x),y:stableNumber(part.box.y+part.fit.fontSize+index*part.fit.lineHeight),
|
|
1330
1305
|
'text-anchor':'start','font-family':fontStack(part.style.fontFamily,'monospace'),
|
|
1331
1306
|
'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',
|
|
1307
|
+
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='body'?(lineColors?.plain??'#E5E7EB'):'#93C5FD',
|
|
1333
1308
|
...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
|
|
1334
1309
|
'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
1310
|
},line.segments.map(segment=>segmentSpan({
|
|
@@ -1338,7 +1313,8 @@ function renderCode(item, box, bound, options) {
|
|
|
1338
1313
|
...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
|
|
1339
1314
|
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
1340
1315
|
// 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(''))
|
|
1316
|
+
},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(''));
|
|
1317
|
+
});
|
|
1342
1318
|
children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
|
|
1343
1319
|
'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
1320
|
...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
|
|
@@ -1357,7 +1333,7 @@ function renderMetric(item, box, bound, options) {
|
|
|
1357
1333
|
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
1358
1334
|
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
1335
|
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,
|
|
1336
|
+
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
1337
|
const partRtl=paragraphRtl(bound,part.text);
|
|
1362
1338
|
// Anchor untabbed lines at the accepted alignment edge, as PPTX export does,
|
|
1363
1339
|
// so a shaper whose advance differs from the accepted width keeps the edge.
|
|
@@ -1393,37 +1369,58 @@ function renderMetric(item, box, bound, options) {
|
|
|
1393
1369
|
return tag('g',{...traceAttrs(options,item.path),...(options.trace?{'data-opf-metric-container':'true'}:{})},children.join('\n'));
|
|
1394
1370
|
}
|
|
1395
1371
|
|
|
1396
|
-
// The trend arrow and colour for a laid-out metric (core metricTrendMark); undefined without a trend
|
|
1372
|
+
// The trend arrow and colour for a laid-out metric (core metricTrendMark); undefined without a trend.
|
|
1397
1373
|
function metricTrendMark(layout,bound,options) {
|
|
1398
|
-
|
|
1399
|
-
return opfCore.metricTrendMark(layout,{background:bound.design.backgroundColor??bound.design.colors.background});
|
|
1374
|
+
return coreMetricTrendMark(layout,{background:bound.design.backgroundColor??bound.design.colors.background});
|
|
1400
1375
|
}
|
|
1401
1376
|
|
|
1402
1377
|
function renderQuote(item, box, bound, options) {
|
|
1403
1378
|
const layout = item.quoteLayout;
|
|
1404
1379
|
if (!layout) throw new OPFRenderError('missing-quote-layout', 'Quote rendering requires a coordinated core build with shared quote geometry.', {path:item.path});
|
|
1405
1380
|
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,
|
|
1381
|
+
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});
|
|
1382
|
+
const fill=part.role==='footer'?bound.design.colors.mutedText:bound.design.colors.text;
|
|
1383
|
+
// 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.
|
|
1384
|
+
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
1385
|
return renderTextBox(part.text,part.box,bound,{
|
|
1408
1386
|
path:part.path,fit:part.fit,textStyle:part.style,fontFamily:part.requestedStyle.fontFamily,
|
|
1409
|
-
fill
|
|
1387
|
+
fill,
|
|
1410
1388
|
diagnosticsHandled:true,options,
|
|
1411
1389
|
});
|
|
1412
1390
|
});
|
|
1391
|
+
// FA-12: the attributed person's headshot, cropped to fill the core circle frame (the clip is the outline the native ellipse picture uses).
|
|
1392
|
+
if (layout.photo) children.push(renderQuotePhoto(layout.photo, item, bound, options));
|
|
1413
1393
|
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
1414
1394
|
}
|
|
1415
1395
|
|
|
1396
|
+
function renderQuotePhoto(photo, item, bound, options) {
|
|
1397
|
+
const picture = renderImage({ value: photo.value, path: photo.path }, photo.box, bound, { ...options, imageFit: 'cover' });
|
|
1398
|
+
// An unresolved source keeps the ordinary placeholder, unmasked, like the export.
|
|
1399
|
+
if (isImagePlaceholder(picture)) return picture;
|
|
1400
|
+
const id = `opf-s${bound.index + 1}-quote-photo-${item.path.replace(/[^A-Za-z0-9]+/g, '-')}`;
|
|
1401
|
+
return tag('g', {}, tag('defs', {}, tag('clipPath', { id }, tag('path', { d: photo.shape.path }))) + tag('g', { 'clip-path': `url(#${id})` }, picture));
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1416
1404
|
function renderTimeline(item, box, bound, options) {
|
|
1417
1405
|
const layout=item.timelineLayout;
|
|
1418
1406
|
if(!layout)throw new OPFRenderError('missing-timeline-layout','Timeline rendering requires a coordinated core build with shared timeline geometry.',{path:item.path});
|
|
1419
1407
|
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
1420
1408
|
const children=[tag('line',{...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
|
|
1421
|
-
|
|
1409
|
+
// FA-11: status colors come from the deck (core's timelineMarkerShapes / timelineTextColor); a marker with no status draws the plain primary circle.
|
|
1410
|
+
const background=bound.design.backgroundColor??bound.design.colors.background;
|
|
1411
|
+
const statusColors={background,primary:bound.design.colors.primary,text:bound.design.colors.text,mutedText:bound.design.colors.mutedText};
|
|
1412
|
+
for(const marker of layout.markers){
|
|
1413
|
+
if(!marker.status){
|
|
1414
|
+
children.push(tag('circle',{cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
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}:{})}));
|
|
1418
|
+
}
|
|
1422
1419
|
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,
|
|
1420
|
+
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
1421
|
children.push(tag('g',options.trace?{'data-opf-timeline-role':part.role}:{},renderTextBox(part.text,part.box,bound,{
|
|
1425
1422
|
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,
|
|
1423
|
+
fill:part.status?timelineTextColor(part,statusColors):bound.design.colors.text,diagnosticsHandled:true,options,
|
|
1427
1424
|
})));
|
|
1428
1425
|
}
|
|
1429
1426
|
return tag('g',{...traceAttrs(options,item.path),...(options.trace?{'data-opf-timeline-arrangement':layout.arrangement}:{})},children.join('\n'));
|
|
@@ -1431,7 +1428,7 @@ function renderTimeline(item, box, bound, options) {
|
|
|
1431
1428
|
|
|
1432
1429
|
function renderTable(item, box, bound, options) {
|
|
1433
1430
|
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"} : {})});
|
|
1431
|
+
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
1432
|
const children = [];
|
|
1436
1433
|
// 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
1434
|
const own = isDatasetTable(item, bound) ? () => item.path : (path) => path;
|
|
@@ -1512,12 +1509,12 @@ function renderTableBorders(defaultEdges, explicitEdges, scale, bound, options)
|
|
|
1512
1509
|
}
|
|
1513
1510
|
|
|
1514
1511
|
function renderChart(item, box, bound, options) {
|
|
1515
|
-
// Catalog chart types
|
|
1512
|
+
// Catalog chart types preview the native
|
|
1516
1513
|
// construct opf-pptx exports; other ids keep the legacy single-series preview.
|
|
1517
1514
|
const rendered = renderCatalogChart(item, box, bound, options, { tag, traceAttrs, stableNumber, renderTextBox, reportDiagnostic });
|
|
1518
1515
|
if (rendered) return rendered;
|
|
1519
1516
|
const chart = item.value ?? {};
|
|
1520
|
-
const chartType = chart.type ?? engineDefaults.
|
|
1517
|
+
const chartType = chart.type ?? engineDefaults.chartType;
|
|
1521
1518
|
const data = inlineChartRows(legacyChartData(chart, bound));
|
|
1522
1519
|
// A dataset-backed chart has no data.rows of its own: its parts report the chart's authored path.
|
|
1523
1520
|
const part = isDatasetChart(item, bound) ? () => item.path : (path) => path;
|
|
@@ -1611,10 +1608,9 @@ function renderChart(item, box, bound, options) {
|
|
|
1611
1608
|
}
|
|
1612
1609
|
|
|
1613
1610
|
// 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
|
|
1611
|
+
// not resolve is read as authored.
|
|
1615
1612
|
function legacyChartData(chart, bound) {
|
|
1616
|
-
|
|
1617
|
-
const resolved = opfCore.resolveChartData(chart, bound.presentation);
|
|
1613
|
+
const resolved = resolveChartData(chart, bound.presentation);
|
|
1618
1614
|
return resolved.ok ? { rows: resolved.rows, resolved: true } : chart.data;
|
|
1619
1615
|
}
|
|
1620
1616
|
|
|
@@ -1648,7 +1644,17 @@ function renderFurniture(bound, presentation, width, height, options, kind) {
|
|
|
1648
1644
|
function renderBranding(bound,presentation,width,height,options) {
|
|
1649
1645
|
const design={...presentation.design,...bound.slide.design}, pieces=[];
|
|
1650
1646
|
const rootFor=key=>bound.slide.design?.[key]!==undefined?`${bound.path}.design.${key}`:`design.${key}`;
|
|
1651
|
-
|
|
1647
|
+
// 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.
|
|
1648
|
+
if(design.watermark&&typeof design.watermark==='object'&&typeof design.watermark.text==='string'){
|
|
1649
|
+
const mark=layoutWatermark(design.watermark.text,{width,height},{fontFamily:bound.design.fonts.heading,fontWeight:700,textMeasurement:options.textMeasurement});
|
|
1650
|
+
if(mark){
|
|
1651
|
+
const opacity=typeof design.watermark.opacity==='number'&&Number.isFinite(design.watermark.opacity)?Math.min(1,Math.max(0,design.watermark.opacity)):.08;
|
|
1652
|
+
const cx=width/2,cy=height/2;
|
|
1653
|
+
pieces.push(tag('g',{opacity:stableNumber(opacity),transform:`rotate(${stableNumber(mark.rotation)} ${stableNumber(cx)} ${stableNumber(cy)})`,...traceAttrs(options,rootFor('watermark'))},
|
|
1654
|
+
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,
|
|
1655
|
+
fill:bound.design.colors.text,'xml:space':'preserve','text-rendering':'geometricPrecision'},escapeText(mark.text))));
|
|
1656
|
+
}
|
|
1657
|
+
} else if(design.watermark){
|
|
1652
1658
|
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
1659
|
}
|
|
1654
1660
|
// Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
|
|
@@ -1675,20 +1681,16 @@ function fontStack(family, type) {
|
|
|
1675
1681
|
// alignment and measured advances are unchanged.
|
|
1676
1682
|
const RIGHT_TO_LEFT_ISOLATE = "\u2067", POP_DIRECTIONAL_ISOLATE = "\u2069";
|
|
1677
1683
|
// 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
1684
|
// 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;
|
|
1685
|
+
// right-to-left line) and `right` the end edge. Core owns that rule too (physicalAlignment, imported above).
|
|
1684
1686
|
/** Maps a source offset of the text to whether its paragraph is right to left. */
|
|
1685
1687
|
function paragraphRtl(bound, text) {
|
|
1686
|
-
if (bound.scriptFonts?.rtl !== true
|
|
1688
|
+
if (bound.scriptFonts?.rtl !== true) return () => false;
|
|
1687
1689
|
const source = String(text ?? ""), spans = [];
|
|
1688
1690
|
let start = 0;
|
|
1689
1691
|
for (const match of source.matchAll(/\r\n|\r|\n/g)) { spans.push([start, match.index]); start = match.index + match[0].length; }
|
|
1690
1692
|
spans.push([start, source.length]);
|
|
1691
|
-
const rtl = spans.map(([from, to]) =>
|
|
1693
|
+
const rtl = spans.map(([from, to]) => paragraphDirection(source.slice(from, to), "rtl") === "rtl");
|
|
1692
1694
|
return offset => { for (let index = spans.length - 1; index >= 0; index--) if (offset >= spans[index][0]) return rtl[index]; return rtl[0]; };
|
|
1693
1695
|
}
|
|
1694
1696
|
|
|
@@ -1710,7 +1712,7 @@ function nestedReset(style, run) {
|
|
|
1710
1712
|
|
|
1711
1713
|
function scriptLine(text, style, bound, type, { rtl = false, placement, trace, fontSize } = {}) {
|
|
1712
1714
|
const value = String(text ?? "");
|
|
1713
|
-
const scripts = bound.scriptFonts;
|
|
1715
|
+
const scripts = (style?.lang && bound.scriptFontsFor?.(style.lang)) || bound.scriptFonts;
|
|
1714
1716
|
rtl = rtl && value !== "";
|
|
1715
1717
|
const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
|
|
1716
1718
|
const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
|
|
@@ -1759,7 +1761,7 @@ function segmentSpan(attrs, segment, text, style, bound, type, options, syntax)
|
|
|
1759
1761
|
// Syntax highlighting (RR-07): a coloured text segment becomes consecutive sibling tspans, one per run, each a
|
|
1760
1762
|
// traced segment of its own source range holding one text node (the editor's caret mapping reads that), the first
|
|
1761
1763
|
// 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" ?
|
|
1764
|
+
const runs = syntax && segment.kind !== "tab" ? codeLineRuns(syntax.tokens, segment.start, segment.end) : undefined;
|
|
1763
1765
|
if (runs?.some(run => run.kind)) {
|
|
1764
1766
|
return runs.map((run, index) => {
|
|
1765
1767
|
const sizeOf = line => line.sizeAdjust && options?.fontSize > 0 ? stableNumber(adjustedFontSize(options.fontSize, line.sizeAdjust)) : undefined;
|
|
@@ -1773,16 +1775,27 @@ function segmentSpan(attrs, segment, text, style, bound, type, options, syntax)
|
|
|
1773
1775
|
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
1776
|
}
|
|
1775
1777
|
|
|
1778
|
+
// FA-13: the marked lines of code.highlight, their bands (runs of displayed lines) and the lit/dimmed colours, from core (the PPTX
|
|
1779
|
+
// export calls the same functions); undefined when the code marks no line.
|
|
1780
|
+
function codeHighlight(item, layout, bound) {
|
|
1781
|
+
const highlight = item.value?.highlight;
|
|
1782
|
+
if (!Array.isArray(highlight)) return undefined;
|
|
1783
|
+
const body = layout.parts.find(part => part.role === "body");
|
|
1784
|
+
if (!body?.fit) return undefined;
|
|
1785
|
+
const lines = codeHighlightLines(highlight, body.text).lines;
|
|
1786
|
+
if (!lines.length) return undefined;
|
|
1787
|
+
return { marked: new Set(lines), numbers: codeLineNumbers(body.fit.sourceLines), bands: codeHighlightBands(body.fit.sourceLines, lines), colors: codeHighlightColors(bound.design.colorScheme) };
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1776
1790
|
// Token ranges of the code body and the palette to paint them with; undefined for plain code (an unknown language,
|
|
1777
|
-
// no language
|
|
1791
|
+
// no language). The preview and the PPTX export read the same tables.
|
|
1778
1792
|
function codeSyntax(item, layout, bound, options) {
|
|
1779
1793
|
const body = layout.parts.find(part => part.role === "body");
|
|
1780
1794
|
const language = typeof item.value?.language === "string" ? item.value.language : layout.parts.find(part => part.role === "language")?.text;
|
|
1781
1795
|
if (!body || !language) return undefined;
|
|
1782
|
-
|
|
1783
|
-
const tokens = opfCore.tokenizeCode(body.text, language);
|
|
1796
|
+
const tokens = tokenizeCode(body.text, language);
|
|
1784
1797
|
if (!tokens.length) return undefined;
|
|
1785
|
-
return { tokens, palette:
|
|
1798
|
+
return { tokens, palette: codeSyntaxPaletteForScheme(bound.design.colorScheme) };
|
|
1786
1799
|
}
|
|
1787
1800
|
|
|
1788
1801
|
// Faces flagged embed:"used" (the vendored open, Intos and script-pack faces) are embedded only when the slide's own markup draws
|
|
@@ -1840,8 +1853,8 @@ function renderEmbeddedFonts(fonts = []) {
|
|
|
1840
1853
|
|
|
1841
1854
|
function renderRichTextBox(value, box, bound, config) {
|
|
1842
1855
|
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,{
|
|
1856
|
+
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});
|
|
1857
|
+
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
1858
|
return renderRichLines(value,fit,box,bound,config);
|
|
1846
1859
|
}
|
|
1847
1860
|
|
|
@@ -1866,10 +1879,11 @@ function trimRichLineEdges(line,textMeasurement) {
|
|
|
1866
1879
|
}
|
|
1867
1880
|
|
|
1868
1881
|
function renderRichLines(value,fit,box,bound,config) {
|
|
1869
|
-
const logicalAlignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1882
|
+
const logicalAlignment=fit.placement?.alignment??config.align??bound.geometry.design.contentAlignment;
|
|
1870
1883
|
// Each line takes its paragraph's direction from core (every wrapped line shares it) and its physical edge from that.
|
|
1871
1884
|
const lineAlignment=index=>fit.placement?.lines[index]?.alignment??physicalAlignment(logicalAlignment,fit.directions?.[index]);
|
|
1872
|
-
|
|
1885
|
+
// 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.
|
|
1886
|
+
let textOffset=config.textOffset??0;
|
|
1873
1887
|
const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
|
|
1874
1888
|
const richRtl=paragraphRtl(bound,value.map(run=>typeof run==='string'?run:run.text).join(''));
|
|
1875
1889
|
// With no measurement provider, fragment advances are estimates. Let SVG
|
|
@@ -1901,19 +1915,23 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1901
1915
|
// Estimated (natural-flow) lines keep logical order; the browser reorders them.
|
|
1902
1916
|
const fragmentX=rtl&&!naturalFlow?line.width-fragment.x-fragment.width:fragment.x;
|
|
1903
1917
|
const position=asFlow?{'baseline-shift':fragment.baselineShift?stableNumber(-fragment.baselineShift):undefined}:{x:stableNumber(originX+fragmentX),y:stableNumber(baseline+fragment.baselineShift)};
|
|
1918
|
+
// A link run is drawn as PowerPoint draws one: underlined, in the scheme hyperlink color unless the run sets its own color.
|
|
1919
|
+
const linked = fragment.kind !== 'marker' && typeof run.link === 'string' && LINK_URL.test(run.link);
|
|
1904
1920
|
const runFill = run.color == null
|
|
1905
|
-
? config.fill
|
|
1921
|
+
? (linked ? bound.design.colors.hyperlink : config.fill)
|
|
1906
1922
|
: resolveColorRef(run.color, bound, config.fill);
|
|
1907
1923
|
let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
|
|
1908
|
-
|
|
1924
|
+
// FA-13: an inline code run falls back to a monospace face, and a run with its own language declares it.
|
|
1925
|
+
const runLang=fragment.kind!=='tab'&&fragment.style.lang?(bound.scriptFontsFor?.(fragment.style.lang)?.profile.bcp47??fragment.style.lang):undefined;
|
|
1926
|
+
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
1927
|
placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
|
|
1910
1928
|
if(scripted.positioned)fixedAdvance=false;
|
|
1911
1929
|
const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
|
|
1912
1930
|
// RR-34: a citation/footnote marker is generated text (no source range): it is traced as a marker
|
|
1913
1931
|
// segment without text offsets, so editors never read it as part of the run, and it is not linked.
|
|
1914
1932
|
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(
|
|
1933
|
+
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);
|
|
1934
|
+
if(linked)return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
|
|
1917
1935
|
return rendered;
|
|
1918
1936
|
};
|
|
1919
1937
|
if(naturalFlow&&lineHasTab) {
|
|
@@ -1958,13 +1976,13 @@ function renderTextBox(text, box, bound, config) {
|
|
|
1958
1976
|
const diagnostic = { code: "text-overflow", path: config.path,
|
|
1959
1977
|
message: "Text exceeds its cell at the minimum font size; shorten it, increase its space, or split the slide." };
|
|
1960
1978
|
reportDiagnostic(diagnostic, config.options);
|
|
1961
|
-
if ((bound.composition ?? bound.geometry.composition).overflow === "error") throw new OPFRenderError("layout-overflow", diagnostic.message, { path: diagnostic.path,
|
|
1979
|
+
if ((bound.composition ?? bound.geometry.composition).overflow === "error") throw new OPFRenderError("layout-overflow", diagnostic.message, { path: diagnostic.path, diagnostics: [diagnostic] });
|
|
1962
1980
|
}
|
|
1963
1981
|
const size = fit.fontSize;
|
|
1964
1982
|
const totalHeight = fit.lines.length * fit.lineHeight;
|
|
1965
1983
|
const startY = config.verticalAlign === "middle"
|
|
1966
1984
|
? box.y + Math.max(0, (box.height - totalHeight) / 2) + size : box.y + size;
|
|
1967
|
-
const logicalAlignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1985
|
+
const logicalAlignment=fit.placement?.alignment??config.align??bound.geometry.design.contentAlignment;
|
|
1968
1986
|
const type = config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type;
|
|
1969
1987
|
const source = String(text ?? ""), boxRtl = paragraphRtl(bound, source);
|
|
1970
1988
|
let cursor = 0;
|