@openpresentation/opf-render 0.9.0 → 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
@@ -4,6 +4,10 @@ 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";
7
11
 
8
12
  export const packageName = "@openpresentation/opf-render";
9
13
 
@@ -51,6 +55,12 @@ export const engineDefaults = Object.freeze({
51
55
  ])
52
56
  });
53
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
+
54
64
  export class OPFRenderError extends Error {
55
65
  constructor(code, message, details = {}) {
56
66
  super(message);
@@ -227,12 +237,47 @@ function sourceRecordsFor(kind, source, options) {
227
237
  }
228
238
 
229
239
  function engineDefaultId(kind) {
230
- if (kind === "fontSchemes") return engineDefaults.fontScheme.google.latin;
240
+ if (kind === "fontSchemes") return DEFAULT_FONT_SCHEME;
231
241
  if (kind === "chartTypes") return engineDefaults.chartTypes[0];
232
242
  const key = kind.endsWith("s") ? kind.slice(0, -1) : kind;
233
243
  return engineDefaults[key];
234
244
  }
235
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
+
236
281
  function resolveCatalogRecord(kind, reference, context, path, fallbackId = engineDefaultId(kind)) {
237
282
  const id = referenceId(reference) ?? fallbackId;
238
283
  const documentCatalog = context.presentation.catalogs?.[kind];
@@ -338,7 +383,7 @@ function colorLuminance(color) {
338
383
  return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
339
384
  }
340
385
 
341
- function resolveDesign(presentation, slide, context) {
386
+ function resolveDesign(presentation, slide, context, index) {
342
387
  const deckDesign = presentation.design ?? {};
343
388
  const slideDesign = slide.design ?? {};
344
389
 
@@ -354,11 +399,13 @@ function resolveDesign(presentation, slide, context) {
354
399
  context,
355
400
  "design.colorScheme"
356
401
  );
357
- const fontScheme = resolveCatalogRecord(
358
- "fontSchemes",
359
- 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,
360
407
  context,
361
- "design.fontScheme"
408
+ fontPath
362
409
  );
363
410
  const dimensions = resolveDimensions(slideDesign.dimensions ?? deckDesign.dimensions ?? theme.dimensions);
364
411
  const backgroundDefinition = slideDesign.background ?? deckDesign.background ?? theme.background;
@@ -386,10 +433,58 @@ function resolveDesign(presentation, slide, context) {
386
433
  accent: normalizeColor(colorScheme.accent, null) ?? colorFromScheme(colorScheme, "accent3", "#F59E0B"),
387
434
  border: colorFromScheme(colorScheme, "accent5", "#CBD5E1")
388
435
  },
389
- 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"
390
477
  };
391
478
  }
392
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
+
393
488
  function inferLayoutId(slide) {
394
489
  if (slide.layout) return slide.layout;
395
490
  if (slide.blocks?.length) return "blank";
@@ -511,10 +606,16 @@ function bindSlide(presentation, slide, layout, index, context) {
511
606
  ];
512
607
  const blocks = blockContent(slide, slidePath);
513
608
 
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 });
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) });
517
616
  return {
617
+ scriptFonts,
618
+ textMeasurement,
518
619
  geometry,
519
620
  assets: presentation.assets ?? {},
520
621
  index,
@@ -565,13 +666,18 @@ export function renderSvgDeck(input, options = {}) {
565
666
  }
566
667
 
567
668
  function renderResolvedSlide(resolved, slideIndex, options) {
568
- options = { ...options, _diagnosticPaths: new Set() };
569
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;
570
675
  const { width, height } = bound.design.dimensions;
571
676
  const title = bound.slide.title ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
572
677
  const children = [
573
678
  renderEmbeddedFonts(options.embeddedFonts),
574
679
  renderBackground(bound, width, height, options),
680
+ renderSlideImage(bound, options),
575
681
  renderBranding(bound, resolved.presentation, width, height, options),
576
682
  ...renderSlideContent(bound, width, height, options),
577
683
  renderFurniture(bound, resolved.presentation, width, height, options, "header"),
@@ -587,6 +693,8 @@ function renderResolvedSlide(resolved, slideIndex, options) {
587
693
  viewBox: `0 0 ${width} ${height}`,
588
694
  width,
589
695
  height,
696
+ lang,
697
+ "xml:lang": lang,
590
698
  ...traceAttrs(options, bound.path)
591
699
  },
592
700
  `\n${children.join("\n")}\n`
@@ -634,7 +742,10 @@ function renderBackground(bound, width, height, options) {
634
742
  }
635
743
  if(isPlainObject(background) && background.type==='pattern'){
636
744
  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}):'';
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'):'';
638
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);
639
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})`}));
640
751
  }
@@ -649,11 +760,68 @@ function renderBackground(bound, width, height, options) {
649
760
  });
650
761
  }
651
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
+
652
820
  function renderSlideContent(bound, width, height, options) {
653
- for (const diagnostic of bound.geometry.diagnostics) reportDiagnostic(diagnostic, options);
821
+ for (const diagnostic of [...bound.design.diagnostics, ...bound.geometry.diagnostics]) reportDiagnostic(diagnostic, options);
654
822
  return bound.geometry.items.map(item => {
655
823
  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}) : '';
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)}) : '';
657
825
  return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options);
658
826
  });
659
827
  }
@@ -695,7 +863,10 @@ function renderPayload(item, box, bound, options) {
695
863
  function renderTextPayload(item, box, bound, options) {
696
864
  return (Array.isArray(item.value) ? renderRichTextBox : renderTextBox)(Array.isArray(item.value) ? item.value : flattenText(item.value), box, bound, {
697
865
  path: item.path,
698
- 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),
699
870
  fontSize: item.field === "title" ? 54 : item.field === "tag" ? 16 : 25,
700
871
  fontFamily: item.field === "title" ? bound.design.fonts.heading : bound.design.fonts.body,
701
872
  fontWeight: item.field === "title" ? 700 : 400,
@@ -853,12 +1024,13 @@ function renderCode(item, box, bound, options) {
853
1024
  'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='body'?'#E5E7EB':'#93C5FD',
854
1025
  ...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
855
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}:{}),
856
- },line.segments.map(segment=>tag('tspan',{
1027
+ },line.segments.map(segment=>segmentSpan({
857
1028
  x:stableNumber(part.box.x+segment.x),
858
1029
  // SVG's CSS tab-size does not place literal tabs at their measured stops.
859
1030
  ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
860
1031
  ...(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('')));
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('')));
862
1034
  children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
863
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}:{}),
864
1036
  ...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
@@ -875,18 +1047,32 @@ function renderMetric(item, box, bound, options) {
875
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});
876
1048
  if (!part.visible) continue;
877
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;
878
1055
  const lines=part.fit.sourceLines.map((line,index)=>{
879
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(''));
880
1066
  return tag('text',{x:stableNumber(origin.x),y:stableNumber(origin.baseline),'text-anchor':'start',
881
1067
  'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
882
1068
  'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
883
1069
  'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
884
1070
  ...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
885
1071
  '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),
1072
+ },line.segments.map(segment=>segmentSpan({x:stableNumber(origin.x+segment.x),
887
1073
  ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
888
1074
  ...(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(''));
1075
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
890
1076
  });
891
1077
  children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,
892
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}:{}),
@@ -1037,10 +1223,23 @@ function renderImportedChart(item, box, bound, options) {
1037
1223
  const horizontal=kind==='bar',legendHeight=series.length>1?34:8;
1038
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)};
1039
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;
1040
- 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;
1041
1235
  const zero=horizontal?xValue(0):yValue(0);
1042
1236
  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));
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);
1044
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}));
1045
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'));
1046
1245
  }
@@ -1202,7 +1401,75 @@ function renderBranding(bound,presentation,width,height,options) {
1202
1401
 
1203
1402
  function fontStack(family, type) {
1204
1403
  const fallback = type === "serif" ? "serif" : type === "monospace" ? "monospace" : "sans-serif";
1205
- 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);
1206
1473
  }
1207
1474
 
1208
1475
  function renderEmbeddedFonts(fonts = []) {
@@ -1226,25 +1493,51 @@ function renderRichLines(value,fit,box,bound,config) {
1226
1493
  const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1227
1494
  let textOffset=0;
1228
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(''));
1229
1497
  // With no measurement provider, fragment advances are estimates. Let SVG
1230
1498
  // shape adjacent runs naturally inside each estimated line instead of turning
1231
1499
  // those estimates into visible gaps. Supplied measurements keep exact origins.
1232
- 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'));
1233
1502
  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;
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;
1236
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;
1237
1511
  // Accepted outline placement owns the horizontal advance. Geometric precision
1238
1512
  // avoids hinted browser advances; textLength also removes fractional-size
1239
1513
  // 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)};
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)};
1241
1518
  const runFill = run.color == null
1242
1519
  ? config.fill
1243
1520
  : 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));
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);
1245
1527
  if(run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
1246
1528
  return rendered;
1247
- });
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}:{}));
1248
1541
  if(!flow)return fragments.join('\n');
1249
1542
  const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
1250
1543
  const first=line.fragments[0];
@@ -1257,9 +1550,11 @@ function renderRichLines(value,fit,box,bound,config) {
1257
1550
  const start=cursor;cursor+=(fit.lines[index]??'').length;
1258
1551
  const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1259
1552
  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};
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'}:{})};
1261
1555
  }):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'));
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'));
1263
1558
  }
1264
1559
 
1265
1560
  function renderTextBox(text, box, bound, config) {
@@ -1282,21 +1577,39 @@ function renderTextBox(text, box, bound, config) {
1282
1577
  const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1283
1578
  const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
1284
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;
1285
1583
  const lines = fit.lines.map((line, index) => {
1286
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);
1287
1588
  const origin=placed?.x??x-(sourceLine?.width??0)*factor;
1288
1589
  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);
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;
1294
1607
  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),
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),
1297
1610
  "font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
1298
1611
  '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,
1612
+ textLength:!start&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
1300
1613
  ...traceAttrs(config.options, config.path),
1301
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}:{}),
1302
1615
  }, content);