@openpresentation/opf-render 0.9.0 → 0.11.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.
Files changed (98) hide show
  1. package/README.md +115 -13
  2. package/dist/font-compatibility.js +72 -3
  3. package/dist/font-manifest.js +1746 -12
  4. package/dist/font-policy.js +2518 -0
  5. package/dist/fonts-browser.d.ts +65 -1
  6. package/dist/fonts-browser.js +229 -35
  7. package/dist/fonts-node.d.ts +68 -10
  8. package/dist/fonts-node.js +152 -16
  9. package/dist/fonts.d.ts +87 -5
  10. package/dist/fonts.js +147 -37
  11. package/dist/index.d.ts +31 -2
  12. package/dist/lazy-font-list.js +23 -0
  13. package/dist/lazy-fonts.js +52 -0
  14. package/dist/raster.js +3 -1
  15. package/dist/script-font-pack.js +179 -0
  16. package/dist/script-fonts.js +563 -0
  17. package/dist/svg.d.ts +29 -2
  18. package/dist/svg.js +446 -41
  19. package/fonts/bebas-neue/BebasNeue_400Regular.ttf +0 -0
  20. package/fonts/bebas-neue/OFL.txt +93 -0
  21. package/fonts/bebas-neue/PROVENANCE.json +23 -0
  22. package/fonts/carlito/Carlito-Bold.ttf +0 -0
  23. package/fonts/carlito/Carlito-BoldItalic.ttf +0 -0
  24. package/fonts/carlito/Carlito-Italic.ttf +0 -0
  25. package/fonts/carlito/Carlito-Regular.ttf +0 -0
  26. package/fonts/carlito/OFL.txt +94 -0
  27. package/fonts/intos/Intos-Bold.ttf +0 -0
  28. package/fonts/intos/Intos-BoldItalic.ttf +0 -0
  29. package/fonts/intos/Intos-Italic.ttf +0 -0
  30. package/fonts/intos/Intos-Regular.ttf +0 -0
  31. package/fonts/intos/IntosDisplay-Bold.ttf +0 -0
  32. package/fonts/intos/IntosDisplay-BoldItalic.ttf +0 -0
  33. package/fonts/intos/IntosDisplay-Italic.ttf +0 -0
  34. package/fonts/intos/IntosDisplay-Regular.ttf +0 -0
  35. package/fonts/intos/IntosNarrow-Bold.ttf +0 -0
  36. package/fonts/intos/IntosNarrow-BoldItalic.ttf +0 -0
  37. package/fonts/intos/IntosNarrow-Italic.ttf +0 -0
  38. package/fonts/intos/IntosNarrow-Regular.ttf +0 -0
  39. package/fonts/intos/IntosSerif-Bold.ttf +0 -0
  40. package/fonts/intos/IntosSerif-BoldItalic.ttf +0 -0
  41. package/fonts/intos/IntosSerif-Italic.ttf +0 -0
  42. package/fonts/intos/IntosSerif-Regular.ttf +0 -0
  43. package/fonts/intos/NOTICE.md +18 -0
  44. package/fonts/intos/OFL.txt +94 -0
  45. package/fonts/intos/PROVENANCE.json +144 -0
  46. package/fonts/lora/Lora-Bold.ttf +0 -0
  47. package/fonts/lora/Lora-BoldItalic.ttf +0 -0
  48. package/fonts/lora/Lora-Italic.ttf +0 -0
  49. package/fonts/lora/Lora-Regular.ttf +0 -0
  50. package/fonts/lora/OFL.txt +93 -0
  51. package/fonts/lora/PROVENANCE.json +48 -0
  52. package/fonts/merriweather-sans/MerriweatherSans-Bold.ttf +0 -0
  53. package/fonts/merriweather-sans/MerriweatherSans-BoldItalic.ttf +0 -0
  54. package/fonts/merriweather-sans/MerriweatherSans-Italic.ttf +0 -0
  55. package/fonts/merriweather-sans/MerriweatherSans-Regular.ttf +0 -0
  56. package/fonts/merriweather-sans/OFL.txt +96 -0
  57. package/fonts/merriweather-sans/PROVENANCE.json +48 -0
  58. package/fonts/montserrat/Montserrat_400Regular.ttf +0 -0
  59. package/fonts/montserrat/Montserrat_400Regular_Italic.ttf +0 -0
  60. package/fonts/montserrat/Montserrat_700Bold.ttf +0 -0
  61. package/fonts/montserrat/Montserrat_700Bold_Italic.ttf +0 -0
  62. package/fonts/montserrat/Montserrat_900Black.ttf +0 -0
  63. package/fonts/montserrat/OFL.txt +93 -0
  64. package/fonts/montserrat/PROVENANCE.json +55 -0
  65. package/fonts/open-sans/OFL.txt +92 -0
  66. package/fonts/open-sans/OpenSans_400Regular.ttf +0 -0
  67. package/fonts/open-sans/OpenSans_400Regular_Italic.ttf +0 -0
  68. package/fonts/open-sans/OpenSans_700Bold.ttf +0 -0
  69. package/fonts/open-sans/OpenSans_700Bold_Italic.ttf +0 -0
  70. package/fonts/open-sans/PROVENANCE.json +47 -0
  71. package/fonts/poppins/OFL.txt +93 -0
  72. package/fonts/poppins/PROVENANCE.json +47 -0
  73. package/fonts/poppins/Poppins_400Regular.ttf +0 -0
  74. package/fonts/poppins/Poppins_400Regular_Italic.ttf +0 -0
  75. package/fonts/poppins/Poppins_700Bold.ttf +0 -0
  76. package/fonts/poppins/Poppins_700Bold_Italic.ttf +0 -0
  77. package/fonts/pt-serif/OFL.txt +93 -0
  78. package/fonts/pt-serif/PROVENANCE.json +50 -0
  79. package/fonts/pt-serif/PT_Serif-Web-Bold.ttf +0 -0
  80. package/fonts/pt-serif/PT_Serif-Web-BoldItalic.ttf +0 -0
  81. package/fonts/pt-serif/PT_Serif-Web-Italic.ttf +0 -0
  82. package/fonts/pt-serif/PT_Serif-Web-Regular.ttf +0 -0
  83. package/fonts/red-hat-display/OFL.txt +93 -0
  84. package/fonts/red-hat-display/PROVENANCE.json +38 -0
  85. package/fonts/red-hat-display/RedHatDisplay-Bold.ttf +0 -0
  86. package/fonts/red-hat-display/RedHatDisplay-Light.ttf +0 -0
  87. package/fonts/red-hat-display/RedHatDisplay-Regular.ttf +0 -0
  88. package/fonts/red-hat-text/OFL.txt +93 -0
  89. package/fonts/red-hat-text/PROVENANCE.json +30 -0
  90. package/fonts/red-hat-text/RedHatText-Bold.ttf +0 -0
  91. package/fonts/red-hat-text/RedHatText-Regular.ttf +0 -0
  92. package/fonts/source-sans-3/OFL.txt +93 -0
  93. package/fonts/source-sans-3/PROVENANCE.json +49 -0
  94. package/fonts/source-sans-3/SourceSans3-Bold.ttf +0 -0
  95. package/fonts/source-sans-3/SourceSans3-BoldIt.ttf +0 -0
  96. package/fonts/source-sans-3/SourceSans3-It.ttf +0 -0
  97. package/fonts/source-sans-3/SourceSans3-Regular.ttf +0 -0
  98. package/package.json +167 -6
package/dist/svg.js CHANGED
@@ -4,6 +4,11 @@ import {
4
4
  resolveColorRef as resolveCoreColorRef,
5
5
  validatePresentation
6
6
  } 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
+ import { createScriptFonts } from "./script-fonts.js";
11
+ import { disabledFeaturesStyle } from "./font-compatibility.js";
7
12
 
8
13
  export const packageName = "@openpresentation/opf-render";
9
14
 
@@ -51,6 +56,12 @@ export const engineDefaults = Object.freeze({
51
56
  ])
52
57
  });
53
58
 
59
+ // Last-resort font scheme shared by every engine (core pagination, opf-editor and
60
+ // opf-pptx): DEFAULT_FONT_SCHEME in @openpresentation/opf. It applies only when the
61
+ // slide, deck and resolved theme name no font scheme. fontScheme.google is kept for a
62
+ // future Google Slides target and is not used by this renderer.
63
+ const DEFAULT_FONT_SCHEME = engineDefaults.fontScheme.pptx.latin;
64
+
54
65
  export class OPFRenderError extends Error {
55
66
  constructor(code, message, details = {}) {
56
67
  super(message);
@@ -227,12 +238,47 @@ function sourceRecordsFor(kind, source, options) {
227
238
  }
228
239
 
229
240
  function engineDefaultId(kind) {
230
- if (kind === "fontSchemes") return engineDefaults.fontScheme.google.latin;
241
+ if (kind === "fontSchemes") return DEFAULT_FONT_SCHEME;
231
242
  if (kind === "chartTypes") return engineDefaults.chartTypes[0];
232
243
  const key = kind.endsWith("s") ? kind.slice(0, -1) : kind;
233
244
  return engineDefaults[key];
234
245
  }
235
246
 
247
+ function findCatalogRecord(kind, id, context) {
248
+ if (!id) return null;
249
+ const documentCatalog = context.presentation.catalogs?.[kind];
250
+ return findById(normalizeSourceRecords(documentCatalog), id) ??
251
+ findById(sourceRecordsFor(kind, documentCatalog?.source, context.options), id) ??
252
+ findById(normalizeSourceRecords(context.options.catalogs?.[kind]), id) ??
253
+ findById(sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options), id) ??
254
+ findById(defaultCatalogFor(kind), id);
255
+ }
256
+
257
+ // Font schemes follow the shared core rule (resolveFontSchemeReference in
258
+ // @openpresentation/opf): an id that matches no record reports one
259
+ // `unresolved-font-scheme` diagnostic and uses the DEFAULT_FONT_SCHEME record as the
260
+ // base, with sibling overrides on top, so preview and PPTX export use the same fonts.
261
+ function resolveFontSchemeRecord(reference, context, path) {
262
+ const id = referenceId(reference);
263
+ const found = findCatalogRecord("fontSchemes", id, context);
264
+ const base = found ?? findCatalogRecord("fontSchemes", DEFAULT_FONT_SCHEME, context) ?? {};
265
+ const scheme = cloneWithSortedKeys(isPlainObject(reference) ? { ...base, ...reference } : base);
266
+ if (!id || found) return { scheme };
267
+ 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}'.` } };
268
+ }
269
+
270
+ // Generated socials furniture formats handles in core. Core applies inline
271
+ // document records first; the host supplies the rest in resolution order.
272
+ function socialPlatformRecords(context) {
273
+ const kind = "socialPlatforms";
274
+ return [
275
+ ...sourceRecordsFor(kind, context.presentation.catalogs?.[kind]?.source, context.options),
276
+ ...normalizeSourceRecords(context.options.catalogs?.[kind]),
277
+ ...sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options),
278
+ ...defaultCatalogFor(kind)
279
+ ];
280
+ }
281
+
236
282
  function resolveCatalogRecord(kind, reference, context, path, fallbackId = engineDefaultId(kind)) {
237
283
  const id = referenceId(reference) ?? fallbackId;
238
284
  const documentCatalog = context.presentation.catalogs?.[kind];
@@ -338,7 +384,7 @@ function colorLuminance(color) {
338
384
  return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
339
385
  }
340
386
 
341
- function resolveDesign(presentation, slide, context) {
387
+ function resolveDesign(presentation, slide, context, index) {
342
388
  const deckDesign = presentation.design ?? {};
343
389
  const slideDesign = slide.design ?? {};
344
390
 
@@ -354,11 +400,13 @@ function resolveDesign(presentation, slide, context) {
354
400
  context,
355
401
  "design.colorScheme"
356
402
  );
357
- const fontScheme = resolveCatalogRecord(
358
- "fontSchemes",
359
- slideDesign.fontScheme ?? deckDesign.fontScheme ?? theme.fontScheme ?? engineDefaults.fontScheme.google.latin,
403
+ const fontPath = slideDesign.fontScheme !== undefined ? `slides.${index}.design.fontScheme`
404
+ : deckDesign.fontScheme !== undefined ? "design.fontScheme"
405
+ : slideDesign.theme !== undefined ? `slides.${index}.design.theme` : "design.theme";
406
+ const { scheme: fontScheme, diagnostic: fontSchemeDiagnostic } = resolveFontSchemeRecord(
407
+ slideDesign.fontScheme ?? deckDesign.fontScheme ?? theme.fontScheme ?? DEFAULT_FONT_SCHEME,
360
408
  context,
361
- "design.fontScheme"
409
+ fontPath
362
410
  );
363
411
  const dimensions = resolveDimensions(slideDesign.dimensions ?? deckDesign.dimensions ?? theme.dimensions);
364
412
  const backgroundDefinition = slideDesign.background ?? deckDesign.background ?? theme.background;
@@ -386,10 +434,80 @@ function resolveDesign(presentation, slide, context) {
386
434
  accent: normalizeColor(colorScheme.accent, null) ?? colorFromScheme(colorScheme, "accent3", "#F59E0B"),
387
435
  border: colorFromScheme(colorScheme, "accent5", "#CBD5E1")
388
436
  },
389
- fonts: resolveFontFamilies(fontScheme)
437
+ fonts: resolveFontFamilies(fontScheme),
438
+ diagnostics: fontSchemeDiagnostic ? [fontSchemeDiagnostic] : []
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Script font slots, language tags and direction for one slide from core
444
+ * `resolveScriptFonts` (FF-18). The latin slot is always the renderer's design
445
+ * font; a script slot that core fills from the latin family follows it too.
446
+ * Without core support every slot repeats the latin family.
447
+ */
448
+ function scriptProfile(presentation, index, design, context) {
449
+ let resolved;
450
+ if (typeof opfCore.resolveScriptFonts === "function") {
451
+ try { resolved = opfCore.resolveScriptFonts(presentation, { slideIndex: index }); }
452
+ catch (error) {
453
+ reportLanguageDiagnostic(context, { code: "language-preview-unresolved", path: "language",
454
+ 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." });
455
+ }
456
+ } else if (presentation.language !== undefined) {
457
+ reportLanguageDiagnostic(context, { code: "language-preview-unavailable", path: "language",
458
+ 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." });
459
+ }
460
+ if (resolved?.rtl === true && typeof opfCore.paragraphDirection !== "function") {
461
+ reportLanguageDiagnostic(context, { code: "paragraph-direction-unavailable", path: "language",
462
+ 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." });
463
+ }
464
+ const slots = role => {
465
+ const latin = design.fonts[role];
466
+ const slot = key => !resolved || resolved.sources?.[key] === "latin" || resolved.sources?.[key] === "schemeFamily" ? latin : resolved[role]?.[key] ?? latin;
467
+ return { latin, eastAsian: slot("eastAsian"), complexScript: slot("complexScript") };
468
+ };
469
+ return {
470
+ heading: slots("heading"),
471
+ body: slots("body"),
472
+ ...(resolved?.supplement ? { supplement: resolved.supplement } : {}),
473
+ script: resolved?.script ?? "Zzzz",
474
+ ...(resolved?.scriptRole ? { scriptRole: resolved.scriptRole } : {}),
475
+ ...(resolved ? { bcp47: resolved.bcp47, lang: resolved.lang, languageSource: resolved.languageSource, direction: resolved.direction } : {}),
476
+ rtl: resolved?.rtl === true,
477
+ serif: design.fontScheme?.type === "serif"
390
478
  };
391
479
  }
392
480
 
481
+ /** Report a language diagnostic once per resolvePresentation call. */
482
+ function reportLanguageDiagnostic(context, diagnostic) {
483
+ context.languageDiagnostics ??= new Set();
484
+ if (context.languageDiagnostics.has(diagnostic.code)) return;
485
+ context.languageDiagnostics.add(diagnostic.code);
486
+ context.options.onDiagnostic?.(diagnostic);
487
+ }
488
+
489
+ /**
490
+ * Report a glyph fallback once per resolvePresentation call, family pair and path (FF-19).
491
+ * It is a note, not an error: the face the font scheme resolved to lacks glyphs for the text,
492
+ * so a bundled face that has them draws those characters. The PPTX still names the chosen font.
493
+ */
494
+ function reportGlyphFallback(context, note) {
495
+ context.glyphFallbackDiagnostics ??= new Set();
496
+ const key = `${note.fontFamily}\u0000${note.fallbackFamily}\u0000${note.path ?? ""}`;
497
+ if (context.glyphFallbackDiagnostics.has(key)) return;
498
+ context.glyphFallbackDiagnostics.add(key);
499
+ context.options.onDiagnostic?.({
500
+ code: "font-glyph-fallback",
501
+ ...(note.path ? { path: note.path } : {}),
502
+ // The note is reported once per family pair and path: it names characters the face lacks, such as these, not every one.
503
+ 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.`,
504
+ fontFamily: note.fontFamily,
505
+ fallbackFamily: note.fallbackFamily,
506
+ scripts: [...note.scripts],
507
+ characters: [...note.characters]
508
+ });
509
+ }
510
+
393
511
  function inferLayoutId(slide) {
394
512
  if (slide.layout) return slide.layout;
395
513
  if (slide.blocks?.length) return "blank";
@@ -511,10 +629,19 @@ function bindSlide(presentation, slide, layout, index, context) {
511
629
  ];
512
630
  const blocks = blockContent(slide, slidePath);
513
631
 
514
- const design = resolveDesign(presentation, slide, context);
515
- for (const role of ["heading","body","code"]) design.fonts[role] = resolveTextStyle({fontFamily:design.fonts[role],fontWeight:role === "heading" ? 700 : 400},context.options.textMeasurement).fontFamily;
516
- 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: context.options.textMeasurement });
632
+ const design = resolveDesign(presentation, slide, context, index);
633
+ // Script fonts (FF-19): each text run is measured and drawn with its script
634
+ // slot's face; the latin slot stays the design font scheme's family.
635
+ const scriptFonts = createScriptFonts(scriptProfile(presentation, index, design, context), context.options.textMeasurement, {
636
+ glyphFallback: context.options.glyphFallback,
637
+ onFallback: note => reportGlyphFallback(context, note)
638
+ });
639
+ const textMeasurement = scriptFonts.textMeasurement ?? context.options.textMeasurement;
640
+ for (const role of ["heading","body","code"]) design.fonts[role] = resolveTextStyle({fontFamily:design.fonts[role],fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
641
+ 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) });
517
642
  return {
643
+ scriptFonts,
644
+ textMeasurement,
518
645
  geometry,
519
646
  assets: presentation.assets ?? {},
520
647
  index,
@@ -565,18 +692,23 @@ export function renderSvgDeck(input, options = {}) {
565
692
  }
566
693
 
567
694
  function renderResolvedSlide(resolved, slideIndex, options) {
568
- options = { ...options, _diagnosticPaths: new Set() };
569
695
  const bound = resolved.slides[slideIndex];
696
+ // Paint with the same script-aware measurement composition used.
697
+ options = { ...options, ...(bound.textMeasurement ? { textMeasurement: bound.textMeasurement } : {}), _diagnosticPaths: new Set() };
698
+ const script = bound.scriptFonts?.profile;
699
+ // Only a language the document names sets lang; the renderer default does not.
700
+ const lang = script?.languageSource === "document" || script?.languageSource === "option" ? script.bcp47 : undefined;
570
701
  const { width, height } = bound.design.dimensions;
571
702
  const title = bound.slide.title ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
572
- const children = [
573
- renderEmbeddedFonts(options.embeddedFonts),
703
+ const content = [
574
704
  renderBackground(bound, width, height, options),
705
+ renderSlideImage(bound, options),
575
706
  renderBranding(bound, resolved.presentation, width, height, options),
576
707
  ...renderSlideContent(bound, width, height, options),
577
708
  renderFurniture(bound, resolved.presentation, width, height, options, "header"),
578
709
  renderFurniture(bound, resolved.presentation, width, height, options, "footer")
579
710
  ].filter(Boolean);
711
+ const children = [renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
580
712
 
581
713
  return tag(
582
714
  "svg",
@@ -587,6 +719,8 @@ function renderResolvedSlide(resolved, slideIndex, options) {
587
719
  viewBox: `0 0 ${width} ${height}`,
588
720
  width,
589
721
  height,
722
+ lang,
723
+ "xml:lang": lang,
590
724
  ...traceAttrs(options, bound.path)
591
725
  },
592
726
  `\n${children.join("\n")}\n`
@@ -634,7 +768,10 @@ function renderBackground(bound, width, height, options) {
634
768
  }
635
769
  if(isPlainObject(background) && background.type==='pattern'){
636
770
  const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=normalizeColor(pattern.foregroundColor,bound.design.colors.text),preset=pattern.preset;
637
- const mark=preset==='ltHorz'?tag('path',{d:'M0 4H8',stroke:color,'stroke-width':1}):preset==='diagStripe'?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}):'';
771
+ // 8px cells approximating DrawingML presets. diagStripe is the engine id that
772
+ // PPTX export writes as wdUpDiag, so both draw the same rising stripe.
773
+ const stroke=(d,width=1)=>tag('path',{d,fill:'none',stroke:color,'stroke-width':width});
774
+ 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'):'';
638
775
  if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
639
776
  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})`}));
640
777
  }
@@ -649,11 +786,68 @@ function renderBackground(bound, width, height, options) {
649
786
  });
650
787
  }
651
788
 
789
+ // design.slideImage: the shared composition frame, beneath branding and content.
790
+ // crop covers the frame and fit centers the whole image, matching native a:srcRect.
791
+ // Treatments mirror the native picture: the preset mask (core outline), recolor
792
+ // and alphaModFix on the pixels only, the centered line, then the overlay shape.
793
+ function renderSlideImage(bound, options) {
794
+ const image = bound.geometry.slideImage;
795
+ if (!image) return '';
796
+ const trace = options.trace ? { 'data-opf-slide-image': image.path, 'data-opf-slide-image-position': image.position } : {};
797
+ const value = image.alt === undefined ? image.value : { ...normalizeAsset(image.value), alt: image.alt };
798
+ const picture = renderImage({ value, path: image.sourcePath }, image.box, bound, { ...options, imageFit: image.fill === 'crop' ? 'cover' : 'contain' });
799
+ // Unresolved sources keep the ordinary placeholder without treatments, like the export.
800
+ if (!picture.startsWith('<image')) return tag('g', trace, picture);
801
+ const id = `opf-s${bound.index + 1}-slide-image`, shape = image.shape, defs = [];
802
+ const masked = shape && shape.preset !== 'rect';
803
+ if (masked) defs.push(tag('clipPath', { id: `${id}-clip` }, tag('path', { d: shape.path })));
804
+ const matrix = slideImageRecolorMatrix(image.recolor, bound);
805
+ if (matrix) defs.push(tag('filter', { id: `${id}-recolor`, 'color-interpolation-filters': 'sRGB' }, tag('feColorMatrix', { type: 'matrix', values: matrix })));
806
+ let pixels = matrix || image.opacity !== undefined ? tag('g', { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: image.opacity === undefined ? undefined : preciseNumber(image.opacity) }, picture) : picture;
807
+ if (masked) pixels = tag('g', { 'clip-path': `url(#${id}-clip)` }, pixels);
808
+ const children = [defs.length ? tag('defs', {}, defs.join('')) : '', pixels];
809
+ if (image.border) {
810
+ const paint = slideImagePaint(image.border.color, bound, bound.design.colors.border);
811
+ children.push(tag('path', { d: shape?.path ?? rectanglePath(image.box), fill: 'none', stroke: paint.color, 'stroke-opacity': paint.alpha < 1 ? preciseNumber(paint.alpha) : undefined, 'stroke-width': stableNumber(image.border.width), 'stroke-linejoin': 'miter', 'stroke-miterlimit': 8 }));
812
+ }
813
+ if (image.overlay) {
814
+ const paint = slideImagePaint(image.overlay.color, bound, bound.design.colors.text);
815
+ children.push(tag('path', { d: image.overlay.shape?.path ?? rectanglePath(image.overlay.box), fill: paint.color, 'fill-opacity': preciseNumber(paint.alpha * image.overlay.opacity), ...(options.trace ? { 'data-opf-slide-image-overlay': `${image.path}.overlay` } : {}) }));
816
+ }
817
+ return tag('g', trace, children.filter(Boolean).join(''));
818
+ }
819
+
820
+ // Native alpha and color-matrix values keep 1/100000 precision; three decimals would drift.
821
+ const preciseNumber = value => String(Math.round(value * 1e6) / 1e6);
822
+
823
+ function rectanglePath(box) {
824
+ return `M${stableNumber(box.x)} ${stableNumber(box.y)}H${stableNumber(box.x + box.width)}V${stableNumber(box.y + box.height)}H${stableNumber(box.x)}Z`;
825
+ }
826
+
827
+ // ColorRef -> opaque #RRGGBB plus the AA byte as alpha, as the native srgbClr + alpha.
828
+ function slideImagePaint(value, bound, fallback) {
829
+ const hex = resolveColorRef(value, bound, fallback) ?? fallback;
830
+ const raw = normalizeColor(hex, fallback).slice(1);
831
+ return { color: `#${raw.slice(0, 6).toUpperCase()}`, alpha: raw.length === 8 ? parseInt(raw.slice(6), 16) / 255 : 1 };
832
+ }
833
+
834
+ // Rec. 601 luminance on sRGB values; duotone maps it linearly from dark to light.
835
+ function slideImageRecolorMatrix(recolor, bound) {
836
+ if (!recolor) return undefined;
837
+ const weights = [0.299, 0.587, 0.114];
838
+ const channels = recolor.type === 'duotone'
839
+ ? [slideImagePaint(recolor.dark, bound, '#000000'), slideImagePaint(recolor.light, bound, '#FFFFFF')].map(paint => [1, 3, 5].map(at => parseInt(paint.color.slice(at, at + 2), 16) / 255))
840
+ : [[0, 0, 0], [1, 1, 1]];
841
+ const [dark, light] = channels;
842
+ const rows = [0, 1, 2].map(channel => [...weights.map(weight => preciseNumber((light[channel] - dark[channel]) * weight)), 0, preciseNumber(dark[channel])].join(' '));
843
+ return [...rows, '0 0 0 1 0'].join(' ');
844
+ }
845
+
652
846
  function renderSlideContent(bound, width, height, options) {
653
- for (const diagnostic of bound.geometry.diagnostics) reportDiagnostic(diagnostic, options);
847
+ for (const diagnostic of [...bound.design.diagnostics, ...bound.geometry.diagnostics]) reportDiagnostic(diagnostic, options);
654
848
  return bound.geometry.items.map(item => {
655
849
  const frame=item.frameBox;
656
- 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}) : '';
850
+ 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)}) : '';
657
851
  return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options);
658
852
  });
659
853
  }
@@ -695,7 +889,10 @@ function renderPayload(item, box, bound, options) {
695
889
  function renderTextPayload(item, box, bound, options) {
696
890
  return (Array.isArray(item.value) ? renderRichTextBox : renderTextBox)(Array.isArray(item.value) ? item.value : flattenText(item.value), box, bound, {
697
891
  path: item.path,
698
- align: item.field === "title" ? bound.design.titleAlignment : bound.design.contentAlignment,
892
+ // Core resolves one alignment per composed item for every engine. The
893
+ // fallback keeps cores published before item.alignment working: titles
894
+ // follow design.titleAlignment only (unset is left, as in core composition).
895
+ align: item.alignment ?? (item.field === "title" ? bound.design.titleAlignment ?? "left" : bound.design.contentAlignment),
699
896
  fontSize: item.field === "title" ? 54 : item.field === "tag" ? 16 : 25,
700
897
  fontFamily: item.field === "title" ? bound.design.fonts.heading : bound.design.fonts.body,
701
898
  fontWeight: item.field === "title" ? 700 : 400,
@@ -853,12 +1050,13 @@ function renderCode(item, box, bound, options) {
853
1050
  'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='body'?'#E5E7EB':'#93C5FD',
854
1051
  ...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
855
1052
  '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}:{}),
856
- },line.segments.map(segment=>tag('tspan',{
1053
+ },line.segments.map(segment=>segmentSpan({
857
1054
  x:stableNumber(part.box.x+segment.x),
858
1055
  // SVG's CSS tab-size does not place literal tabs at their measured stops.
859
1056
  ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
860
1057
  ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
861
- },escapeText(part.text.slice(segment.start,segment.end)))).join('')));
1058
+ // Code stays left to right; script runs still take their slot fonts.
1059
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false})).join('')));
862
1060
  children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
863
1061
  '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}:{}),
864
1062
  ...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
@@ -875,18 +1073,32 @@ function renderMetric(item, box, bound, options) {
875
1073
  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});
876
1074
  if (!part.visible) continue;
877
1075
  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});
1076
+ const partRtl=paragraphRtl(bound,part.text);
1077
+ // Anchor untabbed lines at the accepted alignment edge, as PPTX export does,
1078
+ // so a shaper whose advance differs from the accepted width keeps the edge.
1079
+ // Tabbed lines keep their accepted segment origins.
1080
+ const factor=layout.alignment==='right'?1:layout.alignment==='center'?.5:0;
878
1081
  const lines=part.fit.sourceLines.map((line,index)=>{
879
1082
  const origin=part.linePositions[index];
1083
+ 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',
1084
+ 'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
1085
+ 'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
1086
+ 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
1087
+ ...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
1088
+ 'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
1089
+ },line.segments.map(segment=>segmentSpan({
1090
+ ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
1091
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
880
1092
  return tag('text',{x:stableNumber(origin.x),y:stableNumber(origin.baseline),'text-anchor':'start',
881
1093
  'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
882
1094
  'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
883
1095
  'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
884
1096
  ...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
885
1097
  'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
886
- },line.segments.map(segment=>tag('tspan',{x:stableNumber(origin.x+segment.x),
1098
+ },line.segments.map(segment=>segmentSpan({x:stableNumber(origin.x+segment.x),
887
1099
  ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
888
1100
  ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
889
- },escapeText(part.text.slice(segment.start,segment.end)))).join(''));
1101
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
890
1102
  });
891
1103
  children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,
892
1104
  '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}:{}),
@@ -1037,10 +1249,23 @@ function renderImportedChart(item, box, bound, options) {
1037
1249
  const horizontal=kind==='bar',legendHeight=series.length>1?34:8;
1038
1250
  const plot={x:box.x+(horizontal?100:64),y:box.y+legendHeight+14,width:Math.max(1,box.width-(horizontal?124:84)),height:Math.max(1,box.height-legendHeight-62)};
1039
1251
  const values=series.flatMap(s=>s.values).filter(v=>v!==null),min=Math.min(0,...values),rawMax=Math.max(0,...values),max=rawMax===min?min+1:rawMax,range=max-min;
1040
- const xValue=v=>plot.x+(v-min)/range*plot.width,yValue=v=>plot.y+(max-v)/range*plot.height;
1252
+ const fraction=(v,reverse=false)=>{
1253
+ const distance=reverse?max-v:v-min;
1254
+ if(Number.isFinite(range)&&Number.isFinite(distance))return distance/range;
1255
+ // Finite endpoints can overflow during subtraction. Keep ordinary
1256
+ // arithmetic unchanged and normalize only that exceptional range.
1257
+ const unit=Math.max(Math.abs(min),Math.abs(max)),low=min/unit,high=max/unit,position=v/unit;
1258
+ return (reverse?high-position:position-low)/(high-low);
1259
+ };
1260
+ const xValue=v=>plot.x+fraction(v)*plot.width,yValue=v=>plot.y+fraction(v,true)*plot.height;
1041
1261
  const zero=horizontal?xValue(0):yValue(0);
1042
1262
  for(let tick=0;tick<=4;tick++){
1043
- const value=min+range*tick/4,at=horizontal?xValue(value):yValue(value),label=Number(value.toPrecision(4));
1263
+ const direct=min+range*tick/4;
1264
+ const value=Number.isFinite(direct)?direct:min*(1-tick/4)+max*(tick/4);
1265
+ const at=horizontal?xValue(value):yValue(value),rounded=Number(value.toPrecision(4));
1266
+ // Rounding MAX_VALUE to four digits can overflow although the tick is
1267
+ // finite. Its exact shortest decimal remains truthful and parseable.
1268
+ const label=Number.isFinite(rounded)?rounded:String(value);
1044
1269
  children.push(tag('line',{x1:horizontal?at:plot.x,x2:horizontal?at:plot.x+plot.width,y1:horizontal?plot.y:at,y2:horizontal?plot.y+plot.height:at,stroke:bound.design.colors.border,'stroke-width':1}));
1045
1270
  children.push(text(label,horizontal?{x:at-32,y:plot.y+plot.height+6,width:64,height:26}:{x:box.x+4,y:at-12,width:52,height:24},item.path,13,horizontal?'center':'right'));
1046
1271
  }
@@ -1202,7 +1427,126 @@ function renderBranding(bound,presentation,width,height,options) {
1202
1427
 
1203
1428
  function fontStack(family, type) {
1204
1429
  const fallback = type === "serif" ? "serif" : type === "monospace" ? "monospace" : "sans-serif";
1205
- return `${family}, ${fallback}`;
1430
+ return `${[family].flat().join(", ")}, ${fallback}`;
1431
+ }
1432
+
1433
+ // Unicode directional isolates (FF-19): each paragraph (text between hard line
1434
+ // breaks) of a right-to-left-language deck takes one base direction from
1435
+ // paragraphDirection(text, deckDirection), the rule the PPTX export uses for
1436
+ // a:pPr rtl. Every wrapped line of a right-to-left paragraph is laid out as a
1437
+ // right-to-left isolate, so its lines never differ in direction. Absolute
1438
+ // alignment and measured advances are unchanged.
1439
+ const RIGHT_TO_LEFT_ISOLATE = "\u2067", POP_DIRECTIONAL_ISOLATE = "\u2069";
1440
+ // Core owns the rule (paragraphDirection, core #134), so preview and export agree.
1441
+ // Without it (published core 0.11.0) every paragraph is left to right, as in export.
1442
+ const coreParagraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
1443
+ /** Maps a source offset of the text to whether its paragraph is right to left. */
1444
+ function paragraphRtl(bound, text) {
1445
+ if (bound.scriptFonts?.rtl !== true || !coreParagraphDirection) return () => false;
1446
+ const source = String(text ?? ""), spans = [];
1447
+ let start = 0;
1448
+ for (const match of source.matchAll(/\r\n|\r|\n/g)) { spans.push([start, match.index]); start = match.index + match[0].length; }
1449
+ spans.push([start, source.length]);
1450
+ const rtl = spans.map(([from, to]) => coreParagraphDirection(source.slice(from, to), "rtl") === "rtl");
1451
+ return offset => { for (let index = spans.length - 1; index >= 0; index--) if (offset >= spans[index][0]) return rtl[index]; return rtl[0]; };
1452
+ }
1453
+
1454
+ /**
1455
+ * SVG content for `text` drawn in `style` (FF-19). Text in the style's own
1456
+ * family is emitted unchanged. A single run in another family returns that
1457
+ * family for the enclosing element. Several runs become tspans naming their
1458
+ * script slot's family. With `placement` ({x, width, fontSize}) the runs are
1459
+ * positioned at their measured advances, each with its own textLength, because
1460
+ * a textLength spanning differently fonted tspans does not rasterize reliably;
1461
+ * the caller then drops its own textLength and anchors at the start.
1462
+ */
1463
+ // Nested script runs of a line drawn with a flagged face (Gelasio) opt back into default shaping,
1464
+ // unless the run itself uses a flagged family (tag() then adds that family's own style).
1465
+ function nestedReset(style, run) {
1466
+ const parent = disabledFeaturesStyle(style?.fontFamily);
1467
+ return parent && !disabledFeaturesStyle([run.stack ?? run.family].flat()[0]) ? RESET_POLICY_FEATURES : undefined;
1468
+ }
1469
+
1470
+ function scriptLine(text, style, bound, type, { rtl = false, placement, trace } = {}) {
1471
+ const value = String(text ?? "");
1472
+ const scripts = bound.scriptFonts;
1473
+ rtl = rtl && value !== "";
1474
+ const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
1475
+ const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
1476
+ if (runs.length === 1) {
1477
+ const [run] = runs;
1478
+ return { content: isolate(escapeText(value)), family: run.own ? undefined : fontStack(run.stack ?? run.family, type) };
1479
+ }
1480
+ const widths = placement && placement.width > 0 ? scripts.runWidths(runs, placement.fontSize, style) : undefined;
1481
+ if (!widths) {
1482
+ return { content: isolate(runs.map(run => run.own ? escapeText(run.text)
1483
+ : tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type), style: nestedReset(style, run) }, escapeText(run.text))).join("")) };
1484
+ }
1485
+ const total = widths.reduce((sum, width) => sum + width, 0), factor = total > 0 ? placement.width / total : 1;
1486
+ let advance = 0, offset = 0;
1487
+ const content = runs.map((run, index) => {
1488
+ const width = widths[index] * factor, left = rtl ? placement.width - advance - width : advance;
1489
+ advance += width;
1490
+ const start = offset;
1491
+ offset += run.text.length;
1492
+ return tag("tspan", {
1493
+ x: stableNumber(placement.x + left),
1494
+ textLength: width > 0 ? stableNumber(width) : undefined, lengthAdjust: width > 0 ? "spacingAndGlyphs" : undefined,
1495
+ "font-family": run.own ? undefined : fontStack(run.family, type),
1496
+ style: run.own ? undefined : nestedReset(style, run),
1497
+ ...(trace ? trace(start, offset) : {})
1498
+ }, isolate(escapeText(run.text)));
1499
+ }).join("");
1500
+ return { content, positioned: true };
1501
+ }
1502
+
1503
+ /** One positioned code/metric segment tspan; its script runs flow inside it (no textLength). */
1504
+ function segmentSpan(attrs, segment, text, style, bound, type, options) {
1505
+ const scripted = segment.kind === "tab" ? { content: escapeText(text) } : scriptLine(text, style, bound, type, options);
1506
+ return tag("tspan", { ...attrs, "font-family": scripted.family }, scripted.content);
1507
+ }
1508
+
1509
+ // Faces flagged embed:"used" (the vendored open, Intos and script-pack faces) are embedded only when the slide's own markup draws
1510
+ // them: the family in a font-family list, at a font-weight and font-style some text of the slide takes (attributes are
1511
+ // inherited down the element tree, as in SVG). A used family none of whose faces matches a drawn weight and style keeps all
1512
+ // its faces, so the browser can always choose. Every other face (the npm packs) is embedded as before.
1513
+ function embeddedFontsFor(fonts = [], content) {
1514
+ if (!fonts.some(font => font?.embed === "used")) return fonts;
1515
+ const drawn = drawnFaces(content.join("\n"));
1516
+ const wanted = font => {
1517
+ const triples = drawn.get(String(font.family).toLowerCase());
1518
+ if (!triples) return false;
1519
+ const family = fonts.filter(other => other?.embed === "used" && String(other.family).toLowerCase() === String(font.family).toLowerCase());
1520
+ const matching = family.filter(other => triples.has(`${other.weight}|${other.italic ? "italic" : "normal"}`));
1521
+ return matching.length ? matching.includes(font) : true;
1522
+ };
1523
+ return fonts.filter(font => font?.embed !== "used" || wanted(font));
1524
+ }
1525
+
1526
+ const FONT_WEIGHT_KEYWORDS = { normal: "400", bold: "700" };
1527
+ /** family (lowercase) to the set of "weight|style" pairs the markup draws text in. */
1528
+ function drawnFaces(markup) {
1529
+ const drawn = new Map();
1530
+ const tokens = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|[^<]+/g;
1531
+ const stack = [{ families: [], weight: "400", style: "normal", text: false }];
1532
+ const attribute = (attributes, name) => { const found = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(attributes); return found ? found[1] ?? found[2] : undefined; };
1533
+ for (const [token, close, name, attributes, selfClose] of markup.matchAll(tokens)) {
1534
+ const top = stack.at(-1);
1535
+ if (name === undefined) {
1536
+ if (top.text && /\S/.test(token)) for (const family of top.families) { const set = drawn.get(family) ?? new Set(); set.add(`${top.weight}|${top.style}`); drawn.set(family, set); }
1537
+ continue;
1538
+ }
1539
+ if (close) { if (stack.length > 1) stack.pop(); continue; }
1540
+ const family = attribute(attributes, "font-family"), weight = attribute(attributes, "font-weight"), style = attribute(attributes, "font-style");
1541
+ const next = {
1542
+ families: family === undefined ? top.families : family.split(",").map(item => item.trim().replace(/^&quot;|&quot;$|^["']|["']$/g, "").toLowerCase()).filter(Boolean),
1543
+ weight: weight === undefined ? top.weight : FONT_WEIGHT_KEYWORDS[weight.trim()] ?? weight.trim(),
1544
+ style: style === undefined ? top.style : /italic|oblique/.test(style) ? "italic" : "normal",
1545
+ text: top.text || name === "text",
1546
+ };
1547
+ if (!selfClose) stack.push(next);
1548
+ }
1549
+ return drawn;
1206
1550
  }
1207
1551
 
1208
1552
  function renderEmbeddedFonts(fonts = []) {
@@ -1226,29 +1570,55 @@ function renderRichLines(value,fit,box,bound,config) {
1226
1570
  const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1227
1571
  let textOffset=0;
1228
1572
  const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
1573
+ const richRtl=paragraphRtl(bound,value.map(run=>typeof run==='string'?run:run.text).join(''));
1229
1574
  // With no measurement provider, fragment advances are estimates. Let SVG
1230
1575
  // shape adjacent runs naturally inside each estimated line instead of turning
1231
1576
  // those estimates into visible gaps. Supplied measurements keep exact origins.
1232
- const flow=!config.options.textMeasurement?.measure&&!fit.placement;
1577
+ const naturalFlow=!config.options.textMeasurement?.measure&&!fit.placement;
1578
+ const hasTabs=fit.richLines.some(line=>line.fragments.some(fragment=>fragment.kind==='tab'));
1233
1579
  const content=fit.richLines.map((line,lineIndex)=>{
1234
- const fragments=line.fragments.map(fragment=>{
1235
- const run=fragment.run,offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1580
+ const lineHasTab=line.fragments.some(fragment=>fragment.kind==='tab');
1581
+ const flow=naturalFlow&&!lineHasTab;
1582
+ const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1236
1583
  const placed=fit.placement?.lines[lineIndex];
1584
+ const originX=placed?.x??box.x+offset,baseline=placed?.baseline??box.y+line.baseline;
1585
+ const firstFragment=line.fragments[0],rtl=richRtl(firstFragment?runOffsets[firstFragment.runIndex]+firstFragment.start:0);
1586
+ const renderFragment=(fragment,asFlow,edges={})=>{
1587
+ const run=fragment.run;
1237
1588
  // Accepted outline placement owns the horizontal advance. Geometric precision
1238
1589
  // avoids hinted browser advances; textLength also removes fractional-size
1239
1590
  // quantization drift. Height and baseline retain the selected font size.
1240
- const position=flow?{'baseline-shift':fragment.baselineShift?stableNumber(-fragment.baselineShift):undefined}:{x:stableNumber((placed?.x??box.x+offset)+fragment.x),y:stableNumber((placed?.baseline??box.y+line.baseline)+fragment.baselineShift)};
1591
+ // A right-to-left line places its fragments from the right edge (FF-19).
1592
+ // Estimated (natural-flow) lines keep logical order; the browser reorders them.
1593
+ const fragmentX=rtl&&!naturalFlow?line.width-fragment.x-fragment.width:fragment.x;
1594
+ const position=asFlow?{'baseline-shift':fragment.baselineShift?stableNumber(-fragment.baselineShift):undefined}:{x:stableNumber(originX+fragmentX),y:stableNumber(baseline+fragment.baselineShift)};
1241
1595
  const runFill = run.color == null
1242
1596
  ? config.fill
1243
1597
  : resolveColorRef(run.color, bound, config.fill);
1244
- const rendered=tag(flow?'tspan':'text',{...(config.options.trace?{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end}:{}),...position,'xml:space':'preserve','text-rendering':flow?undefined:'geometricPrecision',textLength:placed&&fragment.width>0?stableNumber(fragment.width):undefined,lengthAdjust:placed&&fragment.width>0?'spacingAndGlyphs':undefined,'font-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':flow?'normal':undefined,'text-decoration':[run.underline?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},escapeText(fragment.text));
1598
+ let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
1599
+ const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(fragment.text,fragment.style,bound,bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
1600
+ placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined});
1601
+ if(scripted.positioned)fixedAdvance=false;
1602
+ const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
1603
+ 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);
1245
1604
  if(run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
1246
1605
  return rendered;
1247
- });
1606
+ };
1607
+ if(naturalFlow&&lineHasTab) {
1608
+ const chunks=[];let textChunk=[];
1609
+ const flush=()=>{if(!textChunk.length)return;const first=textChunk[0];chunks.push(tag('text',{x:stableNumber(originX+first.x),y:stableNumber(baseline),'xml:space':'preserve'},textChunk.map((fragment,index)=>renderFragment(fragment,true,{first:rtl&&!index,last:rtl&&index===textChunk.length-1})).join('')));textChunk=[];};
1610
+ for(const fragment of line.fragments) {
1611
+ if(fragment.kind==='tab'){flush();chunks.push(renderFragment(fragment,false));}
1612
+ else textChunk.push(fragment);
1613
+ }
1614
+ flush();return chunks.join('\n');
1615
+ }
1616
+ // A flowing right-to-left line is one isolate across its fragments.
1617
+ const fragments=line.fragments.map((fragment,index)=>renderFragment(fragment,flow,flow?{first:rtl&&!index,last:rtl&&index===line.fragments.length-1}:{}));
1248
1618
  if(!flow)return fragments.join('\n');
1249
1619
  const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
1250
1620
  const first=line.fragments[0];
1251
- 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},fragments.join(''));
1621
+ 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(''));
1252
1622
  });
1253
1623
  let cursor=0;
1254
1624
  const whole=value.map(run=>typeof run==='string'?run:run.text).join('');
@@ -1257,9 +1627,11 @@ function renderRichLines(value,fit,box,bound,config) {
1257
1627
  const start=cursor;cursor+=(fit.lines[index]??'').length;
1258
1628
  const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1259
1629
  const placed=fit.placement?.lines[index];
1260
- return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height};
1630
+ return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height,
1631
+ ...(naturalFlow&&line.fragments.some(fragment=>fragment.kind==='tab')?{spacing:'natural-chunks-estimated-tabs'}:{})};
1261
1632
  }):undefined;
1262
- return tag('g',{...traceAttrs(config.options,config.path),...(config.options.trace?{'data-opf-box-width':box.width,'data-opf-rich-text':config.rich===false?undefined:'true','data-opf-rich-lines':JSON.stringify(lineTrace),'data-opf-rich-spacing':flow?'natural':'measured'}:{}),...(fit.overflow?{'data-opf-overflow':'true'}:{})},content.join('\n'));
1633
+ const spacing=naturalFlow?(hasTabs?'mixed-estimated-tabs':'natural'):'measured';
1634
+ return tag('g',{...traceAttrs(config.options,config.path),...(config.options.trace?{'data-opf-box-width':box.width,'data-opf-rich-text':config.rich===false?undefined:'true','data-opf-rich-lines':JSON.stringify(lineTrace),'data-opf-rich-spacing':spacing}:{}),...(fit.overflow?{'data-opf-overflow':'true'}:{})},content.join('\n'));
1263
1635
  }
1264
1636
 
1265
1637
  function renderTextBox(text, box, bound, config) {
@@ -1282,21 +1654,39 @@ function renderTextBox(text, box, bound, config) {
1282
1654
  const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1283
1655
  const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
1284
1656
  const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
1657
+ const type = config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type;
1658
+ const source = String(text ?? ""), boxRtl = paragraphRtl(bound, source);
1659
+ let cursor = 0;
1285
1660
  const lines = fit.lines.map((line, index) => {
1286
1661
  const sourceLine=fit.sourceLines?.[index],placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1662
+ // The line's paragraph decides its direction: source offsets when layout has them, else the next match.
1663
+ const found=sourceLine?sourceLine.start:source.indexOf(line,cursor),lineStart=found>=0?found:cursor;cursor=lineStart+line.length;
1664
+ const rtl=boxRtl(lineStart);
1287
1665
  const origin=placed?.x??x-(sourceLine?.width??0)*factor;
1288
1666
  const tabs=sourceLine?.segments.some(segment=>segment.kind==='tab');
1289
- const content=tabs?sourceLine.segments.map(segment=>tag('tspan',{
1290
- x:stableNumber(origin+segment.x),textLength:segment.kind==='tab'||placed?stableNumber(segment.width):undefined,
1291
- lengthAdjust:segment.kind==='tab'||placed?'spacingAndGlyphs':undefined,
1292
- ...(config.options.trace?{'data-opf-source-start':segment.start,'data-opf-source-end':segment.end,'data-opf-segment':segment.kind}:{}),
1293
- },escapeText(line.slice(segment.start-sourceLine.start,segment.end-sourceLine.start)))).join(''):escapeText(line);
1667
+ let content,family,positioned=false;
1668
+ if(tabs) content=sourceLine.segments.map(segment=>{
1669
+ const segmentText=line.slice(segment.start-sourceLine.start,segment.end-sourceLine.start),fixed=segment.kind==='tab'||placed;
1670
+ const traced=(start,end)=>config.options.trace?{'data-opf-source-start':start,'data-opf-source-end':end,'data-opf-segment':segment.kind}:{};
1671
+ const scripted=segment.kind==='tab'?{content:escapeText(segmentText)}:scriptLine(segmentText,style,bound,type,{rtl,
1672
+ placement:placed?{x:origin+segment.x,width:segment.width,fontSize:size}:undefined,
1673
+ trace:config.options.trace?(start,end)=>traced(segment.start+start,segment.start+end):undefined});
1674
+ if(scripted.positioned)return scripted.content;
1675
+ return tag('tspan',{
1676
+ x:stableNumber(origin+segment.x),textLength:fixed?stableNumber(segment.width):undefined,
1677
+ lengthAdjust:fixed?'spacingAndGlyphs':undefined,'font-family':scripted.family,
1678
+ ...traced(segment.start,segment.end),
1679
+ },scripted.content);
1680
+ }).join('');
1681
+ else ({content,family,positioned=false}=scriptLine(line,style,bound,type,{rtl,placement:placed?.width>0?{x:placed.x,width:placed.width,fontSize:size}:undefined}));
1682
+ // Positioned script runs carry their own x and textLength (FF-19).
1683
+ const start=tabs||positioned;
1294
1684
  return tag("text", {
1295
- x: stableNumber(tabs?origin:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
1296
- "text-anchor": tabs?'start':anchor, "font-family": fontStack(style.fontFamily, config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type),
1685
+ x: stableNumber(tabs?origin:positioned?placed.x:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
1686
+ "text-anchor": start?'start':anchor, "font-family": family ?? fontStack(style.fontFamily, type),
1297
1687
  "font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
1298
1688
  'xml:space':'preserve',style:'white-space:pre','text-rendering':config.options.textMeasurement?.measure?'geometricPrecision':undefined,
1299
- textLength:!tabs&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!tabs&&placed?.width>0?'spacingAndGlyphs':undefined,
1689
+ textLength:!start&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
1300
1690
  ...traceAttrs(config.options, config.path),
1301
1691
  ...(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}:{}),
1302
1692
  }, content);
@@ -1381,7 +1771,22 @@ function escapeAttr(value) {
1381
1771
  return escapeText(value).replaceAll('"', "&quot;");
1382
1772
  }
1383
1773
 
1774
+ // FF-31: text drawn with a face whose features the font policy turns off (Gelasio for Georgia) says so, so
1775
+ // browsers do not ligate what the measurement did not. Only text or tspan elements that name such a family.
1776
+ // A flow line's outer text element, which only carries the first fragment's family, opts out: each of its
1777
+ // tspans names its own family and gets its own style, so other faces do not inherit ligatures:none.
1778
+ const NO_POLICY_FEATURES = Symbol("noPolicyFeatures");
1779
+ // Text in another script nested in a flagged run (scriptLine tspans) goes back to default shaping.
1780
+ const RESET_POLICY_FEATURES = "font-variant-ligatures:normal;font-feature-settings:normal";
1781
+ function withPolicyFeatures(name, attrs) {
1782
+ if (attrs[NO_POLICY_FEATURES]) return attrs;
1783
+ const family = (name === "text" || name === "tspan") && typeof attrs["font-family"] === "string" ? attrs["font-family"].split(",")[0].trim() : undefined;
1784
+ const features = family && disabledFeaturesStyle(family);
1785
+ return features ? { ...attrs, style: attrs.style ? `${attrs.style};${features}` : features } : attrs;
1786
+ }
1787
+
1384
1788
  function tag(name, attrs = {}, children = "") {
1789
+ attrs = withPolicyFeatures(name, attrs);
1385
1790
  const serializedAttrs = Object.keys(attrs)
1386
1791
  .filter((key) => attrs[key] !== undefined && attrs[key] !== null && attrs[key] !== false)
1387
1792
  .sort()