@openpresentation/opf-render 0.15.0 → 0.17.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
@@ -540,7 +540,7 @@ function resolveContext(presentation, index, context) {
540
540
  }
541
541
  }
542
542
 
543
- function bindSlide(presentation, slide, index, context) {
543
+ function bindSlide(presentation, index, context) {
544
544
  const slidePath = `slides.${index}`;
545
545
  // Core resolves the slide's canvas, layout, theme, colour scheme, font scheme and family names the one way every engine must
546
546
  // (slide design, deck design, theme, engine default), from the records the document embeds and the catalogs the host
@@ -548,6 +548,9 @@ function bindSlide(presentation, slide, index, context) {
548
548
  // `layout` composes automatically; one whose reference resolves nowhere does too, with an `unresolved-reference` diagnostic,
549
549
  // or the render fails under `strictReferences`.
550
550
  const slideContext = resolveContext(presentation, index, context);
551
+ // FA-31: every string the slide draws (titles, body text, table cells, notes) is the substituted slide: {{slide.number}}, {{slide.section}} and
552
+ // {{deck.slideCount}} hold this slide's number, its section and the deck's slide count, the same values the furniture draws. Core resolved them.
553
+ const slide = slideContext.slide;
551
554
  const layout = slideContext.options.layout;
552
555
  const placeholders = Array.isArray(layout?.placeholders) ? layout.placeholders : [];
553
556
  const titleBindings = [];
@@ -635,10 +638,14 @@ function bindSlide(presentation, slide, index, context) {
635
638
  }
636
639
 
637
640
  // `options.fonts` is the handle `loadFonts()` returns (or any object with a `textMeasurement` and, to embed faces in the SVG, an
638
- // `embeddedFonts` list). The renderer reads the two as plain options from here on.
641
+ // `embeddedFonts` list). The renderer reads the two as plain options from here on. `embedFonts: false` (RR-61) keeps the
642
+ // measurement and drops the faces, for a host whose page already holds them.
643
+ // `textAsPaths: true` (RR-64) draws text as glyph outlines with the handle's `outlines` engine (a `loadFonts` handle carries one).
639
644
  function normalizeOptions(options) {
640
- const { fonts, ...rest } = options ?? {};
641
- return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: fonts?.embeddedFonts };
645
+ const { fonts, embedFonts, textAsPaths, ...rest } = options ?? {};
646
+ if (textAsPaths !== undefined && typeof textAsPaths !== "boolean") throw new OPFRenderError("invalid-render-options", "textAsPaths must be true or false.", { option: "textAsPaths" });
647
+ if (textAsPaths && typeof fonts?.outlines?.outlineSlideText !== "function") throw new OPFRenderError("text-as-paths-needs-fonts", "textAsPaths needs the fonts handle loadFonts() returns (from /fonts-node or /fonts-browser), passed as `fonts`.", { option: "textAsPaths" });
648
+ return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: embedFonts === false ? undefined : fonts?.embeddedFonts, ...(textAsPaths ? { outlines: fonts.outlines } : {}) };
642
649
  }
643
650
 
644
651
  /**
@@ -656,7 +663,7 @@ function resolveDeck(input, options) {
656
663
  }
657
664
 
658
665
  const context = { presentation, options };
659
- const slides = presentation.slides.map((slide, index) => bindSlide(presentation, slide, index, context));
666
+ const slides = presentation.slides.map((_, index) => bindSlide(presentation, index, context));
660
667
 
661
668
  return {
662
669
  presentation,
@@ -699,7 +706,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
699
706
  const { width, height } = bound.design.dimensions;
700
707
  // The title may be TextRun[] (FA-10): the accessible name is its plain text.
701
708
  const title = (Array.isArray(bound.slide.title) ? flattenText(bound.slide.title) : bound.slide.title) ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
702
- const content = [
709
+ let content = [
703
710
  renderBackground(bound, width, height, options),
704
711
  renderBranding(bound, resolved.presentation, width, height, options),
705
712
  ...renderSlideContent(bound, width, height, options),
@@ -708,7 +715,14 @@ function renderResolvedSlide(resolved, slideIndex, options) {
708
715
  renderFurniture(bound, resolved.presentation, width, height, options, "header"),
709
716
  renderFurniture(bound, resolved.presentation, width, height, options, "footer")
710
717
  ].filter(Boolean);
711
- const children = [renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
718
+ // RR-64: text as glyph outlines. The used-face rule below then embeds only the faces of text that stays text.
719
+ let glyphs = "";
720
+ if (options.outlines) {
721
+ const outlined = options.outlines.outlineSlideText(content, { rootStyle: lang ? { lang } : {}, report: diagnostic => reportDiagnostic(diagnostic, options), fail: (code, message) => new OPFRenderError(code, message, { slideIndex }) });
722
+ content = outlined.content;
723
+ glyphs = outlined.defs;
724
+ }
725
+ const children = [glyphs, renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
712
726
  // FF-44: Chromium (123+) trims adjacent fullwidth punctuation by default (CSS text-spacing-trim: normal; a sequence such as
713
727
  // 「」。 is up to 10 percent narrower), but measurement and PowerPoint advance every such character by its full width, so the browser
714
728
  // would stretch the glyphs back to the pinned textLength. space-all keeps the drawn advances equal to the measured ones. Only slides
@@ -720,7 +734,9 @@ function renderResolvedSlide(resolved, slideIndex, options) {
720
734
  "svg",
721
735
  {
722
736
  xmlns: "http://www.w3.org/2000/svg",
723
- role: "img",
737
+ // FA-30: a labelled container, not an image: role="img" makes every text node inside presentational.
738
+ role: "group",
739
+ "aria-roledescription": "slide",
724
740
  "aria-label": title,
725
741
  viewBox: `0 0 ${width} ${height}`,
726
742
  width,
@@ -756,6 +772,7 @@ function renderBackground(bound, width, height, options) {
756
772
  y2: `${50+dy}%`
757
773
  }, stopTags.join(""))),
758
774
  tag("rect", {
775
+ ...HIDDEN,
759
776
  x: 0,
760
777
  y: 0,
761
778
  width,
@@ -773,9 +790,10 @@ function renderBackground(bound, width, height, options) {
773
790
  const runs=patternRuns(preset);
774
791
  const mark=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'}):'';
775
792
  if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
776
- 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})`}));
793
+ return tag('g',{...HIDDEN,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})`}));
777
794
  }
778
795
  return tag("rect", {
796
+ ...HIDDEN,
779
797
  x: 0,
780
798
  y: 0,
781
799
  width,
@@ -792,9 +810,9 @@ function renderBackground(bound, width, height, options) {
792
810
  // and stretch use the shared fit math with the focus point; tile repeats the picture at its intrinsic size (1 picture px = 1
793
811
  // reference px) from the canvas top-left. alt is the picture's accessible name; without alt the picture is decorative.
794
812
  function renderBackgroundImage(background, bound, width, height, options) {
795
- const canvas = tag("rect", { x: 0, y: 0, width, height, fill: bound.design.backgroundColor ?? bound.design.colors.background, ...traceAttrs(options, `${bound.path}.design.background`) });
813
+ const canvas = tag("rect", { ...HIDDEN, x: 0, y: 0, width, height, fill: bound.design.backgroundColor ?? bound.design.colors.background, ...traceAttrs(options, `${bound.path}.design.background`) });
796
814
  const item = { value: background.alt === undefined ? background.src : { src: background.src, alt: background.alt }, path: background.path };
797
- const drawOptions = { ...options, imageDecorative: background.alt === undefined };
815
+ const drawOptions = { ...options, imageDecorative: background.alt === undefined || background.alt === "" };
798
816
  const picture = background.fit === "tile"
799
817
  ? renderTiledImage(item, background.box, bound, drawOptions)
800
818
  : renderImage(item, background.box, bound, { ...drawOptions, imageFit: background.fit, imageFocus: background.focus, imagePicture: background.picture });
@@ -838,7 +856,7 @@ function renderImageBlock(item, bound, options) {
838
856
  const children = [defs.length ? tag("defs", {}, defs.join("")) : "", pixels];
839
857
  if (image.border) {
840
858
  const paint = imagePaint(image.border.color, bound, bound.design.colors.border);
841
- 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 }));
859
+ children.push(tag("path", { ...HIDDEN, 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 }));
842
860
  }
843
861
  if (image.overlay) children.push(renderImageOverlay(image.overlay, bound, options));
844
862
  if (children.length === 2 && !children[0] && !Object.keys(trace).length) return pixels;
@@ -849,7 +867,7 @@ function renderImageBlock(item, bound, options) {
849
867
  // overlay opacity.
850
868
  function renderImageOverlay(overlay, bound, options) {
851
869
  const paint = imagePaint(overlay.color, bound, bound.design.colors.text);
852
- return tag("path", { d: overlay.shape?.path ?? rectanglePath(overlay.box), fill: paint.color, "fill-opacity": preciseNumber(paint.alpha * overlay.opacity), ...(options.trace ? { "data-opf-image-overlay": overlay.path } : {}) });
870
+ return tag("path", { ...HIDDEN, d: overlay.shape?.path ?? rectanglePath(overlay.box), fill: paint.color, "fill-opacity": preciseNumber(paint.alpha * overlay.opacity), ...(options.trace ? { "data-opf-image-overlay": overlay.path } : {}) });
853
871
  }
854
872
 
855
873
  // The placeholder renderImage draws for a source it cannot resolve.
@@ -885,7 +903,7 @@ function renderSlideContent(bound, width, height, options) {
885
903
  for (const diagnostic of [...bound.design.diagnostics, ...bound.geometry.diagnostics]) reportDiagnostic(diagnostic, options);
886
904
  return bound.geometry.items.map(item => {
887
905
  const frame=item.frameBox;
888
- 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)}) : '';
906
+ const surface=frame ? tag('rect',{...HIDDEN,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)}) : '';
889
907
  // RR-34: a captioned item draws its media in item.box and its caption band after it (src/annotations.js).
890
908
  return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options)+(item.caption?renderCaption(item,{ ...bound, composition: item.composition },options,drawHelpers):'');
891
909
  });
@@ -900,11 +918,13 @@ function reportDiagnostic(diagnostic, options) {
900
918
  options.onDiagnostic?.(diagnostic);
901
919
  }
902
920
 
903
- // FA-09: Chart.alt is the chart's accessible name: a role="img" group with aria-label, which also makes the drawn marks and labels
904
- // inside presentational. An empty alt marks the chart decorative (aria-hidden). Without alt the SVG is unchanged.
905
- function chartText(chart, alt) {
906
- if (typeof alt !== "string" || !chart) return chart;
907
- return alt === "" ? tag("g", { "aria-hidden": "true" }, chart) : tag("g", { role: "img", "aria-label": alt }, chart);
921
+ // FA-09, FA-27: Chart.alt and Table.alt name the drawn payload with an aria-label on a wrapping group. A chart is a picture: role="img",
922
+ // which also makes its marks and labels presentational. A table's alt is a summary (the cells are real text and stay readable, as in
923
+ // PowerPoint, where descr on a table frame hides nothing): role="group" names it without hiding its children. An empty alt marks the payload
924
+ // decorative (aria-hidden) for both. Without alt the SVG is unchanged.
925
+ function altText(drawn, alt, role) {
926
+ if (typeof alt !== "string" || !drawn) return drawn;
927
+ return alt === "" ? tag("g", { "aria-hidden": "true" }, drawn) : tag("g", { role, "aria-label": alt }, drawn);
908
928
  }
909
929
 
910
930
  function renderPayload(item, box, bound, options) {
@@ -912,9 +932,9 @@ function renderPayload(item, box, bound, options) {
912
932
  if (item.field === "items" || item.field === "bullets") return renderList(item, box, bound, options);
913
933
  switch (item.type) {
914
934
  case "chart":
915
- return chartText(renderChart(item, box, bound, options), item.value?.alt);
935
+ return altText(renderChart(item, box, bound, options), item.value?.alt, "img");
916
936
  case "table":
917
- return renderTable(item, box, bound, options);
937
+ return altText(renderTable(item, box, bound, options), item.value?.alt, "group");
918
938
  case "image":
919
939
  return renderImageBlock(item, bound, options);
920
940
  case "video":
@@ -999,10 +1019,17 @@ function resolveImageSource(item, bound, options) {
999
1019
  return { asset, source: svg ?? source, drawable, missingReference };
1000
1020
  }
1001
1021
 
1022
+ // FA-30: purely decorative drawing is hidden from assistive technology so the slide's text and real pictures are what a reader meets
1023
+ // (background, overlays, card frames, dividers, table cell fills and borders, accent shapes). SVG-AAM already leaves an unnamed shape
1024
+ // out of the tree; the attribute states the intent for the readers that do not.
1025
+ const HIDDEN = Object.freeze({ "aria-hidden": "true" });
1026
+
1002
1027
  // The accessible name of a drawn picture: its alt text, else the caller's label; a decorative picture (an image background
1003
1028
  // without alt) is hidden from assistive technology.
1004
1029
  function imageAccessibility(asset, options) {
1005
- if (options.imageDecorative) return { "aria-hidden": "true" };
1030
+ // An empty alt marks the picture decorative (FA-30), as it does a chart; a picture that is only furniture (a header or footer
1031
+ // logo repeated on every slide) is decorative unless the author named it.
1032
+ if (options.imageDecorative || asset.alt === "" || (options.imageUnlabelled && asset.alt === undefined)) return HIDDEN;
1006
1033
  return { role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image" };
1007
1034
  }
1008
1035
 
@@ -1227,6 +1254,7 @@ function renderMedia(item, box, bound, options) {
1227
1254
  const iconBox = centeredBox(box, 72, 72);
1228
1255
  const children = [
1229
1256
  tag("rect", {
1257
+ ...HIDDEN,
1230
1258
  x: box.x,
1231
1259
  y: box.y,
1232
1260
  width: box.width,
@@ -1237,6 +1265,7 @@ function renderMedia(item, box, bound, options) {
1237
1265
  ...traceAttrs(options, item.path)
1238
1266
  }),
1239
1267
  tag("circle", {
1268
+ ...HIDDEN,
1240
1269
  cx: iconBox.x + iconBox.width / 2,
1241
1270
  cy: iconBox.y + iconBox.height / 2,
1242
1271
  r: 36,
@@ -1244,6 +1273,7 @@ function renderMedia(item, box, bound, options) {
1244
1273
  ...traceAttrs(options, item.path)
1245
1274
  }),
1246
1275
  tag("path", {
1276
+ ...HIDDEN,
1247
1277
  d: trianglePath(iconBox.x + 28, iconBox.y + 22, 28, 28),
1248
1278
  fill: "#FFFFFF",
1249
1279
  ...traceAttrs(options, item.path)
@@ -1274,6 +1304,7 @@ function renderCode(item, box, bound, options) {
1274
1304
  const highlight = codeHighlight(item, layout, bound);
1275
1305
  const children = [
1276
1306
  tag("rect", {
1307
+ ...HIDDEN,
1277
1308
  x: box.x,
1278
1309
  y: box.y,
1279
1310
  width: box.width,
@@ -1290,6 +1321,7 @@ function renderCode(item, box, bound, options) {
1290
1321
  const body = layout.parts.find(part => part.role === "body");
1291
1322
  for (const band of highlight.bands) {
1292
1323
  children.push(tag("rect", {
1324
+ ...HIDDEN,
1293
1325
  x: stableNumber(box.x + 1), y: stableNumber(body.box.y + band.first * body.fit.lineHeight), width: stableNumber(box.width - 2),
1294
1326
  height: stableNumber((band.last - band.first + 1) * body.fit.lineHeight), fill: highlight.colors.band,
1295
1327
  ...(options.trace ? { "data-opf-code-highlight": "true" } : {})
@@ -1405,16 +1437,16 @@ function renderTimeline(item, box, bound, options) {
1405
1437
  const layout=item.timelineLayout;
1406
1438
  if(!layout)throw new OPFRenderError('missing-timeline-layout','Timeline rendering requires a coordinated core build with shared timeline geometry.',{path:item.path});
1407
1439
  const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
1408
- const children=[tag('line',{...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
1440
+ const children=[tag('line',{...HIDDEN,...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
1409
1441
  // FA-11: status colors come from the deck (core's timelineMarkerShapes / timelineTextColor); a marker with no status draws the plain primary circle.
1410
1442
  const background=bound.design.backgroundColor??bound.design.colors.background;
1411
1443
  const statusColors={background,primary:bound.design.colors.primary,text:bound.design.colors.text,mutedText:bound.design.colors.mutedText};
1412
1444
  for(const marker of layout.markers){
1413
1445
  if(!marker.status){
1414
- children.push(tag('circle',{cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
1446
+ children.push(tag('circle',{...HIDDEN,cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
1415
1447
  continue;
1416
1448
  }
1417
- for(const shape of timelineMarkerShapes(marker,statusColors))children.push(tag('circle',{cx:shape.cx,cy:shape.cy,r:shape.radius,fill:shape.fill??'none',...(shape.stroke?{stroke:shape.stroke.color,'stroke-width':shape.stroke.width}:{}),...traceAttrs(options,marker.path),...(options.trace?{'data-opf-timeline-status':marker.status,'data-opf-timeline-shape':shape.role}:{})}));
1449
+ for(const shape of timelineMarkerShapes(marker,statusColors))children.push(tag('circle',{...HIDDEN,cx:shape.cx,cy:shape.cy,r:shape.radius,fill:shape.fill??'none',...(shape.stroke?{stroke:shape.stroke.color,'stroke-width':shape.stroke.width}:{}),...traceAttrs(options,marker.path),...(options.trace?{'data-opf-timeline-status':marker.status,'data-opf-timeline-shape':shape.role}:{})}));
1418
1450
  }
1419
1451
  for(const part of layout.parts){
1420
1452
  if(!part.fit)throw new OPFRenderError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,diagnostics:layout.diagnostics});
@@ -1445,6 +1477,7 @@ function renderTable(item, box, bound, options) {
1445
1477
  ? defaultText
1446
1478
  : resolveColorRef(style.color, bound, defaultText);
1447
1479
  children.push(tag("rect", {
1480
+ ...HIDDEN,
1448
1481
  x: stableNumber(cell.box.x), y: stableNumber(cell.box.y),
1449
1482
  width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
1450
1483
  fill,
@@ -1500,6 +1533,7 @@ function renderTableBorders(defaultEdges, explicitEdges, scale, bound, options)
1500
1533
  ? defaultColor
1501
1534
  : resolveColorRef(border.color, bound, defaultColor);
1502
1535
  return [tag('line', {
1536
+ ...HIDDEN,
1503
1537
  x1:stableNumber(x1), y1:stableNumber(y1), x2:stableNumber(x2), y2:stableNumber(y2),
1504
1538
  stroke, 'stroke-width':stableNumber(width),
1505
1539
  'stroke-dasharray':border?.dash === 'dash' ? `${width*4} ${width*3}` : border?.dash === 'dot' ? `${width} ${width*2}` : undefined,
@@ -1637,7 +1671,7 @@ function renderFurniture(bound, presentation, width, height, options, kind) {
1637
1671
  }
1638
1672
  return tag('g',{},layout.parts.filter(part=>part.kind===kind).map(part=>{
1639
1673
  const trace=options.trace?{'data-opf-furniture-kind':kind,'data-opf-furniture-field':part.field,'data-opf-furniture-generated':String(part.generated),'data-opf-furniture-editable':!part.generated?'true':undefined,'data-opf-furniture-source':part.sourcePath}:{};
1640
- if(part.type==='image')return tag('g',trace,renderImage({value:part.image,path:part.path},part.box,bound,{...options,imageFit:'contain'}));
1674
+ if(part.type==='image')return tag('g',trace,renderImage({value:part.image,path:part.path},part.box,bound,{...options,imageFit:'contain',imageUnlabelled:true}));
1641
1675
  return tag('g',trace,renderTextBox(part.text,part.box,bound,{path:part.path,fit:part.fit,textStyle:part.style,fontFamily:part.style.fontFamily,align:part.alignment,fill:bound.design.colors.mutedText,diagnosticsHandled:true,options}));
1642
1676
  }).join(''));
1643
1677
  }
@@ -1650,12 +1684,13 @@ function renderBranding(bound,presentation,width,height,options) {
1650
1684
  if(mark){
1651
1685
  const opacity=typeof design.watermark.opacity==='number'&&Number.isFinite(design.watermark.opacity)?Math.min(1,Math.max(0,design.watermark.opacity)):.08;
1652
1686
  const cx=width/2,cy=height/2;
1653
- pieces.push(tag('g',{opacity:stableNumber(opacity),transform:`rotate(${stableNumber(mark.rotation)} ${stableNumber(cx)} ${stableNumber(cy)})`,...traceAttrs(options,rootFor('watermark'))},
1687
+ pieces.push(tag('g',{...HIDDEN,opacity:stableNumber(opacity),transform:`rotate(${stableNumber(mark.rotation)} ${stableNumber(cx)} ${stableNumber(cy)})`,...traceAttrs(options,rootFor('watermark'))},
1654
1688
  tag('text',{x:stableNumber(cx),y:stableNumber(cy+mark.fontSize*.35),'text-anchor':'middle','font-family':fontStack(mark.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(mark.fontSize),'font-weight':mark.fontWeight,
1655
1689
  fill:bound.design.colors.text,'xml:space':'preserve','text-rendering':'geometricPrecision'},escapeText(mark.text))));
1656
1690
  }
1657
1691
  } else if(design.watermark){
1658
- pieces.push(tag('g',{opacity:typeof design.watermark==='object'?design.watermark.opacity??.08:.08},renderImage({value:design.watermark,path:rootFor('watermark')},{x:width*.3,y:height*.3,width:width*.4,height:height*.4},bound,{...options,imageFit:'contain'})));
1692
+ // The group keeps its bare opacity attribute (opf-pptx's watermark parity test reads it); the picture itself is hidden.
1693
+ pieces.push(tag('g',{opacity:typeof design.watermark==='object'?design.watermark.opacity??.08:.08},renderImage({value:design.watermark,path:rootFor('watermark')},{x:width*.3,y:height*.3,width:width*.4,height:height*.4},bound,{...options,imageFit:'contain',imageDecorative:true})));
1659
1694
  }
1660
1695
  // Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
1661
1696
  const logo=bound.geometry.logo;
@@ -1710,12 +1745,30 @@ function nestedReset(style, run) {
1710
1745
  return parent && !disabledFeaturesStyle([run.stack ?? run.family].flat()[0]) ? RESET_POLICY_FEATURES : undefined;
1711
1746
  }
1712
1747
 
1713
- function scriptLine(text, style, bound, type, { rtl = false, placement, trace, fontSize } = {}) {
1748
+ // RR-59 (#175): bidi rule L1 resets white space at the end of a line (with the isolate marks around it) to the paragraph level. A run
1749
+ // drawn as its own text chunk (a <text>, or a tspan with its own x) is such a line, and its paragraph is left to right, so the trailing
1750
+ // space of a right-to-left isolate drew at the run's right edge instead of its left, where it separates the run from the next one: at an
1751
+ // Arabic/Latin boundary the gap closed up (PowerPoint keeps it). A positioned run of a right-to-left line therefore draws without its
1752
+ // trailing white space, its glyphs starting after that white space's measured advance. Returns the white space ("" when there is none,
1753
+ // or when the text is all white space).
1754
+ function rtlTrailingSpace(text) {
1755
+ const found = /\S(\s+)$/u.exec(text);
1756
+ return found ? found[1] : "";
1757
+ }
1758
+
1759
+ /**
1760
+ * `runPlacement` ({x, width, fontSize}) positions the runs like `placement`, but only when the text plans into more than one run
1761
+ * (RR-59, #175): a list entry's line has no placed origin from core, and drawn as one chunk of nested tspans resvg shapes the whole
1762
+ * chunk with the outer face first and, when a fallback face covers every character, replaces every glyph with that face's (the Latin
1763
+ * phrase of an Arabic item took a wider fallback face and ran into the bullet).
1764
+ */
1765
+ function scriptLine(text, style, bound, type, { rtl = false, placement, runPlacement, trace, fontSize } = {}) {
1714
1766
  const value = String(text ?? "");
1715
1767
  const scripts = (style?.lang && bound.scriptFontsFor?.(style.lang)) || bound.scriptFonts;
1716
1768
  rtl = rtl && value !== "";
1717
1769
  const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
1718
1770
  const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
1771
+ if (!placement && runPlacement && runs.length > 1) placement = runPlacement;
1719
1772
  // 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.
1720
1773
  const shiftOf = list => { const shift = baselineShift(list); return shift ? { baselineShift: shift } : {}; };
1721
1774
  // FF-45: symbol runs (Wingdings, Symbol, Webdings codes drawn as their Unicode equivalents) are always positioned when the
@@ -1741,16 +1794,20 @@ function scriptLine(text, style, bound, type, { rtl = false, placement, trace, f
1741
1794
  advance += width;
1742
1795
  const start = offset;
1743
1796
  offset += run.symbol ? run.symbol.source.length : run.text.length;
1797
+ // RR-59: a right-to-left run's trailing white space sits at its left edge (see rtlTrailingSpace); the glyphs draw after it.
1798
+ const trail = rtl && !run.symbol ? rtlTrailingSpace(run.text) : "";
1799
+ const drawn = trail ? run.text.slice(0, -trail.length) : run.text;
1800
+ const gap = trail ? Math.min(width, Math.max(0, width - scripts.runWidths([{ ...run, text: drawn }], placement.fontSize, style)[0] * factor)) : 0;
1744
1801
  // A symbol glyph keeps the open face's shape: it is compressed to its code's advance only when wider, never stretched.
1745
- const pinned = run.symbol ? natural[index] > width + 1e-6 : width > 0;
1802
+ const pinned = run.symbol ? natural[index] > width + 1e-6 : width - gap > 0;
1746
1803
  return tag("tspan", {
1747
- x: stableNumber(placement.x + left),
1748
- textLength: pinned ? stableNumber(width) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
1804
+ x: stableNumber(placement.x + left + gap),
1805
+ textLength: pinned ? stableNumber(width - gap) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
1749
1806
  "font-family": run.own ? undefined : fontStack(run.family, type),
1750
1807
  "font-size": adjusted(run),
1751
1808
  style: run.own ? undefined : nestedReset(style, run),
1752
- ...(trace ? trace(start, offset) : {})
1753
- }, isolate(escapeText(run.text)));
1809
+ ...(trace ? trace(start, offset - trail.length) : {})
1810
+ }, isolate(escapeText(drawn)));
1754
1811
  }).join("");
1755
1812
  return { content, positioned: true, ...shiftOf(runs) };
1756
1813
  }
@@ -1798,21 +1855,26 @@ function codeSyntax(item, layout, bound, options) {
1798
1855
  return { tokens, palette: codeSyntaxPaletteForScheme(bound.design.colorScheme) };
1799
1856
  }
1800
1857
 
1801
- // Faces flagged embed:"used" (the vendored open, Intos and script-pack faces) are embedded only when the slide's own markup draws
1802
- // them: the family in a font-family list, at a font-weight and font-style some text of the slide takes (attributes are
1803
- // inherited down the element tree, as in SVG). A used family none of whose faces matches a drawn weight and style keeps all
1804
- // its faces, so the browser can always choose. Every other face (the npm packs) is embedded as before.
1858
+ // RR-61: a face is embedded only when the slide's own markup draws it: the family in a font-family list, at a font-weight and
1859
+ // font-style some text of the slide takes (attributes are inherited down the element tree, as in SVG). A drawn family none of
1860
+ // whose faces matches a drawn weight and style keeps all its faces, so the browser can always choose. A face no font-family of
1861
+ // the SVG names is one the browser can never select, so leaving it out changes no pixel. Only a face flagged embed:"always" is
1862
+ // embedded in every SVG. RR-59: each drawn style is checked on its own, so a family keeps an exactly matched face and, for
1863
+ // a drawn style none of its faces matches (a weight the family lacks), every face the browser could choose from.
1805
1864
  function embeddedFontsFor(fonts = [], content) {
1806
- if (!fonts.some(font => font?.embed === "used")) return fonts;
1865
+ if (!fonts.length) return fonts;
1866
+ fonts.forEach(assertEmbeddableFont);
1807
1867
  const drawn = drawnFaces(content.join("\n"));
1808
1868
  const wanted = font => {
1809
1869
  const triples = drawn.get(String(font.family).toLowerCase());
1810
1870
  if (!triples) return false;
1811
- const family = fonts.filter(other => other?.embed === "used" && String(other.family).toLowerCase() === String(font.family).toLowerCase());
1812
- const matching = family.filter(other => triples.has(`${other.weight}|${other.italic ? "italic" : "normal"}`));
1813
- return matching.length ? matching.includes(font) : true;
1871
+ const family = fonts.filter(other => String(other?.family).toLowerCase() === String(font.family).toLowerCase());
1872
+ return [...triples].some(triple => {
1873
+ const matching = family.filter(other => triple === `${other.weight}|${other.italic ? "italic" : "normal"}`);
1874
+ return matching.length ? matching.includes(font) : true;
1875
+ });
1814
1876
  };
1815
- return fonts.filter(font => font?.embed !== "used" || wanted(font));
1877
+ return fonts.filter(font => font?.embed === "always" || wanted(font));
1816
1878
  }
1817
1879
 
1818
1880
  const FONT_WEIGHT_KEYWORDS = { normal: "400", bold: "700" };
@@ -1841,10 +1903,21 @@ function drawnFaces(markup) {
1841
1903
  return drawn;
1842
1904
  }
1843
1905
 
1906
+ // Every supplied face is checked, drawn or not, so a bad entry fails on any slide. The data URI check is remembered per face
1907
+ // object while its dataUrl is unchanged: the handle's list holds the same objects on every slide, and its URIs run to tens of
1908
+ // megabytes.
1909
+ const checkedDataUrls = new WeakMap();
1910
+ function assertEmbeddableFont(font) {
1911
+ const valid = typeof font?.family === "string" && !/[\u0000-\u001f"'\\<>;]/.test(font.family) && Number.isInteger(font.weight) && font.weight >= 1 && font.weight <= 1000
1912
+ && (checkedDataUrls.get(font) === font.dataUrl || /^data:font\/(ttf|otf|woff|woff2);base64,[A-Za-z0-9+/=]+$/.test(font.dataUrl));
1913
+ if (!valid) throw new OPFRenderError("invalid-embedded-font", "Embedded fonts require a plain family name, valid weight, and a font data URI.");
1914
+ checkedDataUrls.set(font, font.dataUrl);
1915
+ }
1916
+
1844
1917
  function renderEmbeddedFonts(fonts = []) {
1845
1918
  if (!fonts.length) return "";
1846
1919
  const css = fonts.map(font => {
1847
- if (typeof font.family !== "string" || /[\u0000-\u001f"'\\<>;]/.test(font.family) || !/^data:font\/(ttf|otf|woff|woff2);base64,[A-Za-z0-9+/=]+$/.test(font.dataUrl) || !Number.isInteger(font.weight) || font.weight < 1 || font.weight > 1000) throw new OPFRenderError("invalid-embedded-font", "Embedded fonts require a plain family name, valid weight, and a font data URI.");
1920
+ assertEmbeddableFont(font);
1848
1921
  return `@font-face{font-family:"${font.family}";font-weight:${font.weight};font-style:${font.italic ? "italic" : "normal"};src:url("${font.dataUrl}")}`;
1849
1922
  }).join("\n");
1850
1923
  const licenses = [...new Set(fonts.map(font=>font.license).filter(Boolean))];
@@ -1921,16 +1994,29 @@ function renderRichLines(value,fit,box,bound,config) {
1921
1994
  ? (linked ? bound.design.colors.hyperlink : config.fill)
1922
1995
  : resolveColorRef(run.color, bound, config.fill);
1923
1996
  let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
1997
+ // RR-59 (#175): measured fragments of a line core did not place (a list entry) keep their measured origin; a mixed-script one
1998
+ // positions its runs (scriptLine runPlacement) as a placed line does.
1999
+ const measuredLine=!asFlow&&!naturalFlow&&fragment.kind!=='tab'&&fragment.width>0;
2000
+ // A right-to-left fragment draws without its trailing white space, after that white space's measured advance (rtlTrailingSpace).
2001
+ const trail=rtl&&measuredLine&&fragment.kind!=='marker'?rtlTrailingSpace(fragment.text):'';
2002
+ let gap=0;
2003
+ if(trail){
2004
+ const measure=textWidthMeasurer(fragment.style,config.options.textMeasurement),full=measure(fragment.text,fragment.fontSize);
2005
+ gap=full>0?Math.min(fragment.width,Math.max(0,fragment.width*(1-measure(fragment.text.slice(0,-trail.length),fragment.fontSize)/full))):0;
2006
+ if(!asFlow)position.x=stableNumber(originX+fragmentX+gap);
2007
+ }
2008
+ const drawnText=trail?fragment.text.slice(0,-trail.length):fragment.text,drawnWidth=fragment.width-gap;
1924
2009
  // FA-13: an inline code run falls back to a monospace face, and a run with its own language declares it.
1925
2010
  const runLang=fragment.kind!=='tab'&&fragment.style.lang?(bound.scriptFontsFor?.(fragment.style.lang)?.profile.bcp47??fragment.style.lang):undefined;
1926
- const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(fragment.text,fragment.style,bound,run.code===true?'monospace':bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
1927
- placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
2011
+ const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(drawnText,fragment.style,bound,run.code===true?'monospace':bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
2012
+ placement:fixedAdvance&&!asFlow?{x:originX+fragmentX+gap,width:drawnWidth,fontSize:fragment.fontSize}:undefined,
2013
+ runPlacement:measuredLine&&!fixedAdvance?{x:originX+fragmentX+gap,width:drawnWidth,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
1928
2014
  if(scripted.positioned)fixedAdvance=false;
1929
2015
  const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
1930
2016
  // RR-34: a citation/footnote marker is generated text (no source range): it is traced as a marker
1931
2017
  // segment without text offsets, so editors never read it as part of the run, and it is not linked.
1932
2018
  const marker=fragment.kind==='marker';
1933
- const rendered=tag(asFlow?'tspan':'text',{...(runLang?{lang:runLang,'xml:lang':runLang}:{}),...(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,run.code===true?'monospace':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||linked?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
2019
+ const rendered=tag(asFlow?'tspan':'text',{...(runLang?{lang:runLang,'xml:lang':runLang}:{}),...(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-trail.length,'data-opf-segment':fragment.kind}:{}),...position,'xml:space':'preserve','text-rendering':asFlow?undefined:'geometricPrecision',textLength:fixedAdvance?stableNumber(drawnWidth):undefined,lengthAdjust:fixedAdvance?'spacingAndGlyphs':undefined,'font-family':scripted.family??fontStack(fragment.style.fontFamily,run.code===true?'monospace':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||linked?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
1934
2020
  if(linked)return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
1935
2021
  return rendered;
1936
2022
  };