@openpresentation/opf-render 0.11.9 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/svg.js CHANGED
@@ -7,9 +7,11 @@ import {
7
7
  // Optional core exports are read from the namespace so an older published core
8
8
  // still loads; resolveScriptFonts ships with core FF-18.
9
9
  import * as opfCore from "@openpresentation/opf";
10
- import { createScriptFonts } from "./script-fonts.js";
10
+ import { adjustedFontSize, baselineShift, createScriptFonts } from "./script-fonts.js";
11
11
  import { disabledFeaturesStyle } from "./font-compatibility.js";
12
+ import { fontPolicyFor } from "./font-policy.js";
12
13
  import { renderCatalogChart } from "./charts.js";
14
+ import { renderCaption, renderFootnotes } from "./annotations.js";
13
15
 
14
16
  export const packageName = "@openpresentation/opf-render";
15
17
 
@@ -181,6 +183,36 @@ function parseInput(input) {
181
183
  throw new OPFRenderError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
182
184
  }
183
185
 
186
+ // Template variables (core resolveVariables, RR-32): a deck that uses content variables, or a template, is resolved
187
+ // to a concrete deck before composition, so the preview and the PPTX exporter see the same text, numbers, dates and
188
+ // images. A template previews with each unfilled variable's example; a normal deck with an unfilled required
189
+ // variable is refused. Decks without content variables are returned untouched. Read from the namespace so an older
190
+ // published core still loads.
191
+ function resolveTemplateInput(presentation, options) {
192
+ if (typeof opfCore.resolveVariables !== "function" || !isPlainObject(presentation)) return presentation;
193
+ const values = options.variables;
194
+ // variables: false draws the document as authored, tokens and var: references included (the template editing view).
195
+ if (values === false) return presentation;
196
+ if (values !== undefined && !isPlainObject(values)) {
197
+ throw new OPFRenderError("invalid-variables", "The variables option must be an object keyed by variable id.", { path: "options.variables" });
198
+ }
199
+ const template = opfCore.isTemplate(presentation);
200
+ if (!template && !opfCore.hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
201
+ const result = opfCore.resolveVariables(presentation, values ?? {}, { examples: template });
202
+ const errors = result.diagnostics.filter((entry) => entry.severity === "error");
203
+ if (errors.length) {
204
+ const unfilled = errors.some((entry) => entry.code === "variable-unfilled");
205
+ throw new OPFRenderError(unfilled ? "unfilled-variables" : "invalid-variables", errors[0].message, {
206
+ issues: errors,
207
+ path: errors[0].path
208
+ });
209
+ }
210
+ for (const entry of result.diagnostics) {
211
+ if (entry.code === "variable-example-used") options.onDiagnostic?.({ code: "variable-example-used", path: entry.path, message: entry.message, id: entry.id });
212
+ }
213
+ return result.presentation;
214
+ }
215
+
184
216
  function assertValidBoundary(presentation) {
185
217
  const result = validatePresentation(presentation);
186
218
  if (!result.valid) {
@@ -539,18 +571,26 @@ function reportLanguageDiagnostic(context, diagnostic) {
539
571
  */
540
572
  function reportGlyphFallback(context, note) {
541
573
  context.glyphFallbackDiagnostics ??= new Set();
542
- const key = `${note.fontFamily}\u0000${note.fallbackFamily}\u0000${note.path ?? ""}`;
574
+ const key = `${note.fontFamily}\u0000${note.fallbackFamily}\u0000${note.placeholder ?? ""}\u0000${note.path ?? ""}`;
543
575
  if (context.glyphFallbackDiagnostics.has(key)) return;
544
576
  context.glyphFallbackDiagnostics.add(key);
577
+ // FF-45: a symbol-encoded family's note lists the codes drawn as their Unicode equivalents, or as the placeholder.
578
+ const symbol = Array.isArray(note.codes);
579
+ const codes = symbol ? note.codes.slice(0, 8).map(code => `0x${code}`).join(", ") : "";
545
580
  context.options.onDiagnostic?.({
546
581
  code: "font-glyph-fallback",
547
582
  ...(note.path ? { path: note.path } : {}),
548
583
  // The note is reported once per family pair and path: it names characters the face lacks, such as these, not every one.
549
- message: `'${note.fontFamily}' lacks glyphs for characters such as ${note.characters.slice(0, 8).map(character => `U+${character.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(", ")}; the preview draws those with '${note.fallbackFamily}'. The PPTX keeps the chosen font.`,
584
+ message: symbol
585
+ ? (note.placeholder
586
+ ? `'${note.fontFamily}' is symbol-encoded; codes such as ${codes} have no Unicode equivalent that a loaded face draws, so the preview draws the placeholder '${note.placeholder}' with '${note.fallbackFamily}'. The PPTX keeps the chosen font and the original codes.`
587
+ : `'${note.fontFamily}' is symbol-encoded and not bundled; the preview draws codes such as ${codes} as their Unicode equivalents with '${note.fallbackFamily}'. The PPTX keeps the chosen font and the original codes.`)
588
+ : `'${note.fontFamily}' lacks glyphs for characters such as ${note.characters.slice(0, 8).map(character => `U+${character.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(", ")}; the preview draws those with '${note.fallbackFamily}'. The PPTX keeps the chosen font.`,
550
589
  fontFamily: note.fontFamily,
551
590
  fallbackFamily: note.fallbackFamily,
552
591
  scripts: [...note.scripts],
553
- characters: [...note.characters]
592
+ characters: [...note.characters],
593
+ ...(symbol ? { codes: [...note.codes], ...(note.placeholder ? { placeholder: note.placeholder } : {}) } : {})
554
594
  });
555
595
  }
556
596
 
@@ -684,7 +724,13 @@ function bindSlide(presentation, slide, layout, index, context) {
684
724
  });
685
725
  const textMeasurement = scriptFonts.textMeasurement ?? context.options.textMeasurement;
686
726
  // fontScheme.accent (the tag and quote text) exists only with a core that resolves it; it takes the same look-alike policy.
687
- for (const role of ["heading","body","code","accent"]) if (design.fonts[role] !== undefined) design.fonts[role] =resolveTextStyle({fontFamily:design.fonts[role],fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
727
+ // RR-17 (FF-41): a family that names its weight (Arial Black, Segoe UI Semibold and Light) keeps its own name through composition, so each run
728
+ // resolves it again and draws the replacement's encoded weight (Montserrat 900, Red Hat Display 600 and 300); resolving the role to the
729
+ // replacement family first would draw every run at 400 or 700 and measure it that way. Every other family resolves once, here.
730
+ for (const role of ["heading","body","code","accent"]) if (design.fonts[role] !== undefined) {
731
+ const named = design.fonts[role], resolved = resolveTextStyle({fontFamily:named,fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
732
+ design.fonts[role] = fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
733
+ }
688
734
  const geometry = composeSlide(slide, { ...design.dimensions, layout, presentation, slideIndex: index, fonts: design.fonts, contentAlignment:design.contentAlignment, titleAlignment:design.titleAlignment, textRasterPadding:context.options.textRasterPadding, contentBox:design.contentBox, darkBackground: design.darkBackground, textMeasurement, date: context.options.date, socialPlatforms: socialPlatformRecords(context) });
689
735
  return {
690
736
  scriptFonts,
@@ -703,7 +749,7 @@ function bindSlide(presentation, slide, layout, index, context) {
703
749
  }
704
750
 
705
751
  export function resolvePresentation(input, options = {}) {
706
- const presentation = parseInput(input);
752
+ const presentation = resolveTemplateInput(parseInput(input), options);
707
753
  if (options.validate !== false) {
708
754
  assertValidBoundary(presentation);
709
755
  }
@@ -752,10 +798,18 @@ function renderResolvedSlide(resolved, slideIndex, options) {
752
798
  renderSlideImage(bound, options),
753
799
  renderBranding(bound, resolved.presentation, width, height, options),
754
800
  ...renderSlideContent(bound, width, height, options),
801
+ // RR-34: the footnote area core reserved above the footer band, after the content and before the furniture.
802
+ renderFootnotes(bound, options, drawHelpers),
755
803
  renderFurniture(bound, resolved.presentation, width, height, options, "header"),
756
804
  renderFurniture(bound, resolved.presentation, width, height, options, "footer")
757
805
  ].filter(Boolean);
758
806
  const children = [renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
807
+ // FF-44: Chromium (123+) trims adjacent fullwidth punctuation by default (CSS text-spacing-trim: normal; a sequence such as
808
+ // 「」。 is up to 10 percent narrower), but measurement and PowerPoint advance every such character by its full width, so the browser
809
+ // would stretch the glyphs back to the pinned textLength. space-all keeps the drawn advances equal to the measured ones. Only slides
810
+ // that draw such punctuation carry it, so other output is unchanged.
811
+ const drawn = content.join("");
812
+ const trimsPunctuation = /[ -〿＀-￯]/.test(drawn) || (script?.scriptRole === "eastAsian" && /[‘-”]/.test(drawn));
759
813
 
760
814
  return tag(
761
815
  "svg",
@@ -768,6 +822,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
768
822
  height,
769
823
  lang,
770
824
  "xml:lang": lang,
825
+ style: trimsPunctuation ? "text-spacing-trim:space-all" : undefined,
771
826
  ...traceAttrs(options, bound.path)
772
827
  },
773
828
  `\n${children.join("\n")}\n`
@@ -815,10 +870,14 @@ function renderBackground(bound, width, height, options) {
815
870
  }
816
871
  if(isPlainObject(background) && background.type==='pattern'){
817
872
  const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=resolveBackgroundColor(pattern.foregroundColor,bound.design,bound.design.colors.text),preset=pattern.preset;
818
- // 8px cells approximating DrawingML presets. diagStripe is the engine id that
819
- // PPTX export writes as wdUpDiag, so both draw the same rising stripe.
873
+ // Without core's pattern table (an older core) five presets keep their hand-drawn approximation. diagStripe is the
874
+ // engine id that PPTX export writes as wdUpDiag, so both draw the same rising stripe.
820
875
  const stroke=(d,width=1)=>tag('path',{d,fill:'none',stroke:color,'stroke-width':width});
821
- const mark=preset==='ltHorz'?tag('path',{d:'M0 4H8',stroke:color,'stroke-width':1}):preset==='diagStripe'||preset==='wdUpDiag'?tag('path',{d:'M-2 2L2 -2M0 8L8 0M6 10L10 6',stroke:color,'stroke-width':2}):preset==='pct5'?tag('circle',{cx:2,cy:2,r:.8,fill:color}):preset==='openDmnd'?stroke('M0 4L4 0L8 4L4 8Z'):preset==='wave'?stroke('M0 4C2 1 2 1 4 4S6 7 8 4'):'';
876
+ // RR-07: every DrawingML preset (core PATTERN_PRESETS) is an 8x8 bitmap, one unit per 1/96 inch, anchored at the slide origin.
877
+ const runs=typeof opfCore.patternRuns==='function'?opfCore.patternRuns(preset):undefined;
878
+ const bitmap=runs?tag('path',{d:runs.map(run=>`M${run.x} ${run.y}h${run.width}v1h-${run.width}z`).join(''),fill:color,'shape-rendering':'crispEdges'}):'';
879
+ const legacy=preset==='ltHorz'?tag('path',{d:'M0 4H8',stroke:color,'stroke-width':1}):preset==='diagStripe'||preset==='wdUpDiag'?tag('path',{d:'M-2 2L2 -2M0 8L8 0M6 10L10 6',stroke:color,'stroke-width':2}):preset==='pct5'?tag('circle',{cx:2,cy:2,r:.8,fill:color}):preset==='openDmnd'?stroke('M0 4L4 0L8 4L4 8Z'):preset==='wave'?stroke('M0 4C2 1 2 1 4 4S6 7 8 4'):'';
880
+ const mark=bitmap||legacy;
822
881
  if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
823
882
  return tag('g',{opacity:background.opacity??1},tag('rect',{width,height,fill:bound.design.backgroundColor??'#FFFFFF'})+tag('defs',{},tag('pattern',{id,width:8,height:8,patternUnits:'userSpaceOnUse'},mark))+tag('rect',{width,height,fill:`url(#${id})`}));
824
883
  }
@@ -895,9 +954,12 @@ function renderSlideContent(bound, width, height, options) {
895
954
  return bound.geometry.items.map(item => {
896
955
  const frame=item.frameBox;
897
956
  const surface=frame ? tag('rect',{x:frame.x,y:frame.y,width:frame.width,height:frame.height,rx:8*Math.min(width,height)/720,fill:bound.design.colors.surface,stroke:bound.design.colors.border,...traceAttrs(options,item.path)}) : '';
898
- return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options);
957
+ // RR-34: a captioned item draws its media in item.box and its caption band after it (src/annotations.js).
958
+ return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options)+(item.caption?renderCaption(item,{ ...bound, composition: item.composition },options,drawHelpers):'');
899
959
  });
900
960
  }
961
+ // Draw helpers injected into src/annotations.js (captions and footnote areas).
962
+ const drawHelpers = { tag, renderTextBox, renderRichLines };
901
963
 
902
964
  function reportDiagnostic(diagnostic, options) {
903
965
  const key = `${diagnostic.code}:${diagnostic.path}:${diagnostic.reason ?? ''}`;
@@ -957,13 +1019,13 @@ function renderTextPayload(item, box, bound, options) {
957
1019
 
958
1020
  function renderList(item, box, bound, options) {
959
1021
  const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
960
- const fit=item.text?.listEntries?item.text:fitList(item.value,box,25*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,{style:{fontFamily:bound.design.fonts.body,fontWeight:400,path:item.path},textMeasurement:options.textMeasurement});
1022
+ const fit=item.text?.listEntries?item.text:fitList(item.value,box,25*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,{style:{fontFamily:bound.design.fonts.body,fontWeight:400,path:item.path},textMeasurement:options.textMeasurement,...(item.payload?.numbering!==undefined?{numbering:item.payload.numbering}:{})});
961
1023
  const children=[];
962
1024
  // design.listBullet=image: core attaches the icon logo as item.bulletImage and its box (entry.bulletBox: 0.65 em, as PowerPoint draws a:buBlip). An icon that cannot be drawn keeps the glyph marker.
963
1025
  const bullet=item.bulletImage?resolveBulletImage(item.bulletImage,bound,options):undefined;
964
1026
  for(const entry of fit.listEntries){
965
1027
  if(bullet){const box=entry.bulletBox??{x:entry.marker.x,y:entry.marker.y-entry.marker.fontSize*.65,width:entry.marker.fontSize*.65,height:entry.marker.fontSize*.65};children.push(tag('image',{x:stableNumber(box.x),y:stableNumber(box.y),width:stableNumber(box.width),height:stableNumber(box.height),href:bullet,preserveAspectRatio:'xMidYMid meet','aria-hidden':'true',...(options.trace?{'data-opf-generated':'true'}:{})}));}
966
- else children.push(tag('text',{x:stableNumber(entry.marker.x),y:stableNumber(entry.marker.y),'font-family':fontStack(entry.marker.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(entry.marker.fontSize),fill:bound.design.colors.text,'aria-hidden':'true'},escapeText(entry.marker.text)));
1028
+ else children.push(tag('text',{x:stableNumber(entry.marker.x),y:stableNumber(entry.marker.y),'font-family':fontStack(entry.marker.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(entry.marker.fontSize),...numberMarkerStyle(entry.marker),fill:bound.design.colors.text,'aria-hidden':'true',...(entry.marker.anchor==='end'?{'text-anchor':'end'}:{})},escapeText(entry.marker.text)));
967
1029
  const config={path:entry.textPath,align:'left',fill:bound.design.colors.text,rich:Array.isArray(entry.value),options};
968
1030
  children.push(renderRichLines(typeof entry.value==='string'?[entry.value]:entry.value,entry.text,entry.textBox,bound,config));
969
1031
  if(entry.description)children.push(renderRichLines(typeof entry.descriptionValue==='string'?[entry.descriptionValue]:entry.descriptionValue,entry.description,entry.descriptionBox,bound,{...config,path:entry.descriptionPath,rich:Array.isArray(entry.descriptionValue),fill:bound.design.colors.mutedText}));
@@ -971,6 +1033,13 @@ function renderList(item, box, bound, options) {
971
1033
  return tag('g',{...traceAttrs(options,item.path),...(fit.overflow?{'data-opf-overflow':'true'}:{})},children.join('\n'));
972
1034
  }
973
1035
 
1036
+ // A numbered list's marker (numbering) draws the number with the weight and slant core measured it at: PowerPoint draws an
1037
+ // auto-number in the first run's character formatting. Bullet markers carry no such attributes.
1038
+ function numberMarkerStyle(marker) {
1039
+ if (!marker.number) return {};
1040
+ return {'font-weight':marker.style.fontWeight===400?undefined:marker.style.fontWeight,'font-style':marker.style.italic?'italic':undefined};
1041
+ }
1042
+
974
1043
  // Follows asset: references and the host imageResolver to the drawable source of an image value.
975
1044
  function resolveImageSource(item, bound, options) {
976
1045
  let asset = normalizeAsset(item.value);
@@ -985,8 +1054,146 @@ function resolveImageSource(item, bound, options) {
985
1054
  asset = {...normalizeAsset(bound.assets[id]),...overrides};
986
1055
  }
987
1056
  const source = options.imageResolver?.(asset.src, { asset, path: item.path }) ?? asset.src;
988
- const drawable = typeof source === "string" && /^data:image\/(png|jpeg|gif|webp);base64,/i.test(source);
989
- return { asset, source, drawable, missingReference };
1057
+ // An SVG data URI (base64 or text, any encoding) is drawn as an image too, as a base64 URI: an SVG used as an image never
1058
+ // runs scripts or loads anything outside itself, in a browser or in the PNG/PDF rasterizer.
1059
+ const svg = typeof source === "string" ? svgImageSource(source) : null;
1060
+ const drawable = svg !== null || (typeof source === "string" && /^data:image\/(png|jpeg|gif|webp);base64,/i.test(source));
1061
+ return { asset, source: svg ?? source, drawable, missingReference };
1062
+ }
1063
+
1064
+ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
1065
+
1066
+ // The base64 data URI of a drawable SVG, else null. Drawable: a data URI whose type is image/svg+xml, whose text is
1067
+ // well-formed XML (and safe: no external or markup entity) with an <svg> root in the SVG namespace and an intrinsic size
1068
+ // (width and height, or a viewBox) - the same checks opf-pptx makes before it writes a native SVG picture over a PNG
1069
+ // fallback - so a document the export refuses (malformed, unsafe, no size) is the placeholder in the preview too, and the
1070
+ // two agree. The scripts, foreign objects and external references a valid document may carry are never run or loaded: an
1071
+ // SVG used as an image reaches nothing outside itself.
1072
+ function svgImageSource(uri) {
1073
+ const header = /^data:([^;,]*)((?:;[^;,]*)*),/i.exec(uri);
1074
+ if (!header || !/^image\/svg\+xml$/i.test(header[1])) return null;
1075
+ let bytes;
1076
+ try {
1077
+ const payload = uri.slice(header[0].length);
1078
+ bytes = /;base64/i.test(header[2]) ? Uint8Array.from(atob(payload.replace(/\s+/g, "")), char => char.charCodeAt(0)) : new TextEncoder().encode(decodeURIComponent(payload));
1079
+ } catch { return null; }
1080
+ if (bytes.length > SVG_IMAGE_MAX_BYTES) return null;
1081
+ let text;
1082
+ try { text = decodeSvgText(bytes); } catch { return null; }
1083
+ const root = svgRootAttributes(text);
1084
+ if (!root || !svgHasIntrinsicSize(root)) return null;
1085
+ let binary = "";
1086
+ for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
1087
+ return `data:image/svg+xml;base64,${btoa(binary)}`;
1088
+ }
1089
+
1090
+ const SVG_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
1091
+
1092
+ // UTF-8 (with or without a BOM), UTF-16 with a BOM, or the encoding the XML declaration names; throws on bytes the encoding cannot decode.
1093
+ function decodeSvgText(bytes) {
1094
+ let start = 0, label = "utf-8";
1095
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) start = 3;
1096
+ else if (bytes[0] === 0xff && bytes[1] === 0xfe) { label = "utf-16le"; start = 2; }
1097
+ else if (bytes[0] === 0xfe && bytes[1] === 0xff) { label = "utf-16be"; start = 2; }
1098
+ else {
1099
+ const declared = /^\s*<\?xml\b[^>]*\bencoding\s*=\s*["']([A-Za-z0-9._-]+)["']/.exec(String.fromCharCode(...bytes.subarray(0, 200)))?.[1];
1100
+ if (declared && !/^utf-?8$/i.test(declared)) label = declared;
1101
+ }
1102
+ return new TextDecoder(label, { fatal: true }).decode(bytes.subarray(start));
1103
+ }
1104
+
1105
+ const SVG_START_TAG = /<([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)((?:\s+[^\s=\/>"'<]+\s*=\s*(?:"[^"<]*"|'[^'<]*'))*)\s*(\/?)>/y;
1106
+ const SVG_ATTRIBUTE = /\s+([^\s=\/>"'<]+)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/y;
1107
+ const SVG_END_TAG = /<\/([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)\s*>/y;
1108
+
1109
+ // The root element's attributes (references expanded) of a well-formed, safe SVG document in the SVG namespace, else null.
1110
+ // A small XML check, not a parser: tags nest and close, attributes are quoted and unique, the one root is <svg>, text sits
1111
+ // inside it, and every reference is one of the five predefined entities, a numeric reference or a plain-text entity the
1112
+ // DOCTYPE declares. An entity that is external or contains markup, or expansions beyond the size limit, refuse the document.
1113
+ function svgRootAttributes(text) {
1114
+ const stack = [], entities = new Map();
1115
+ let index = 0, rootDone = false, rootAttributes = null, expansion = 0;
1116
+ const references = value => value.replace(/&([^;\s&<]*);?/g, (all, name) => {
1117
+ if (!all.endsWith(";")) throw new Error("reference");
1118
+ if (/^(?:amp|lt|gt|quot|apos|#\d{1,7}|#x[0-9a-fA-F]{1,6})$/.test(name)) return all;
1119
+ if (!entities.has(name)) throw new Error("undeclared entity");
1120
+ const expanded = entities.get(name);
1121
+ if ((expansion += expanded.length) > SVG_IMAGE_MAX_BYTES) throw new Error("entity expansion");
1122
+ return expanded;
1123
+ });
1124
+ try {
1125
+ while (index < text.length) {
1126
+ if (text[index] !== "<") {
1127
+ const end = text.indexOf("<", index), chunk = text.slice(index, end < 0 ? text.length : end);
1128
+ index += chunk.length;
1129
+ if (!stack.length) { if (chunk.trim()) return null; }
1130
+ else { if (chunk.includes("]]>")) return null; references(chunk); }
1131
+ continue;
1132
+ }
1133
+ if (text.startsWith("<!--", index)) {
1134
+ const end = text.indexOf("-->", index + 4);
1135
+ if (end < 0) return null;
1136
+ index = end + 3;
1137
+ } else if (text.startsWith("<![CDATA[", index)) {
1138
+ const end = text.indexOf("]]>", index + 9);
1139
+ if (end < 0 || !stack.length) return null;
1140
+ index = end + 3;
1141
+ } else if (text.startsWith("<?", index)) {
1142
+ const end = text.indexOf("?>", index + 2);
1143
+ if (end < 0) return null;
1144
+ index = end + 2;
1145
+ } else if (text.startsWith("<!DOCTYPE", index)) {
1146
+ if (rootDone || stack.length || rootAttributes) return null;
1147
+ const match = /^<!DOCTYPE\s+[^\[>]*(?:\[([\s\S]*?)\]\s*)?>/.exec(text.slice(index));
1148
+ if (!match) return null;
1149
+ for (const declaration of (match[1] ?? "").matchAll(/<!ENTITY\b[^>]*>/g)) {
1150
+ const entity = /^<!ENTITY\s+([A-Za-z_][\w.-]*)\s+(?:"([^"]*)"|'([^']*)')\s*>$/.exec(declaration[0]);
1151
+ const value = entity?.[2] ?? entity?.[3];
1152
+ if (!entity || value.includes("&") || value.includes("<")) return null;
1153
+ entities.set(entity[1], value);
1154
+ }
1155
+ index += match[0].length;
1156
+ } else if (text.startsWith("</", index)) {
1157
+ SVG_END_TAG.lastIndex = index;
1158
+ const match = SVG_END_TAG.exec(text);
1159
+ if (!match || stack.pop() !== match[1]) return null;
1160
+ index = SVG_END_TAG.lastIndex;
1161
+ if (!stack.length) rootDone = true;
1162
+ } else {
1163
+ SVG_START_TAG.lastIndex = index;
1164
+ const match = SVG_START_TAG.exec(text);
1165
+ if (!match) return null;
1166
+ index = SVG_START_TAG.lastIndex;
1167
+ const [, qualified, source, selfClosing] = match;
1168
+ const attributes = [], names = new Set();
1169
+ SVG_ATTRIBUTE.lastIndex = 0;
1170
+ for (let attribute; source && (attribute = SVG_ATTRIBUTE.exec(source));) {
1171
+ if (names.has(attribute[1])) return null;
1172
+ names.add(attribute[1]);
1173
+ attributes.push([attribute[1], references(attribute[2] ?? attribute[3])]);
1174
+ }
1175
+ if (!stack.length) {
1176
+ if (rootDone || rootAttributes) return null;
1177
+ const colon = qualified.indexOf(":");
1178
+ if (qualified.slice(colon + 1) !== "svg") return null;
1179
+ const namespace = colon < 0 ? "xmlns" : `xmlns:${qualified.slice(0, colon)}`;
1180
+ if (!attributes.some(([key, value]) => key === namespace && value === SVG_NAMESPACE)) return null;
1181
+ rootAttributes = Object.fromEntries(attributes);
1182
+ }
1183
+ if (selfClosing) { if (!stack.length) rootDone = true; }
1184
+ else stack.push(qualified);
1185
+ }
1186
+ }
1187
+ } catch { return null; }
1188
+ return stack.length || !rootAttributes ? null : rootAttributes;
1189
+ }
1190
+
1191
+ // The root has an intrinsic size: width and height lengths, or a viewBox (the same rule core and opf-pptx use).
1192
+ function svgHasIntrinsicSize(attributes) {
1193
+ const length = value => { const match = value && /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(px|pt|pc|mm|cm|in|q)?\s*$/i.exec(value); return match && Number(match[1]) > 0 ? Number(match[1]) : undefined; };
1194
+ const box = (attributes.viewBox ?? "").trim().split(/[\s,]+/).map(Number);
1195
+ const view = box.length === 4 && box.every(Number.isFinite) && box[2] > 0 && box[3] > 0;
1196
+ return Boolean((length(attributes.width) && length(attributes.height)) || view);
990
1197
  }
991
1198
 
992
1199
  // The drawable data URI of a picture bullet, or undefined (after reporting unresolved-asset once) so the glyph marker stays.
@@ -1004,11 +1211,11 @@ function renderImage(item, box, bound, options) {
1004
1211
  const { asset, source, drawable, missingReference } = resolveImageSource(item, bound, options);
1005
1212
  if (drawable) {
1006
1213
  return tag("image", { x: box.x, y: box.y, width: box.width, height: box.height,
1007
- href: source, preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : (options.imageFit ?? (bound.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image",
1214
+ href: source, preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : options.imageAnchor === "right" ? "xMaxYMid meet" : (options.imageFit ?? (bound.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image",
1008
1215
  ...traceAttrs(options, item.path), ...generatedAttrs(options) });
1009
1216
  }
1010
1217
  const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
1011
- const message=missingReference?`Image asset ${missingReference} is missing; supply the referenced asset or resolve it in the host.`:'Image requires an embedded raster data URI or a host imageResolver.';
1218
+ const message=missingReference?`Image asset ${missingReference} is missing; supply the referenced asset or resolve it in the host.`:'Image requires an embedded raster or SVG data URI, or a host imageResolver.';
1012
1219
  if (options.strictAssets) throw new OPFRenderError("unresolved-asset", message, { path: item.path,reason });
1013
1220
  const fill = bound.design.colors.surface;
1014
1221
  const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
@@ -1101,6 +1308,7 @@ function renderCode(item, box, bound, options) {
1101
1308
  const invalid = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
1102
1309
  if (invalid) throw new OPFRenderError('invalid-code-text', `Code text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before rendering.`, {path:part.path});
1103
1310
  }
1311
+ const syntax = codeSyntax(item, layout, bound, options);
1104
1312
  const children = [
1105
1313
  tag("rect", {
1106
1314
  x: box.x,
@@ -1128,7 +1336,7 @@ function renderCode(item, box, bound, options) {
1128
1336
  ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
1129
1337
  ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
1130
1338
  // Code stays left to right; script runs still take their slot fonts.
1131
- },segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false})).join('')));
1339
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false,fontSize:part.fit.fontSize},part.role==='body'?syntax:undefined)).join('')));
1132
1340
  children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
1133
1341
  'data-opf-box-x':part.box.x,'data-opf-box-y':part.box.y,'data-opf-box-width':part.box.width,'data-opf-box-height':part.box.height}:{}),
1134
1342
  ...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
@@ -1140,6 +1348,9 @@ function renderMetric(item, box, bound, options) {
1140
1348
  const layout=item.metricLayout;
1141
1349
  if (!layout) throw new OPFRenderError('missing-metric-layout','Metric rendering requires coordinated core metric geometry.',{path:item.path});
1142
1350
  const children=[];
1351
+ // RR-07: a trend draws an arrow beside its word and colours the trend and delta text, from core's accepted geometry.
1352
+ const trendMark=metricTrendMark(layout,bound,options);
1353
+ const partFill=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color:part.role==='value'?bound.design.colors.primary:bound.design.colors.text;
1143
1354
  for (const part of layout.parts) {
1144
1355
  const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
1145
1356
  if (invalid) throw new OPFRenderError('invalid-metric-text',`Metric text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before rendering.`,{path:part.path});
@@ -1155,30 +1366,37 @@ function renderMetric(item, box, bound, options) {
1155
1366
  if(factor&&!line.segments.some(segment=>segment.kind==='tab'))return tag('text',{x:stableNumber(origin.x+line.width*factor),y:stableNumber(origin.baseline),'text-anchor':factor===1?'end':'middle',
1156
1367
  'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
1157
1368
  'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
1158
- 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
1369
+ 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:partFill(part),
1159
1370
  ...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
1160
1371
  'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
1161
1372
  },line.segments.map(segment=>segmentSpan({
1162
1373
  ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
1163
- },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
1374
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start),fontSize:part.fit.fontSize})).join(''));
1164
1375
  return tag('text',{x:stableNumber(origin.x),y:stableNumber(origin.baseline),'text-anchor':'start',
1165
1376
  'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
1166
1377
  'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
1167
- 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
1378
+ 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:partFill(part),
1168
1379
  ...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
1169
1380
  'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
1170
1381
  },line.segments.map(segment=>segmentSpan({x:stableNumber(origin.x+segment.x),
1171
1382
  ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
1172
1383
  ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
1173
- },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
1384
+ },segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start),fontSize:part.fit.fontSize})).join(''));
1174
1385
  });
1175
1386
  children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,
1176
1387
  'data-opf-box-x':part.box.x,'data-opf-box-y':part.box.y,'data-opf-box-width':part.box.width,'data-opf-box-height':part.box.height}:{}),
1177
1388
  ...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
1178
1389
  }
1390
+ if (trendMark) children.push(tag('g',{role:'img','aria-label':trendMark.ariaLabel},tag('polygon',{points:trendMark.points.map(([x,y])=>`${stableNumber(x)},${stableNumber(y)}`).join(' '),fill:trendMark.color})));
1179
1391
  return tag('g',{...traceAttrs(options,item.path),...(options.trace?{'data-opf-metric-container':'true'}:{})},children.join('\n'));
1180
1392
  }
1181
1393
 
1394
+ // The trend arrow and colour for a laid-out metric (core metricTrendMark); undefined without a trend or without core support.
1395
+ function metricTrendMark(layout,bound,options) {
1396
+ if (typeof opfCore.metricTrendMark!=='function') return undefined;
1397
+ return opfCore.metricTrendMark(layout,{background:bound.design.backgroundColor??bound.design.colors.background});
1398
+ }
1399
+
1182
1400
  function renderQuote(item, box, bound, options) {
1183
1401
  const layout = item.quoteLayout;
1184
1402
  if (!layout) throw new OPFRenderError('missing-quote-layout', 'Quote rendering requires a coordinated core build with shared quote geometry.', {path:item.path});
@@ -1211,7 +1429,7 @@ function renderTimeline(item, box, bound, options) {
1211
1429
 
1212
1430
  function renderTable(item, box, bound, options) {
1213
1431
  const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
1214
- const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, textMeasurement:options.textMeasurement, path:item.path});
1432
+ const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, textMeasurement:options.textMeasurement, path:item.path, ...(bound.geometry?.direction === "rtl" ? {direction:"rtl"} : {})});
1215
1433
  const children = [];
1216
1434
  const separateBorders = layout.rows.some(row => row.cells.some(cell => cell.style?.borders));
1217
1435
  const defaultEdges = [], explicitEdges = [];
@@ -1419,13 +1637,18 @@ function renderBranding(bound,presentation,width,height,options) {
1419
1637
  }
1420
1638
  // Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
1421
1639
  const logo=bound.geometry.logo;
1422
- if(logo)pieces.push(renderImage({value:logo.source,path:logo.path},logo.box,bound,{...options,imageAnchor:'left',imageLabel:'Logo',imageGenerated:true}));
1640
+ if(logo)pieces.push(renderImage({value:logo.source,path:logo.path},logo.box,bound,{...options,imageAnchor:logo.anchor??'left',imageLabel:'Logo',imageGenerated:true}));
1423
1641
  return pieces.join('');
1424
1642
  }
1425
1643
 
1644
+ // A family name is written unquoted only when it is a valid sequence of CSS identifiers; a word that starts with a digit
1645
+ // ("Source Sans 3", "Noto Sans Symbols 2") or any other non-identifier character makes an unquoted name invalid CSS, which
1646
+ // drops the whole font-family in a browser (FF-45). Such names are single-quoted (the attribute is double-quoted).
1647
+ const cssIdentifierSequence = /^(?:-?(?:[A-Za-z_ -￿]|\\.)(?:[\w -￿-]|\\.)*)(?: (?:-?(?:[A-Za-z_ -￿]|\\.)(?:[\w -￿-]|\\.)*))*$/;
1648
+ const cssFamily = name => cssIdentifierSequence.test(name) && !/^(?:inherit|initial|unset|default|serif|sans-serif|monospace|cursive|fantasy|system-ui)$/i.test(name) ? name : `'${name.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
1426
1649
  function fontStack(family, type) {
1427
1650
  const fallback = type === "serif" ? "serif" : type === "monospace" ? "monospace" : "sans-serif";
1428
- return `${[family].flat().join(", ")}, ${fallback}`;
1651
+ return `${[family].flat().map(cssFamily).join(", ")}, ${fallback}`;
1429
1652
  }
1430
1653
 
1431
1654
  // Unicode directional isolates (FF-19): each paragraph (text between hard line
@@ -1438,6 +1661,10 @@ const RIGHT_TO_LEFT_ISOLATE = "\u2067", POP_DIRECTIONAL_ISOLATE = "\u2069";
1438
1661
  // Core owns the rule (paragraphDirection, core #134), so preview and export agree.
1439
1662
  // Without it (published core 0.11.0) every paragraph is left to right, as in export.
1440
1663
  const coreParagraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
1664
+ // RR-05: authored alignment is logical for right-to-left text, so `left` is the start edge (drawn at the right of a
1665
+ // right-to-left line) and `right` the end edge. Core owns the rule; the fallback only serves a core without it, which
1666
+ // also reports no line directions, so it never flips.
1667
+ const physicalAlignment = typeof opfCore.physicalAlignment === "function" ? opfCore.physicalAlignment : alignment => alignment;
1441
1668
  /** Maps a source offset of the text to whether its paragraph is right to left. */
1442
1669
  function paragraphRtl(bound, text) {
1443
1670
  if (bound.scriptFonts?.rtl !== true || !coreParagraphDirection) return () => false;
@@ -1465,43 +1692,81 @@ function nestedReset(style, run) {
1465
1692
  return parent && !disabledFeaturesStyle([run.stack ?? run.family].flat()[0]) ? RESET_POLICY_FEATURES : undefined;
1466
1693
  }
1467
1694
 
1468
- function scriptLine(text, style, bound, type, { rtl = false, placement, trace } = {}) {
1695
+ function scriptLine(text, style, bound, type, { rtl = false, placement, trace, fontSize } = {}) {
1469
1696
  const value = String(text ?? "");
1470
1697
  const scripts = bound.scriptFonts;
1471
1698
  rtl = rtl && value !== "";
1472
1699
  const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
1473
1700
  const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
1474
- if (runs.length === 1) {
1701
+ // RR-38: PowerPoint's baseline of a line in Arabic Typesetting sits above core's (one em below the line top): the caller moves it up by `baselineShift` em.
1702
+ const shiftOf = list => { const shift = baselineShift(list); return shift ? { baselineShift: shift } : {}; };
1703
+ // FF-45: symbol runs (Wingdings, Symbol, Webdings codes drawn as their Unicode equivalents) are always positioned when the
1704
+ // line is placed, one tspan per glyph at the verified symbol font's advance, so a single mapped glyph is never stretched to it.
1705
+ const symbols = runs.some(run => run.symbol);
1706
+ if (runs.length === 1 && !(symbols && placement && placement.width > 0)) {
1475
1707
  const [run] = runs;
1476
- return { content: isolate(escapeText(value)), family: run.own ? undefined : fontStack(run.stack ?? run.family, type) };
1708
+ // RR-38: a replacement the policy scales (Arabic Typesetting -> Noto Naskh Arabic, 0.64) is drawn at the scaled size; the caller applies it.
1709
+ return { content: isolate(escapeText(run.own ? value : run.text)), family: run.own ? undefined : fontStack(run.stack ?? run.family, type), ...(run.sizeAdjust ? { sizeAdjust: run.sizeAdjust } : {}), ...shiftOf(runs) };
1477
1710
  }
1711
+ const baseSize = placement?.fontSize ?? fontSize;
1712
+ const adjusted = run => run.sizeAdjust && baseSize > 0 ? stableNumber(adjustedFontSize(baseSize, run.sizeAdjust)) : undefined;
1478
1713
  const widths = placement && placement.width > 0 ? scripts.runWidths(runs, placement.fontSize, style) : undefined;
1479
1714
  if (!widths) {
1480
1715
  return { content: isolate(runs.map(run => run.own ? escapeText(run.text)
1481
- : tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type), style: nestedReset(style, run) }, escapeText(run.text))).join("")) };
1716
+ : tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type), "font-size": adjusted(run), style: nestedReset(style, run) }, escapeText(run.text))).join("")), ...shiftOf(runs) };
1482
1717
  }
1718
+ const natural = symbols ? scripts.runWidths(runs, placement.fontSize, style, { natural: true }) : undefined;
1483
1719
  const total = widths.reduce((sum, width) => sum + width, 0), factor = total > 0 ? placement.width / total : 1;
1484
1720
  let advance = 0, offset = 0;
1485
1721
  const content = runs.map((run, index) => {
1486
1722
  const width = widths[index] * factor, left = rtl ? placement.width - advance - width : advance;
1487
1723
  advance += width;
1488
1724
  const start = offset;
1489
- offset += run.text.length;
1725
+ offset += run.symbol ? run.symbol.source.length : run.text.length;
1726
+ // A symbol glyph keeps the open face's shape: it is compressed to its code's advance only when wider, never stretched.
1727
+ const pinned = run.symbol ? natural[index] > width + 1e-6 : width > 0;
1490
1728
  return tag("tspan", {
1491
1729
  x: stableNumber(placement.x + left),
1492
- textLength: width > 0 ? stableNumber(width) : undefined, lengthAdjust: width > 0 ? "spacingAndGlyphs" : undefined,
1730
+ textLength: pinned ? stableNumber(width) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
1493
1731
  "font-family": run.own ? undefined : fontStack(run.family, type),
1732
+ "font-size": adjusted(run),
1494
1733
  style: run.own ? undefined : nestedReset(style, run),
1495
1734
  ...(trace ? trace(start, offset) : {})
1496
1735
  }, isolate(escapeText(run.text)));
1497
1736
  }).join("");
1498
- return { content, positioned: true };
1737
+ return { content, positioned: true, ...shiftOf(runs) };
1499
1738
  }
1500
1739
 
1501
1740
  /** One positioned code/metric segment tspan; its script runs flow inside it (no textLength). */
1502
- function segmentSpan(attrs, segment, text, style, bound, type, options) {
1741
+ function segmentSpan(attrs, segment, text, style, bound, type, options, syntax) {
1503
1742
  const scripted = segment.kind === "tab" ? { content: escapeText(text) } : scriptLine(text, style, bound, type, options);
1504
- return tag("tspan", { ...attrs, "font-family": scripted.family }, scripted.content);
1743
+ // Syntax highlighting (RR-07): a coloured text segment becomes consecutive sibling tspans, one per run, each a
1744
+ // traced segment of its own source range holding one text node (the editor's caret mapping reads that), the first
1745
+ // keeping the accepted x and the rest flowing after it. Plain code keeps one tspan per accepted segment.
1746
+ const runs = syntax && segment.kind !== "tab" ? opfCore.codeLineRuns(syntax.tokens, segment.start, segment.end) : undefined;
1747
+ if (runs?.some(run => run.kind)) {
1748
+ return runs.map((run, index) => {
1749
+ const sizeOf = line => line.sizeAdjust && options?.fontSize > 0 ? stableNumber(adjustedFontSize(options.fontSize, line.sizeAdjust)) : undefined;
1750
+ const piece = scriptLine(text.slice(run.start - segment.start, run.end - segment.start), style, bound, type, options);
1751
+ const own = { ...attrs };
1752
+ if (index > 0) delete own.x;
1753
+ if (own["data-opf-text-start"] !== undefined) { own["data-opf-text-start"] = run.start; own["data-opf-text-end"] = run.end; }
1754
+ return tag("tspan", { ...own, fill: run.kind ? syntax.palette[run.kind] : undefined, "font-family": piece.family, "font-size": sizeOf(piece) }, piece.content);
1755
+ }).join("");
1756
+ }
1757
+ return tag("tspan", { ...attrs, "font-family": scripted.family, "font-size": scripted.sizeAdjust && options?.fontSize > 0 ? stableNumber(adjustedFontSize(options.fontSize, scripted.sizeAdjust)) : undefined }, scripted.content);
1758
+ }
1759
+
1760
+ // Token ranges of the code body and the palette to paint them with; undefined for plain code (an unknown language,
1761
+ // no language, or a core without the shared highlighter). The preview and the PPTX export read the same tables.
1762
+ function codeSyntax(item, layout, bound, options) {
1763
+ const body = layout.parts.find(part => part.role === "body");
1764
+ const language = typeof item.value?.language === "string" ? item.value.language : layout.parts.find(part => part.role === "language")?.text;
1765
+ if (!body || !language) return undefined;
1766
+ if (typeof opfCore.tokenizeCode !== "function") return undefined;
1767
+ const tokens = opfCore.tokenizeCode(body.text, language);
1768
+ if (!tokens.length) return undefined;
1769
+ return { tokens, palette: opfCore.codeSyntaxPaletteForScheme(bound.design.colorScheme) };
1505
1770
  }
1506
1771
 
1507
1772
  // Faces flagged embed:"used" (the vendored open, Intos and script-pack faces) are embedded only when the slide's own markup draws
@@ -1564,8 +1829,30 @@ function renderRichTextBox(value, box, bound, config) {
1564
1829
  return renderRichLines(value,fit,box,bound,config);
1565
1830
  }
1566
1831
 
1832
+ // A rich line without its leading and trailing whitespace: fragments keep their order and styles, positions and widths follow the trimmed text.
1833
+ // Returns null when there is nothing to trim or the line holds a tab.
1834
+ function trimRichLineEdges(line,textMeasurement) {
1835
+ const fragments=line.fragments.map(fragment=>({...fragment}));
1836
+ if(!fragments.length||fragments.some(fragment=>fragment.kind==='tab'))return null;
1837
+ const width=(fragment,text)=>text?textWidthMeasurer(fragment.style,textMeasurement)(text,fragment.fontSize):0;
1838
+ let changed=false;
1839
+ while(fragments.length&&!/\S/.test(fragments[0].text)){fragments.shift();changed=true;}
1840
+ while(fragments.length&&!/\S/.test(fragments.at(-1).text)){fragments.pop();changed=true;}
1841
+ if(!fragments.length)return null;
1842
+ const first=fragments[0],last=fragments.at(-1);
1843
+ const lead=/^\s*/.exec(first.text)[0],trail=/\s*$/.exec(last.text)[0];
1844
+ if(lead){first.text=first.text.slice(lead.length);first.start+=lead.length;first.width-=width(first,lead);changed=true;}
1845
+ if(trail){last.text=last.text.slice(0,-trail.length);last.end-=trail.length;last.width-=width(last,trail);changed=true;}
1846
+ if(!changed)return null;
1847
+ let x=0;
1848
+ for(const fragment of fragments){fragment.x=x;x+=fragment.width;}
1849
+ return {...line,fragments,width:x};
1850
+ }
1851
+
1567
1852
  function renderRichLines(value,fit,box,bound,config) {
1568
- const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1853
+ const logicalAlignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1854
+ // Each line takes its paragraph's direction from core (every wrapped line shares it) and its physical edge from that.
1855
+ const lineAlignment=index=>fit.placement?.lines[index]?.alignment??physicalAlignment(logicalAlignment,fit.directions?.[index]);
1569
1856
  let textOffset=0;
1570
1857
  const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
1571
1858
  const richRtl=paragraphRtl(bound,value.map(run=>typeof run==='string'?run:run.text).join(''));
@@ -1574,13 +1861,21 @@ function renderRichLines(value,fit,box,bound,config) {
1574
1861
  // those estimates into visible gaps. Supplied measurements keep exact origins.
1575
1862
  const naturalFlow=!config.options.textMeasurement?.measure&&!fit.placement;
1576
1863
  const hasTabs=fit.richLines.some(line=>line.fragments.some(fragment=>fragment.kind==='tab'));
1577
- const content=fit.richLines.map((line,lineIndex)=>{
1864
+ const content=fit.richLines.map((sourceLine,lineIndex)=>{
1865
+ const alignment=lineAlignment(lineIndex);
1866
+ const sourceFirst=sourceLine.fragments[0],rtl=fit.directions?fit.directions[lineIndex]==='rtl':richRtl(sourceFirst?runOffsets[sourceFirst.runIndex]+sourceFirst.start:0);
1867
+ // PowerPoint ignores whitespace at either edge of a right-aligned right-to-left line, so the glyph edge stays on the box edge (RR-05).
1868
+ const edgeTrim=rtl&&alignment==='right'?trimRichLineEdges(sourceLine,config.options.textMeasurement):null;
1869
+ const line=edgeTrim??sourceLine;
1578
1870
  const lineHasTab=line.fragments.some(fragment=>fragment.kind==='tab');
1579
1871
  const flow=naturalFlow&&!lineHasTab;
1580
1872
  const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1581
1873
  const placed=fit.placement?.lines[lineIndex];
1582
- const originX=placed?.x??box.x+offset,baseline=placed?.baseline??box.y+line.baseline;
1583
- const firstFragment=line.fragments[0],rtl=richRtl(firstFragment?runOffsets[firstFragment.runIndex]+firstFragment.start:0);
1874
+ // RR-38: a line with runs in a policy replacement (Arabic Typesetting) takes PowerPoint's baseline of the real font, above core's.
1875
+ const lineShift=bound.scriptFonts?baselineShift(...line.fragments.filter(fragment=>fragment.kind!=='tab'&&fragment.text).map(fragment=>bound.scriptFonts.plan(fragment.text,fragment.style))):0;
1876
+ const shiftPx=lineShift*Math.max(fit.fontSize,...line.fragments.map(fragment=>fragment.fontSize));
1877
+ const originX=placed?placed.x+(edgeTrim?sourceLine.width-line.width:0):box.x+offset,baseline=(placed?.baseline??box.y+line.baseline)-shiftPx;
1878
+ const firstFragment=line.fragments[0];
1584
1879
  const renderFragment=(fragment,asFlow,edges={})=>{
1585
1880
  const run=fragment.run;
1586
1881
  // Accepted outline placement owns the horizontal advance. Geometric precision
@@ -1595,11 +1890,14 @@ function renderRichLines(value,fit,box,bound,config) {
1595
1890
  : resolveColorRef(run.color, bound, config.fill);
1596
1891
  let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
1597
1892
  const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(fragment.text,fragment.style,bound,bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
1598
- placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined});
1893
+ placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
1599
1894
  if(scripted.positioned)fixedAdvance=false;
1600
1895
  const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
1601
- 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);
1602
- if(run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
1896
+ // RR-34: a citation/footnote marker is generated text (no source range): it is traced as a marker
1897
+ // segment without text offsets, so editors never read it as part of the run, and it is not linked.
1898
+ const marker=fragment.kind==='marker';
1899
+ const rendered=tag(asFlow?'tspan':'text',{...(config.options.trace?marker?{'data-opf-segment':'marker','data-opf-marker':fragment.text}:{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end,'data-opf-segment':fragment.kind}:{}),...position,'xml:space':'preserve','text-rendering':asFlow?undefined:'geometricPrecision',textLength:fixedAdvance?stableNumber(fragment.width):undefined,lengthAdjust:fixedAdvance?'spacingAndGlyphs':undefined,'font-family':scripted.family??fontStack(fragment.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(adjustedFontSize(fragment.fontSize,scripted.sizeAdjust)),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':asFlow?'normal':undefined,'text-decoration':marker?undefined:[run.underline?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
1900
+ if(!marker&&run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
1603
1901
  return rendered;
1604
1902
  };
1605
1903
  if(naturalFlow&&lineHasTab) {
@@ -1616,13 +1914,14 @@ function renderRichLines(value,fit,box,bound,config) {
1616
1914
  if(!flow)return fragments.join('\n');
1617
1915
  const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
1618
1916
  const first=line.fragments[0];
1619
- return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined,[NO_POLICY_FEATURES]:true},fragments.join(''));
1917
+ return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline-shiftPx),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined,[NO_POLICY_FEATURES]:true},fragments.join(''));
1620
1918
  });
1621
1919
  let cursor=0;
1622
1920
  const whole=value.map(run=>typeof run==='string'?run:run.text).join('');
1623
1921
  const lineTrace=config.options.trace?fit.richLines.map((line,index)=>{
1624
1922
  if(index){const newline=/^(\r\n|\r|\n)/.exec(whole.slice(cursor));if(newline)cursor+=newline[0].length;}
1625
1923
  const start=cursor;cursor+=(fit.lines[index]??'').length;
1924
+ const alignment=lineAlignment(index);
1626
1925
  const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1627
1926
  const placed=fit.placement?.lines[index];
1628
1927
  return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height,
@@ -1649,42 +1948,50 @@ function renderTextBox(text, box, bound, config) {
1649
1948
  const totalHeight = fit.lines.length * fit.lineHeight;
1650
1949
  const startY = config.verticalAlign === "middle"
1651
1950
  ? box.y + Math.max(0, (box.height - totalHeight) / 2) + size : box.y + size;
1652
- const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1653
- const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
1654
- const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
1951
+ const logicalAlignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1655
1952
  const type = config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type;
1656
1953
  const source = String(text ?? ""), boxRtl = paragraphRtl(bound, source);
1657
1954
  let cursor = 0;
1658
1955
  const lines = fit.lines.map((line, index) => {
1956
+ // Logical alignment (RR-05): a right-to-left line starts at the right edge.
1957
+ const alignment=fit.placement?.lines[index]?.alignment??physicalAlignment(logicalAlignment,fit.directions?.[index]);
1958
+ const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
1959
+ const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
1659
1960
  const sourceLine=fit.sourceLines?.[index],placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1660
1961
  // The line's paragraph decides its direction: source offsets when layout has them, else the next match.
1661
1962
  const found=sourceLine?sourceLine.start:source.indexOf(line,cursor),lineStart=found>=0?found:cursor;cursor=lineStart+line.length;
1662
- const rtl=boxRtl(lineStart);
1963
+ const rtl=fit.directions?fit.directions[index]==='rtl':boxRtl(lineStart);
1663
1964
  const origin=placed?.x??x-(sourceLine?.width??0)*factor;
1664
1965
  const tabs=sourceLine?.segments.some(segment=>segment.kind==='tab');
1665
- let content,family,positioned=false;
1966
+ let content,family,positioned=false,trimWidth=0,sizeAdjust,shift=0;
1666
1967
  if(tabs) content=sourceLine.segments.map(segment=>{
1667
1968
  const segmentText=line.slice(segment.start-sourceLine.start,segment.end-sourceLine.start),fixed=segment.kind==='tab'||placed;
1668
1969
  const traced=(start,end)=>config.options.trace?{'data-opf-source-start':start,'data-opf-source-end':end,'data-opf-segment':segment.kind}:{};
1669
1970
  const scripted=segment.kind==='tab'?{content:escapeText(segmentText)}:scriptLine(segmentText,style,bound,type,{rtl,
1670
- placement:placed?{x:origin+segment.x,width:segment.width,fontSize:size}:undefined,
1971
+ placement:placed?{x:origin+segment.x,width:segment.width,fontSize:size}:undefined,fontSize:size,
1671
1972
  trace:config.options.trace?(start,end)=>traced(segment.start+start,segment.start+end):undefined});
1672
1973
  if(scripted.positioned)return scripted.content;
1673
1974
  return tag('tspan',{
1674
1975
  x:stableNumber(origin+segment.x),textLength:fixed?stableNumber(segment.width):undefined,
1675
- lengthAdjust:fixed?'spacingAndGlyphs':undefined,'font-family':scripted.family,
1976
+ lengthAdjust:fixed?'spacingAndGlyphs':undefined,'font-family':scripted.family,'font-size':scripted.sizeAdjust?stableNumber(adjustedFontSize(size,scripted.sizeAdjust)):undefined,
1676
1977
  ...traced(segment.start,segment.end),
1677
1978
  },scripted.content);
1678
1979
  }).join('');
1679
- else ({content,family,positioned=false}=scriptLine(line,style,bound,type,{rtl,placement:placed?.width>0?{x:placed.x,width:placed.width,fontSize:size}:undefined}));
1980
+ else {
1981
+ // PowerPoint ignores whitespace at either edge of a right-aligned right-to-left line (the glyph edge stays on the box edge, native check
1982
+ // 2026-10-02), so a soft-wrapped line that keeps its trailing space, or a leading space, draws without it (RR-05).
1983
+ const edge=rtl&&alignment==='right'?/^(\s*)([\s\S]*?)(\s*)$/.exec(line):null,trimmed=edge&&edge[2]&&(edge[1]||edge[3])?edge:null;
1984
+ if(trimmed){const measureEdge=textWidthMeasurer(style,config.options.textMeasurement);trimWidth=measureEdge(trimmed[1],size)+measureEdge(trimmed[3],size);}
1985
+ ({content,family,positioned=false,sizeAdjust,baselineShift:shift=0}=scriptLine(trimmed?trimmed[2]:line,style,bound,type,{rtl,placement:placed?.width>0?{x:placed.x+trimWidth,width:placed.width-trimWidth,fontSize:size}:undefined,fontSize:size}));
1986
+ }
1680
1987
  // Positioned script runs carry their own x and textLength (FF-19).
1681
1988
  const start=tabs||positioned;
1682
1989
  return tag("text", {
1683
- x: stableNumber(tabs?origin:positioned?placed.x:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
1990
+ x: stableNumber(tabs?origin:positioned?placed.x:placed?placed.x+placed.width*factor:x), y: stableNumber((placed?.baseline??startY + index * fit.lineHeight) - shift * size),
1684
1991
  "text-anchor": start?'start':anchor, "font-family": family ?? fontStack(style.fontFamily, type),
1685
- "font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
1992
+ "font-size": stableNumber(adjustedFontSize(size,sizeAdjust)), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
1686
1993
  'xml:space':'preserve',style:'white-space:pre','text-rendering':config.options.textMeasurement?.measure?'geometricPrecision':undefined,
1687
- textLength:!start&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
1994
+ textLength:!start&&placed?.width>0?stableNumber(placed.width-trimWidth):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
1688
1995
  ...traceAttrs(config.options, config.path),
1689
1996
  ...(config.options.trace&&sourceLine?{'data-opf-source-start':sourceLine.start,'data-opf-source-end':sourceLine.end,'data-opf-source-next-start':sourceLine.nextStart,'data-opf-line-boundary':sourceLine.boundary}:{}),
1690
1997
  }, content);
@@ -1783,7 +2090,7 @@ const NO_POLICY_FEATURES = Symbol("noPolicyFeatures");
1783
2090
  const RESET_POLICY_FEATURES = "font-variant-ligatures:normal;font-feature-settings:normal";
1784
2091
  function withPolicyFeatures(name, attrs) {
1785
2092
  if (attrs[NO_POLICY_FEATURES]) return attrs;
1786
- const family = (name === "text" || name === "tspan") && typeof attrs["font-family"] === "string" ? attrs["font-family"].split(",")[0].trim() : undefined;
2093
+ const family = (name === "text" || name === "tspan") && typeof attrs["font-family"] === "string" ? attrs["font-family"].split(",")[0].trim().replace(/^'(.*)'$/, "$1") : undefined;
1787
2094
  const features = family && disabledFeaturesStyle(family);
1788
2095
  return features ? { ...attrs, style: attrs.style ? `${attrs.style};${features}` : features } : attrs;
1789
2096
  }