@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/dist/svg.js CHANGED
@@ -1,15 +1,25 @@
1
- import { layoutTable, fitList, fitRichText, composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textWidthMeasurer, fitText, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
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
- catalogs as bundledCatalogs,
4
- resolveColorRef as resolveCoreColorRef,
5
- validatePresentation
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 "./font-policy.js";
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
- catalogs: Object.freeze({
34
- narratives: Object.freeze({ source: "https://www.pptx.gallery/narratives" }),
35
- themes: Object.freeze({ source: "https://www.pptx.gallery/themes" }),
36
- colorSchemes: Object.freeze({ source: "https://www.pptx.gallery/color-schemes" }),
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
- // Last-resort font scheme shared by every engine (core pagination, opf-editor and
63
- // opf-pptx): DEFAULT_FONT_SCHEME in @openpresentation/opf. It applies only when the
64
- // slide, deck and resolved theme name no font scheme. fontScheme.google is kept for a
65
- // future Google Slides target and is not used by this renderer.
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.issues) this.issues = details.issues;
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. Read from the namespace so an older
190
- // published core still loads.
175
+ // variable is refused. Decks without content variables are returned untouched.
191
176
  function resolveTemplateInput(presentation, options) {
192
- if (typeof opfCore.resolveVariables !== "function" || !isPlainObject(presentation)) return presentation;
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 = opfCore.isTemplate(presentation);
200
- if (!template && !opfCore.hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
201
- const result = opfCore.resolveVariables(presentation, values ?? {}, { examples: template });
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
- issues: errors,
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: "variable-example-used", path: entry.path, message: entry.message, id: entry.id });
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
- function assertValidBoundary(presentation) {
217
- const result = validatePresentation(presentation);
218
- if (!result.valid) {
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
- issues: result.errors,
221
- result
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
- if (!background) return colorFromScheme(colorScheme, "light1", "#FFFFFF");
426
- if (typeof background === "string") return colorFromScheme(colorScheme, background, "#FFFFFF");
427
- if (background.type === "theme") return colorFromScheme(colorScheme, background.slot, "#FFFFFF");
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 colorFromScheme(colorScheme, "light1", "#FFFFFF");
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
- function resolveDesign(presentation, slide, context, index) {
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 = resolveCatalogRecord(
464
- "themes",
465
- slideDesign.theme ?? deckDesign.theme ?? engineDefaults.theme,
466
- context,
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 primary = normalizeColor(colorScheme.primary, null) ?? colorFromScheme(colorScheme, "accent1", "#2563EB");
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
- const darkBackground = colorLuminance(backgroundColor ?? "#FFFFFF") < 0.179;
492
- const textColor = colorFromScheme(colorScheme, darkBackground ? "light1" : "dark1", darkBackground ? "#FFFFFF" : "#111827");
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: colorFromScheme(colorScheme, "light1", "#FFFFFF"),
506
- surface: colorFromScheme(colorScheme, darkBackground ? "dark2" : "light2", darkBackground ? "#1E293B" : "#F8FAFC"),
507
- text: textColor,
508
- mutedText: colorFromScheme(colorScheme, darkBackground ? "light2" : "dark2", darkBackground ? "#E2E8F0" : "#334155"),
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: resolveFontFamilies(fontScheme),
382
+ fonts: { ...composeOptions.fontFamilies },
515
383
  darkBackground,
516
- diagnostics: fontSchemeDiagnostic ? [fontSchemeDiagnostic] : []
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
- * Without core support every slot repeats the latin family.
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
- if (typeof opfCore.resolveScriptFonts === "function") {
529
- try { resolved = opfCore.resolveScriptFonts(presentation, { slideIndex: index }); }
530
- catch (error) {
531
- reportLanguageDiagnostic(context, { code: "language-preview-unresolved", path: "language",
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
- function bindSlide(presentation, slide, layout, index, context) {
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
- const placeholders = Array.isArray(layout.placeholders) ? layout.placeholders : [];
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, context, index);
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
- const textMeasurement = scriptFonts.textMeasurement ?? context.options.textMeasurement;
726
- // fontScheme.accent (the tag and quote text) exists only with a core that resolves it; it takes the same look-alike policy.
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, { ...design.dimensions, layout, presentation, slideIndex: index, fonts: design.fonts, contentAlignment:design.contentAlignment, titleAlignment:design.titleAlignment, textRasterPadding:context.options.textRasterPadding, contentBox:design.contentBox, darkBackground: design.darkBackground, textMeasurement, date: context.options.date, socialPlatforms: socialPlatformRecords(context) });
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 resolved = resolvePresentation(input, options);
774
- const slideIndex = options.slideIndex ?? 0;
775
- if (!Number.isInteger(slideIndex) || slideIndex < 0 || slideIndex >= resolved.slides.length) {
776
- throw new OPFRenderError("slide-index-out-of-range", `Slide index ${slideIndex} is out of range.`, {
777
- slideIndex,
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, slideIndex, options);
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
- const title = bound.slide.title ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
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
- const runs=typeof opfCore.patternRuns==='function'?opfCore.patternRuns(preset):undefined;
880
- const bitmap=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'}):'';
881
- const legacy=preset==='ltHorz'?tag('path',{d:'M0 4H8',stroke:color,'stroke-width':1}):preset==='diagStripe'||preset==='wdUpDiag'?tag('path',{d:'M-2 2L2 -2M0 8L8 0M6 10L10 6',stroke:color,'stroke-width':2}):preset==='pct5'?tag('circle',{cx:2,cy:2,r:.8,fill:color}):preset==='openDmnd'?stroke('M0 4L4 0L8 4L4 8Z'):preset==='wave'?stroke('M0 4C2 1 2 1 4 4S6 7 8 4'):'';
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: typeof background==='object'?background?.opacity??1:1,
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
- // design.slideImage: the shared composition frame, beneath branding and content.
898
- // crop covers the frame and fit centers the whole image, matching native a:srcRect.
899
- // Treatments mirror the native picture: the preset mask (core outline), recolor
900
- // and alphaModFix on the pixels only, the centered line, then the overlay shape.
901
- function renderSlideImage(bound, options) {
902
- const image = bound.geometry.slideImage;
903
- if (!image) return '';
904
- const trace = options.trace ? { 'data-opf-slide-image': image.path, 'data-opf-slide-image-position': image.position } : {};
905
- const value = image.alt === undefined ? image.value : { ...normalizeAsset(image.value), alt: image.alt };
906
- const picture = renderImage({ value, path: image.sourcePath }, image.box, bound, { ...options, imageFit: image.fill === 'crop' ? 'cover' : 'contain' });
907
- // Unresolved sources keep the ordinary placeholder without treatments, like the export.
908
- if (!picture.startsWith('<image')) return tag('g', trace, picture);
909
- const id = `opf-s${bound.index + 1}-slide-image`, shape = image.shape, defs = [];
910
- const masked = shape && shape.preset !== 'rect';
911
- if (masked) defs.push(tag('clipPath', { id: `${id}-clip` }, tag('path', { d: shape.path })));
912
- const matrix = slideImageRecolorMatrix(image.recolor, bound);
913
- if (matrix) defs.push(tag('filter', { id: `${id}-recolor`, 'color-interpolation-filters': 'sRGB' }, tag('feColorMatrix', { type: 'matrix', values: matrix })));
914
- let pixels = matrix || image.opacity !== undefined ? tag('g', { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: image.opacity === undefined ? undefined : preciseNumber(image.opacity) }, picture) : picture;
915
- if (masked) pixels = tag('g', { 'clip-path': `url(#${id}-clip)` }, pixels);
916
- const children = [defs.length ? tag('defs', {}, defs.join('')) : '', pixels];
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 = slideImagePaint(image.border.color, bound, bound.design.colors.border);
919
- 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 }));
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
- return tag('g', trace, children.filter(Boolean).join(''));
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 slideImagePaint(value, bound, fallback) {
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 slideImageRecolorMatrix(recolor, bound) {
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
- ? [slideImagePaint(recolor.dark, bound, '#000000'), slideImagePaint(recolor.light, bound, '#FFFFFF')].map(paint => [1, 3, 5].map(at => parseInt(paint.color.slice(at, at + 2), 16) / 255))
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 renderImage(item, box, bound, options);
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. The
1004
- // fallback keeps cores published before item.alignment working: titles
1005
- // follow design.titleAlignment only (unset is left, as in core composition).
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
- href: source, preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : options.imageAnchor === "right" ? "xMaxYMid meet" : (options.imageFit ?? (bound.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image",
1217
- ...traceAttrs(options, item.path), ...generatedAttrs(options) });
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,issues:layout.diagnostics});
1328
- const lines=part.fit.sourceLines.map((line,index)=>tag('text',{
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,issues:layout.diagnostics});
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 or without core support.
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
- if (typeof opfCore.metricTrendMark!=='function') return undefined;
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,issues:layout.diagnostics});
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:part.role==='footer'?bound.design.colors.mutedText:bound.design.colors.text,
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
- for(const marker of layout.markers)children.push(tag('circle',{cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
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,issues:layout.diagnostics});
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 (kept, deprecated and aliased ids) preview the native
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.chartTypes[0];
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, or a core without the resolver, is read as authored.
1611
+ // not resolve is read as authored.
1615
1612
  function legacyChartData(chart, bound) {
1616
- if (typeof opfCore.resolveChartData !== "function") return chart.data;
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
- if(design.watermark){
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 the rule; the fallback only serves a core without it, which
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 || !coreParagraphDirection) return () => false;
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]) => coreParagraphDirection(source.slice(from, to), "rtl") === "rtl");
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" ? opfCore.codeLineRuns(syntax.tokens, segment.start, segment.end) : undefined;
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, or a core without the shared highlighter). The preview and the PPTX export read the same tables.
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
- if (typeof opfCore.tokenizeCode !== "function") return undefined;
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: opfCore.codeSyntaxPaletteForScheme(bound.design.colorScheme) };
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,{issues:[diagnostic]});}
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
- let textOffset=0;
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
- const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(fragment.text,fragment.style,bound,bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
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(!marker&&run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
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, issues: [diagnostic] });
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;