@openpresentation/opf-pptx 0.11.8 → 0.11.9

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/index.js CHANGED
@@ -17,7 +17,9 @@ import {attachFurnitureTags, furnitureManifest, importFurniture, manifestPartInd
17
17
  import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance} from './document-provenance.js';
18
18
  import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
19
19
  import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
20
+ import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, writeNativeMasters} from './native-furniture.js';
20
21
  import {importImageOrientation} from './image-import.js';
22
+ import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
21
23
  import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
22
24
  import {dedupeMedia} from './media-dedupe.js';
23
25
  import {placeWatermarks, importWatermark, watermarkName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
@@ -26,7 +28,8 @@ import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} fr
26
28
  import {importBackground} from './background-import.js';
27
29
  import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
28
30
  import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
29
- import { webpToPng } from '#image-fallback';
31
+ import { webpToPng, svgToPng, readLocalFile } from '#image-fallback';
32
+ import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
30
33
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
31
34
  import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
32
35
  import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
@@ -39,6 +42,7 @@ import {
39
42
  } from "@openpresentation/opf";
40
43
 
41
44
  export {checkPptxTypefaces, inventoryPptxTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
45
+ export {DEFAULT_SIGNAL_LIMITS, SIGNALS_VERSION, isMonospaceFamily} from './import-signals.js';
42
46
 
43
47
  export const packageName = "@openpresentation/opf-pptx";
44
48
 
@@ -203,7 +207,7 @@ export async function toPptx(input, options = {}) {
203
207
  }
204
208
  const presentation = parseInput(input);
205
209
  assertValidBoundary(presentation);
206
- options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement)};
210
+ options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement), svgRasters: new Map()};
207
211
 
208
212
  const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
209
213
  context.listMarkers = new Map();
@@ -211,6 +215,7 @@ export async function toPptx(input, options = {}) {
211
215
  context.tableHeaders = new Map();
212
216
  context.tableCells = new Map();
213
217
  context.imagePlacements = new Map();
218
+ context.svgPictures = new Map();
214
219
  context.pictureText = new Map();
215
220
  context.slideImages = new Map();
216
221
  context.watermarks = new Map();
@@ -230,6 +235,8 @@ export async function toPptx(input, options = {}) {
230
235
  context.furnitureLogoTags = new Map();
231
236
  context.furnitureFields = new Map();
232
237
  context.furnitureManifests = new Map();
238
+ context.nativeFurniture = new Map();
239
+ context.hostDate = options.date;
233
240
  context.codeTags = new Map();
234
241
  context.metricTags = new Map();
235
242
  context.chartHeadings = new Map();
@@ -289,6 +296,8 @@ export async function toPptx(input, options = {}) {
289
296
  }
290
297
 
291
298
  export async function fromPptx(input, options = {}) {
299
+ // Opt-in raw shape signals (import-signals.js). Validated first so a bad option fails before any work.
300
+ const signalLimits = normalizeSignalOptions(options.signals, (code, message, details) => new OPFPptxError(code, message, details));
292
301
  const entries = readPptxZip(input);
293
302
  const presentationDoc = parseRequiredXml(entries, "ppt/presentation.xml");
294
303
  const presentationRoot = presentationDoc["p:presentation"];
@@ -323,11 +332,16 @@ export async function fromPptx(input, options = {}) {
323
332
  const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
324
333
  if (Object.keys(design).length) imported.design = design;
325
334
 
335
+ // Layouts and the master are read once however many slides inherit a placeholder from them (RR-11).
336
+ const cached = read => { const memo = new Map(); return path => { if (!memo.has(path)) memo.set(path, read(path)); return memo.get(path); }; };
337
+ const cachedRelationships = cached(path => parseRelationships(entries, path)), cachedPart = cached(path => parseOptionalXml(entries, path));
326
338
  const furnitureContexts = slidePaths.map((slidePath, index) => {
327
339
  const root = parseRequiredXml(entries, slidePath)['p:sld'];
328
340
  if (!root) throw new OPFPptxError('invalid-pptx', `PPTX slide is not a PresentationML slide: ${slidePath}.`, {path: slidePath});
329
341
  const tree = root['p:cSld']?.['p:spTree'], relationships = parseRelationships(entries, slidePath);
330
342
  return {root, relationships, shapes: nativeTextShapes(tree), pictures: nativePictures(tree),
343
+ // RR-11: native footer placeholders resolve their position through the layout and master.
344
+ path: slidePath, slideWidth: Number(presentationRoot['p:sldSz']?.cx), relationshipsOf: cachedRelationships, readPart: cachedPart,
331
345
  paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
332
346
  readPicture: picture => importPicture(entries, picture, slidePath, relationships,
333
347
  diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))};
@@ -366,6 +380,7 @@ export async function fromPptx(input, options = {}) {
366
380
  // Layout-structure recovery (FF-29) reads the stored OPF_SLIDE_V1 record from
367
381
  // here instead of writing a second slide tag.
368
382
  let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
383
+ let restoredGroups = [];
369
384
  try {
370
385
  const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
371
386
  // Host catalogs format stored socials exactly as export did (FF-34).
@@ -376,6 +391,7 @@ export async function fromPptx(input, options = {}) {
376
391
  restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
377
392
  imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
378
393
  slideProvenance = restored.slides;
394
+ restoredGroups = restored.groups;
379
395
  } catch (error) {
380
396
  report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
381
397
  }
@@ -392,7 +408,14 @@ export async function fromPptx(input, options = {}) {
392
408
  });
393
409
  }
394
410
 
395
- return imported;
411
+ if (!signalLimits) return imported;
412
+ // Where document provenance rebuilt the authored structure, flat block indexes map to their rebuilt paths.
413
+ const contentPaths = slidePaths.map(() => null);
414
+ for (const group of restoredGroups) if (group.applied && group.contentPaths?.paths) contentPaths[group.contentPaths.slide] = group.contentPaths.paths;
415
+ const archive = {part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]};
416
+ const signals = extractSignals({archive, presentationRoot, slidePaths, themeFacts: themeFactsFor(slidePaths[0], archive), limits: signalLimits,
417
+ recorded: furnitureContexts.map(context => context.signalSources), slideProvenance, contentPaths});
418
+ return {document: imported, signals};
396
419
  }
397
420
 
398
421
  // A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the logo only when nothing else
@@ -617,11 +640,14 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
617
640
  }
618
641
  }
619
642
  const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items))
620
- .map((item) => ({payload: payloadFromSlideItem(item), bounds: item.visualBounds ?? item.bounds}))
643
+ .map((item) => ({payload: payloadFromSlideItem(item), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? []}))
621
644
  .filter((entry) => entry.payload);
622
645
  const blocks = content.map((entry) => entry.payload);
623
646
  if (blocks.length > 0) slide.blocks = blocks;
624
647
  // Native bounds of each block in reference px, for content topology matching (document provenance).
648
+ // Which native shapes fed each field and block (import signals); never part of the document.
649
+ nativeContext.signalSources = {tag: tagItem?.sources ?? [], title: titleItem?.sources ?? [], subtitle: subtitleItem?.sources ?? [],
650
+ blocks: content.map(entry => ({sources: entry.sources, type: entry.payload.type})), roles: nativeContext.signalRoles ?? new Map()};
625
651
  nativeContext.contentBounds = content.map((entry) => entry.bounds ? {x: entry.bounds.x * 96, y: entry.bounds.y * 96, width: entry.bounds.w * 96, height: entry.bounds.h * 96} : null);
626
652
 
627
653
  const notes = readSlideNotes(entries, relationships);
@@ -638,6 +664,11 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
638
664
  const shapes = nativeContext.shapes;
639
665
  if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text and pictures are retained, but group transforms and unsupported group members are not reconstructed; review the reflowed OPF.'});
640
666
  const paragraphs = nativeContext.paragraphs;
667
+ // Native shape keys (indexes in nativeTextShapes / nativePictures / frame order) for import signals.
668
+ const shapeKeys = new Map(shapes.map((shape, index) => [shape, `sp:${index}`]));
669
+ const sourcesOf = group => group.flatMap(shape => shapeKeys.has(shape) ? [shapeKeys.get(shape)] : []);
670
+ const roles = new Map();
671
+ nativeContext.signalRoles = roles;
641
672
  const readBody = nativeBodyReader(slidePath, {
642
673
  part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
643
674
  }, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}));
@@ -655,23 +686,26 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
655
686
  if(item){
656
687
  const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
657
688
  if(bounds.length){const x=Math.min(...bounds.map(b=>b.x)),y=Math.min(...bounds.map(b=>b.y));item.bounds={x,y,w:Math.max(...bounds.map(b=>b.x+b.w))-x,h:Math.max(...bounds.map(b=>b.y+b.h))-y};}
658
- items.push({...item,heading:group.field,sourceText:!group.field});
689
+ items.push({...item,heading:group.field,sourceText:!group.field,sources:sourcesOf(group.shapes)});
659
690
  }
660
691
  }
661
692
  for(const group of timelines.items){
662
693
  const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
663
694
  let union;for(const bound of bounds){if(!union)union={...bound};else{const x=Math.min(union.x,bound.x),y=Math.min(union.y,bound.y);union={x,y,w:Math.max(union.x+union.w,bound.x+bound.w)-x,h:Math.max(union.y+union.h,bound.y+bound.h)-y};}}
664
- items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload});
695
+ items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload,sources:sourcesOf(group.shapes)});
665
696
  }
666
697
  for(const group of quotes.items){
667
698
  const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
668
699
  let union;for(const bound of bounds){if(!union)union={...bound};else{const x=Math.min(union.x,bound.x),y=Math.min(union.y,bound.y);union={x,y,w:Math.max(union.x+union.w,bound.x+bound.w)-x,h:Math.max(union.y+union.h,bound.y+bound.h)-y};}}
669
- items.push({kind:'quote',bounds:union,payload:group.payload});
700
+ items.push({kind:'quote',bounds:union,payload:group.payload,sources:sourcesOf(group.shapes)});
670
701
  }
671
- for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
672
- for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
673
- for (const item of media.items) items.push({kind:'media',sourceText:item.payload.type==='text',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
702
+ for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
703
+ for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
704
+ for (const item of media.items) items.push({kind:'media',sourceText:item.payload.type==='text',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
674
705
  for (const [index,shape] of shapes.entries()) {
706
+ const role = furniture.text.has(index) ? 'furniture' : nativeContext.slideImageShapes?.has(index) ? 'slide-image' : nativeContext.logoPlaceholderShapes?.has(index) ? 'logo' : cards.has(shape) ? 'card-frame'
707
+ : code.consumed.has(shape) ? 'code' : metric.consumed.has(shape) ? 'metric' : media.consumed.has(shape) ? 'media' : timelines.consumed.has(shape) ? 'timeline' : quotes.consumed.has(shape) ? 'quote' : undefined;
708
+ if (role) roles.set(shapeKeys.get(shape), role);
675
709
  if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
676
710
  if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||media.consumed.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)||quotes.consumed.has(shape)) continue;
677
711
  const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
@@ -680,7 +714,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
680
714
  // A damaged/edited furniture group falls back to current native text,
681
715
  // including cleared text boxes, without inventing a title or shape label.
682
716
  if (item && (furniture.taggedText.has(index) || media.captionShapes.has(shape))) item.sourceText = true;
683
- if (item) items.push(item);
717
+ if (item) items.push({...item, sources: [`sp:${index}`]});
684
718
  }
685
719
 
686
720
  const frames = asArray(tree?.["p:graphicFrame"]);
@@ -690,23 +724,24 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
690
724
  }, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions) : [];
691
725
  for (const [index, frame] of frames.entries()) {
692
726
  const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
693
- if (item) items.push(item);
727
+ if (item) items.push({...item, sources: [`frame:${index}`]});
694
728
  }
695
729
  // A chartex chart is an mc:AlternateContent: the choice frame references the cx:chartSpace part; the fallback is a classic chart frame
696
730
  // (this exporter) or a text shape (PowerPoint). The choice is read first and the fallback only when it names no OPF chart type.
697
- for (const alternate of asArray(tree?.["mc:AlternateContent"])) {
731
+ for (const [alternateIndex, alternate] of asArray(tree?.["mc:AlternateContent"]).entries()) {
698
732
  const choice = asArray(alternate?.["mc:Choice"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
699
733
  const fallback = asArray(alternate?.["mc:Fallback"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
700
734
  const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships) : null;
701
735
  const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships) : chosen;
702
- if (item && item.kind !== "unknown") items.push(item);
736
+ if (item && item.kind !== "unknown") items.push({...item, sources: [`alt:${alternateIndex}`]});
703
737
  }
704
738
 
705
739
  for (const [index, picture] of nativeContext.pictures.entries()) {
706
- if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index) || nativeContext.logoPictures?.has(index)) continue;
740
+ const pictureRole = furniture.pictures.has(index) ? 'furniture' : nativeContext.slideImagePictures?.has(index) ? 'slide-image' : nativeContext.watermarkPictures?.has(index) ? 'watermark' : nativeContext.logoPictures?.has(index) ? 'logo' : undefined;
741
+ if (pictureRole) { roles.set(`pic:${index}`, pictureRole); continue; }
707
742
  const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
708
743
  const item = importPicture(entries, picture, slidePath, relationships, report);
709
- if (item) items.push(item);
744
+ if (item) items.push({...item, sources: [`pic:${index}`]});
710
745
  }
711
746
 
712
747
  return items;
@@ -800,7 +835,19 @@ function importPicture(entries, picture, slidePath, relationships, report) {
800
835
  const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
801
836
  const relationship = relationships.get(relId);
802
837
  let bytes = relationship?.path ? entries[relationship.path] : null;
803
- if (!bytes) {
838
+ // A picture PowerPoint stores as SVG carries the SVG beside its PNG fallback: the SVG is the image.
839
+ let svg = null;
840
+ const svgRelationship = relationships.get(svgBlipRelationship(picture["p:blipFill"]?.["a:blip"]));
841
+ if (svgRelationship && svgRelationship.targetMode !== "External" && entries[svgRelationship.path]) {
842
+ const prepared = prepareSvg(entries[svgRelationship.path]);
843
+ if (prepared.error) report({code: "invalid-svg-image", message: `A picture's SVG was not imported (${prepared.error.message}); its PNG fallback was imported instead (none: the picture has no image).`});
844
+ else {
845
+ svg = prepared.bytes;
846
+ if (prepared.removed.length) report({code: "svg-sanitized", message: `The SVG had ${prepared.removed.join(", ")} removed on import.`});
847
+ }
848
+ }
849
+ // PowerPoint can save an SVG picture with no PNG fallback at all (an a:blip with no r:embed): the SVG alone is the image.
850
+ if (!bytes && !svg) {
804
851
  return {
805
852
  kind: "unknown",
806
853
  bounds,
@@ -813,7 +860,7 @@ function importPicture(entries, picture, slidePath, relationships, report) {
813
860
  if (crop && ['l','r','t','b'].some(key => Number(crop[key] ?? 0) !== 0)) {
814
861
  report({code: 'unsupported-image-crop', message: 'Native picture crop is not represented by the imported OPF asset; the full image was retained.'});
815
862
  }
816
- bytes = importImageOrientation(bytes, picture["p:spPr"]?.["a:xfrm"], report);
863
+ bytes = importImageOrientation(svg ?? bytes, picture["p:spPr"]?.["a:xfrm"], report);
817
864
 
818
865
  return {
819
866
  kind: "image",
@@ -823,7 +870,7 @@ function importPicture(entries, picture, slidePath, relationships, report) {
823
870
  payload: {
824
871
  type: "image",
825
872
  image: {
826
- src: `data:${rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship.path)};base64,${bytesToBase64(bytes)}`,
873
+ src: `data:${svg ? "image/svg+xml" : rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship?.path)};base64,${bytesToBase64(bytes)}`,
827
874
  ...(alt ? { alt } : {}),
828
875
  ...(title && alt ? { title } : {})
829
876
  }
@@ -953,7 +1000,7 @@ function mergeOpfListShapes(items) {
953
1000
  consumed.add(item);
954
1001
  }
955
1002
  const first = entries[0].item;
956
- merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined});
1003
+ merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined, sources: entries.flatMap(({item}) => item.sources ?? [])});
957
1004
  }
958
1005
  return items.flatMap(item => merged.has(item) ? [merged.get(item)] : consumed.has(item) ? [] : [item]);
959
1006
  }
@@ -971,6 +1018,7 @@ function mergeAdjacentBulletShapes(items) {
971
1018
  &&item.bounds.y-(previous.bounds.y+previous.bounds.h)<.3){
972
1019
  previous.paragraphs.push(...item.paragraphs);
973
1020
  previous.text+='\n'+item.text;
1021
+ previous.sources=[...(previous.sources??[]),...(item.sources??[])];
974
1022
  previous.bounds.h=item.bounds.y+item.bounds.h-previous.bounds.y;
975
1023
  }else result.push({...item,paragraphs:item.paragraphs?[...item.paragraphs]:undefined,bounds:item.bounds?{...item.bounds}:undefined});
976
1024
  }
@@ -1467,7 +1515,8 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1467
1515
  }
1468
1516
  if (backgroundDefinition?.type === 'image') {
1469
1517
  const imagePath = `${backgroundPath}.image`;
1470
- const outcome = {};
1518
+ // An SVG background is its PNG raster at the slide's size: a slide background picture fill carries no SVG.
1519
+ const outcome = { box: { w: slideContext.dimensions.widthInches, h: slideContext.dimensions.heightInches }, cover: (backgroundDefinition.image?.fit ?? 'cover') !== 'contain' };
1471
1520
  const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath, outcome);
1472
1521
  if (resolved) {
1473
1522
  // PptxGenJS embeds the raster and its relationship; packaging replaces
@@ -1497,7 +1546,10 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1497
1546
  // Core composition resolves one alignment per composed item for every engine
1498
1547
  // (FF-29); the design fallback keeps cores published before item.alignment working.
1499
1548
  const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
1500
- const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment, titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, darkBackground: isDarkHex(slideContext.colors.background), textMeasurement: options.textMeasurement, date: options.date, socialPlatforms: socialPlatformRecords(presentation, options) });
1549
+ const composeOptions = { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment, titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, darkBackground: isDarkHex(slideContext.colors.background), textMeasurement: options.textMeasurement, date: options.date, socialPlatforms: socialPlatformRecords(presentation, options) };
1550
+ const geometry = composeSlide(opfSlide, composeOptions);
1551
+ // The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
1552
+ if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
1501
1553
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
1502
1554
  // The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
1503
1555
  recordContentTopology(context.documentProvenance, opfSlide, slideIndex, geometry.items);
@@ -1683,7 +1735,8 @@ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,s
1683
1735
  const part=`ppt/slides/slide${slideIndex+1}.xml`;
1684
1736
  if(context.bulletImages.has(part))picture=context.bulletImages.get(part);
1685
1737
  else{
1686
- const outcome={};
1738
+ // A picture bullet (a:buBlip) can only reference a raster: an SVG icon is its PNG fallback.
1739
+ const outcome={box:{w:.5,h:.5}};
1687
1740
  const resolved=await resolveImage(bulletImage.source,presentation,options,bulletImage.path,outcome);
1688
1741
  if(resolved){
1689
1742
  slide.addImage({...resolved,objectName:bulletImageName(),x:0,y:0,w:.1,h:.1,altText:''});
@@ -1754,13 +1807,15 @@ function addListPayload(slide, items, region, context) {
1754
1807
  }
1755
1808
 
1756
1809
  async function addImagePayload(slide, presentation, asset, region, path, context, options) {
1757
- const resolved = await resolveImage(asset, presentation, options, path);
1810
+ const outcome = { box: region, cover: context.imageFill === 'crop' };
1811
+ const resolved = await resolveImage(asset, presentation, options, path, outcome);
1758
1812
  if (!resolved) {
1759
1813
  addImagePlaceholder(slide, presentation, asset, region, path, context, options);
1760
1814
  return;
1761
1815
  }
1762
1816
  const objectName = `OPF image ${context.imagePlacements.size + 1}`;
1763
1817
  context.imagePlacements.set(objectName, { region, mode: context.imageFill, path });
1818
+ if (outcome.svg) context.svgPictures.set(objectName, outcome.svg);
1764
1819
  context.pictureText.set(objectName, pictureText(assetAlt(asset, presentation), asset, presentation));
1765
1820
  slide.addImage({
1766
1821
  ...resolved,
@@ -1789,16 +1844,18 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
1789
1844
  notExported('The watermark needs an image source (a string, or an object with src); no watermark was exported for this slide.');
1790
1845
  return;
1791
1846
  }
1792
- const outcome = {};
1847
+ const {widthInches, heightInches} = slideContext.dimensions;
1848
+ const box = watermarkBox(widthInches, heightInches);
1849
+ const outcome = {box};
1793
1850
  const resolved = await resolveImage(asset, presentation, options, path, outcome);
1794
1851
  if (!resolved) {
1795
1852
  if (!outcome.reported) options.onDiagnostic?.({code: 'unresolved-asset', path, message: 'The watermark image needs an embedded raster, a declared asset or a host imageResolver; no watermark was exported for this slide.'});
1796
1853
  return;
1797
1854
  }
1798
- const {widthInches, heightInches} = slideContext.dimensions;
1799
- const box = watermarkBox(widthInches, heightInches);
1800
1855
  const opacity = watermarkOpacity(watermark);
1801
1856
  context.watermarks.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box, opacity, path});
1857
+ // Opacity is an a:alphaModFix on the blip, which PowerPoint applies to an SVG picture (native check 2026-10-01).
1858
+ if (outcome.svg) context.svgPictures.set(`ppt/slides/slide${slideIndex + 1}.xml|${watermarkName()}`, outcome.svg);
1802
1859
  slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
1803
1860
  }
1804
1861
 
@@ -1808,7 +1865,7 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
1808
1865
  // PptxGenJS embeds the bytes. An unresolved source draws the preview's "Image unavailable" panel in the logo box.
1809
1866
  async function addLogo(slide, presentation, logo, slideIndex, slideContext, context, options) {
1810
1867
  const region = {x: logo.box.x / 96, y: logo.box.y / 96, w: logo.box.width / 96, h: logo.box.height / 96};
1811
- const outcome = {};
1868
+ const outcome = {box: region};
1812
1869
  const resolved = await resolveImage(logo.source, presentation, options, logo.path, outcome);
1813
1870
  if (!resolved) {
1814
1871
  if (!outcome.reported) options.onDiagnostic?.({code: 'unresolved-asset', path: logo.path, message: 'The logo needs an embedded raster, a declared asset or a host imageResolver; the logo panel shows "Image unavailable" instead.'});
@@ -1817,6 +1874,7 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
1817
1874
  return;
1818
1875
  }
1819
1876
  context.logos.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box: region, path: logo.path, variant: logo.variant});
1877
+ if (outcome.svg) context.svgPictures.set(`ppt/slides/slide${slideIndex + 1}.xml|${logoName()}`, outcome.svg);
1820
1878
  slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
1821
1879
  }
1822
1880
 
@@ -1824,7 +1882,8 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
1824
1882
  // Crop/fit and treatments are written after PptxGenJS embeds the bytes.
1825
1883
  async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
1826
1884
  const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
1827
- const resolved = await resolveImage(image.value, presentation, options, image.sourcePath);
1885
+ const outcome = { box, cover: image.fill === 'crop' };
1886
+ const resolved = await resolveImage(image.value, presentation, options, image.sourcePath, outcome);
1828
1887
  if (!resolved) {
1829
1888
  addImagePlaceholder(slide, presentation, image.value, box, image.sourcePath, context, options);
1830
1889
  return;
@@ -1849,6 +1908,14 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
1849
1908
  // FF-53: a root `image` with the slide image's source is the slide image (core `replacesContent`); the
1850
1909
  // manifest records that so an unchanged picture imports back as both design.slideImage and slide.image.
1851
1910
  context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects, content: image.replacesContent === true });
1911
+ // PowerPoint applies opacity (a:alphaModFix), grayscale (a:grayscl) and the border (a:ln) to an SVG picture (native check
1912
+ // 2026-10-01). A duotone recolor and a non-rectangular mask are not confirmed on an SVG picture: with either the SVG stays its
1913
+ // PNG raster, so the effect applies as in the preview.
1914
+ if (outcome.svg) {
1915
+ const treated = [effects.recolor?.type === 'duotone' && 'duotone recolor', effects.shape && effects.shape.preset !== 'rect' && 'shape'].filter(Boolean);
1916
+ if (treated.length) options.onDiagnostic?.({ code: 'svg-image-rasterized', path: image.sourcePath, message: `An SVG slide image with ${treated.join(', ')} exports as its PNG raster, so the effect applies as in the preview; PowerPoint has not been confirmed to apply it to an SVG picture.` });
1917
+ else context.svgPictures.set(objectName, outcome.svg);
1918
+ }
1852
1919
  const slideImageAlt = image.alt ?? assetAlt(image.value, presentation);
1853
1920
  context.pictureText.set(objectName, pictureText(slideImageAlt, image.value, presentation));
1854
1921
  slide.addImage({ ...resolved, objectName, ...box, altText: slideImageAlt });
@@ -2090,6 +2157,17 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
2090
2157
  }
2091
2158
  }
2092
2159
 
2160
+ // The default footer band core composes for a deck with no footer of its own: where a footer added natively lands.
2161
+ function defaultFooterParts(composeOptions) {
2162
+ try {
2163
+ const slide = {design: {footer: {left: {date: '2026-01-01'}, center: {text: 'Footer'}, right: {slideNumber: true}}}};
2164
+ // Only the zone boxes and line height are used, and they do not depend on text widths: estimated measurement keeps the host's measurer (and its call count) out of this.
2165
+ return (composeSlide(slide, {...composeOptions, textMeasurement: undefined}).furniture?.parts ?? []).filter(part => part.kind === 'footer' && part.type === 'text');
2166
+ } catch {
2167
+ return undefined;
2168
+ }
2169
+ }
2170
+
2093
2171
  async function addFurniture(slide,presentation,source,layout,context,options,slideIndex) {
2094
2172
  if(!layout){
2095
2173
  if(['header','footer'].some(kind=>(source.design?.[kind]??presentation.design?.[kind])))throw new OPFPptxError('missing-furniture-layout','Header/footer export requires coordinated core furniture geometry.',{path:`slides.${slideIndex}.design`});
@@ -2098,6 +2176,8 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2098
2176
  return;
2099
2177
  }
2100
2178
  const staticDates = new Map();
2179
+ // RR-11: the first footer text, date and slide number that fit one line are native placeholders (src/native-furniture.js).
2180
+ const natives = nativeFurnitureParts(layout);
2101
2181
  for(const [index,part]of layout.parts.entries()){
2102
2182
  if(part.type==='image'){
2103
2183
  const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
@@ -2125,9 +2205,10 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2125
2205
  }
2126
2206
  }
2127
2207
  addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.mutedColor,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:manifestPartIndex(layout.parts,index)},liveFields,links:part.links});
2208
+ if(natives.has(index))context.nativeFurniture.set(`OPF furniture ${slideIndex} part ${index} line 0`,natives.get(index));
2128
2209
  }
2129
2210
  }
2130
- const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
2211
+ const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates, natives);
2131
2212
  if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
2132
2213
  }
2133
2214
 
@@ -2558,17 +2639,32 @@ function histogramBins(values) {
2558
2639
  }
2559
2640
  }
2560
2641
 
2561
- // Embedded bytes must be a raster the exporter can measure and PptxGenJS can embed (PNG, JPEG, GIF, WebP). An SVG, or
2562
- // bytes that are no readable image, would otherwise fail deep inside packaging. Like an unresolved asset it is not
2563
- // exported as a picture: the caller draws the preview's placeholder (or omits the picture) and one `unresolved-asset`
2564
- // diagnostic names the cause; `strictAssets` keeps the `unsupported-image-dimensions` error. `outcome.reported` tells a
2565
- // caller that the diagnostic was already emitted. Hosts that need SVG art supply a raster through `imageResolver`
2566
- // (for example opf-render's `svgToPng`). Local paths are read by PptxGenJS and checked after embedding.
2642
+ // Embedded bytes must be a raster the exporter can measure and PptxGenJS can embed (PNG, JPEG, GIF, WebP), or an SVG,
2643
+ // which exports as a native SVG picture over a PNG fallback raster (see resolveSvgImage). Bytes that are no readable
2644
+ // image would otherwise fail deep inside packaging. Like an unresolved asset they are not exported as a picture: the
2645
+ // caller draws the preview's placeholder (or omits the picture) and one `unresolved-asset` diagnostic names the cause;
2646
+ // `strictAssets` keeps the `unsupported-image-dimensions` error. `outcome.reported` tells a caller that the diagnostic
2647
+ // was already emitted. `outcome.box` (inches, in) sizes an SVG's fallback raster; `outcome.svg` (out) carries the SVG to
2648
+ // attach to the picture the caller adds. Local paths are read by PptxGenJS and checked after embedding.
2567
2649
  async function resolveImage(asset, presentation, options, path, outcome = {}) {
2568
2650
  const resolved = await resolveImageSource(asset, presentation, options, path);
2651
+ // A local SVG file is read here (PptxGenJS cannot size it); a raster path is read by PptxGenJS itself.
2652
+ if (resolved?.path && /\.svg$/i.test(resolved.path)) {
2653
+ let file;
2654
+ try { file = await readLocalFile(resolved.path); }
2655
+ catch (error) {
2656
+ if (options.strictAssets) throw new OPFPptxError("invalid-svg-image", `The SVG file could not be read (${errorMessage(error)}).`, { path, reason: "svg-unreadable" });
2657
+ options.onDiagnostic?.({ code: "unresolved-asset", path, reason: "svg-unreadable", message: `The SVG file could not be read (${errorMessage(error)}). The image exports as the unavailable-image placeholder.` });
2658
+ outcome.reported = true;
2659
+ return null;
2660
+ }
2661
+ return resolveSvgImage(file, options, path, outcome);
2662
+ }
2569
2663
  if (!resolved?.data) return resolved;
2570
2664
  const bytes = dataUriBytes(resolved.data);
2571
2665
  if (bytes && rasterMetadata(bytes)) return resolved;
2666
+ const svg = svgDataUriBytes(resolved.data);
2667
+ if (svg) return resolveSvgImage(svg, options, path, outcome);
2572
2668
  const message = `The image is not a readable PNG, JPEG, GIF or WebP (${dataUriMediaType(resolved.data) ?? "unknown type"}); supply a raster through imageResolver (for example opf-render svgToPng).`;
2573
2669
  if (options.strictAssets) throw new OPFPptxError("unsupported-image-dimensions", `Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. ${message}`, { path });
2574
2670
  options.onDiagnostic?.({ code: "unresolved-asset", path, reason: "unsupported-format", message });
@@ -2576,6 +2672,44 @@ async function resolveImage(asset, presentation, options, path, outcome = {}) {
2576
2672
  return null;
2577
2673
  }
2578
2674
 
2675
+ // An SVG picture is PowerPoint 2016's native form: the SVG itself (sanitized, never executed or fetched) plus a PNG
2676
+ // fallback raster drawn from it at 192 dpi of the displayed size by options.svgRasterizer (default: opf-render's resvg,
2677
+ // deterministic, with its bundled fonts). The caller adds the PNG as the picture and attaches `outcome.svg` at packaging.
2678
+ // A malformed SVG, an SVG without an intrinsic size or without a rasterizer exports like any unreadable image.
2679
+ async function resolveSvgImage(source, options, path, outcome) {
2680
+ const unresolved = (code, reason, message) => {
2681
+ if (options.strictAssets) throw new OPFPptxError(code, message, { path, reason });
2682
+ options.onDiagnostic?.({ code: "unresolved-asset", path, reason, message: `${message} The image exports as the unavailable-image placeholder.` });
2683
+ outcome.reported = true;
2684
+ return null;
2685
+ };
2686
+ if (source.error) return unresolved("invalid-svg-image", "svg-malformed", source.error);
2687
+ const prepared = prepareSvg(source);
2688
+ if (prepared.error) return unresolved("invalid-svg-image", prepared.error.reason, prepared.error.message);
2689
+ if (prepared.removed.length) {
2690
+ options.onDiagnostic?.({ code: "svg-sanitized", path, message: `The SVG had ${prepared.removed.join(", ")} removed before it was embedded: an exported SVG never runs code or refers to anything outside the file.` });
2691
+ }
2692
+ const scale = svgRasterScale(prepared, outcome.box, outcome.cover === true);
2693
+ const width = Math.max(1, Math.round(prepared.width * scale)), height = Math.max(1, Math.round(prepared.height * scale));
2694
+ const cacheKey = `${scale}|${prepared.text}`;
2695
+ let png = options.svgRasters.get(cacheKey);
2696
+ if (!png) {
2697
+ try {
2698
+ const rasterize = options.svgRasterizer ?? ((svg, hint) => svgToPng(svg, hint));
2699
+ png = await rasterize(prepared.text, { width, height, scale, text: prepared.hasText });
2700
+ if (!(png instanceof Uint8Array) || rasterMetadata(png)?.mediaType !== "image/png") throw Object.assign(new Error("The rasterizer did not return a PNG."), { code: "svg-render-failed" });
2701
+ } catch (error) {
2702
+ const reason = error?.code === "svg-rasterizer-unavailable" ? "svg-rasterizer-unavailable" : "svg-render-failed";
2703
+ return unresolved(reason, reason, reason === "svg-rasterizer-unavailable"
2704
+ ? "An SVG image needs a rasterizer for its PNG fallback: install the optional peer @openpresentation/opf-render, or pass options.svgRasterizer (or an imageResolver that returns a raster)."
2705
+ : `The SVG image could not be rasterized for its PNG fallback (${errorMessage(error)}).`);
2706
+ }
2707
+ options.svgRasters.set(cacheKey, png);
2708
+ }
2709
+ outcome.svg = { bytes: prepared.bytes, width: prepared.width, height: prepared.height };
2710
+ return { data: `data:image/png;base64,${bytesToBase64(png)}` };
2711
+ }
2712
+
2579
2713
  function dataUriMediaType(uri) {
2580
2714
  return /^data:([^;,]+)/i.exec(uri)?.[1]?.toLowerCase();
2581
2715
  }
@@ -2812,6 +2946,27 @@ function isDarkHex(value) {
2812
2946
  return (red * 299 + green * 587 + blue * 114) / 1000 < 128;
2813
2947
  }
2814
2948
 
2949
+ // The master, layout and notes master half of the native header/footer (RR-11): placeholders and p:hf on the finished parts.
2950
+ // Every deck gets them (flags off when no slide uses a type), so Insert > Header & Footer works on a deck with no footer too.
2951
+ function writeNativeFurnitureMasters(output, context) {
2952
+ const size = /<p:sldSz\b[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/.exec(decodeText(output['ppt/presentation.xml'][0]));
2953
+ const defaults = new Map((context.defaultFooterOptions ? defaultFooterParts(context.defaultFooterOptions) ?? [] : []).flatMap(part => {
2954
+ const ph = {date: 'dt', text: 'ftr', slideNumber: 'sldNum'}[part.field];
2955
+ const geometry = ph && defaultPlaceholderGeometry(part);
2956
+ return geometry ? [[ph, geometry]] : [];
2957
+ }));
2958
+ const info = {used: context.nativePlaceholders.used, first: context.nativePlaceholders.first, names: new Set(context.nativeFurniture.keys()), defaults,
2959
+ slideSize: {width: size ? Number(size[1]) : 12192000, height: size ? Number(size[2]) : 6858000}, dateText: nativeDateText(context.hostDate)};
2960
+ const paths = Object.keys(output);
2961
+ try {
2962
+ // The placeholders carry the deck's language and direction like every other generated part (partScriptFonts is idempotent).
2963
+ const written = (path, bytes) => { output[path] = [context.scriptFonts ? encodeText(partScriptFonts(path, decodeText(bytes), context.scriptFonts, 0)) : bytes, output[path][1]]; };
2964
+ writeNativeMasters(paths, path => output[path][0], written, info);
2965
+ } catch (error) {
2966
+ throw new OPFPptxError('packaging-failed', 'Native header/footer placeholders could not be written.', {cause: errorMessage(error)});
2967
+ }
2968
+ }
2969
+
2815
2970
  async function normalizePptxZip(raw, context) {
2816
2971
  let entries;
2817
2972
  try {
@@ -2832,6 +2987,8 @@ async function normalizePptxZip(raw, context) {
2832
2987
  attachQuoteTags(entries,context.quoteTags);
2833
2988
  attachFurnitureFields(entries,context.furnitureFields);
2834
2989
  attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests,context.furnitureLogoTags);
2990
+ // RR-11: the recorded footer shapes become PowerPoint's date, footer and slide-number placeholders.
2991
+ context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture);
2835
2992
  // The "Image unavailable" panel of an unresolved logo: identity only, so import does not read it as content.
2836
2993
  attachTextTags(entries,context.logoPlaceholderTags,LOGO_TAG,'opfLogoPlaceholder','logo placeholder');
2837
2994
  for(const [part,bytes]of Object.entries(entries)){
@@ -2878,10 +3035,14 @@ async function normalizePptxZip(raw, context) {
2878
3035
  const backgroundId = background && decodeText(bytes).match(/<p:bg>[\s\S]*?<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
2879
3036
  if (backgroundId) imageSources.set(relationships.get(backgroundId)?.path, background.path);
2880
3037
  }
3038
+ // SVG pictures: the media part, its relationship and the asvg:svgBlip extension beside the PNG fallback blip. Fitting then
3039
+ // uses the SVG's own proportions (the fallback raster only approximates them at integer pixels).
3040
+ const svgSizes = attachSvgPictures(entries, context.svgPictures);
2881
3041
  const imageMetadata = new Map();
2882
3042
  for (const [part, bytes] of Object.entries(entries)) {
2883
3043
  if (!part.startsWith('ppt/media/')) continue;
2884
3044
  let metadata = rasterMetadata(bytes);
3045
+ if (metadata && svgSizes.has(part)) metadata = {...metadata, ...svgSizes.get(part)};
2885
3046
  if (metadata?.mediaType === 'image/webp' && context.imageFormat === 'compatible') {
2886
3047
  try {
2887
3048
  if (metadata.width * metadata.height > 40_000_000) throw new Error('Image dimensions exceed the 40 megapixel conversion limit.');
@@ -2941,6 +3102,7 @@ async function normalizePptxZip(raw, context) {
2941
3102
  }];
2942
3103
  }
2943
3104
 
3105
+ writeNativeFurnitureMasters(output, context);
2944
3106
  finalizeFontsUsed(output);
2945
3107
  // Document references record evidence from the final normalized parts.
2946
3108
  if (context.documentProvenance) {
@@ -1,4 +1,5 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
+ import {normalizeCrop, withoutSvgBlip} from './svg-image.js';
2
3
  import {decodeTextTag, encodeTextTag} from './code-provenance.js';
3
4
  import {pictureTransform} from './image-geometry.js';
4
5
 
@@ -25,8 +26,8 @@ export const LOGO_TAG = TAG;
25
26
  export const logoName = () => 'OPF logo';
26
27
 
27
28
  function blipFillIdentity(blipFill) {
28
- const {['a:blip']: blip, ...rest} = blipFill ?? {};
29
- const {['r:embed']: _embed, ...blipRest} = blip ?? {};
29
+ const {['a:blip']: blip, ...rest} = normalizeCrop(blipFill) ?? {};
30
+ const {['r:embed']: _embed, ...blipRest} = withoutSvgBlip(blip) ?? {};
30
31
  return {...rest, 'a:blip': blipRest};
31
32
  }
32
33
 
@@ -162,7 +163,7 @@ export function importLogo(pictures, relationships, entries, slideIndex, readPic
162
163
  const manifest = decodeTextTag(own[0].val);
163
164
  if (manifest?.v !== 1 || manifest.slide !== `slides.${slideIndex}` || typeof manifest.path !== 'string' || !LOGO_PATH.test(manifest.path) || typeof manifest.variant !== 'string' || manifest.variant.length > 32) throw Error('Invalid logo manifest.');
164
165
  if (picture['p:nvPicPr']?.['p:cNvPr']?.name !== logoName()) throw Error('Logo identity changed.');
165
- if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(manifest.blipFill)) throw Error('Logo geometry changed.');
166
+ if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(normalizeCrop(manifest.blipFill))) throw Error('Logo geometry changed.');
166
167
  const image = readPicture(picture)?.payload?.image;
167
168
  consumed.add(index);
168
169
  return {consumed, ...(typeof image?.src === 'string' ? {fallback: {path: manifest.path, image: {src: image.src, ...(typeof image.alt === 'string' ? {alt: image.alt} : {})}}} : {})};