@openpresentation/opf-render 0.11.8 → 0.12.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
@@ -7,9 +7,11 @@ import {
7
7
  // Optional core exports are read from the namespace so an older published core
8
8
  // still loads; resolveScriptFonts ships with core FF-18.
9
9
  import * as opfCore from "@openpresentation/opf";
10
- import { createScriptFonts } from "./script-fonts.js";
10
+ import { adjustedFontSize, baselineShift, createScriptFonts } from "./script-fonts.js";
11
11
  import { disabledFeaturesStyle } from "./font-compatibility.js";
12
+ import { fontPolicyFor } from "./font-policy.js";
12
13
  import { renderCatalogChart } from "./charts.js";
14
+ import { renderCaption, renderFootnotes } from "./annotations.js";
13
15
 
14
16
  export const packageName = "@openpresentation/opf-render";
15
17
 
@@ -181,6 +183,36 @@ function parseInput(input) {
181
183
  throw new OPFRenderError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
182
184
  }
183
185
 
186
+ // Template variables (core resolveVariables, RR-32): a deck that uses content variables, or a template, is resolved
187
+ // to a concrete deck before composition, so the preview and the PPTX exporter see the same text, numbers, dates and
188
+ // 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.
191
+ function resolveTemplateInput(presentation, options) {
192
+ if (typeof opfCore.resolveVariables !== "function" || !isPlainObject(presentation)) return presentation;
193
+ const values = options.variables;
194
+ // variables: false draws the document as authored, tokens and var: references included (the template editing view).
195
+ if (values === false) return presentation;
196
+ if (values !== undefined && !isPlainObject(values)) {
197
+ throw new OPFRenderError("invalid-variables", "The variables option must be an object keyed by variable id.", { path: "options.variables" });
198
+ }
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 });
202
+ const errors = result.diagnostics.filter((entry) => entry.severity === "error");
203
+ if (errors.length) {
204
+ const unfilled = errors.some((entry) => entry.code === "variable-unfilled");
205
+ throw new OPFRenderError(unfilled ? "unfilled-variables" : "invalid-variables", errors[0].message, {
206
+ issues: errors,
207
+ path: errors[0].path
208
+ });
209
+ }
210
+ 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 });
212
+ }
213
+ return result.presentation;
214
+ }
215
+
184
216
  function assertValidBoundary(presentation) {
185
217
  const result = validatePresentation(presentation);
186
218
  if (!result.valid) {
@@ -228,8 +260,14 @@ function defaultCatalogFor(kind) {
228
260
  return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
229
261
  }
230
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.
231
268
  function sourceRecordsFor(kind, source, options) {
232
- if (!source) return [];
269
+ if (Array.isArray(source)) return source.flatMap((entry) => sourceRecordsFor(kind, entry, options));
270
+ if (typeof source !== "string" || !source) return [];
233
271
  const bySource = options.catalogSources?.[source];
234
272
  if (bySource) return normalizeSourceRecords(bySource);
235
273
  if (source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/")) {
@@ -341,6 +379,12 @@ function normalizeColor(value, fallback) {
341
379
 
342
380
  /** Resolve content ColorRef via core; keep authored #RRGGBB / #RRGGBBAA casing. */
343
381
  function resolveColorRef(value, bound, fallback) {
382
+ return resolveColorRefIn(value, bound.design, fallback);
383
+ }
384
+
385
+ // `design` supplies colorScheme, colors (roles) and variables; resolveDesign passes a
386
+ // partial one for the background, before the text-dependent roles exist.
387
+ function resolveColorRefIn(value, design, fallback) {
344
388
  if (value == null || value === "") return fallback;
345
389
  if (typeof value !== "string") return fallback;
346
390
  const trimmed = value.trim();
@@ -348,9 +392,9 @@ function resolveColorRef(value, bound, fallback) {
348
392
  // Packed-browser editor checks keep toolbar hex like #2563eb as authored.
349
393
  if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(trimmed)) return trimmed;
350
394
  if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) return normalizeColor(trimmed, fallback);
351
- const colors = bound.design.colors ?? {};
395
+ const colors = design.colors ?? {};
352
396
  return resolveCoreColorRef(trimmed, {
353
- colorScheme: bound.design.colorScheme ?? {},
397
+ colorScheme: design.colorScheme ?? {},
354
398
  roles: {
355
399
  primary: colors.primary,
356
400
  secondary: colors.secondary,
@@ -360,18 +404,30 @@ function resolveColorRef(value, bound, fallback) {
360
404
  text: colors.text,
361
405
  textSecondary: colors.mutedText
362
406
  },
363
- variables: bound.design.variables,
407
+ variables: design.variables,
364
408
  fallback
365
409
  });
366
410
  }
367
411
 
368
- function resolveBackground(background, colorScheme) {
412
+ // Background fills: a literal hex keeps normalizeColor's behaviour (uppercase, alpha kept);
413
+ // anything else is a ColorRef (var:id, scheme slot or role) resolved like table fills and run
414
+ // colours, falling back exactly as an unparseable literal did.
415
+ // Roles resolve through the colour scheme only: background, surface and text are derived from the
416
+ // background itself (the contrast text), so a reference must not see them.
417
+ function resolveBackgroundColor(value, design, fallback) {
418
+ const literal = normalizeColor(value, null);
419
+ if (literal) return literal;
420
+ const { primary, secondary, accent } = design.colors ?? {};
421
+ return resolveColorRefIn(value, { colorScheme: design.colorScheme, colors: { primary, secondary, accent }, variables: design.variables }, fallback);
422
+ }
423
+
424
+ function resolveBackground(background, colorScheme, design) {
369
425
  if (!background) return colorFromScheme(colorScheme, "light1", "#FFFFFF");
370
426
  if (typeof background === "string") return colorFromScheme(colorScheme, background, "#FFFFFF");
371
427
  if (background.type === "theme") return colorFromScheme(colorScheme, background.slot, "#FFFFFF");
372
- if (background.type === "solid") return normalizeColor(background.color, "#FFFFFF");
428
+ if (background.type === "solid") return resolveBackgroundColor(background.color, design, "#FFFFFF");
373
429
  if (background.type === "gradient") return null;
374
- if (background.type === "pattern") return normalizeColor(background.pattern?.backgroundColor, "#FFFFFF");
430
+ if (background.type === "pattern") return resolveBackgroundColor(background.pattern?.backgroundColor, design, "#FFFFFF");
375
431
  return colorFromScheme(colorScheme, "light1", "#FFFFFF");
376
432
  }
377
433
 
@@ -385,6 +441,21 @@ function colorLuminance(color) {
385
441
  return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
386
442
  }
387
443
 
444
+ // FF-61: WCAG 2.x contrast ratio, (L1 + 0.05) / (L2 + 0.05) with L1 the lighter relative luminance. The slide tag
445
+ // draws in the primary colour unless that is under 4.5:1 against the slide background; opf-pptx applies the same
446
+ // rule with the same arithmetic (test/tag-colour.mjs in both repositories pins the same colour pairs).
447
+ const TAG_MIN_CONTRAST = 4.5;
448
+ function contrastRatio(first, second) {
449
+ const a = colorLuminance(first), b = colorLuminance(second);
450
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
451
+ }
452
+ // The slide background the tag sits on: the resolved solid or pattern colour, else (gradient) the scheme light1,
453
+ // the colour the background rect also falls back to and opf-pptx uses for a background that is not one colour.
454
+ function tagFill(design) {
455
+ const background = design.backgroundColor ?? design.colors.background;
456
+ return contrastRatio(design.colors.primary, background) < TAG_MIN_CONTRAST ? design.colors.text : design.colors.primary;
457
+ }
458
+
388
459
  function resolveDesign(presentation, slide, context, index) {
389
460
  const deckDesign = presentation.design ?? {};
390
461
  const slideDesign = slide.design ?? {};
@@ -411,7 +482,12 @@ function resolveDesign(presentation, slide, context, index) {
411
482
  );
412
483
  const dimensions = resolveDimensions(slideDesign.dimensions ?? deckDesign.dimensions ?? theme.dimensions);
413
484
  const backgroundDefinition = slideDesign.background ?? deckDesign.background ?? theme.background;
414
- const backgroundColor = resolveBackground(backgroundDefinition, colorScheme);
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");
488
+ const variables = presentation.variables ?? {};
489
+ // background/surface/text roles depend on the background itself, so a background reference sees only the scheme and the three accent roles.
490
+ const backgroundColor = resolveBackground(backgroundDefinition, colorScheme, { colorScheme, colors: { primary, secondary, accent }, variables });
415
491
  const darkBackground = colorLuminance(backgroundColor ?? "#FFFFFF") < 0.179;
416
492
  const textColor = colorFromScheme(colorScheme, darkBackground ? "light1" : "dark1", darkBackground ? "#FFFFFF" : "#111827");
417
493
 
@@ -422,7 +498,7 @@ function resolveDesign(presentation, slide, context, index) {
422
498
  colorScheme,
423
499
  fontScheme,
424
500
  dimensions,
425
- variables: presentation.variables ?? {},
501
+ variables,
426
502
  background: backgroundDefinition,
427
503
  backgroundColor,
428
504
  colors: {
@@ -430,12 +506,13 @@ function resolveDesign(presentation, slide, context, index) {
430
506
  surface: colorFromScheme(colorScheme, darkBackground ? "dark2" : "light2", darkBackground ? "#1E293B" : "#F8FAFC"),
431
507
  text: textColor,
432
508
  mutedText: colorFromScheme(colorScheme, darkBackground ? "light2" : "dark2", darkBackground ? "#E2E8F0" : "#334155"),
433
- primary: normalizeColor(colorScheme.primary, null) ?? colorFromScheme(colorScheme, "accent1", "#2563EB"),
434
- secondary: normalizeColor(colorScheme.secondary, null) ?? colorFromScheme(colorScheme, "accent2", "#0F766E"),
435
- accent: normalizeColor(colorScheme.accent, null) ?? colorFromScheme(colorScheme, "accent3", "#F59E0B"),
509
+ primary,
510
+ secondary,
511
+ accent,
436
512
  border: colorFromScheme(colorScheme, "accent5", "#CBD5E1")
437
513
  },
438
514
  fonts: resolveFontFamilies(fontScheme),
515
+ darkBackground,
439
516
  diagnostics: fontSchemeDiagnostic ? [fontSchemeDiagnostic] : []
440
517
  };
441
518
  }
@@ -494,18 +571,26 @@ function reportLanguageDiagnostic(context, diagnostic) {
494
571
  */
495
572
  function reportGlyphFallback(context, note) {
496
573
  context.glyphFallbackDiagnostics ??= new Set();
497
- const key = `${note.fontFamily}\u0000${note.fallbackFamily}\u0000${note.path ?? ""}`;
574
+ const key = `${note.fontFamily}\u0000${note.fallbackFamily}\u0000${note.placeholder ?? ""}\u0000${note.path ?? ""}`;
498
575
  if (context.glyphFallbackDiagnostics.has(key)) return;
499
576
  context.glyphFallbackDiagnostics.add(key);
577
+ // FF-45: a symbol-encoded family's note lists the codes drawn as their Unicode equivalents, or as the placeholder.
578
+ const symbol = Array.isArray(note.codes);
579
+ const codes = symbol ? note.codes.slice(0, 8).map(code => `0x${code}`).join(", ") : "";
500
580
  context.options.onDiagnostic?.({
501
581
  code: "font-glyph-fallback",
502
582
  ...(note.path ? { path: note.path } : {}),
503
583
  // The note is reported once per family pair and path: it names characters the face lacks, such as these, not every one.
504
- message: `'${note.fontFamily}' lacks glyphs for characters such as ${note.characters.slice(0, 8).map(character => `U+${character.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(", ")}; the preview draws those with '${note.fallbackFamily}'. The PPTX keeps the chosen font.`,
584
+ message: symbol
585
+ ? (note.placeholder
586
+ ? `'${note.fontFamily}' is symbol-encoded; codes such as ${codes} have no Unicode equivalent that a loaded face draws, so the preview draws the placeholder '${note.placeholder}' with '${note.fallbackFamily}'. The PPTX keeps the chosen font and the original codes.`
587
+ : `'${note.fontFamily}' is symbol-encoded and not bundled; the preview draws codes such as ${codes} as their Unicode equivalents with '${note.fallbackFamily}'. The PPTX keeps the chosen font and the original codes.`)
588
+ : `'${note.fontFamily}' lacks glyphs for characters such as ${note.characters.slice(0, 8).map(character => `U+${character.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(", ")}; the preview draws those with '${note.fallbackFamily}'. The PPTX keeps the chosen font.`,
505
589
  fontFamily: note.fontFamily,
506
590
  fallbackFamily: note.fallbackFamily,
507
591
  scripts: [...note.scripts],
508
- characters: [...note.characters]
592
+ characters: [...note.characters],
593
+ ...(symbol ? { codes: [...note.codes], ...(note.placeholder ? { placeholder: note.placeholder } : {}) } : {})
509
594
  });
510
595
  }
511
596
 
@@ -638,8 +723,15 @@ function bindSlide(presentation, slide, layout, index, context) {
638
723
  onFallback: note => reportGlyphFallback(context, note)
639
724
  });
640
725
  const textMeasurement = scriptFonts.textMeasurement ?? context.options.textMeasurement;
641
- for (const role of ["heading","body","code"]) design.fonts[role] = resolveTextStyle({fontFamily:design.fonts[role],fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
642
- 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, textMeasurement, date: context.options.date, socialPlatforms: socialPlatformRecords(context) });
726
+ // fontScheme.accent (the tag and quote text) exists only with a core that resolves it; it takes the same look-alike policy.
727
+ // 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
+ // resolves it again and draws the replacement's encoded weight (Montserrat 900, Red Hat Display 600 and 300); resolving the role to the
729
+ // replacement family first would draw every run at 400 or 700 and measure it that way. Every other family resolves once, here.
730
+ for (const role of ["heading","body","code","accent"]) if (design.fonts[role] !== undefined) {
731
+ const named = design.fonts[role], resolved = resolveTextStyle({fontFamily:named,fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
732
+ design.fonts[role] = fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
733
+ }
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) });
643
735
  return {
644
736
  scriptFonts,
645
737
  textMeasurement,
@@ -657,7 +749,7 @@ function bindSlide(presentation, slide, layout, index, context) {
657
749
  }
658
750
 
659
751
  export function resolvePresentation(input, options = {}) {
660
- const presentation = parseInput(input);
752
+ const presentation = resolveTemplateInput(parseInput(input), options);
661
753
  if (options.validate !== false) {
662
754
  assertValidBoundary(presentation);
663
755
  }
@@ -706,10 +798,18 @@ function renderResolvedSlide(resolved, slideIndex, options) {
706
798
  renderSlideImage(bound, options),
707
799
  renderBranding(bound, resolved.presentation, width, height, options),
708
800
  ...renderSlideContent(bound, width, height, options),
801
+ // RR-34: the footnote area core reserved above the footer band, after the content and before the furniture.
802
+ renderFootnotes(bound, options, drawHelpers),
709
803
  renderFurniture(bound, resolved.presentation, width, height, options, "header"),
710
804
  renderFurniture(bound, resolved.presentation, width, height, options, "footer")
711
805
  ].filter(Boolean);
712
806
  const children = [renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
807
+ // FF-44: Chromium (123+) trims adjacent fullwidth punctuation by default (CSS text-spacing-trim: normal; a sequence such as
808
+ // 「」。 is up to 10 percent narrower), but measurement and PowerPoint advance every such character by its full width, so the browser
809
+ // would stretch the glyphs back to the pinned textLength. space-all keeps the drawn advances equal to the measured ones. Only slides
810
+ // that draw such punctuation carry it, so other output is unchanged.
811
+ const drawn = content.join("");
812
+ const trimsPunctuation = /[ -〿＀-￯]/.test(drawn) || (script?.scriptRole === "eastAsian" && /[‘-”]/.test(drawn));
713
813
 
714
814
  return tag(
715
815
  "svg",
@@ -722,6 +822,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
722
822
  height,
723
823
  lang,
724
824
  "xml:lang": lang,
825
+ style: trimsPunctuation ? "text-spacing-trim:space-all" : undefined,
725
826
  ...traceAttrs(options, bound.path)
726
827
  },
727
828
  `\n${children.join("\n")}\n`
@@ -737,7 +838,7 @@ function renderBackground(bound, width, height, options) {
737
838
  const dx=Math.cos(angle)*50,dy=Math.sin(angle)*50;
738
839
  const stopTags = stops.map((stop, index) => tag("stop", {
739
840
  offset: stableNumber((stop.position ?? index / Math.max(1, stops.length - 1)) * 100) + "%",
740
- "stop-color": normalizeColor(stop.color, bound.design.colors.background)
841
+ "stop-color": resolveBackgroundColor(stop.color, bound.design, bound.design.colors.background)
741
842
  }));
742
843
  return [
743
844
  tag("defs", traceAttrs(options, `${bound.path}.design.background`), tag("linearGradient", {
@@ -768,13 +869,17 @@ function renderBackground(bound, width, height, options) {
768
869
  return tag('g',{opacity:background.opacity??1},renderImage(imageItem,{x:0,y:0,width,height},bound,{...options,imageFit:background.image.fit??'cover'}));
769
870
  }
770
871
  if(isPlainObject(background) && background.type==='pattern'){
771
- const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=normalizeColor(pattern.foregroundColor,bound.design.colors.text),preset=pattern.preset;
772
- // 8px cells approximating DrawingML presets. diagStripe is the engine id that
773
- // PPTX export writes as wdUpDiag, so both draw the same rising stripe.
872
+ const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=resolveBackgroundColor(pattern.foregroundColor,bound.design,bound.design.colors.text),preset=pattern.preset;
873
+ // Without core's pattern table (an older core) five presets keep their hand-drawn approximation. diagStripe is the
874
+ // engine id that PPTX export writes as wdUpDiag, so both draw the same rising stripe.
774
875
  const stroke=(d,width=1)=>tag('path',{d,fill:'none',stroke:color,'stroke-width':width});
775
- const mark=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'):'';
876
+ // RR-07: every DrawingML preset (core PATTERN_PRESETS) is an 8x8 bitmap, one unit per 1/96 inch, anchored at the slide origin.
877
+ const runs=typeof opfCore.patternRuns==='function'?opfCore.patternRuns(preset):undefined;
878
+ 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'}):'';
879
+ 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'):'';
880
+ const mark=bitmap||legacy;
776
881
  if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
777
- return tag('g',{opacity:background.opacity??1},tag('rect',{width,height,fill:normalizeColor(pattern.backgroundColor,'#FFFFFF')})+tag('defs',{},tag('pattern',{id,width:8,height:8,patternUnits:'userSpaceOnUse'},mark))+tag('rect',{width,height,fill:`url(#${id})`}));
882
+ 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})`}));
778
883
  }
779
884
  return tag("rect", {
780
885
  x: 0,
@@ -849,9 +954,12 @@ function renderSlideContent(bound, width, height, options) {
849
954
  return bound.geometry.items.map(item => {
850
955
  const frame=item.frameBox;
851
956
  const surface=frame ? tag('rect',{x:frame.x,y:frame.y,width:frame.width,height:frame.height,rx:8*Math.min(width,height)/720,fill:bound.design.colors.surface,stroke:bound.design.colors.border,...traceAttrs(options,item.path)}) : '';
852
- return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options);
957
+ // RR-34: a captioned item draws its media in item.box and its caption band after it (src/annotations.js).
958
+ return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options)+(item.caption?renderCaption(item,{ ...bound, composition: item.composition },options,drawHelpers):'');
853
959
  });
854
960
  }
961
+ // Draw helpers injected into src/annotations.js (captions and footnote areas).
962
+ const drawHelpers = { tag, renderTextBox, renderRichLines };
855
963
 
856
964
  function reportDiagnostic(diagnostic, options) {
857
965
  const key = `${diagnostic.code}:${diagnostic.path}:${diagnostic.reason ?? ''}`;
@@ -895,11 +1003,13 @@ function renderTextPayload(item, box, bound, options) {
895
1003
  // follow design.titleAlignment only (unset is left, as in core composition).
896
1004
  align: item.alignment ?? (item.field === "title" ? bound.design.titleAlignment ?? "left" : bound.design.contentAlignment),
897
1005
  fontSize: item.field === "title" ? 54 : item.field === "tag" ? 16 : 25,
898
- fontFamily: item.field === "title" ? bound.design.fonts.heading : bound.design.fonts.body,
1006
+ // fontScheme.accent draws the tag; core's textStyle (item.textStyle) already carries it, this is the fallback family.
1007
+ fontFamily: item.field === "title" ? bound.design.fonts.heading : item.field === "tag" ? bound.design.fonts.accent ?? bound.design.fonts.body : bound.design.fonts.body,
899
1008
  fontWeight: item.field === "title" ? 700 : 400,
900
1009
  // The slide tag is the eyebrow label: it draws in the primary colour (scheme accent1), as the
901
- // PPTX export writes it. Every other text payload here uses the text colour.
902
- fill: item.field === "tag" ? bound.design.colors.primary : bound.design.colors.text,
1010
+ // PPTX export writes it, or in the text colour when the primary is under 4.5:1 against the
1011
+ // slide background (FF-61). Every other text payload here uses the text colour.
1012
+ fill: item.field === "tag" ? tagFill(bound.design) : bound.design.colors.text,
903
1013
  fit: item.text,
904
1014
  textStyle: item.textStyle,
905
1015
  diagnosticsHandled: Boolean(item.text),
@@ -909,10 +1019,13 @@ function renderTextPayload(item, box, bound, options) {
909
1019
 
910
1020
  function renderList(item, box, bound, options) {
911
1021
  const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
912
- 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});
1022
+ 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}:{})});
913
1023
  const children=[];
1024
+ // 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.
1025
+ const bullet=item.bulletImage?resolveBulletImage(item.bulletImage,bound,options):undefined;
914
1026
  for(const entry of fit.listEntries){
915
- children.push(tag('text',{x:stableNumber(entry.marker.x),y:stableNumber(entry.marker.y),'font-family':fontStack(entry.marker.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(entry.marker.fontSize),fill:bound.design.colors.text,'aria-hidden':'true'},escapeText(entry.marker.text)));
1027
+ if(bullet){const box=entry.bulletBox??{x:entry.marker.x,y:entry.marker.y-entry.marker.fontSize*.65,width:entry.marker.fontSize*.65,height:entry.marker.fontSize*.65};children.push(tag('image',{x:stableNumber(box.x),y:stableNumber(box.y),width:stableNumber(box.width),height:stableNumber(box.height),href:bullet,preserveAspectRatio:'xMidYMid meet','aria-hidden':'true',...(options.trace?{'data-opf-generated':'true'}:{})}));}
1028
+ else children.push(tag('text',{x:stableNumber(entry.marker.x),y:stableNumber(entry.marker.y),'font-family':fontStack(entry.marker.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(entry.marker.fontSize),...numberMarkerStyle(entry.marker),fill:bound.design.colors.text,'aria-hidden':'true',...(entry.marker.anchor==='end'?{'text-anchor':'end'}:{})},escapeText(entry.marker.text)));
916
1029
  const config={path:entry.textPath,align:'left',fill:bound.design.colors.text,rich:Array.isArray(entry.value),options};
917
1030
  children.push(renderRichLines(typeof entry.value==='string'?[entry.value]:entry.value,entry.text,entry.textBox,bound,config));
918
1031
  if(entry.description)children.push(renderRichLines(typeof entry.descriptionValue==='string'?[entry.descriptionValue]:entry.descriptionValue,entry.description,entry.descriptionBox,bound,{...config,path:entry.descriptionPath,rich:Array.isArray(entry.descriptionValue),fill:bound.design.colors.mutedText}));
@@ -920,7 +1033,15 @@ function renderList(item, box, bound, options) {
920
1033
  return tag('g',{...traceAttrs(options,item.path),...(fit.overflow?{'data-opf-overflow':'true'}:{})},children.join('\n'));
921
1034
  }
922
1035
 
923
- function renderImage(item, box, bound, options) {
1036
+ // A numbered list's marker (numbering) draws the number with the weight and slant core measured it at: PowerPoint draws an
1037
+ // auto-number in the first run's character formatting. Bullet markers carry no such attributes.
1038
+ function numberMarkerStyle(marker) {
1039
+ if (!marker.number) return {};
1040
+ return {'font-weight':marker.style.fontWeight===400?undefined:marker.style.fontWeight,'font-style':marker.style.italic?'italic':undefined};
1041
+ }
1042
+
1043
+ // Follows asset: references and the host imageResolver to the drawable source of an image value.
1044
+ function resolveImageSource(item, bound, options) {
924
1045
  let asset = normalizeAsset(item.value);
925
1046
  const seen = new Set();
926
1047
  let missingReference;
@@ -933,13 +1054,168 @@ function renderImage(item, box, bound, options) {
933
1054
  asset = {...normalizeAsset(bound.assets[id]),...overrides};
934
1055
  }
935
1056
  const source = options.imageResolver?.(asset.src, { asset, path: item.path }) ?? asset.src;
936
- if (typeof source === "string" && /^data:image\/(png|jpeg|gif|webp);base64,/i.test(source)) {
1057
+ // An SVG data URI (base64 or text, any encoding) is drawn as an image too, as a base64 URI: an SVG used as an image never
1058
+ // runs scripts or loads anything outside itself, in a browser or in the PNG/PDF rasterizer.
1059
+ const svg = typeof source === "string" ? svgImageSource(source) : null;
1060
+ const drawable = svg !== null || (typeof source === "string" && /^data:image\/(png|jpeg|gif|webp);base64,/i.test(source));
1061
+ return { asset, source: svg ?? source, drawable, missingReference };
1062
+ }
1063
+
1064
+ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
1065
+
1066
+ // The base64 data URI of a drawable SVG, else null. Drawable: a data URI whose type is image/svg+xml, whose text is
1067
+ // well-formed XML (and safe: no external or markup entity) with an <svg> root in the SVG namespace and an intrinsic size
1068
+ // (width and height, or a viewBox) - the same checks opf-pptx makes before it writes a native SVG picture over a PNG
1069
+ // fallback - so a document the export refuses (malformed, unsafe, no size) is the placeholder in the preview too, and the
1070
+ // two agree. The scripts, foreign objects and external references a valid document may carry are never run or loaded: an
1071
+ // SVG used as an image reaches nothing outside itself.
1072
+ function svgImageSource(uri) {
1073
+ const header = /^data:([^;,]*)((?:;[^;,]*)*),/i.exec(uri);
1074
+ if (!header || !/^image\/svg\+xml$/i.test(header[1])) return null;
1075
+ let bytes;
1076
+ try {
1077
+ const payload = uri.slice(header[0].length);
1078
+ bytes = /;base64/i.test(header[2]) ? Uint8Array.from(atob(payload.replace(/\s+/g, "")), char => char.charCodeAt(0)) : new TextEncoder().encode(decodeURIComponent(payload));
1079
+ } catch { return null; }
1080
+ if (bytes.length > SVG_IMAGE_MAX_BYTES) return null;
1081
+ let text;
1082
+ try { text = decodeSvgText(bytes); } catch { return null; }
1083
+ const root = svgRootAttributes(text);
1084
+ if (!root || !svgHasIntrinsicSize(root)) return null;
1085
+ let binary = "";
1086
+ for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
1087
+ return `data:image/svg+xml;base64,${btoa(binary)}`;
1088
+ }
1089
+
1090
+ const SVG_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
1091
+
1092
+ // UTF-8 (with or without a BOM), UTF-16 with a BOM, or the encoding the XML declaration names; throws on bytes the encoding cannot decode.
1093
+ function decodeSvgText(bytes) {
1094
+ let start = 0, label = "utf-8";
1095
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) start = 3;
1096
+ else if (bytes[0] === 0xff && bytes[1] === 0xfe) { label = "utf-16le"; start = 2; }
1097
+ else if (bytes[0] === 0xfe && bytes[1] === 0xff) { label = "utf-16be"; start = 2; }
1098
+ else {
1099
+ const declared = /^\s*<\?xml\b[^>]*\bencoding\s*=\s*["']([A-Za-z0-9._-]+)["']/.exec(String.fromCharCode(...bytes.subarray(0, 200)))?.[1];
1100
+ if (declared && !/^utf-?8$/i.test(declared)) label = declared;
1101
+ }
1102
+ return new TextDecoder(label, { fatal: true }).decode(bytes.subarray(start));
1103
+ }
1104
+
1105
+ const SVG_START_TAG = /<([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)((?:\s+[^\s=\/>"'<]+\s*=\s*(?:"[^"<]*"|'[^'<]*'))*)\s*(\/?)>/y;
1106
+ const SVG_ATTRIBUTE = /\s+([^\s=\/>"'<]+)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/y;
1107
+ const SVG_END_TAG = /<\/([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)\s*>/y;
1108
+
1109
+ // The root element's attributes (references expanded) of a well-formed, safe SVG document in the SVG namespace, else null.
1110
+ // A small XML check, not a parser: tags nest and close, attributes are quoted and unique, the one root is <svg>, text sits
1111
+ // inside it, and every reference is one of the five predefined entities, a numeric reference or a plain-text entity the
1112
+ // DOCTYPE declares. An entity that is external or contains markup, or expansions beyond the size limit, refuse the document.
1113
+ function svgRootAttributes(text) {
1114
+ const stack = [], entities = new Map();
1115
+ let index = 0, rootDone = false, rootAttributes = null, expansion = 0;
1116
+ const references = value => value.replace(/&([^;\s&<]*);?/g, (all, name) => {
1117
+ if (!all.endsWith(";")) throw new Error("reference");
1118
+ if (/^(?:amp|lt|gt|quot|apos|#\d{1,7}|#x[0-9a-fA-F]{1,6})$/.test(name)) return all;
1119
+ if (!entities.has(name)) throw new Error("undeclared entity");
1120
+ const expanded = entities.get(name);
1121
+ if ((expansion += expanded.length) > SVG_IMAGE_MAX_BYTES) throw new Error("entity expansion");
1122
+ return expanded;
1123
+ });
1124
+ try {
1125
+ while (index < text.length) {
1126
+ if (text[index] !== "<") {
1127
+ const end = text.indexOf("<", index), chunk = text.slice(index, end < 0 ? text.length : end);
1128
+ index += chunk.length;
1129
+ if (!stack.length) { if (chunk.trim()) return null; }
1130
+ else { if (chunk.includes("]]>")) return null; references(chunk); }
1131
+ continue;
1132
+ }
1133
+ if (text.startsWith("<!--", index)) {
1134
+ const end = text.indexOf("-->", index + 4);
1135
+ if (end < 0) return null;
1136
+ index = end + 3;
1137
+ } else if (text.startsWith("<![CDATA[", index)) {
1138
+ const end = text.indexOf("]]>", index + 9);
1139
+ if (end < 0 || !stack.length) return null;
1140
+ index = end + 3;
1141
+ } else if (text.startsWith("<?", index)) {
1142
+ const end = text.indexOf("?>", index + 2);
1143
+ if (end < 0) return null;
1144
+ index = end + 2;
1145
+ } else if (text.startsWith("<!DOCTYPE", index)) {
1146
+ if (rootDone || stack.length || rootAttributes) return null;
1147
+ const match = /^<!DOCTYPE\s+[^\[>]*(?:\[([\s\S]*?)\]\s*)?>/.exec(text.slice(index));
1148
+ if (!match) return null;
1149
+ for (const declaration of (match[1] ?? "").matchAll(/<!ENTITY\b[^>]*>/g)) {
1150
+ const entity = /^<!ENTITY\s+([A-Za-z_][\w.-]*)\s+(?:"([^"]*)"|'([^']*)')\s*>$/.exec(declaration[0]);
1151
+ const value = entity?.[2] ?? entity?.[3];
1152
+ if (!entity || value.includes("&") || value.includes("<")) return null;
1153
+ entities.set(entity[1], value);
1154
+ }
1155
+ index += match[0].length;
1156
+ } else if (text.startsWith("</", index)) {
1157
+ SVG_END_TAG.lastIndex = index;
1158
+ const match = SVG_END_TAG.exec(text);
1159
+ if (!match || stack.pop() !== match[1]) return null;
1160
+ index = SVG_END_TAG.lastIndex;
1161
+ if (!stack.length) rootDone = true;
1162
+ } else {
1163
+ SVG_START_TAG.lastIndex = index;
1164
+ const match = SVG_START_TAG.exec(text);
1165
+ if (!match) return null;
1166
+ index = SVG_START_TAG.lastIndex;
1167
+ const [, qualified, source, selfClosing] = match;
1168
+ const attributes = [], names = new Set();
1169
+ SVG_ATTRIBUTE.lastIndex = 0;
1170
+ for (let attribute; source && (attribute = SVG_ATTRIBUTE.exec(source));) {
1171
+ if (names.has(attribute[1])) return null;
1172
+ names.add(attribute[1]);
1173
+ attributes.push([attribute[1], references(attribute[2] ?? attribute[3])]);
1174
+ }
1175
+ if (!stack.length) {
1176
+ if (rootDone || rootAttributes) return null;
1177
+ const colon = qualified.indexOf(":");
1178
+ if (qualified.slice(colon + 1) !== "svg") return null;
1179
+ const namespace = colon < 0 ? "xmlns" : `xmlns:${qualified.slice(0, colon)}`;
1180
+ if (!attributes.some(([key, value]) => key === namespace && value === SVG_NAMESPACE)) return null;
1181
+ rootAttributes = Object.fromEntries(attributes);
1182
+ }
1183
+ if (selfClosing) { if (!stack.length) rootDone = true; }
1184
+ else stack.push(qualified);
1185
+ }
1186
+ }
1187
+ } catch { return null; }
1188
+ return stack.length || !rootAttributes ? null : rootAttributes;
1189
+ }
1190
+
1191
+ // The root has an intrinsic size: width and height lengths, or a viewBox (the same rule core and opf-pptx use).
1192
+ function svgHasIntrinsicSize(attributes) {
1193
+ const length = value => { const match = value && /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(px|pt|pc|mm|cm|in|q)?\s*$/i.exec(value); return match && Number(match[1]) > 0 ? Number(match[1]) : undefined; };
1194
+ const box = (attributes.viewBox ?? "").trim().split(/[\s,]+/).map(Number);
1195
+ const view = box.length === 4 && box.every(Number.isFinite) && box[2] > 0 && box[3] > 0;
1196
+ return Boolean((length(attributes.width) && length(attributes.height)) || view);
1197
+ }
1198
+
1199
+ // The drawable data URI of a picture bullet, or undefined (after reporting unresolved-asset once) so the glyph marker stays.
1200
+ function resolveBulletImage(bulletImage, bound, options) {
1201
+ const { drawable, source, asset, missingReference } = resolveImageSource({ value: bulletImage.source, path: bulletImage.path }, bound, options);
1202
+ if (drawable) return source;
1203
+ const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
1204
+ const message=missingReference?`Bullet image asset ${missingReference} is missing; the preview draws the character bullet.`:'A picture bullet requires an embedded raster data URI or a host imageResolver; the preview draws the character bullet.';
1205
+ if (options.strictAssets) throw new OPFRenderError("unresolved-asset", message, { path: bulletImage.path,reason });
1206
+ reportDiagnostic({code:'unresolved-asset',path:bulletImage.path,message,reason,source:asset.src,assetId:missingReference,description:asset.alt??asset.title??'Bullet image',placeholder:'glyph'},options);
1207
+ return undefined;
1208
+ }
1209
+
1210
+ function renderImage(item, box, bound, options) {
1211
+ const { asset, source, drawable, missingReference } = resolveImageSource(item, bound, options);
1212
+ if (drawable) {
937
1213
  return tag("image", { x: box.x, y: box.y, width: box.width, height: box.height,
938
- href: source, preserveAspectRatio: (options.imageFit ?? (bound.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? "Image",
939
- ...traceAttrs(options, item.path) });
1214
+ 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",
1215
+ ...traceAttrs(options, item.path), ...generatedAttrs(options) });
940
1216
  }
941
1217
  const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
942
- const message=missingReference?`Image asset ${missingReference} is missing; supply the referenced asset or resolve it in the host.`:'Image requires an embedded raster data URI or a host imageResolver.';
1218
+ 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.';
943
1219
  if (options.strictAssets) throw new OPFRenderError("unresolved-asset", message, { path: item.path,reason });
944
1220
  const fill = bound.design.colors.surface;
945
1221
  const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
@@ -981,7 +1257,7 @@ function renderImage(item, box, bound, options) {
981
1257
  const size=Math.max(0,Math.min(inner.width,inner.height,24*scale)),icon=centeredBox(inner,size,size);
982
1258
  children.push(tag('path',{d:`M ${icon.x} ${icon.y} L ${icon.x+size} ${icon.y+size} M ${icon.x+size} ${icon.y} L ${icon.x} ${icon.y+size}`,fill:'none',stroke:textColor,'stroke-width':Math.min(2*scale,size/8),'aria-hidden':'true'}));
983
1259
  }
984
- return tag("g", {...traceAttrs(options,item.path),'data-opf-asset-status':'unresolved',role:'img','aria-label':`Image unavailable: ${description}`},children.join("\n"));
1260
+ return tag("g", {...traceAttrs(options,item.path),...generatedAttrs(options),'data-opf-asset-status':'unresolved',role:'img','aria-label':`Image unavailable: ${description}`},children.join("\n"));
985
1261
  }
986
1262
 
987
1263
  function renderMedia(item, box, bound, options) {
@@ -1032,6 +1308,7 @@ function renderCode(item, box, bound, options) {
1032
1308
  const invalid = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
1033
1309
  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});
1034
1310
  }
1311
+ const syntax = codeSyntax(item, layout, bound, options);
1035
1312
  const children = [
1036
1313
  tag("rect", {
1037
1314
  x: box.x,
@@ -1059,7 +1336,7 @@ function renderCode(item, box, bound, options) {
1059
1336
  ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
1060
1337
  ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
1061
1338
  // Code stays left to right; script runs still take their slot fonts.
1062
- },segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false})).join('')));
1339
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false,fontSize:part.fit.fontSize},part.role==='body'?syntax:undefined)).join('')));
1063
1340
  children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
1064
1341
  '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}:{}),
1065
1342
  ...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
@@ -1071,6 +1348,9 @@ function renderMetric(item, box, bound, options) {
1071
1348
  const layout=item.metricLayout;
1072
1349
  if (!layout) throw new OPFRenderError('missing-metric-layout','Metric rendering requires coordinated core metric geometry.',{path:item.path});
1073
1350
  const children=[];
1351
+ // RR-07: a trend draws an arrow beside its word and colours the trend and delta text, from core's accepted geometry.
1352
+ const trendMark=metricTrendMark(layout,bound,options);
1353
+ const partFill=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color:part.role==='value'?bound.design.colors.primary:bound.design.colors.text;
1074
1354
  for (const part of layout.parts) {
1075
1355
  const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
1076
1356
  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});
@@ -1086,30 +1366,37 @@ function renderMetric(item, box, bound, options) {
1086
1366
  if(factor&&!line.segments.some(segment=>segment.kind==='tab'))return tag('text',{x:stableNumber(origin.x+line.width*factor),y:stableNumber(origin.baseline),'text-anchor':factor===1?'end':'middle',
1087
1367
  'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
1088
1368
  'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
1089
- 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
1369
+ 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:partFill(part),
1090
1370
  ...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
1091
1371
  'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
1092
1372
  },line.segments.map(segment=>segmentSpan({
1093
1373
  ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
1094
- },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
1374
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start),fontSize:part.fit.fontSize})).join(''));
1095
1375
  return tag('text',{x:stableNumber(origin.x),y:stableNumber(origin.baseline),'text-anchor':'start',
1096
1376
  'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
1097
1377
  'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
1098
- 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
1378
+ 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:partFill(part),
1099
1379
  ...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
1100
1380
  'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
1101
1381
  },line.segments.map(segment=>segmentSpan({x:stableNumber(origin.x+segment.x),
1102
1382
  ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
1103
1383
  ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
1104
- },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
1384
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start),fontSize:part.fit.fontSize})).join(''));
1105
1385
  });
1106
1386
  children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,
1107
1387
  '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}:{}),
1108
1388
  ...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
1109
1389
  }
1390
+ if (trendMark) children.push(tag('g',{role:'img','aria-label':trendMark.ariaLabel},tag('polygon',{points:trendMark.points.map(([x,y])=>`${stableNumber(x)},${stableNumber(y)}`).join(' '),fill:trendMark.color})));
1110
1391
  return tag('g',{...traceAttrs(options,item.path),...(options.trace?{'data-opf-metric-container':'true'}:{})},children.join('\n'));
1111
1392
  }
1112
1393
 
1394
+ // The trend arrow and colour for a laid-out metric (core metricTrendMark); undefined without a trend or without core support.
1395
+ function metricTrendMark(layout,bound,options) {
1396
+ if (typeof opfCore.metricTrendMark!=='function') return undefined;
1397
+ return opfCore.metricTrendMark(layout,{background:bound.design.backgroundColor??bound.design.colors.background});
1398
+ }
1399
+
1113
1400
  function renderQuote(item, box, bound, options) {
1114
1401
  const layout = item.quoteLayout;
1115
1402
  if (!layout) throw new OPFRenderError('missing-quote-layout', 'Quote rendering requires a coordinated core build with shared quote geometry.', {path:item.path});
@@ -1142,7 +1429,7 @@ function renderTimeline(item, box, bound, options) {
1142
1429
 
1143
1430
  function renderTable(item, box, bound, options) {
1144
1431
  const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
1145
- 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});
1432
+ 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, ...(bound.geometry?.direction === "rtl" ? {direction:"rtl"} : {})});
1146
1433
  const children = [];
1147
1434
  const separateBorders = layout.rows.some(row => row.cells.some(cell => cell.style?.borders));
1148
1435
  const defaultEdges = [], explicitEdges = [];
@@ -1348,12 +1635,20 @@ function renderBranding(bound,presentation,width,height,options) {
1348
1635
  if(design.watermark){
1349
1636
  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'})));
1350
1637
  }
1638
+ // Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
1639
+ const logo=bound.geometry.logo;
1640
+ if(logo)pieces.push(renderImage({value:logo.source,path:logo.path},logo.box,bound,{...options,imageAnchor:logo.anchor??'left',imageLabel:'Logo',imageGenerated:true}));
1351
1641
  return pieces.join('');
1352
1642
  }
1353
1643
 
1644
+ // A family name is written unquoted only when it is a valid sequence of CSS identifiers; a word that starts with a digit
1645
+ // ("Source Sans 3", "Noto Sans Symbols 2") or any other non-identifier character makes an unquoted name invalid CSS, which
1646
+ // drops the whole font-family in a browser (FF-45). Such names are single-quoted (the attribute is double-quoted).
1647
+ const cssIdentifierSequence = /^(?:-?(?:[A-Za-z_ -￿]|\\.)(?:[\w -￿-]|\\.)*)(?: (?:-?(?:[A-Za-z_ -￿]|\\.)(?:[\w -￿-]|\\.)*))*$/;
1648
+ const cssFamily = name => cssIdentifierSequence.test(name) && !/^(?:inherit|initial|unset|default|serif|sans-serif|monospace|cursive|fantasy|system-ui)$/i.test(name) ? name : `'${name.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
1354
1649
  function fontStack(family, type) {
1355
1650
  const fallback = type === "serif" ? "serif" : type === "monospace" ? "monospace" : "sans-serif";
1356
- return `${[family].flat().join(", ")}, ${fallback}`;
1651
+ return `${[family].flat().map(cssFamily).join(", ")}, ${fallback}`;
1357
1652
  }
1358
1653
 
1359
1654
  // Unicode directional isolates (FF-19): each paragraph (text between hard line
@@ -1366,6 +1661,10 @@ const RIGHT_TO_LEFT_ISOLATE = "\u2067", POP_DIRECTIONAL_ISOLATE = "\u2069";
1366
1661
  // Core owns the rule (paragraphDirection, core #134), so preview and export agree.
1367
1662
  // Without it (published core 0.11.0) every paragraph is left to right, as in export.
1368
1663
  const coreParagraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
1664
+ // RR-05: authored alignment is logical for right-to-left text, so `left` is the start edge (drawn at the right of a
1665
+ // right-to-left line) and `right` the end edge. Core owns the rule; the fallback only serves a core without it, which
1666
+ // also reports no line directions, so it never flips.
1667
+ const physicalAlignment = typeof opfCore.physicalAlignment === "function" ? opfCore.physicalAlignment : alignment => alignment;
1369
1668
  /** Maps a source offset of the text to whether its paragraph is right to left. */
1370
1669
  function paragraphRtl(bound, text) {
1371
1670
  if (bound.scriptFonts?.rtl !== true || !coreParagraphDirection) return () => false;
@@ -1393,43 +1692,81 @@ function nestedReset(style, run) {
1393
1692
  return parent && !disabledFeaturesStyle([run.stack ?? run.family].flat()[0]) ? RESET_POLICY_FEATURES : undefined;
1394
1693
  }
1395
1694
 
1396
- function scriptLine(text, style, bound, type, { rtl = false, placement, trace } = {}) {
1695
+ function scriptLine(text, style, bound, type, { rtl = false, placement, trace, fontSize } = {}) {
1397
1696
  const value = String(text ?? "");
1398
1697
  const scripts = bound.scriptFonts;
1399
1698
  rtl = rtl && value !== "";
1400
1699
  const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
1401
1700
  const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
1402
- if (runs.length === 1) {
1701
+ // RR-38: PowerPoint's baseline of a line in Arabic Typesetting sits above core's (one em below the line top): the caller moves it up by `baselineShift` em.
1702
+ const shiftOf = list => { const shift = baselineShift(list); return shift ? { baselineShift: shift } : {}; };
1703
+ // FF-45: symbol runs (Wingdings, Symbol, Webdings codes drawn as their Unicode equivalents) are always positioned when the
1704
+ // line is placed, one tspan per glyph at the verified symbol font's advance, so a single mapped glyph is never stretched to it.
1705
+ const symbols = runs.some(run => run.symbol);
1706
+ if (runs.length === 1 && !(symbols && placement && placement.width > 0)) {
1403
1707
  const [run] = runs;
1404
- return { content: isolate(escapeText(value)), family: run.own ? undefined : fontStack(run.stack ?? run.family, type) };
1708
+ // RR-38: a replacement the policy scales (Arabic Typesetting -> Noto Naskh Arabic, 0.64) is drawn at the scaled size; the caller applies it.
1709
+ return { content: isolate(escapeText(run.own ? value : run.text)), family: run.own ? undefined : fontStack(run.stack ?? run.family, type), ...(run.sizeAdjust ? { sizeAdjust: run.sizeAdjust } : {}), ...shiftOf(runs) };
1405
1710
  }
1711
+ const baseSize = placement?.fontSize ?? fontSize;
1712
+ const adjusted = run => run.sizeAdjust && baseSize > 0 ? stableNumber(adjustedFontSize(baseSize, run.sizeAdjust)) : undefined;
1406
1713
  const widths = placement && placement.width > 0 ? scripts.runWidths(runs, placement.fontSize, style) : undefined;
1407
1714
  if (!widths) {
1408
1715
  return { content: isolate(runs.map(run => run.own ? escapeText(run.text)
1409
- : tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type), style: nestedReset(style, run) }, escapeText(run.text))).join("")) };
1716
+ : tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type), "font-size": adjusted(run), style: nestedReset(style, run) }, escapeText(run.text))).join("")), ...shiftOf(runs) };
1410
1717
  }
1718
+ const natural = symbols ? scripts.runWidths(runs, placement.fontSize, style, { natural: true }) : undefined;
1411
1719
  const total = widths.reduce((sum, width) => sum + width, 0), factor = total > 0 ? placement.width / total : 1;
1412
1720
  let advance = 0, offset = 0;
1413
1721
  const content = runs.map((run, index) => {
1414
1722
  const width = widths[index] * factor, left = rtl ? placement.width - advance - width : advance;
1415
1723
  advance += width;
1416
1724
  const start = offset;
1417
- offset += run.text.length;
1725
+ offset += run.symbol ? run.symbol.source.length : run.text.length;
1726
+ // A symbol glyph keeps the open face's shape: it is compressed to its code's advance only when wider, never stretched.
1727
+ const pinned = run.symbol ? natural[index] > width + 1e-6 : width > 0;
1418
1728
  return tag("tspan", {
1419
1729
  x: stableNumber(placement.x + left),
1420
- textLength: width > 0 ? stableNumber(width) : undefined, lengthAdjust: width > 0 ? "spacingAndGlyphs" : undefined,
1730
+ textLength: pinned ? stableNumber(width) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
1421
1731
  "font-family": run.own ? undefined : fontStack(run.family, type),
1732
+ "font-size": adjusted(run),
1422
1733
  style: run.own ? undefined : nestedReset(style, run),
1423
1734
  ...(trace ? trace(start, offset) : {})
1424
1735
  }, isolate(escapeText(run.text)));
1425
1736
  }).join("");
1426
- return { content, positioned: true };
1737
+ return { content, positioned: true, ...shiftOf(runs) };
1427
1738
  }
1428
1739
 
1429
1740
  /** One positioned code/metric segment tspan; its script runs flow inside it (no textLength). */
1430
- function segmentSpan(attrs, segment, text, style, bound, type, options) {
1741
+ function segmentSpan(attrs, segment, text, style, bound, type, options, syntax) {
1431
1742
  const scripted = segment.kind === "tab" ? { content: escapeText(text) } : scriptLine(text, style, bound, type, options);
1432
- return tag("tspan", { ...attrs, "font-family": scripted.family }, scripted.content);
1743
+ // Syntax highlighting (RR-07): a coloured text segment becomes consecutive sibling tspans, one per run, each a
1744
+ // traced segment of its own source range holding one text node (the editor's caret mapping reads that), the first
1745
+ // keeping the accepted x and the rest flowing after it. Plain code keeps one tspan per accepted segment.
1746
+ const runs = syntax && segment.kind !== "tab" ? opfCore.codeLineRuns(syntax.tokens, segment.start, segment.end) : undefined;
1747
+ if (runs?.some(run => run.kind)) {
1748
+ return runs.map((run, index) => {
1749
+ const sizeOf = line => line.sizeAdjust && options?.fontSize > 0 ? stableNumber(adjustedFontSize(options.fontSize, line.sizeAdjust)) : undefined;
1750
+ const piece = scriptLine(text.slice(run.start - segment.start, run.end - segment.start), style, bound, type, options);
1751
+ const own = { ...attrs };
1752
+ if (index > 0) delete own.x;
1753
+ if (own["data-opf-text-start"] !== undefined) { own["data-opf-text-start"] = run.start; own["data-opf-text-end"] = run.end; }
1754
+ return tag("tspan", { ...own, fill: run.kind ? syntax.palette[run.kind] : undefined, "font-family": piece.family, "font-size": sizeOf(piece) }, piece.content);
1755
+ }).join("");
1756
+ }
1757
+ return tag("tspan", { ...attrs, "font-family": scripted.family, "font-size": scripted.sizeAdjust && options?.fontSize > 0 ? stableNumber(adjustedFontSize(options.fontSize, scripted.sizeAdjust)) : undefined }, scripted.content);
1758
+ }
1759
+
1760
+ // Token ranges of the code body and the palette to paint them with; undefined for plain code (an unknown language,
1761
+ // no language, or a core without the shared highlighter). The preview and the PPTX export read the same tables.
1762
+ function codeSyntax(item, layout, bound, options) {
1763
+ const body = layout.parts.find(part => part.role === "body");
1764
+ const language = typeof item.value?.language === "string" ? item.value.language : layout.parts.find(part => part.role === "language")?.text;
1765
+ if (!body || !language) return undefined;
1766
+ if (typeof opfCore.tokenizeCode !== "function") return undefined;
1767
+ const tokens = opfCore.tokenizeCode(body.text, language);
1768
+ if (!tokens.length) return undefined;
1769
+ return { tokens, palette: opfCore.codeSyntaxPaletteForScheme(bound.design.colorScheme) };
1433
1770
  }
1434
1771
 
1435
1772
  // Faces flagged embed:"used" (the vendored open, Intos and script-pack faces) are embedded only when the slide's own markup draws
@@ -1492,8 +1829,30 @@ function renderRichTextBox(value, box, bound, config) {
1492
1829
  return renderRichLines(value,fit,box,bound,config);
1493
1830
  }
1494
1831
 
1832
+ // A rich line without its leading and trailing whitespace: fragments keep their order and styles, positions and widths follow the trimmed text.
1833
+ // Returns null when there is nothing to trim or the line holds a tab.
1834
+ function trimRichLineEdges(line,textMeasurement) {
1835
+ const fragments=line.fragments.map(fragment=>({...fragment}));
1836
+ if(!fragments.length||fragments.some(fragment=>fragment.kind==='tab'))return null;
1837
+ const width=(fragment,text)=>text?textWidthMeasurer(fragment.style,textMeasurement)(text,fragment.fontSize):0;
1838
+ let changed=false;
1839
+ while(fragments.length&&!/\S/.test(fragments[0].text)){fragments.shift();changed=true;}
1840
+ while(fragments.length&&!/\S/.test(fragments.at(-1).text)){fragments.pop();changed=true;}
1841
+ if(!fragments.length)return null;
1842
+ const first=fragments[0],last=fragments.at(-1);
1843
+ const lead=/^\s*/.exec(first.text)[0],trail=/\s*$/.exec(last.text)[0];
1844
+ if(lead){first.text=first.text.slice(lead.length);first.start+=lead.length;first.width-=width(first,lead);changed=true;}
1845
+ if(trail){last.text=last.text.slice(0,-trail.length);last.end-=trail.length;last.width-=width(last,trail);changed=true;}
1846
+ if(!changed)return null;
1847
+ let x=0;
1848
+ for(const fragment of fragments){fragment.x=x;x+=fragment.width;}
1849
+ return {...line,fragments,width:x};
1850
+ }
1851
+
1495
1852
  function renderRichLines(value,fit,box,bound,config) {
1496
- const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1853
+ const logicalAlignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1854
+ // Each line takes its paragraph's direction from core (every wrapped line shares it) and its physical edge from that.
1855
+ const lineAlignment=index=>fit.placement?.lines[index]?.alignment??physicalAlignment(logicalAlignment,fit.directions?.[index]);
1497
1856
  let textOffset=0;
1498
1857
  const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
1499
1858
  const richRtl=paragraphRtl(bound,value.map(run=>typeof run==='string'?run:run.text).join(''));
@@ -1502,13 +1861,21 @@ function renderRichLines(value,fit,box,bound,config) {
1502
1861
  // those estimates into visible gaps. Supplied measurements keep exact origins.
1503
1862
  const naturalFlow=!config.options.textMeasurement?.measure&&!fit.placement;
1504
1863
  const hasTabs=fit.richLines.some(line=>line.fragments.some(fragment=>fragment.kind==='tab'));
1505
- const content=fit.richLines.map((line,lineIndex)=>{
1864
+ const content=fit.richLines.map((sourceLine,lineIndex)=>{
1865
+ const alignment=lineAlignment(lineIndex);
1866
+ const sourceFirst=sourceLine.fragments[0],rtl=fit.directions?fit.directions[lineIndex]==='rtl':richRtl(sourceFirst?runOffsets[sourceFirst.runIndex]+sourceFirst.start:0);
1867
+ // PowerPoint ignores whitespace at either edge of a right-aligned right-to-left line, so the glyph edge stays on the box edge (RR-05).
1868
+ const edgeTrim=rtl&&alignment==='right'?trimRichLineEdges(sourceLine,config.options.textMeasurement):null;
1869
+ const line=edgeTrim??sourceLine;
1506
1870
  const lineHasTab=line.fragments.some(fragment=>fragment.kind==='tab');
1507
1871
  const flow=naturalFlow&&!lineHasTab;
1508
1872
  const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1509
1873
  const placed=fit.placement?.lines[lineIndex];
1510
- const originX=placed?.x??box.x+offset,baseline=placed?.baseline??box.y+line.baseline;
1511
- const firstFragment=line.fragments[0],rtl=richRtl(firstFragment?runOffsets[firstFragment.runIndex]+firstFragment.start:0);
1874
+ // RR-38: a line with runs in a policy replacement (Arabic Typesetting) takes PowerPoint's baseline of the real font, above core's.
1875
+ const lineShift=bound.scriptFonts?baselineShift(...line.fragments.filter(fragment=>fragment.kind!=='tab'&&fragment.text).map(fragment=>bound.scriptFonts.plan(fragment.text,fragment.style))):0;
1876
+ const shiftPx=lineShift*Math.max(fit.fontSize,...line.fragments.map(fragment=>fragment.fontSize));
1877
+ const originX=placed?placed.x+(edgeTrim?sourceLine.width-line.width:0):box.x+offset,baseline=(placed?.baseline??box.y+line.baseline)-shiftPx;
1878
+ const firstFragment=line.fragments[0];
1512
1879
  const renderFragment=(fragment,asFlow,edges={})=>{
1513
1880
  const run=fragment.run;
1514
1881
  // Accepted outline placement owns the horizontal advance. Geometric precision
@@ -1523,11 +1890,14 @@ function renderRichLines(value,fit,box,bound,config) {
1523
1890
  : resolveColorRef(run.color, bound, config.fill);
1524
1891
  let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
1525
1892
  const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(fragment.text,fragment.style,bound,bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
1526
- placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined});
1893
+ placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
1527
1894
  if(scripted.positioned)fixedAdvance=false;
1528
1895
  const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
1529
- const rendered=tag(asFlow?'tspan':'text',{...(config.options.trace?{'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(fragment.fontSize),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':asFlow?'normal':undefined,'text-decoration':[run.underline?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
1530
- if(run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
1896
+ // RR-34: a citation/footnote marker is generated text (no source range): it is traced as a marker
1897
+ // segment without text offsets, so editors never read it as part of the run, and it is not linked.
1898
+ const marker=fragment.kind==='marker';
1899
+ 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);
1900
+ if(!marker&&run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
1531
1901
  return rendered;
1532
1902
  };
1533
1903
  if(naturalFlow&&lineHasTab) {
@@ -1544,13 +1914,14 @@ function renderRichLines(value,fit,box,bound,config) {
1544
1914
  if(!flow)return fragments.join('\n');
1545
1915
  const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
1546
1916
  const first=line.fragments[0];
1547
- return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined,[NO_POLICY_FEATURES]:true},fragments.join(''));
1917
+ return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline-shiftPx),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined,[NO_POLICY_FEATURES]:true},fragments.join(''));
1548
1918
  });
1549
1919
  let cursor=0;
1550
1920
  const whole=value.map(run=>typeof run==='string'?run:run.text).join('');
1551
1921
  const lineTrace=config.options.trace?fit.richLines.map((line,index)=>{
1552
1922
  if(index){const newline=/^(\r\n|\r|\n)/.exec(whole.slice(cursor));if(newline)cursor+=newline[0].length;}
1553
1923
  const start=cursor;cursor+=(fit.lines[index]??'').length;
1924
+ const alignment=lineAlignment(index);
1554
1925
  const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1555
1926
  const placed=fit.placement?.lines[index];
1556
1927
  return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height,
@@ -1577,42 +1948,50 @@ function renderTextBox(text, box, bound, config) {
1577
1948
  const totalHeight = fit.lines.length * fit.lineHeight;
1578
1949
  const startY = config.verticalAlign === "middle"
1579
1950
  ? box.y + Math.max(0, (box.height - totalHeight) / 2) + size : box.y + size;
1580
- const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1581
- const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
1582
- const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
1951
+ const logicalAlignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1583
1952
  const type = config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type;
1584
1953
  const source = String(text ?? ""), boxRtl = paragraphRtl(bound, source);
1585
1954
  let cursor = 0;
1586
1955
  const lines = fit.lines.map((line, index) => {
1956
+ // Logical alignment (RR-05): a right-to-left line starts at the right edge.
1957
+ const alignment=fit.placement?.lines[index]?.alignment??physicalAlignment(logicalAlignment,fit.directions?.[index]);
1958
+ const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
1959
+ const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
1587
1960
  const sourceLine=fit.sourceLines?.[index],placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1588
1961
  // The line's paragraph decides its direction: source offsets when layout has them, else the next match.
1589
1962
  const found=sourceLine?sourceLine.start:source.indexOf(line,cursor),lineStart=found>=0?found:cursor;cursor=lineStart+line.length;
1590
- const rtl=boxRtl(lineStart);
1963
+ const rtl=fit.directions?fit.directions[index]==='rtl':boxRtl(lineStart);
1591
1964
  const origin=placed?.x??x-(sourceLine?.width??0)*factor;
1592
1965
  const tabs=sourceLine?.segments.some(segment=>segment.kind==='tab');
1593
- let content,family,positioned=false;
1966
+ let content,family,positioned=false,trimWidth=0,sizeAdjust,shift=0;
1594
1967
  if(tabs) content=sourceLine.segments.map(segment=>{
1595
1968
  const segmentText=line.slice(segment.start-sourceLine.start,segment.end-sourceLine.start),fixed=segment.kind==='tab'||placed;
1596
1969
  const traced=(start,end)=>config.options.trace?{'data-opf-source-start':start,'data-opf-source-end':end,'data-opf-segment':segment.kind}:{};
1597
1970
  const scripted=segment.kind==='tab'?{content:escapeText(segmentText)}:scriptLine(segmentText,style,bound,type,{rtl,
1598
- placement:placed?{x:origin+segment.x,width:segment.width,fontSize:size}:undefined,
1971
+ placement:placed?{x:origin+segment.x,width:segment.width,fontSize:size}:undefined,fontSize:size,
1599
1972
  trace:config.options.trace?(start,end)=>traced(segment.start+start,segment.start+end):undefined});
1600
1973
  if(scripted.positioned)return scripted.content;
1601
1974
  return tag('tspan',{
1602
1975
  x:stableNumber(origin+segment.x),textLength:fixed?stableNumber(segment.width):undefined,
1603
- lengthAdjust:fixed?'spacingAndGlyphs':undefined,'font-family':scripted.family,
1976
+ lengthAdjust:fixed?'spacingAndGlyphs':undefined,'font-family':scripted.family,'font-size':scripted.sizeAdjust?stableNumber(adjustedFontSize(size,scripted.sizeAdjust)):undefined,
1604
1977
  ...traced(segment.start,segment.end),
1605
1978
  },scripted.content);
1606
1979
  }).join('');
1607
- else ({content,family,positioned=false}=scriptLine(line,style,bound,type,{rtl,placement:placed?.width>0?{x:placed.x,width:placed.width,fontSize:size}:undefined}));
1980
+ else {
1981
+ // PowerPoint ignores whitespace at either edge of a right-aligned right-to-left line (the glyph edge stays on the box edge, native check
1982
+ // 2026-10-02), so a soft-wrapped line that keeps its trailing space, or a leading space, draws without it (RR-05).
1983
+ const edge=rtl&&alignment==='right'?/^(\s*)([\s\S]*?)(\s*)$/.exec(line):null,trimmed=edge&&edge[2]&&(edge[1]||edge[3])?edge:null;
1984
+ if(trimmed){const measureEdge=textWidthMeasurer(style,config.options.textMeasurement);trimWidth=measureEdge(trimmed[1],size)+measureEdge(trimmed[3],size);}
1985
+ ({content,family,positioned=false,sizeAdjust,baselineShift:shift=0}=scriptLine(trimmed?trimmed[2]:line,style,bound,type,{rtl,placement:placed?.width>0?{x:placed.x+trimWidth,width:placed.width-trimWidth,fontSize:size}:undefined,fontSize:size}));
1986
+ }
1608
1987
  // Positioned script runs carry their own x and textLength (FF-19).
1609
1988
  const start=tabs||positioned;
1610
1989
  return tag("text", {
1611
- x: stableNumber(tabs?origin:positioned?placed.x:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
1990
+ x: stableNumber(tabs?origin:positioned?placed.x:placed?placed.x+placed.width*factor:x), y: stableNumber((placed?.baseline??startY + index * fit.lineHeight) - shift * size),
1612
1991
  "text-anchor": start?'start':anchor, "font-family": family ?? fontStack(style.fontFamily, type),
1613
- "font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
1992
+ "font-size": stableNumber(adjustedFontSize(size,sizeAdjust)), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
1614
1993
  'xml:space':'preserve',style:'white-space:pre','text-rendering':config.options.textMeasurement?.measure?'geometricPrecision':undefined,
1615
- textLength:!start&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
1994
+ textLength:!start&&placed?.width>0?stableNumber(placed.width-trimWidth):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
1616
1995
  ...traceAttrs(config.options, config.path),
1617
1996
  ...(config.options.trace&&sourceLine?{'data-opf-source-start':sourceLine.start,'data-opf-source-end':sourceLine.end,'data-opf-source-next-start':sourceLine.nextStart,'data-opf-line-boundary':sourceLine.boundary}:{}),
1618
1997
  }, content);
@@ -1681,6 +2060,11 @@ function traceAttrs(options, path) {
1681
2060
  return options.trace ? { "data-opf-path": path } : {};
1682
2061
  }
1683
2062
 
2063
+ // Generated pictures (the deck logo) are drawn from design fields, not authored content: editors skip them.
2064
+ function generatedAttrs(options) {
2065
+ return options.trace && options.imageGenerated ? { "data-opf-generated": "true" } : {};
2066
+ }
2067
+
1684
2068
  function stableNumber(value) {
1685
2069
  if (Number.isInteger(value)) return String(value);
1686
2070
  return Number(value).toFixed(3).replace(/\.?0+$/, "");
@@ -1706,7 +2090,7 @@ const NO_POLICY_FEATURES = Symbol("noPolicyFeatures");
1706
2090
  const RESET_POLICY_FEATURES = "font-variant-ligatures:normal;font-feature-settings:normal";
1707
2091
  function withPolicyFeatures(name, attrs) {
1708
2092
  if (attrs[NO_POLICY_FEATURES]) return attrs;
1709
- const family = (name === "text" || name === "tspan") && typeof attrs["font-family"] === "string" ? attrs["font-family"].split(",")[0].trim() : undefined;
2093
+ const family = (name === "text" || name === "tspan") && typeof attrs["font-family"] === "string" ? attrs["font-family"].split(",")[0].trim().replace(/^'(.*)'$/, "$1") : undefined;
1710
2094
  const features = family && disabledFeaturesStyle(family);
1711
2095
  return features ? { ...attrs, style: attrs.style ? `${attrs.style};${features}` : features } : attrs;
1712
2096
  }