@openpresentation/opf-render 0.14.0 → 0.16.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
@@ -5,10 +5,12 @@ import {
5
5
  resolveColorRef as resolveCoreColorRef, resolveScriptFonts, paragraphDirection, physicalAlignment, patternRuns,
6
6
  metricTrendMark as coreMetricTrendMark, tokenizeCode, codeLineRuns, codeSyntaxPaletteForScheme, resolveChartData,
7
7
  resolveColorRoles, defaultSlideBackground, timelineMarkerShapes, timelineTextColor, layoutWatermark,
8
- codeHighlightLines, codeLineNumbers, codeHighlightBands, codeHighlightColors
8
+ codeHighlightLines, codeLineNumbers, codeHighlightBands, codeHighlightColors, fitImage, intrinsicImageAspect, intrinsicImageSize,
9
+ ENGINE_DEFAULT_THEME, ENGINE_DEFAULT_COLOR_SCHEME, ENGINE_DEFAULT_FONT_SCHEME, ENGINE_DEFAULT_CHART_TYPES
9
10
  } from "@openpresentation/opf/composition";
11
+ // The renderer is a library: it never imports a catalog. Hosts register theirs (`options.catalogs`, core `Catalog[]`, for
12
+ // example `defaultCatalog` from `@openpresentation/opf/catalog`) and core resolves every reference against them.
10
13
  import {
11
- catalogs as bundledCatalogs,
12
14
  hasContentVariables,
13
15
  isTemplate,
14
16
  resolveSlideContext,
@@ -37,34 +39,13 @@ export const runtimePolicy = Object.freeze({
37
39
  deterministicLocalExecution: true
38
40
  });
39
41
 
42
+ // What draws when nothing resolves: core's engine defaults (OPF 0.15 ships no catalog records), and the chart type a chart
43
+ // without one previews as.
40
44
  export const engineDefaults = Object.freeze({
41
- catalogs: Object.freeze({
42
- narratives: Object.freeze({ source: "https://www.pptx.gallery/narratives" }),
43
- themes: Object.freeze({ source: "https://www.pptx.gallery/themes" }),
44
- colorSchemes: Object.freeze({ source: "https://www.pptx.gallery/color-schemes" }),
45
- fontSchemes: Object.freeze({ source: "https://www.pptx.gallery/font-schemes" }),
46
- languages: Object.freeze({ source: "https://www.pptx.gallery/languages" }),
47
- layouts: Object.freeze({ source: "https://www.pptx.gallery/layouts" }),
48
- chartTypes: Object.freeze({ source: "https://www.pptx.gallery/chart-types" }),
49
- tones: Object.freeze({ source: "https://www.pptx.gallery/tones" }),
50
- audiences: Object.freeze({ source: "https://www.pptx.gallery/audiences" }),
51
- socialPlatforms: Object.freeze({ source: "https://www.pptx.gallery/social-platforms" })
52
- }),
53
- theme: "minimal",
54
- colorScheme: "cool-horizon",
55
- language: "english",
56
- narrative: "classic-story",
57
- tone: "formal",
58
- audience: "executives",
59
- fontScheme: Object.freeze({
60
- pptx: Object.freeze({ latin: "aptos", ea: "microsoft-yahei", cs: "nirmala-ui" }),
61
- google: Object.freeze({ latin: "roboto", ea: "noto-sans-sc", cs: "noto-sans" })
62
- }),
63
- chartTypes: Object.freeze([
64
- "stacked-column",
65
- "stacked-area",
66
- "line-with-markers"
67
- ])
45
+ theme: ENGINE_DEFAULT_THEME,
46
+ colorScheme: ENGINE_DEFAULT_COLOR_SCHEME,
47
+ fontScheme: ENGINE_DEFAULT_FONT_SCHEME,
48
+ chartType: ENGINE_DEFAULT_CHART_TYPES[0]
68
49
  });
69
50
 
70
51
  /**
@@ -164,7 +145,6 @@ const FIELD_TYPE = Object.freeze({
164
145
  });
165
146
 
166
147
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
167
- const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
168
148
 
169
149
 
170
150
  function parseInput(input) {
@@ -220,8 +200,8 @@ function resolveTemplateInput(presentation, options) {
220
200
 
221
201
  // The boundary check is the format check only (schema and the cross-field rules a schema cannot say): layout, accessibility
222
202
  // and content findings are `validate`'s job, and drawing a slide reports what it cannot fit.
223
- function assertValidBoundary(presentation) {
224
- const report = validate(presentation, { only: ["format"] });
203
+ function assertValidBoundary(presentation, options) {
204
+ const report = validate(presentation, { only: ["format"], ...(options.catalogs !== undefined ? { catalogs: options.catalogs } : {}) });
225
205
  if (!report.valid) {
226
206
  throw new OPFRenderError("invalid-opf", "OPF validation failed.", {
227
207
  findings: report.findings.filter((finding) => finding.severity === "error"),
@@ -246,63 +226,6 @@ function isPlainObject(value) {
246
226
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
247
227
  }
248
228
 
249
- function normalizeSourceRecords(value) {
250
- if (!value) return [];
251
- if (Array.isArray(value)) return value;
252
- if (Array.isArray(value.records)) return value.records;
253
- return [];
254
- }
255
-
256
- function defaultCatalogFor(kind) {
257
- return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
258
- }
259
-
260
- // CatalogEntry.source is one source or an ordered search path (first match wins; the
261
- // default catalog is appended by the callers). An array concatenates each source's
262
- // records in order, so the findById chain keeps the first match. Non-string entries are
263
- // ignored. Nothing is fetched: a source resolves from options.catalogSources or, for the
264
- // bundled default prefixes, from the bundled snapshot.
265
- function sourceRecordsFor(kind, source, options) {
266
- if (Array.isArray(source)) return source.flatMap((entry) => sourceRecordsFor(kind, entry, options));
267
- if (typeof source !== "string" || !source) return [];
268
- const bySource = options.catalogSources?.[source];
269
- if (bySource) return normalizeSourceRecords(bySource);
270
- if (source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/")) {
271
- return defaultCatalogFor(kind);
272
- }
273
- return [];
274
- }
275
-
276
- // The records a host adds to core's `resolveSlideContext`, which looks a layout, theme, colour scheme or font scheme up in the
277
- // deck's inline records, then these, then its bundled catalogs. In the renderer's order they are the records a
278
- // `catalogs.<kind>.source` names (a bundled source, or `options.catalogSources`), then `options.catalogs`.
279
- const CONTEXT_CATALOG_KINDS = ["themes", "colorSchemes", "fontSchemes", "layouts"];
280
- function contextCatalogs(context) {
281
- if (context.contextCatalogs) return context.contextCatalogs;
282
- const out = {};
283
- for (const kind of CONTEXT_CATALOG_KINDS) {
284
- const records = [
285
- ...sourceRecordsFor(kind, context.presentation.catalogs?.[kind]?.source, context.options),
286
- ...normalizeSourceRecords(context.options.catalogs?.[kind]),
287
- ...sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options)
288
- ];
289
- if (records.length) out[kind] = records;
290
- }
291
- return (context.contextCatalogs = out);
292
- }
293
-
294
- // Generated socials furniture formats handles in core. Core applies inline
295
- // document records first; the host supplies the rest in resolution order, ahead of the bundled ones.
296
- function socialPlatformRecords(context, bundled) {
297
- const kind = "socialPlatforms";
298
- return [
299
- ...sourceRecordsFor(kind, context.presentation.catalogs?.[kind]?.source, context.options),
300
- ...normalizeSourceRecords(context.options.catalogs?.[kind]),
301
- ...sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options),
302
- ...bundled
303
- ];
304
- }
305
-
306
229
  function colorFromScheme(scheme, slot, fallback) {
307
230
  if (!slot) return fallback;
308
231
  if (typeof slot === "string") {
@@ -368,12 +291,15 @@ function resolveBackgroundColor(value, design, fallback) {
368
291
  return resolveColorRefIn(value, { colorScheme: design.colorScheme, colors: { primary, secondary, accent }, variables: design.variables }, fallback);
369
292
  }
370
293
 
294
+ // Image sources the background string shorthand accepts (a cover image); any other string is a theme slot or a hex colour.
295
+ const IMAGE_BACKGROUND_SOURCE = /^(asset:|https:\/\/|data:|\.\/|\.\.\/)/;
296
+
371
297
  // The slide's single background color, or null when there is none (a gradient, or no color the engine can read).
372
298
  // With no definition, or a picture, the scheme's default slide background (core defaultSlideBackground) is the canvas.
373
299
  function resolveBackground(background, colorScheme, design) {
374
300
  const canvas = defaultSlideBackground(colorScheme);
375
301
  if (!background) return canvas;
376
- if (typeof background === "string") return colorFromScheme(colorScheme, background, canvas);
302
+ if (typeof background === "string") return IMAGE_BACKGROUND_SOURCE.test(background) ? canvas : colorFromScheme(colorScheme, background, canvas);
377
303
  if (background.type === "theme") return colorFromScheme(colorScheme, background.slot, canvas);
378
304
  if (background.type === "solid") return resolveBackgroundColor(background.color, design, "#FFFFFF");
379
305
  if (background.type === "gradient") return null;
@@ -410,10 +336,9 @@ const LINK_URL = /^(https?:|mailto:|tel:)/i;
410
336
 
411
337
  // What the renderer paints with, from the records core's `resolveSlideContext` resolved (slide design, then deck design, then
412
338
  // theme, then the engine default, per field: the one order every engine uses). The theme and the colour scheme keep their
413
- // sorted keys, so the output does not depend on the order a catalog lists a field in. An id no catalog has never throws: the
414
- // context falls back (a theme to `minimal`, a colour scheme to `cool-horizon`, a font scheme to `aptos`, a layout to none) and
415
- // reports `unresolved-theme`, `unresolved-color-scheme`, `unresolved-font-scheme` or `unresolved-layout`, which are forwarded
416
- // as render diagnostics.
339
+ // sorted keys, so the output does not depend on the order a catalog lists a field in. A reference that resolves nowhere never
340
+ // throws (unless `strictReferences`): the context falls back to core's engine defaults (ENGINE_DEFAULT_THEME, _COLOR_SCHEME,
341
+ // _FONT_SCHEME; a layout to automatic composition) and reports `unresolved-reference`, which is forwarded as a render diagnostic.
417
342
  function resolveDesign(presentation, slide, slideContext) {
418
343
  const deckDesign = presentation.design ?? {};
419
344
  const slideDesign = slide.design ?? {};
@@ -468,7 +393,7 @@ function resolveDesign(presentation, slide, slideContext) {
468
393
  */
469
394
  function scriptProfile(presentation, index, design, context, language) {
470
395
  let resolved;
471
- try { resolved = resolveScriptFonts(presentation, { slideIndex: index, ...(language === undefined ? {} : { language }) }); }
396
+ try { resolved = resolveScriptFonts(presentation, { slideIndex: index, ...(context.options.catalogs !== undefined ? { catalogs: context.options.catalogs } : {}), ...(language === undefined ? {} : { language }) }); }
472
397
  catch (error) {
473
398
  reportLanguageDiagnostic(context, { code: "language-preview-unresolved", path: "language",
474
399
  message: "Script fonts could not be resolved (" + (error instanceof Error ? error.message : String(error)) + "), so the preview uses the design font for every script, sets no lang and lays out every paragraph left to right." });
@@ -594,16 +519,35 @@ function normalizeFutureContent(slide, slidePath) {
594
519
  });
595
520
  }
596
521
 
522
+ // The core catalog options every resolution takes: the host's catalogs, unchanged, and its strict reference policy.
523
+ function catalogOptions(options) {
524
+ return { ...(options.catalogs !== undefined ? { catalogs: options.catalogs } : {}), ...(options.strictReferences === true ? { strictReferences: true } : {}) };
525
+ }
526
+
527
+ function resolveContext(presentation, index, context) {
528
+ try {
529
+ return resolveSlideContext(presentation, index, {
530
+ fonts: { textMeasurement: context.options.textMeasurement },
531
+ ...catalogOptions(context.options),
532
+ date: context.options.date
533
+ });
534
+ } catch (error) {
535
+ // Strict references: core refuses a reference that resolves nowhere (OPFUnresolvedReferenceError) instead of falling back.
536
+ if (Array.isArray(error?.diagnostics) && error.diagnostics.some(entry => entry?.code === "unresolved-reference")) {
537
+ throw new OPFRenderError("unresolved-reference", error.message, { diagnostics: error.diagnostics, path: error.diagnostics[0].path, cause: error.message });
538
+ }
539
+ throw error;
540
+ }
541
+ }
542
+
597
543
  function bindSlide(presentation, slide, index, context) {
598
544
  const slidePath = `slides.${index}`;
599
545
  // Core resolves the slide's canvas, layout, theme, colour scheme, font scheme and family names the one way every engine must
600
- // (slide design, deck design, theme, default); `fonts` carries the measurement the host loaded. A slide with no `layout`, or one
601
- // that names an id no catalog has, composes with no layout record.
602
- const slideContext = resolveSlideContext(presentation, index, {
603
- fonts: { textMeasurement: context.options.textMeasurement },
604
- catalogs: contextCatalogs(context),
605
- date: context.options.date
606
- });
546
+ // (slide design, deck design, theme, engine default), from the records the document embeds and the catalogs the host
547
+ // registered (`options.catalogs`, passed unchanged); `fonts` carries the measurement the host loaded. A slide with no
548
+ // `layout` composes automatically; one whose reference resolves nowhere does too, with an `unresolved-reference` diagnostic,
549
+ // or the render fails under `strictReferences`.
550
+ const slideContext = resolveContext(presentation, index, context);
607
551
  const layout = slideContext.options.layout;
608
552
  const placeholders = Array.isArray(layout?.placeholders) ? layout.placeholders : [];
609
553
  const titleBindings = [];
@@ -670,7 +614,7 @@ function bindSlide(presentation, slide, index, context) {
670
614
  const named = design.fonts[role], resolved = resolveTextStyle({fontFamily:named,fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
671
615
  design.fonts[role] = fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
672
616
  }
673
- const geometry = composeSlide(slide, { ...slideContext.options, fontFamilies: design.fonts, textRasterPadding:context.options.textRasterPadding, textMeasurement, socialPlatforms: socialPlatformRecords(context, slideContext.options.socialPlatforms) });
617
+ const geometry = composeSlide(slide, { ...slideContext.options, fontFamilies: design.fonts, textRasterPadding:context.options.textRasterPadding, textMeasurement });
674
618
  return {
675
619
  scriptFonts,
676
620
  scriptFontsFor,
@@ -691,10 +635,11 @@ function bindSlide(presentation, slide, index, context) {
691
635
  }
692
636
 
693
637
  // `options.fonts` is the handle `loadFonts()` returns (or any object with a `textMeasurement` and, to embed faces in the SVG, an
694
- // `embeddedFonts` list). The renderer reads the two as plain options from here on.
638
+ // `embeddedFonts` list). The renderer reads the two as plain options from here on. `embedFonts: false` (RR-61) keeps the
639
+ // measurement and drops the faces, for a host whose page already holds them.
695
640
  function normalizeOptions(options) {
696
- const { fonts, ...rest } = options ?? {};
697
- return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: fonts?.embeddedFonts };
641
+ const { fonts, embedFonts, ...rest } = options ?? {};
642
+ return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: embedFonts === false ? undefined : fonts?.embeddedFonts };
698
643
  }
699
644
 
700
645
  /**
@@ -708,7 +653,7 @@ export function resolvePresentation(input, options = {}) {
708
653
  function resolveDeck(input, options) {
709
654
  const presentation = resolveTemplateInput(parseInput(input), options);
710
655
  if (options.validate !== false) {
711
- assertValidBoundary(presentation);
656
+ assertValidBoundary(presentation, options);
712
657
  }
713
658
 
714
659
  const context = { presentation, options };
@@ -757,7 +702,6 @@ function renderResolvedSlide(resolved, slideIndex, options) {
757
702
  const title = (Array.isArray(bound.slide.title) ? flattenText(bound.slide.title) : bound.slide.title) ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
758
703
  const content = [
759
704
  renderBackground(bound, width, height, options),
760
- renderSlideImage(bound, options),
761
705
  renderBranding(bound, resolved.presentation, width, height, options),
762
706
  ...renderSlideContent(bound, width, height, options),
763
707
  // RR-34: the footnote area core reserved above the footer band, after the content and before the furniture.
@@ -777,7 +721,9 @@ function renderResolvedSlide(resolved, slideIndex, options) {
777
721
  "svg",
778
722
  {
779
723
  xmlns: "http://www.w3.org/2000/svg",
780
- role: "img",
724
+ // FA-30: a labelled container, not an image: role="img" makes every text node inside presentational.
725
+ role: "group",
726
+ "aria-roledescription": "slide",
781
727
  "aria-label": title,
782
728
  viewBox: `0 0 ${width} ${height}`,
783
729
  width,
@@ -793,6 +739,8 @@ function renderResolvedSlide(resolved, slideIndex, options) {
793
739
 
794
740
  function renderBackground(bound, width, height, options) {
795
741
  const background = bound.design.background;
742
+ // An image background (core SlideComposition.backgroundImage) fills the canvas and moves nothing.
743
+ if (bound.geometry.backgroundImage) return renderBackgroundImage(bound.geometry.backgroundImage, bound, width, height, options);
796
744
  if (isPlainObject(background) && background.type === "gradient") {
797
745
  const id = `opf-s${bound.index + 1}-background`;
798
746
  const stops = Array.isArray(background.gradient?.stops) ? background.gradient.stops : [];
@@ -811,6 +759,7 @@ function renderBackground(bound, width, height, options) {
811
759
  y2: `${50+dy}%`
812
760
  }, stopTags.join(""))),
813
761
  tag("rect", {
762
+ ...HIDDEN,
814
763
  x: 0,
815
764
  y: 0,
816
765
  width,
@@ -821,15 +770,6 @@ function renderBackground(bound, width, height, options) {
821
770
  })
822
771
  ].join("\n");
823
772
  }
824
-
825
- if(isPlainObject(background) && background.type==='image'){
826
- const imageItem={value:background.image,path:`${bound.path}.design.background.image`};
827
- if(background.image.fit==='tile'){
828
- const size=Math.min(width,height)/4,id=`opf-s${bound.index+1}-image-tile`;
829
- return tag('g',{opacity:background.opacity??1},tag('defs',{},tag('pattern',{id,width:size,height:size,patternUnits:'userSpaceOnUse'},renderImage(imageItem,{x:0,y:0,width:size,height:size},bound,options)))+tag('rect',{width,height,fill:`url(#${id})`}));
830
- }
831
- return tag('g',{opacity:background.opacity??1},renderImage(imageItem,{x:0,y:0,width,height},bound,{...options,imageFit:background.image.fit??'cover'}));
832
- }
833
773
  if(isPlainObject(background) && background.type==='pattern'){
834
774
  const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=resolveBackgroundColor(pattern.foregroundColor,bound.design,bound.design.colors.text),preset=pattern.preset;
835
775
  // RR-07: every DrawingML preset (core PATTERN_PRESETS) is an 8x8 bitmap, one unit per 1/96 inch, anchored at the slide origin.
@@ -837,50 +777,89 @@ function renderBackground(bound, width, height, options) {
837
777
  const runs=patternRuns(preset);
838
778
  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'}):'';
839
779
  if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
840
- 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})`}));
780
+ 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})`}));
841
781
  }
842
782
  return tag("rect", {
783
+ ...HIDDEN,
843
784
  x: 0,
844
785
  y: 0,
845
786
  width,
846
787
  height,
847
- opacity: typeof background==='object'?background?.opacity??1:1,
788
+ opacity: isPlainObject(background) && background.type !== 'image' ? background.opacity ?? 1 : 1,
848
789
  fill: bound.design.backgroundColor ?? bound.design.colors.background,
849
790
  ...traceAttrs(options, `${bound.path}.design.background`)
850
791
  });
851
792
  }
852
793
 
853
- // design.slideImage: the shared composition frame, beneath branding and content.
854
- // crop covers the frame and fit centers the whole image, matching native a:srcRect.
855
- // Treatments mirror the native picture: the preset mask (core outline), recolor
856
- // and alphaModFix on the pixels only, the centered line, then the overlay shape.
857
- function renderSlideImage(bound, options) {
858
- const image = bound.geometry.slideImage;
859
- if (!image) return '';
860
- const trace = options.trace ? { 'data-opf-slide-image': image.path, 'data-opf-slide-image-position': image.position } : {};
861
- const value = image.alt === undefined ? image.value : { ...normalizeAsset(image.value), alt: image.alt };
862
- const picture = renderImage({ value, path: image.sourcePath }, image.box, bound, { ...options, imageFit: image.fill === 'crop' ? 'cover' : 'contain' });
863
- // Unresolved sources keep the ordinary placeholder without treatments, like the export.
864
- if (!picture.startsWith('<image')) return tag('g', trace, picture);
865
- const id = `opf-s${bound.index + 1}-slide-image`, shape = image.shape, defs = [];
866
- const masked = shape && shape.preset !== 'rect';
867
- if (masked) defs.push(tag('clipPath', { id: `${id}-clip` }, tag('path', { d: shape.path })));
868
- const matrix = slideImageRecolorMatrix(image.recolor, bound);
869
- if (matrix) defs.push(tag('filter', { id: `${id}-recolor`, 'color-interpolation-filters': 'sRGB' }, tag('feColorMatrix', { type: 'matrix', values: matrix })));
870
- let pixels = matrix || image.opacity !== undefined ? tag('g', { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: image.opacity === undefined ? undefined : preciseNumber(image.opacity) }, picture) : picture;
871
- if (masked) pixels = tag('g', { 'clip-path': `url(#${id}-clip)` }, pixels);
872
- const children = [defs.length ? tag('defs', {}, defs.join('')) : '', pixels];
794
+ // FA-22 image background, in the paint order of the geometry contract: the canvas colour (the colour scheme's default slide
795
+ // background), the picture (recolor and opacity on its pixels only, as for an image block), then the overlay (the whole canvas
796
+ // or an edge band). cover, contain
797
+ // and stretch use the shared fit math with the focus point; tile repeats the picture at its intrinsic size (1 picture px = 1
798
+ // reference px) from the canvas top-left. alt is the picture's accessible name; without alt the picture is decorative.
799
+ function renderBackgroundImage(background, bound, width, height, options) {
800
+ 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`) });
801
+ const item = { value: background.alt === undefined ? background.src : { src: background.src, alt: background.alt }, path: background.path };
802
+ const drawOptions = { ...options, imageDecorative: background.alt === undefined || background.alt === "" };
803
+ const picture = background.fit === "tile"
804
+ ? renderTiledImage(item, background.box, bound, drawOptions)
805
+ : renderImage(item, background.box, bound, { ...drawOptions, imageFit: background.fit, imageFocus: background.focus, imagePicture: background.picture });
806
+ // An unresolved source draws the ordinary placeholder at the background's opacity (as 0.14 did), without recolor or overlay.
807
+ if (isImagePlaceholder(picture)) return [canvas, background.opacity !== undefined ? tag("g", { opacity: background.opacity }, picture) : picture].join("\n");
808
+ const id = `opf-s${bound.index + 1}-background`, matrix = imageRecolorMatrix(background.recolor, bound);
809
+ const defs = matrix ? tag("defs", {}, tag("filter", { id: `${id}-recolor`, "color-interpolation-filters": "sRGB" }, tag("feColorMatrix", { type: "matrix", values: matrix }))) : "";
810
+ const pixels = matrix || background.opacity !== undefined ? tag("g", { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: background.opacity === undefined ? undefined : preciseNumber(background.opacity) }, picture) : picture;
811
+ return [canvas, defs, pixels, background.overlay ? renderImageOverlay(background.overlay, bound, options) : ""].filter(Boolean).join("\n");
812
+ }
813
+
814
+ // tile: a pattern cell of the picture's intrinsic size, anchored at the canvas origin.
815
+ function renderTiledImage(item, box, bound, options) {
816
+ const { source, drawable } = resolveImageSource(item, bound, options);
817
+ if (!drawable) return renderImage(item, box, bound, options);
818
+ const size = intrinsicImageSize(source) ?? { width: Math.min(box.width, box.height) / 4, height: Math.min(box.width, box.height) / 4 };
819
+ const id = `opf-s${bound.index + 1}-background-tile`;
820
+ const cell = tag("image", { x: 0, y: 0, width: stableNumber(size.width), height: stableNumber(size.height), href: source, preserveAspectRatio: "none" });
821
+ return tag("defs", {}, tag("pattern", { id, x: 0, y: 0, width: stableNumber(size.width), height: stableNumber(size.height), patternUnits: "userSpaceOnUse" }, cell))
822
+ + tag("rect", { x: box.x, y: box.y, width: box.width, height: box.height, fill: `url(#${id})`, ...imageAccessibility(normalizeAsset(item.value), options), ...traceAttrs(options, item.path) });
823
+ }
824
+
825
+ // FA-22 image block (core ComposedItem.image): the picture in its frame (image.box) with the block's fit and focus, clipped to
826
+ // the frame's shape unless it is a rectangle, with recolor and opacity on the pixels only; then the line centered on the shape
827
+ // outline; then the overlay (the frame's shape, or an edge band). The outlines are core's imageShape() paths, so the preview
828
+ // draws exactly the native preset geometry, in the paint order the export writes. A placed block (image.placement) arrives
829
+ // with its band as the region and needs nothing else. An unresolved source keeps the ordinary placeholder without treatments.
830
+ function renderImageBlock(item, bound, options) {
831
+ const image = item.image;
832
+ if (!image) throw new OPFRenderError("missing-image-geometry", "Image rendering requires a coordinated core build with image block geometry (ComposedItem.image).", { path: item.path });
833
+ const picture = renderImage(item, image.box, bound, { ...options, imageFit: image.fit, imageFocus: image.focus, imagePicture: image.picture });
834
+ const trace = options.trace && image.placement ? { "data-opf-image-placement": image.placement.edge } : {};
835
+ if (isImagePlaceholder(picture)) return Object.keys(trace).length ? tag("g", trace, picture) : picture;
836
+ const id = `opf-s${bound.index + 1}-image-${item.path.replace(/[^A-Za-z0-9]+/g, "-")}`, shape = image.shape, defs = [];
837
+ const masked = shape && shape.preset !== "rect";
838
+ if (masked) defs.push(tag("clipPath", { id: `${id}-clip` }, tag("path", { d: shape.path })));
839
+ const matrix = imageRecolorMatrix(image.recolor, bound);
840
+ if (matrix) defs.push(tag("filter", { id: `${id}-recolor`, "color-interpolation-filters": "sRGB" }, tag("feColorMatrix", { type: "matrix", values: matrix })));
841
+ let pixels = matrix || image.opacity !== undefined ? tag("g", { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: image.opacity === undefined ? undefined : preciseNumber(image.opacity) }, picture) : picture;
842
+ if (masked) pixels = tag("g", { "clip-path": `url(#${id}-clip)` }, pixels);
843
+ const children = [defs.length ? tag("defs", {}, defs.join("")) : "", pixels];
873
844
  if (image.border) {
874
- const paint = slideImagePaint(image.border.color, bound, bound.design.colors.border);
875
- 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 }));
845
+ const paint = imagePaint(image.border.color, bound, bound.design.colors.border);
846
+ 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 }));
876
847
  }
877
- if (image.overlay) {
878
- const paint = slideImagePaint(image.overlay.color, bound, bound.design.colors.text);
879
- children.push(tag('path', { d: image.overlay.shape?.path ?? rectanglePath(image.overlay.box), fill: paint.color, 'fill-opacity': preciseNumber(paint.alpha * image.overlay.opacity), ...(options.trace ? { 'data-opf-slide-image-overlay': `${image.path}.overlay` } : {}) }));
880
- }
881
- return tag('g', trace, children.filter(Boolean).join(''));
848
+ if (image.overlay) children.push(renderImageOverlay(image.overlay, bound, options));
849
+ if (children.length === 2 && !children[0] && !Object.keys(trace).length) return pixels;
850
+ return tag("g", trace, children.filter(Boolean).join(""));
851
+ }
852
+
853
+ // One overlay (shared by image backgrounds and image blocks): its outline filled with the colour, the colour's alpha times the
854
+ // overlay opacity.
855
+ function renderImageOverlay(overlay, bound, options) {
856
+ const paint = imagePaint(overlay.color, bound, bound.design.colors.text);
857
+ 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 } : {}) });
882
858
  }
883
859
 
860
+ // The placeholder renderImage draws for a source it cannot resolve.
861
+ const isImagePlaceholder = svg => svg.startsWith("<g") && svg.includes('data-opf-asset-status="unresolved"');
862
+
884
863
  // Native alpha and color-matrix values keep 1/100000 precision; three decimals would drift.
885
864
  const preciseNumber = value => String(Math.round(value * 1e6) / 1e6);
886
865
 
@@ -889,18 +868,18 @@ function rectanglePath(box) {
889
868
  }
890
869
 
891
870
  // ColorRef -> opaque #RRGGBB plus the AA byte as alpha, as the native srgbClr + alpha.
892
- function slideImagePaint(value, bound, fallback) {
871
+ function imagePaint(value, bound, fallback) {
893
872
  const hex = resolveColorRef(value, bound, fallback) ?? fallback;
894
873
  const raw = normalizeColor(hex, fallback).slice(1);
895
874
  return { color: `#${raw.slice(0, 6).toUpperCase()}`, alpha: raw.length === 8 ? parseInt(raw.slice(6), 16) / 255 : 1 };
896
875
  }
897
876
 
898
877
  // Rec. 601 luminance on sRGB values; duotone maps it linearly from dark to light.
899
- function slideImageRecolorMatrix(recolor, bound) {
878
+ function imageRecolorMatrix(recolor, bound) {
900
879
  if (!recolor) return undefined;
901
880
  const weights = [0.299, 0.587, 0.114];
902
881
  const channels = recolor.type === 'duotone'
903
- ? [slideImagePaint(recolor.dark, bound, '#000000'), slideImagePaint(recolor.light, bound, '#FFFFFF')].map(paint => [1, 3, 5].map(at => parseInt(paint.color.slice(at, at + 2), 16) / 255))
882
+ ? [imagePaint(recolor.dark, bound, '#000000'), imagePaint(recolor.light, bound, '#FFFFFF')].map(paint => [1, 3, 5].map(at => parseInt(paint.color.slice(at, at + 2), 16) / 255))
904
883
  : [[0, 0, 0], [1, 1, 1]];
905
884
  const [dark, light] = channels;
906
885
  const rows = [0, 1, 2].map(channel => [...weights.map(weight => preciseNumber((light[channel] - dark[channel]) * weight)), 0, preciseNumber(dark[channel])].join(' '));
@@ -911,7 +890,7 @@ function renderSlideContent(bound, width, height, options) {
911
890
  for (const diagnostic of [...bound.design.diagnostics, ...bound.geometry.diagnostics]) reportDiagnostic(diagnostic, options);
912
891
  return bound.geometry.items.map(item => {
913
892
  const frame=item.frameBox;
914
- 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)}) : '';
893
+ 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)}) : '';
915
894
  // RR-34: a captioned item draws its media in item.box and its caption band after it (src/annotations.js).
916
895
  return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options)+(item.caption?renderCaption(item,{ ...bound, composition: item.composition },options,drawHelpers):'');
917
896
  });
@@ -926,11 +905,13 @@ function reportDiagnostic(diagnostic, options) {
926
905
  options.onDiagnostic?.(diagnostic);
927
906
  }
928
907
 
929
- // 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
930
- // inside presentational. An empty alt marks the chart decorative (aria-hidden). Without alt the SVG is unchanged.
931
- function chartText(chart, alt) {
932
- if (typeof alt !== "string" || !chart) return chart;
933
- return alt === "" ? tag("g", { "aria-hidden": "true" }, chart) : tag("g", { role: "img", "aria-label": alt }, chart);
908
+ // 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",
909
+ // 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
910
+ // PowerPoint, where descr on a table frame hides nothing): role="group" names it without hiding its children. An empty alt marks the payload
911
+ // decorative (aria-hidden) for both. Without alt the SVG is unchanged.
912
+ function altText(drawn, alt, role) {
913
+ if (typeof alt !== "string" || !drawn) return drawn;
914
+ return alt === "" ? tag("g", { "aria-hidden": "true" }, drawn) : tag("g", { role, "aria-label": alt }, drawn);
934
915
  }
935
916
 
936
917
  function renderPayload(item, box, bound, options) {
@@ -938,11 +919,11 @@ function renderPayload(item, box, bound, options) {
938
919
  if (item.field === "items" || item.field === "bullets") return renderList(item, box, bound, options);
939
920
  switch (item.type) {
940
921
  case "chart":
941
- return chartText(renderChart(item, box, bound, options), item.value?.alt);
922
+ return altText(renderChart(item, box, bound, options), item.value?.alt, "img");
942
923
  case "table":
943
- return renderTable(item, box, bound, options);
924
+ return altText(renderTable(item, box, bound, options), item.value?.alt, "group");
944
925
  case "image":
945
- return renderImage(item, box, bound, options);
926
+ return renderImageBlock(item, bound, options);
946
927
  case "video":
947
928
  return renderMedia(item, box, bound, options);
948
929
  case "code":
@@ -1025,6 +1006,20 @@ function resolveImageSource(item, bound, options) {
1025
1006
  return { asset, source: svg ?? source, drawable, missingReference };
1026
1007
  }
1027
1008
 
1009
+ // FA-30: purely decorative drawing is hidden from assistive technology so the slide's text and real pictures are what a reader meets
1010
+ // (background, overlays, card frames, dividers, table cell fills and borders, accent shapes). SVG-AAM already leaves an unnamed shape
1011
+ // out of the tree; the attribute states the intent for the readers that do not.
1012
+ const HIDDEN = Object.freeze({ "aria-hidden": "true" });
1013
+
1014
+ // The accessible name of a drawn picture: its alt text, else the caller's label; a decorative picture (an image background
1015
+ // without alt) is hidden from assistive technology.
1016
+ function imageAccessibility(asset, options) {
1017
+ // An empty alt marks the picture decorative (FA-30), as it does a chart; a picture that is only furniture (a header or footer
1018
+ // logo repeated on every slide) is decorative unless the author named it.
1019
+ if (options.imageDecorative || asset.alt === "" || (options.imageUnlabelled && asset.alt === undefined)) return HIDDEN;
1020
+ return { role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image" };
1021
+ }
1022
+
1028
1023
  const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
1029
1024
 
1030
1025
  // The base64 data URI of a drawable SVG, else null. Drawable: a data URI whose type is image/svg+xml, whose text is
@@ -1171,12 +1166,29 @@ function resolveBulletImage(bulletImage, bound, options) {
1171
1166
  return undefined;
1172
1167
  }
1173
1168
 
1169
+ // A drawable picture in `box` with `options.imageFit`: cover (the default for content), contain or stretch. cover takes
1170
+ // `options.imageFocus`: centered, it is the native center crop; anywhere else the picture is placed by core's fit math
1171
+ // (`options.imagePicture`, core's ImageFitPlacement when core read the aspect, else fitImage on core's intrinsicImageAspect
1172
+ // of the resolved source, for pictures a host imageResolver supplies) and
1173
+ // clipped to the box by a nested viewport. A logo keeps its anchor (`options.imageAnchor`).
1174
1174
  function renderImage(item, box, bound, options) {
1175
1175
  const { asset, source, drawable, missingReference } = resolveImageSource(item, bound, options);
1176
1176
  if (drawable) {
1177
+ const fit = options.imageFit ?? "contain", focus = options.imageFocus;
1178
+ const attrs = { href: source, ...imageAccessibility(asset, options), ...traceAttrs(options, item.path), ...generatedAttrs(options) };
1179
+ if (fit === "cover" && focus && !options.imageAnchor && (focus.x !== 0.5 || focus.y !== 0.5)) {
1180
+ // The drawable source (after asset references and the host imageResolver): core reads its size (intrinsicImageAspect).
1181
+ const aspect = options.imagePicture ? undefined : intrinsicImageAspect(source);
1182
+ const placement = options.imagePicture ?? (aspect ? fitImage(box, "cover", aspect, focus) : undefined);
1183
+ if (placement) {
1184
+ const picture = placement.image;
1185
+ return tag("svg", { x: box.x, y: box.y, width: box.width, height: box.height, overflow: "hidden" },
1186
+ tag("image", { x: stableNumber(picture.x - box.x), y: stableNumber(picture.y - box.y), width: stableNumber(picture.width), height: stableNumber(picture.height), preserveAspectRatio: "none", ...attrs }));
1187
+ }
1188
+ }
1177
1189
  return tag("image", { x: box.x, y: box.y, width: box.width, height: box.height,
1178
- href: source, preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : options.imageAnchor === "right" ? "xMaxYMid meet" : (options.imageFit ?? (bound.geometry.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image",
1179
- ...traceAttrs(options, item.path), ...generatedAttrs(options) });
1190
+ preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : options.imageAnchor === "right" ? "xMaxYMid meet" : fit === "cover" ? "xMidYMid slice" : fit === "stretch" ? "none" : "xMidYMid meet",
1191
+ ...attrs });
1180
1192
  }
1181
1193
  const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
1182
1194
  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.';
@@ -1229,6 +1241,7 @@ function renderMedia(item, box, bound, options) {
1229
1241
  const iconBox = centeredBox(box, 72, 72);
1230
1242
  const children = [
1231
1243
  tag("rect", {
1244
+ ...HIDDEN,
1232
1245
  x: box.x,
1233
1246
  y: box.y,
1234
1247
  width: box.width,
@@ -1239,6 +1252,7 @@ function renderMedia(item, box, bound, options) {
1239
1252
  ...traceAttrs(options, item.path)
1240
1253
  }),
1241
1254
  tag("circle", {
1255
+ ...HIDDEN,
1242
1256
  cx: iconBox.x + iconBox.width / 2,
1243
1257
  cy: iconBox.y + iconBox.height / 2,
1244
1258
  r: 36,
@@ -1246,6 +1260,7 @@ function renderMedia(item, box, bound, options) {
1246
1260
  ...traceAttrs(options, item.path)
1247
1261
  }),
1248
1262
  tag("path", {
1263
+ ...HIDDEN,
1249
1264
  d: trianglePath(iconBox.x + 28, iconBox.y + 22, 28, 28),
1250
1265
  fill: "#FFFFFF",
1251
1266
  ...traceAttrs(options, item.path)
@@ -1276,6 +1291,7 @@ function renderCode(item, box, bound, options) {
1276
1291
  const highlight = codeHighlight(item, layout, bound);
1277
1292
  const children = [
1278
1293
  tag("rect", {
1294
+ ...HIDDEN,
1279
1295
  x: box.x,
1280
1296
  y: box.y,
1281
1297
  width: box.width,
@@ -1292,6 +1308,7 @@ function renderCode(item, box, bound, options) {
1292
1308
  const body = layout.parts.find(part => part.role === "body");
1293
1309
  for (const band of highlight.bands) {
1294
1310
  children.push(tag("rect", {
1311
+ ...HIDDEN,
1295
1312
  x: stableNumber(box.x + 1), y: stableNumber(body.box.y + band.first * body.fit.lineHeight), width: stableNumber(box.width - 2),
1296
1313
  height: stableNumber((band.last - band.first + 1) * body.fit.lineHeight), fill: highlight.colors.band,
1297
1314
  ...(options.trace ? { "data-opf-code-highlight": "true" } : {})
@@ -1398,7 +1415,7 @@ function renderQuote(item, box, bound, options) {
1398
1415
  function renderQuotePhoto(photo, item, bound, options) {
1399
1416
  const picture = renderImage({ value: photo.value, path: photo.path }, photo.box, bound, { ...options, imageFit: 'cover' });
1400
1417
  // An unresolved source keeps the ordinary placeholder, unmasked, like the export.
1401
- if (!picture.startsWith('<image')) return picture;
1418
+ if (isImagePlaceholder(picture)) return picture;
1402
1419
  const id = `opf-s${bound.index + 1}-quote-photo-${item.path.replace(/[^A-Za-z0-9]+/g, '-')}`;
1403
1420
  return tag('g', {}, tag('defs', {}, tag('clipPath', { id }, tag('path', { d: photo.shape.path }))) + tag('g', { 'clip-path': `url(#${id})` }, picture));
1404
1421
  }
@@ -1407,16 +1424,16 @@ function renderTimeline(item, box, bound, options) {
1407
1424
  const layout=item.timelineLayout;
1408
1425
  if(!layout)throw new OPFRenderError('missing-timeline-layout','Timeline rendering requires a coordinated core build with shared timeline geometry.',{path:item.path});
1409
1426
  const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
1410
- const children=[tag('line',{...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
1427
+ const children=[tag('line',{...HIDDEN,...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
1411
1428
  // FA-11: status colors come from the deck (core's timelineMarkerShapes / timelineTextColor); a marker with no status draws the plain primary circle.
1412
1429
  const background=bound.design.backgroundColor??bound.design.colors.background;
1413
1430
  const statusColors={background,primary:bound.design.colors.primary,text:bound.design.colors.text,mutedText:bound.design.colors.mutedText};
1414
1431
  for(const marker of layout.markers){
1415
1432
  if(!marker.status){
1416
- children.push(tag('circle',{cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
1433
+ children.push(tag('circle',{...HIDDEN,cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
1417
1434
  continue;
1418
1435
  }
1419
- 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}:{})}));
1436
+ 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}:{})}));
1420
1437
  }
1421
1438
  for(const part of layout.parts){
1422
1439
  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});
@@ -1447,6 +1464,7 @@ function renderTable(item, box, bound, options) {
1447
1464
  ? defaultText
1448
1465
  : resolveColorRef(style.color, bound, defaultText);
1449
1466
  children.push(tag("rect", {
1467
+ ...HIDDEN,
1450
1468
  x: stableNumber(cell.box.x), y: stableNumber(cell.box.y),
1451
1469
  width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
1452
1470
  fill,
@@ -1502,6 +1520,7 @@ function renderTableBorders(defaultEdges, explicitEdges, scale, bound, options)
1502
1520
  ? defaultColor
1503
1521
  : resolveColorRef(border.color, bound, defaultColor);
1504
1522
  return [tag('line', {
1523
+ ...HIDDEN,
1505
1524
  x1:stableNumber(x1), y1:stableNumber(y1), x2:stableNumber(x2), y2:stableNumber(y2),
1506
1525
  stroke, 'stroke-width':stableNumber(width),
1507
1526
  'stroke-dasharray':border?.dash === 'dash' ? `${width*4} ${width*3}` : border?.dash === 'dot' ? `${width} ${width*2}` : undefined,
@@ -1516,7 +1535,7 @@ function renderChart(item, box, bound, options) {
1516
1535
  const rendered = renderCatalogChart(item, box, bound, options, { tag, traceAttrs, stableNumber, renderTextBox, reportDiagnostic });
1517
1536
  if (rendered) return rendered;
1518
1537
  const chart = item.value ?? {};
1519
- const chartType = chart.type ?? engineDefaults.chartTypes[0];
1538
+ const chartType = chart.type ?? engineDefaults.chartType;
1520
1539
  const data = inlineChartRows(legacyChartData(chart, bound));
1521
1540
  // A dataset-backed chart has no data.rows of its own: its parts report the chart's authored path.
1522
1541
  const part = isDatasetChart(item, bound) ? () => item.path : (path) => path;
@@ -1639,7 +1658,7 @@ function renderFurniture(bound, presentation, width, height, options, kind) {
1639
1658
  }
1640
1659
  return tag('g',{},layout.parts.filter(part=>part.kind===kind).map(part=>{
1641
1660
  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}:{};
1642
- if(part.type==='image')return tag('g',trace,renderImage({value:part.image,path:part.path},part.box,bound,{...options,imageFit:'contain'}));
1661
+ if(part.type==='image')return tag('g',trace,renderImage({value:part.image,path:part.path},part.box,bound,{...options,imageFit:'contain',imageUnlabelled:true}));
1643
1662
  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}));
1644
1663
  }).join(''));
1645
1664
  }
@@ -1652,12 +1671,13 @@ function renderBranding(bound,presentation,width,height,options) {
1652
1671
  if(mark){
1653
1672
  const opacity=typeof design.watermark.opacity==='number'&&Number.isFinite(design.watermark.opacity)?Math.min(1,Math.max(0,design.watermark.opacity)):.08;
1654
1673
  const cx=width/2,cy=height/2;
1655
- pieces.push(tag('g',{opacity:stableNumber(opacity),transform:`rotate(${stableNumber(mark.rotation)} ${stableNumber(cx)} ${stableNumber(cy)})`,...traceAttrs(options,rootFor('watermark'))},
1674
+ pieces.push(tag('g',{...HIDDEN,opacity:stableNumber(opacity),transform:`rotate(${stableNumber(mark.rotation)} ${stableNumber(cx)} ${stableNumber(cy)})`,...traceAttrs(options,rootFor('watermark'))},
1656
1675
  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,
1657
1676
  fill:bound.design.colors.text,'xml:space':'preserve','text-rendering':'geometricPrecision'},escapeText(mark.text))));
1658
1677
  }
1659
1678
  } else if(design.watermark){
1660
- 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'})));
1679
+ // The group keeps its bare opacity attribute (opf-pptx's watermark parity test reads it); the picture itself is hidden.
1680
+ 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})));
1661
1681
  }
1662
1682
  // Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
1663
1683
  const logo=bound.geometry.logo;
@@ -1800,21 +1820,26 @@ function codeSyntax(item, layout, bound, options) {
1800
1820
  return { tokens, palette: codeSyntaxPaletteForScheme(bound.design.colorScheme) };
1801
1821
  }
1802
1822
 
1803
- // Faces flagged embed:"used" (the vendored open, Intos and script-pack faces) are embedded only when the slide's own markup draws
1804
- // them: the family in a font-family list, at a font-weight and font-style some text of the slide takes (attributes are
1805
- // inherited down the element tree, as in SVG). A used family none of whose faces matches a drawn weight and style keeps all
1806
- // its faces, so the browser can always choose. Every other face (the npm packs) is embedded as before.
1823
+ // 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
1824
+ // font-style some text of the slide takes (attributes are inherited down the element tree, as in SVG). A drawn family none of
1825
+ // whose faces matches a drawn weight and style keeps all its faces, so the browser can always choose. A face no font-family of
1826
+ // the SVG names is one the browser can never select, so leaving it out changes no pixel. Only a face flagged embed:"always" is
1827
+ // embedded in every SVG. RR-59: each drawn style is checked on its own, so a family keeps an exactly matched face and, for
1828
+ // a drawn style none of its faces matches (a weight the family lacks), every face the browser could choose from.
1807
1829
  function embeddedFontsFor(fonts = [], content) {
1808
- if (!fonts.some(font => font?.embed === "used")) return fonts;
1830
+ if (!fonts.length) return fonts;
1831
+ fonts.forEach(assertEmbeddableFont);
1809
1832
  const drawn = drawnFaces(content.join("\n"));
1810
1833
  const wanted = font => {
1811
1834
  const triples = drawn.get(String(font.family).toLowerCase());
1812
1835
  if (!triples) return false;
1813
- const family = fonts.filter(other => other?.embed === "used" && String(other.family).toLowerCase() === String(font.family).toLowerCase());
1814
- const matching = family.filter(other => triples.has(`${other.weight}|${other.italic ? "italic" : "normal"}`));
1815
- return matching.length ? matching.includes(font) : true;
1836
+ const family = fonts.filter(other => String(other?.family).toLowerCase() === String(font.family).toLowerCase());
1837
+ return [...triples].some(triple => {
1838
+ const matching = family.filter(other => triple === `${other.weight}|${other.italic ? "italic" : "normal"}`);
1839
+ return matching.length ? matching.includes(font) : true;
1840
+ });
1816
1841
  };
1817
- return fonts.filter(font => font?.embed !== "used" || wanted(font));
1842
+ return fonts.filter(font => font?.embed === "always" || wanted(font));
1818
1843
  }
1819
1844
 
1820
1845
  const FONT_WEIGHT_KEYWORDS = { normal: "400", bold: "700" };
@@ -1843,10 +1868,21 @@ function drawnFaces(markup) {
1843
1868
  return drawn;
1844
1869
  }
1845
1870
 
1871
+ // Every supplied face is checked, drawn or not, so a bad entry fails on any slide. The data URI check is remembered per face
1872
+ // object while its dataUrl is unchanged: the handle's list holds the same objects on every slide, and its URIs run to tens of
1873
+ // megabytes.
1874
+ const checkedDataUrls = new WeakMap();
1875
+ function assertEmbeddableFont(font) {
1876
+ const valid = typeof font?.family === "string" && !/[\u0000-\u001f"'\\<>;]/.test(font.family) && Number.isInteger(font.weight) && font.weight >= 1 && font.weight <= 1000
1877
+ && (checkedDataUrls.get(font) === font.dataUrl || /^data:font\/(ttf|otf|woff|woff2);base64,[A-Za-z0-9+/=]+$/.test(font.dataUrl));
1878
+ if (!valid) throw new OPFRenderError("invalid-embedded-font", "Embedded fonts require a plain family name, valid weight, and a font data URI.");
1879
+ checkedDataUrls.set(font, font.dataUrl);
1880
+ }
1881
+
1846
1882
  function renderEmbeddedFonts(fonts = []) {
1847
1883
  if (!fonts.length) return "";
1848
1884
  const css = fonts.map(font => {
1849
- 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.");
1885
+ assertEmbeddableFont(font);
1850
1886
  return `@font-face{font-family:"${font.family}";font-weight:${font.weight};font-style:${font.italic ? "italic" : "normal"};src:url("${font.dataUrl}")}`;
1851
1887
  }).join("\n");
1852
1888
  const licenses = [...new Set(fonts.map(font=>font.license).filter(Boolean))];