@openpresentation/opf-render 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/svg.js CHANGED
@@ -1,8 +1,13 @@
1
1
  import { layoutTable, fitList, fitRichText, composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textWidthMeasurer, fitText, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
2
2
  import {
3
3
  catalogs as bundledCatalogs,
4
+ resolveColorRef as resolveCoreColorRef,
4
5
  validatePresentation
5
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";
6
11
 
7
12
  export const packageName = "@openpresentation/opf-render";
8
13
 
@@ -50,6 +55,12 @@ export const engineDefaults = Object.freeze({
50
55
  ])
51
56
  });
52
57
 
58
+ // Last-resort font scheme shared by every engine (core pagination, opf-editor and
59
+ // opf-pptx): DEFAULT_FONT_SCHEME in @openpresentation/opf. It applies only when the
60
+ // slide, deck and resolved theme name no font scheme. fontScheme.google is kept for a
61
+ // future Google Slides target and is not used by this renderer.
62
+ const DEFAULT_FONT_SCHEME = engineDefaults.fontScheme.pptx.latin;
63
+
53
64
  export class OPFRenderError extends Error {
54
65
  constructor(code, message, details = {}) {
55
66
  super(message);
@@ -226,12 +237,47 @@ function sourceRecordsFor(kind, source, options) {
226
237
  }
227
238
 
228
239
  function engineDefaultId(kind) {
229
- if (kind === "fontSchemes") return engineDefaults.fontScheme.google.latin;
240
+ if (kind === "fontSchemes") return DEFAULT_FONT_SCHEME;
230
241
  if (kind === "chartTypes") return engineDefaults.chartTypes[0];
231
242
  const key = kind.endsWith("s") ? kind.slice(0, -1) : kind;
232
243
  return engineDefaults[key];
233
244
  }
234
245
 
246
+ function findCatalogRecord(kind, id, context) {
247
+ if (!id) return null;
248
+ const documentCatalog = context.presentation.catalogs?.[kind];
249
+ return findById(normalizeSourceRecords(documentCatalog), id) ??
250
+ findById(sourceRecordsFor(kind, documentCatalog?.source, context.options), id) ??
251
+ findById(normalizeSourceRecords(context.options.catalogs?.[kind]), id) ??
252
+ findById(sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options), id) ??
253
+ findById(defaultCatalogFor(kind), id);
254
+ }
255
+
256
+ // Font schemes follow the shared core rule (resolveFontSchemeReference in
257
+ // @openpresentation/opf): an id that matches no record reports one
258
+ // `unresolved-font-scheme` diagnostic and uses the DEFAULT_FONT_SCHEME record as the
259
+ // base, with sibling overrides on top, so preview and PPTX export use the same fonts.
260
+ function resolveFontSchemeRecord(reference, context, path) {
261
+ const id = referenceId(reference);
262
+ const found = findCatalogRecord("fontSchemes", id, context);
263
+ const base = found ?? findCatalogRecord("fontSchemes", DEFAULT_FONT_SCHEME, context) ?? {};
264
+ const scheme = cloneWithSortedKeys(isPlainObject(reference) ? { ...base, ...reference } : base);
265
+ if (!id || found) return { scheme };
266
+ 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}'.` } };
267
+ }
268
+
269
+ // Generated socials furniture formats handles in core. Core applies inline
270
+ // document records first; the host supplies the rest in resolution order.
271
+ function socialPlatformRecords(context) {
272
+ const kind = "socialPlatforms";
273
+ return [
274
+ ...sourceRecordsFor(kind, context.presentation.catalogs?.[kind]?.source, context.options),
275
+ ...normalizeSourceRecords(context.options.catalogs?.[kind]),
276
+ ...sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options),
277
+ ...defaultCatalogFor(kind)
278
+ ];
279
+ }
280
+
235
281
  function resolveCatalogRecord(kind, reference, context, path, fallbackId = engineDefaultId(kind)) {
236
282
  const id = referenceId(reference) ?? fallbackId;
237
283
  const documentCatalog = context.presentation.catalogs?.[kind];
@@ -291,6 +337,32 @@ function normalizeColor(value, fallback) {
291
337
  return fallback;
292
338
  }
293
339
 
340
+ /** Resolve content ColorRef via core; keep authored #RRGGBB / #RRGGBBAA casing. */
341
+ function resolveColorRef(value, bound, fallback) {
342
+ if (value == null || value === "") return fallback;
343
+ if (typeof value !== "string") return fallback;
344
+ const trimmed = value.trim();
345
+ // Core normalizeHexColor uppercases literals and strips #RRGGBBAA alpha.
346
+ // Packed-browser editor checks keep toolbar hex like #2563eb as authored.
347
+ if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(trimmed)) return trimmed;
348
+ if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) return normalizeColor(trimmed, fallback);
349
+ const colors = bound.design.colors ?? {};
350
+ return resolveCoreColorRef(trimmed, {
351
+ colorScheme: bound.design.colorScheme ?? {},
352
+ roles: {
353
+ primary: colors.primary,
354
+ secondary: colors.secondary,
355
+ accent: colors.accent,
356
+ background: colors.background,
357
+ surface: colors.surface,
358
+ text: colors.text,
359
+ textSecondary: colors.mutedText
360
+ },
361
+ variables: bound.design.variables,
362
+ fallback
363
+ });
364
+ }
365
+
294
366
  function resolveBackground(background, colorScheme) {
295
367
  if (!background) return colorFromScheme(colorScheme, "light1", "#FFFFFF");
296
368
  if (typeof background === "string") return colorFromScheme(colorScheme, background, "#FFFFFF");
@@ -311,7 +383,7 @@ function colorLuminance(color) {
311
383
  return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
312
384
  }
313
385
 
314
- function resolveDesign(presentation, slide, context) {
386
+ function resolveDesign(presentation, slide, context, index) {
315
387
  const deckDesign = presentation.design ?? {};
316
388
  const slideDesign = slide.design ?? {};
317
389
 
@@ -327,11 +399,13 @@ function resolveDesign(presentation, slide, context) {
327
399
  context,
328
400
  "design.colorScheme"
329
401
  );
330
- const fontScheme = resolveCatalogRecord(
331
- "fontSchemes",
332
- slideDesign.fontScheme ?? deckDesign.fontScheme ?? theme.fontScheme ?? engineDefaults.fontScheme.google.latin,
402
+ const fontPath = slideDesign.fontScheme !== undefined ? `slides.${index}.design.fontScheme`
403
+ : deckDesign.fontScheme !== undefined ? "design.fontScheme"
404
+ : slideDesign.theme !== undefined ? `slides.${index}.design.theme` : "design.theme";
405
+ const { scheme: fontScheme, diagnostic: fontSchemeDiagnostic } = resolveFontSchemeRecord(
406
+ slideDesign.fontScheme ?? deckDesign.fontScheme ?? theme.fontScheme ?? DEFAULT_FONT_SCHEME,
333
407
  context,
334
- "design.fontScheme"
408
+ fontPath
335
409
  );
336
410
  const dimensions = resolveDimensions(slideDesign.dimensions ?? deckDesign.dimensions ?? theme.dimensions);
337
411
  const backgroundDefinition = slideDesign.background ?? deckDesign.background ?? theme.background;
@@ -346,6 +420,7 @@ function resolveDesign(presentation, slide, context) {
346
420
  colorScheme,
347
421
  fontScheme,
348
422
  dimensions,
423
+ variables: presentation.variables ?? {},
349
424
  background: backgroundDefinition,
350
425
  backgroundColor,
351
426
  colors: {
@@ -358,10 +433,58 @@ function resolveDesign(presentation, slide, context) {
358
433
  accent: normalizeColor(colorScheme.accent, null) ?? colorFromScheme(colorScheme, "accent3", "#F59E0B"),
359
434
  border: colorFromScheme(colorScheme, "accent5", "#CBD5E1")
360
435
  },
361
- fonts: resolveFontFamilies(fontScheme)
436
+ fonts: resolveFontFamilies(fontScheme),
437
+ diagnostics: fontSchemeDiagnostic ? [fontSchemeDiagnostic] : []
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Script font slots, language tags and direction for one slide from core
443
+ * `resolveScriptFonts` (FF-18). The latin slot is always the renderer's design
444
+ * font; a script slot that core fills from the latin family follows it too.
445
+ * Without core support every slot repeats the latin family.
446
+ */
447
+ function scriptProfile(presentation, index, design, context) {
448
+ let resolved;
449
+ if (typeof opfCore.resolveScriptFonts === "function") {
450
+ try { resolved = opfCore.resolveScriptFonts(presentation, { slideIndex: index }); }
451
+ catch (error) {
452
+ reportLanguageDiagnostic(context, { code: "language-preview-unresolved", path: "language",
453
+ 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." });
454
+ }
455
+ } else if (presentation.language !== undefined) {
456
+ reportLanguageDiagnostic(context, { code: "language-preview-unavailable", path: "language",
457
+ 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." });
458
+ }
459
+ if (resolved?.rtl === true && typeof opfCore.paragraphDirection !== "function") {
460
+ reportLanguageDiagnostic(context, { code: "paragraph-direction-unavailable", path: "language",
461
+ 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." });
462
+ }
463
+ const slots = role => {
464
+ const latin = design.fonts[role];
465
+ const slot = key => !resolved || resolved.sources?.[key] === "latin" || resolved.sources?.[key] === "schemeFamily" ? latin : resolved[role]?.[key] ?? latin;
466
+ return { latin, eastAsian: slot("eastAsian"), complexScript: slot("complexScript") };
467
+ };
468
+ return {
469
+ heading: slots("heading"),
470
+ body: slots("body"),
471
+ ...(resolved?.supplement ? { supplement: resolved.supplement } : {}),
472
+ script: resolved?.script ?? "Zzzz",
473
+ ...(resolved?.scriptRole ? { scriptRole: resolved.scriptRole } : {}),
474
+ ...(resolved ? { bcp47: resolved.bcp47, lang: resolved.lang, languageSource: resolved.languageSource, direction: resolved.direction } : {}),
475
+ rtl: resolved?.rtl === true,
476
+ serif: design.fontScheme?.type === "serif"
362
477
  };
363
478
  }
364
479
 
480
+ /** Report a language diagnostic once per resolvePresentation call. */
481
+ function reportLanguageDiagnostic(context, diagnostic) {
482
+ context.languageDiagnostics ??= new Set();
483
+ if (context.languageDiagnostics.has(diagnostic.code)) return;
484
+ context.languageDiagnostics.add(diagnostic.code);
485
+ context.options.onDiagnostic?.(diagnostic);
486
+ }
487
+
365
488
  function inferLayoutId(slide) {
366
489
  if (slide.layout) return slide.layout;
367
490
  if (slide.blocks?.length) return "blank";
@@ -483,10 +606,16 @@ function bindSlide(presentation, slide, layout, index, context) {
483
606
  ];
484
607
  const blocks = blockContent(slide, slidePath);
485
608
 
486
- const design = resolveDesign(presentation, slide, context);
487
- for (const role of ["heading","body","code"]) design.fonts[role] = resolveTextStyle({fontFamily:design.fonts[role],fontWeight:role === "heading" ? 700 : 400},context.options.textMeasurement).fontFamily;
488
- 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 });
609
+ const design = resolveDesign(presentation, slide, context, index);
610
+ // Script fonts (FF-19): each text run is measured and drawn with its script
611
+ // slot's face; the latin slot stays the design font scheme's family.
612
+ const scriptFonts = createScriptFonts(scriptProfile(presentation, index, design, context), context.options.textMeasurement);
613
+ const textMeasurement = scriptFonts.textMeasurement ?? context.options.textMeasurement;
614
+ for (const role of ["heading","body","code"]) design.fonts[role] = resolveTextStyle({fontFamily:design.fonts[role],fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
615
+ 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) });
489
616
  return {
617
+ scriptFonts,
618
+ textMeasurement,
490
619
  geometry,
491
620
  assets: presentation.assets ?? {},
492
621
  index,
@@ -502,7 +631,9 @@ function bindSlide(presentation, slide, layout, index, context) {
502
631
 
503
632
  export function resolvePresentation(input, options = {}) {
504
633
  const presentation = parseInput(input);
505
- assertValidBoundary(presentation);
634
+ if (options.validate !== false) {
635
+ assertValidBoundary(presentation);
636
+ }
506
637
 
507
638
  const context = { presentation, options };
508
639
  const slides = presentation.slides.map((slide, index) => {
@@ -535,13 +666,18 @@ export function renderSvgDeck(input, options = {}) {
535
666
  }
536
667
 
537
668
  function renderResolvedSlide(resolved, slideIndex, options) {
538
- options = { ...options, _diagnosticPaths: new Set() };
539
669
  const bound = resolved.slides[slideIndex];
670
+ // Paint with the same script-aware measurement composition used.
671
+ options = { ...options, ...(bound.textMeasurement ? { textMeasurement: bound.textMeasurement } : {}), _diagnosticPaths: new Set() };
672
+ const script = bound.scriptFonts?.profile;
673
+ // Only a language the document names sets lang; the renderer default does not.
674
+ const lang = script?.languageSource === "document" || script?.languageSource === "option" ? script.bcp47 : undefined;
540
675
  const { width, height } = bound.design.dimensions;
541
676
  const title = bound.slide.title ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
542
677
  const children = [
543
678
  renderEmbeddedFonts(options.embeddedFonts),
544
679
  renderBackground(bound, width, height, options),
680
+ renderSlideImage(bound, options),
545
681
  renderBranding(bound, resolved.presentation, width, height, options),
546
682
  ...renderSlideContent(bound, width, height, options),
547
683
  renderFurniture(bound, resolved.presentation, width, height, options, "header"),
@@ -557,6 +693,8 @@ function renderResolvedSlide(resolved, slideIndex, options) {
557
693
  viewBox: `0 0 ${width} ${height}`,
558
694
  width,
559
695
  height,
696
+ lang,
697
+ "xml:lang": lang,
560
698
  ...traceAttrs(options, bound.path)
561
699
  },
562
700
  `\n${children.join("\n")}\n`
@@ -604,7 +742,10 @@ function renderBackground(bound, width, height, options) {
604
742
  }
605
743
  if(isPlainObject(background) && background.type==='pattern'){
606
744
  const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=normalizeColor(pattern.foregroundColor,bound.design.colors.text),preset=pattern.preset;
607
- 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}):'';
745
+ // 8px cells approximating DrawingML presets. diagStripe is the engine id that
746
+ // PPTX export writes as wdUpDiag, so both draw the same rising stripe.
747
+ const stroke=(d,width=1)=>tag('path',{d,fill:'none',stroke:color,'stroke-width':width});
748
+ 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'):'';
608
749
  if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
609
750
  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})`}));
610
751
  }
@@ -619,11 +760,68 @@ function renderBackground(bound, width, height, options) {
619
760
  });
620
761
  }
621
762
 
763
+ // design.slideImage: the shared composition frame, beneath branding and content.
764
+ // crop covers the frame and fit centers the whole image, matching native a:srcRect.
765
+ // Treatments mirror the native picture: the preset mask (core outline), recolor
766
+ // and alphaModFix on the pixels only, the centered line, then the overlay shape.
767
+ function renderSlideImage(bound, options) {
768
+ const image = bound.geometry.slideImage;
769
+ if (!image) return '';
770
+ const trace = options.trace ? { 'data-opf-slide-image': image.path, 'data-opf-slide-image-position': image.position } : {};
771
+ const value = image.alt === undefined ? image.value : { ...normalizeAsset(image.value), alt: image.alt };
772
+ const picture = renderImage({ value, path: image.sourcePath }, image.box, bound, { ...options, imageFit: image.fill === 'crop' ? 'cover' : 'contain' });
773
+ // Unresolved sources keep the ordinary placeholder without treatments, like the export.
774
+ if (!picture.startsWith('<image')) return tag('g', trace, picture);
775
+ const id = `opf-s${bound.index + 1}-slide-image`, shape = image.shape, defs = [];
776
+ const masked = shape && shape.preset !== 'rect';
777
+ if (masked) defs.push(tag('clipPath', { id: `${id}-clip` }, tag('path', { d: shape.path })));
778
+ const matrix = slideImageRecolorMatrix(image.recolor, bound);
779
+ if (matrix) defs.push(tag('filter', { id: `${id}-recolor`, 'color-interpolation-filters': 'sRGB' }, tag('feColorMatrix', { type: 'matrix', values: matrix })));
780
+ let pixels = matrix || image.opacity !== undefined ? tag('g', { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: image.opacity === undefined ? undefined : preciseNumber(image.opacity) }, picture) : picture;
781
+ if (masked) pixels = tag('g', { 'clip-path': `url(#${id}-clip)` }, pixels);
782
+ const children = [defs.length ? tag('defs', {}, defs.join('')) : '', pixels];
783
+ if (image.border) {
784
+ const paint = slideImagePaint(image.border.color, bound, bound.design.colors.border);
785
+ 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 }));
786
+ }
787
+ if (image.overlay) {
788
+ const paint = slideImagePaint(image.overlay.color, bound, bound.design.colors.text);
789
+ 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` } : {}) }));
790
+ }
791
+ return tag('g', trace, children.filter(Boolean).join(''));
792
+ }
793
+
794
+ // Native alpha and color-matrix values keep 1/100000 precision; three decimals would drift.
795
+ const preciseNumber = value => String(Math.round(value * 1e6) / 1e6);
796
+
797
+ function rectanglePath(box) {
798
+ 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`;
799
+ }
800
+
801
+ // ColorRef -> opaque #RRGGBB plus the AA byte as alpha, as the native srgbClr + alpha.
802
+ function slideImagePaint(value, bound, fallback) {
803
+ const hex = resolveColorRef(value, bound, fallback) ?? fallback;
804
+ const raw = normalizeColor(hex, fallback).slice(1);
805
+ return { color: `#${raw.slice(0, 6).toUpperCase()}`, alpha: raw.length === 8 ? parseInt(raw.slice(6), 16) / 255 : 1 };
806
+ }
807
+
808
+ // Rec. 601 luminance on sRGB values; duotone maps it linearly from dark to light.
809
+ function slideImageRecolorMatrix(recolor, bound) {
810
+ if (!recolor) return undefined;
811
+ const weights = [0.299, 0.587, 0.114];
812
+ const channels = recolor.type === 'duotone'
813
+ ? [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))
814
+ : [[0, 0, 0], [1, 1, 1]];
815
+ const [dark, light] = channels;
816
+ const rows = [0, 1, 2].map(channel => [...weights.map(weight => preciseNumber((light[channel] - dark[channel]) * weight)), 0, preciseNumber(dark[channel])].join(' '));
817
+ return [...rows, '0 0 0 1 0'].join(' ');
818
+ }
819
+
622
820
  function renderSlideContent(bound, width, height, options) {
623
- for (const diagnostic of bound.geometry.diagnostics) reportDiagnostic(diagnostic, options);
821
+ for (const diagnostic of [...bound.design.diagnostics, ...bound.geometry.diagnostics]) reportDiagnostic(diagnostic, options);
624
822
  return bound.geometry.items.map(item => {
625
823
  const frame=item.frameBox;
626
- 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}) : '';
824
+ 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)}) : '';
627
825
  return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options);
628
826
  });
629
827
  }
@@ -665,7 +863,10 @@ function renderPayload(item, box, bound, options) {
665
863
  function renderTextPayload(item, box, bound, options) {
666
864
  return (Array.isArray(item.value) ? renderRichTextBox : renderTextBox)(Array.isArray(item.value) ? item.value : flattenText(item.value), box, bound, {
667
865
  path: item.path,
668
- align: item.field === "title" ? bound.design.titleAlignment : bound.design.contentAlignment,
866
+ // Core resolves one alignment per composed item for every engine. The
867
+ // fallback keeps cores published before item.alignment working: titles
868
+ // follow design.titleAlignment only (unset is left, as in core composition).
869
+ align: item.alignment ?? (item.field === "title" ? bound.design.titleAlignment ?? "left" : bound.design.contentAlignment),
669
870
  fontSize: item.field === "title" ? 54 : item.field === "tag" ? 16 : 25,
670
871
  fontFamily: item.field === "title" ? bound.design.fonts.heading : bound.design.fonts.body,
671
872
  fontWeight: item.field === "title" ? 700 : 400,
@@ -823,12 +1024,13 @@ function renderCode(item, box, bound, options) {
823
1024
  'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='body'?'#E5E7EB':'#93C5FD',
824
1025
  ...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
825
1026
  '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}:{}),
826
- },line.segments.map(segment=>tag('tspan',{
1027
+ },line.segments.map(segment=>segmentSpan({
827
1028
  x:stableNumber(part.box.x+segment.x),
828
1029
  // SVG's CSS tab-size does not place literal tabs at their measured stops.
829
1030
  ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
830
1031
  ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
831
- },escapeText(part.text.slice(segment.start,segment.end)))).join('')));
1032
+ // Code stays left to right; script runs still take their slot fonts.
1033
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false})).join('')));
832
1034
  children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
833
1035
  '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}:{}),
834
1036
  ...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
@@ -845,18 +1047,32 @@ function renderMetric(item, box, bound, options) {
845
1047
  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});
846
1048
  if (!part.visible) continue;
847
1049
  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});
1050
+ const partRtl=paragraphRtl(bound,part.text);
1051
+ // Anchor untabbed lines at the accepted alignment edge, as PPTX export does,
1052
+ // so a shaper whose advance differs from the accepted width keeps the edge.
1053
+ // Tabbed lines keep their accepted segment origins.
1054
+ const factor=layout.alignment==='right'?1:layout.alignment==='center'?.5:0;
848
1055
  const lines=part.fit.sourceLines.map((line,index)=>{
849
1056
  const origin=part.linePositions[index];
1057
+ 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',
1058
+ 'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
1059
+ 'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
1060
+ 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
1061
+ ...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
1062
+ 'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
1063
+ },line.segments.map(segment=>segmentSpan({
1064
+ ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
1065
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
850
1066
  return tag('text',{x:stableNumber(origin.x),y:stableNumber(origin.baseline),'text-anchor':'start',
851
1067
  'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
852
1068
  'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
853
1069
  'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
854
1070
  ...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
855
1071
  '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',{x:stableNumber(origin.x+segment.x),
1072
+ },line.segments.map(segment=>segmentSpan({x:stableNumber(origin.x+segment.x),
857
1073
  ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
858
1074
  ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
859
- },escapeText(part.text.slice(segment.start,segment.end)))).join(''));
1075
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
860
1076
  });
861
1077
  children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,
862
1078
  '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}:{}),
@@ -903,7 +1119,14 @@ function renderTable(item, box, bound, options) {
903
1119
  const defaultEdges = [], explicitEdges = [];
904
1120
  for (const row of layout.rows) for (const cell of row.cells) {
905
1121
  const style = cell.style ?? {};
906
- const fill = style.fill ?? (cell.header ? bound.design.colors.primary : bound.design.colors.surface);
1122
+ const defaultFill = cell.header ? bound.design.colors.primary : bound.design.colors.surface;
1123
+ const fill = style.fill == null
1124
+ ? defaultFill
1125
+ : resolveColorRef(style.fill, bound, defaultFill);
1126
+ const defaultText = textColorForFill(fill, cell.header ? "#FFFFFF" : bound.design.colors.text);
1127
+ const textFill = style.color == null
1128
+ ? defaultText
1129
+ : resolveColorRef(style.color, bound, defaultText);
907
1130
  children.push(tag("rect", {
908
1131
  x: stableNumber(cell.box.x), y: stableNumber(cell.box.y),
909
1132
  width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
@@ -923,17 +1146,18 @@ function renderTable(item, box, bound, options) {
923
1146
  children.push((cell.rich ? renderRichTextBox : renderTextBox)(cell.rich ? cell.value : flattenText(cell.value ?? ""), cell.textBox, bound, {
924
1147
  path:cell.path, fontSize:15, fontFamily:bound.design.fonts.body,
925
1148
  fontWeight:cell.header ? 700 : 400, textStyle:cell.textStyle, fit:cell.fit,
926
- fill:style.color ?? textColorForFill(fill, cell.header ? "#FFFFFF" : bound.design.colors.text), align:style.align, options
1149
+ fill:textFill, align:style.align, options
927
1150
  }));
928
1151
  }
929
1152
 
930
- return tag("g", traceAttrs(options, item.path), [...children, ...renderTableBorders(defaultEdges, explicitEdges, scale, bound.design.colors.border, options)].join("\n"));
1153
+ return tag("g", traceAttrs(options, item.path), [...children, ...renderTableBorders(defaultEdges, explicitEdges, scale, bound, options)].join("\n"));
931
1154
  }
932
1155
 
933
1156
  // Explicit edges own their shared segment, including invisible/zero-width edges.
934
1157
  // Split implicit neighbors at merge boundaries so they cannot fill dashed gaps,
935
1158
  // cover alpha strokes, or reintroduce a border the author removed.
936
- function renderTableBorders(defaultEdges, explicitEdges, scale, defaultColor, options) {
1159
+ function renderTableBorders(defaultEdges, explicitEdges, scale, bound, options) {
1160
+ const defaultColor = bound.design.colors.border;
937
1161
  const epsilon = 1e-7;
938
1162
  const segment = ({coordinates:[x1,y1,x2,y2]}) => y1 === y2
939
1163
  ? {horizontal:true, fixed:y1, start:x1, end:x2}
@@ -955,9 +1179,12 @@ function renderTableBorders(defaultEdges, explicitEdges, scale, defaultColor, op
955
1179
  return [...defaults,...explicitEdges].flatMap(({coordinates:[x1,y1,x2,y2],border,path}) => {
956
1180
  const width = border ? border.width * scale : 1;
957
1181
  if (width === 0) return [];
1182
+ const stroke = border?.color == null
1183
+ ? defaultColor
1184
+ : resolveColorRef(border.color, bound, defaultColor);
958
1185
  return [tag('line', {
959
1186
  x1:stableNumber(x1), y1:stableNumber(y1), x2:stableNumber(x2), y2:stableNumber(y2),
960
- stroke:border?.color ?? defaultColor, 'stroke-width':stableNumber(width),
1187
+ stroke, 'stroke-width':stableNumber(width),
961
1188
  'stroke-dasharray':border?.dash === 'dash' ? `${width*4} ${width*3}` : border?.dash === 'dot' ? `${width} ${width*2}` : undefined,
962
1189
  ...traceAttrs(options,path)
963
1190
  })];
@@ -996,10 +1223,23 @@ function renderImportedChart(item, box, bound, options) {
996
1223
  const horizontal=kind==='bar',legendHeight=series.length>1?34:8;
997
1224
  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)};
998
1225
  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;
999
- const xValue=v=>plot.x+(v-min)/range*plot.width,yValue=v=>plot.y+(max-v)/range*plot.height;
1226
+ const fraction=(v,reverse=false)=>{
1227
+ const distance=reverse?max-v:v-min;
1228
+ if(Number.isFinite(range)&&Number.isFinite(distance))return distance/range;
1229
+ // Finite endpoints can overflow during subtraction. Keep ordinary
1230
+ // arithmetic unchanged and normalize only that exceptional range.
1231
+ const unit=Math.max(Math.abs(min),Math.abs(max)),low=min/unit,high=max/unit,position=v/unit;
1232
+ return (reverse?high-position:position-low)/(high-low);
1233
+ };
1234
+ const xValue=v=>plot.x+fraction(v)*plot.width,yValue=v=>plot.y+fraction(v,true)*plot.height;
1000
1235
  const zero=horizontal?xValue(0):yValue(0);
1001
1236
  for(let tick=0;tick<=4;tick++){
1002
- const value=min+range*tick/4,at=horizontal?xValue(value):yValue(value),label=Number(value.toPrecision(4));
1237
+ const direct=min+range*tick/4;
1238
+ const value=Number.isFinite(direct)?direct:min*(1-tick/4)+max*(tick/4);
1239
+ const at=horizontal?xValue(value):yValue(value),rounded=Number(value.toPrecision(4));
1240
+ // Rounding MAX_VALUE to four digits can overflow although the tick is
1241
+ // finite. Its exact shortest decimal remains truthful and parseable.
1242
+ const label=Number.isFinite(rounded)?rounded:String(value);
1003
1243
  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}));
1004
1244
  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'));
1005
1245
  }
@@ -1161,7 +1401,75 @@ function renderBranding(bound,presentation,width,height,options) {
1161
1401
 
1162
1402
  function fontStack(family, type) {
1163
1403
  const fallback = type === "serif" ? "serif" : type === "monospace" ? "monospace" : "sans-serif";
1164
- return `${family}, ${fallback}`;
1404
+ return `${[family].flat().join(", ")}, ${fallback}`;
1405
+ }
1406
+
1407
+ // Unicode directional isolates (FF-19): each paragraph (text between hard line
1408
+ // breaks) of a right-to-left-language deck takes one base direction from
1409
+ // paragraphDirection(text, deckDirection), the rule the PPTX export uses for
1410
+ // a:pPr rtl. Every wrapped line of a right-to-left paragraph is laid out as a
1411
+ // right-to-left isolate, so its lines never differ in direction. Absolute
1412
+ // alignment and measured advances are unchanged.
1413
+ const RIGHT_TO_LEFT_ISOLATE = "\u2067", POP_DIRECTIONAL_ISOLATE = "\u2069";
1414
+ // Core owns the rule (paragraphDirection, core #134), so preview and export agree.
1415
+ // Without it (published core 0.11.0) every paragraph is left to right, as in export.
1416
+ const coreParagraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
1417
+ /** Maps a source offset of the text to whether its paragraph is right to left. */
1418
+ function paragraphRtl(bound, text) {
1419
+ if (bound.scriptFonts?.rtl !== true || !coreParagraphDirection) return () => false;
1420
+ const source = String(text ?? ""), spans = [];
1421
+ let start = 0;
1422
+ for (const match of source.matchAll(/\r\n|\r|\n/g)) { spans.push([start, match.index]); start = match.index + match[0].length; }
1423
+ spans.push([start, source.length]);
1424
+ const rtl = spans.map(([from, to]) => coreParagraphDirection(source.slice(from, to), "rtl") === "rtl");
1425
+ return offset => { for (let index = spans.length - 1; index >= 0; index--) if (offset >= spans[index][0]) return rtl[index]; return rtl[0]; };
1426
+ }
1427
+
1428
+ /**
1429
+ * SVG content for `text` drawn in `style` (FF-19). Text in the style's own
1430
+ * family is emitted unchanged. A single run in another family returns that
1431
+ * family for the enclosing element. Several runs become tspans naming their
1432
+ * script slot's family. With `placement` ({x, width, fontSize}) the runs are
1433
+ * positioned at their measured advances, each with its own textLength, because
1434
+ * a textLength spanning differently fonted tspans does not rasterize reliably;
1435
+ * the caller then drops its own textLength and anchors at the start.
1436
+ */
1437
+ function scriptLine(text, style, bound, type, { rtl = false, placement, trace } = {}) {
1438
+ const value = String(text ?? "");
1439
+ const scripts = bound.scriptFonts;
1440
+ rtl = rtl && value !== "";
1441
+ const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
1442
+ const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
1443
+ if (runs.length === 1) {
1444
+ const [run] = runs;
1445
+ return { content: isolate(escapeText(value)), family: run.own ? undefined : fontStack(run.stack ?? run.family, type) };
1446
+ }
1447
+ const widths = placement && placement.width > 0 ? scripts.runWidths(runs, placement.fontSize, style) : undefined;
1448
+ if (!widths) {
1449
+ return { content: isolate(runs.map(run => run.own ? escapeText(run.text)
1450
+ : tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type) }, escapeText(run.text))).join("")) };
1451
+ }
1452
+ const total = widths.reduce((sum, width) => sum + width, 0), factor = total > 0 ? placement.width / total : 1;
1453
+ let advance = 0, offset = 0;
1454
+ const content = runs.map((run, index) => {
1455
+ const width = widths[index] * factor, left = rtl ? placement.width - advance - width : advance;
1456
+ advance += width;
1457
+ const start = offset;
1458
+ offset += run.text.length;
1459
+ return tag("tspan", {
1460
+ x: stableNumber(placement.x + left),
1461
+ textLength: width > 0 ? stableNumber(width) : undefined, lengthAdjust: width > 0 ? "spacingAndGlyphs" : undefined,
1462
+ "font-family": run.own ? undefined : fontStack(run.family, type),
1463
+ ...(trace ? trace(start, offset) : {})
1464
+ }, isolate(escapeText(run.text)));
1465
+ }).join("");
1466
+ return { content, positioned: true };
1467
+ }
1468
+
1469
+ /** One positioned code/metric segment tspan; its script runs flow inside it (no textLength). */
1470
+ function segmentSpan(attrs, segment, text, style, bound, type, options) {
1471
+ const scripted = segment.kind === "tab" ? { content: escapeText(text) } : scriptLine(text, style, bound, type, options);
1472
+ return tag("tspan", { ...attrs, "font-family": scripted.family }, scripted.content);
1165
1473
  }
1166
1474
 
1167
1475
  function renderEmbeddedFonts(fonts = []) {
@@ -1185,22 +1493,51 @@ function renderRichLines(value,fit,box,bound,config) {
1185
1493
  const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1186
1494
  let textOffset=0;
1187
1495
  const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
1496
+ const richRtl=paragraphRtl(bound,value.map(run=>typeof run==='string'?run:run.text).join(''));
1188
1497
  // With no measurement provider, fragment advances are estimates. Let SVG
1189
1498
  // shape adjacent runs naturally inside each estimated line instead of turning
1190
1499
  // those estimates into visible gaps. Supplied measurements keep exact origins.
1191
- const flow=!config.options.textMeasurement?.measure&&!fit.placement;
1500
+ const naturalFlow=!config.options.textMeasurement?.measure&&!fit.placement;
1501
+ const hasTabs=fit.richLines.some(line=>line.fragments.some(fragment=>fragment.kind==='tab'));
1192
1502
  const content=fit.richLines.map((line,lineIndex)=>{
1193
- const fragments=line.fragments.map(fragment=>{
1194
- const run=fragment.run,offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1503
+ const lineHasTab=line.fragments.some(fragment=>fragment.kind==='tab');
1504
+ const flow=naturalFlow&&!lineHasTab;
1505
+ const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1195
1506
  const placed=fit.placement?.lines[lineIndex];
1507
+ const originX=placed?.x??box.x+offset,baseline=placed?.baseline??box.y+line.baseline;
1508
+ const firstFragment=line.fragments[0],rtl=richRtl(firstFragment?runOffsets[firstFragment.runIndex]+firstFragment.start:0);
1509
+ const renderFragment=(fragment,asFlow,edges={})=>{
1510
+ const run=fragment.run;
1196
1511
  // Accepted outline placement owns the horizontal advance. Geometric precision
1197
1512
  // avoids hinted browser advances; textLength also removes fractional-size
1198
1513
  // quantization drift. Height and baseline retain the selected font size.
1199
- 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)};
1200
- 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:/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(run.color??'')?run.color:config.fill},escapeText(fragment.text));
1514
+ // A right-to-left line places its fragments from the right edge (FF-19).
1515
+ // Estimated (natural-flow) lines keep logical order; the browser reorders them.
1516
+ const fragmentX=rtl&&!naturalFlow?line.width-fragment.x-fragment.width:fragment.x;
1517
+ const position=asFlow?{'baseline-shift':fragment.baselineShift?stableNumber(-fragment.baselineShift):undefined}:{x:stableNumber(originX+fragmentX),y:stableNumber(baseline+fragment.baselineShift)};
1518
+ const runFill = run.color == null
1519
+ ? config.fill
1520
+ : resolveColorRef(run.color, bound, config.fill);
1521
+ let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
1522
+ const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(fragment.text,fragment.style,bound,bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
1523
+ placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined});
1524
+ if(scripted.positioned)fixedAdvance=false;
1525
+ const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
1526
+ 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);
1201
1527
  if(run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
1202
1528
  return rendered;
1203
- });
1529
+ };
1530
+ if(naturalFlow&&lineHasTab) {
1531
+ const chunks=[];let textChunk=[];
1532
+ 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=[];};
1533
+ for(const fragment of line.fragments) {
1534
+ if(fragment.kind==='tab'){flush();chunks.push(renderFragment(fragment,false));}
1535
+ else textChunk.push(fragment);
1536
+ }
1537
+ flush();return chunks.join('\n');
1538
+ }
1539
+ // A flowing right-to-left line is one isolate across its fragments.
1540
+ const fragments=line.fragments.map((fragment,index)=>renderFragment(fragment,flow,flow?{first:rtl&&!index,last:rtl&&index===line.fragments.length-1}:{}));
1204
1541
  if(!flow)return fragments.join('\n');
1205
1542
  const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
1206
1543
  const first=line.fragments[0];
@@ -1213,9 +1550,11 @@ function renderRichLines(value,fit,box,bound,config) {
1213
1550
  const start=cursor;cursor+=(fit.lines[index]??'').length;
1214
1551
  const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1215
1552
  const placed=fit.placement?.lines[index];
1216
- return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height};
1553
+ return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height,
1554
+ ...(naturalFlow&&line.fragments.some(fragment=>fragment.kind==='tab')?{spacing:'natural-chunks-estimated-tabs'}:{})};
1217
1555
  }):undefined;
1218
- 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'));
1556
+ const spacing=naturalFlow?(hasTabs?'mixed-estimated-tabs':'natural'):'measured';
1557
+ 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'));
1219
1558
  }
1220
1559
 
1221
1560
  function renderTextBox(text, box, bound, config) {
@@ -1238,21 +1577,39 @@ function renderTextBox(text, box, bound, config) {
1238
1577
  const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1239
1578
  const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
1240
1579
  const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
1580
+ const type = config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type;
1581
+ const source = String(text ?? ""), boxRtl = paragraphRtl(bound, source);
1582
+ let cursor = 0;
1241
1583
  const lines = fit.lines.map((line, index) => {
1242
1584
  const sourceLine=fit.sourceLines?.[index],placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1585
+ // The line's paragraph decides its direction: source offsets when layout has them, else the next match.
1586
+ const found=sourceLine?sourceLine.start:source.indexOf(line,cursor),lineStart=found>=0?found:cursor;cursor=lineStart+line.length;
1587
+ const rtl=boxRtl(lineStart);
1243
1588
  const origin=placed?.x??x-(sourceLine?.width??0)*factor;
1244
1589
  const tabs=sourceLine?.segments.some(segment=>segment.kind==='tab');
1245
- const content=tabs?sourceLine.segments.map(segment=>tag('tspan',{
1246
- x:stableNumber(origin+segment.x),textLength:segment.kind==='tab'||placed?stableNumber(segment.width):undefined,
1247
- lengthAdjust:segment.kind==='tab'||placed?'spacingAndGlyphs':undefined,
1248
- ...(config.options.trace?{'data-opf-source-start':segment.start,'data-opf-source-end':segment.end,'data-opf-segment':segment.kind}:{}),
1249
- },escapeText(line.slice(segment.start-sourceLine.start,segment.end-sourceLine.start)))).join(''):escapeText(line);
1590
+ let content,family,positioned=false;
1591
+ if(tabs) content=sourceLine.segments.map(segment=>{
1592
+ const segmentText=line.slice(segment.start-sourceLine.start,segment.end-sourceLine.start),fixed=segment.kind==='tab'||placed;
1593
+ const traced=(start,end)=>config.options.trace?{'data-opf-source-start':start,'data-opf-source-end':end,'data-opf-segment':segment.kind}:{};
1594
+ const scripted=segment.kind==='tab'?{content:escapeText(segmentText)}:scriptLine(segmentText,style,bound,type,{rtl,
1595
+ placement:placed?{x:origin+segment.x,width:segment.width,fontSize:size}:undefined,
1596
+ trace:config.options.trace?(start,end)=>traced(segment.start+start,segment.start+end):undefined});
1597
+ if(scripted.positioned)return scripted.content;
1598
+ return tag('tspan',{
1599
+ x:stableNumber(origin+segment.x),textLength:fixed?stableNumber(segment.width):undefined,
1600
+ lengthAdjust:fixed?'spacingAndGlyphs':undefined,'font-family':scripted.family,
1601
+ ...traced(segment.start,segment.end),
1602
+ },scripted.content);
1603
+ }).join('');
1604
+ else ({content,family,positioned=false}=scriptLine(line,style,bound,type,{rtl,placement:placed?.width>0?{x:placed.x,width:placed.width,fontSize:size}:undefined}));
1605
+ // Positioned script runs carry their own x and textLength (FF-19).
1606
+ const start=tabs||positioned;
1250
1607
  return tag("text", {
1251
- x: stableNumber(tabs?origin:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
1252
- "text-anchor": tabs?'start':anchor, "font-family": fontStack(style.fontFamily, config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type),
1608
+ x: stableNumber(tabs?origin:positioned?placed.x:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
1609
+ "text-anchor": start?'start':anchor, "font-family": family ?? fontStack(style.fontFamily, type),
1253
1610
  "font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
1254
1611
  'xml:space':'preserve',style:'white-space:pre','text-rendering':config.options.textMeasurement?.measure?'geometricPrecision':undefined,
1255
- textLength:!tabs&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!tabs&&placed?.width>0?'spacingAndGlyphs':undefined,
1612
+ textLength:!start&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
1256
1613
  ...traceAttrs(config.options, config.path),
1257
1614
  ...(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}:{}),
1258
1615
  }, content);