@openpresentation/opf-render 0.13.1 → 0.14.0

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