@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/README.md +48 -7
- package/dist/content-topology.js +21 -1
- package/dist/document-provenance.js +6 -5
- package/dist/furniture-provenance.js +67 -3
- package/dist/image-fallback-browser.js +12 -0
- package/dist/image-fallback-node.js +22 -0
- package/dist/import-signals.js +809 -0
- package/dist/index.d.ts +286 -4
- package/dist/index.js +198 -36
- package/dist/logo-provenance.js +4 -3
- package/dist/native-furniture.js +297 -0
- package/dist/slide-image-provenance.js +6 -4
- package/dist/svg-image.js +354 -0
- package/dist/watermark-provenance.js +4 -3
- package/package.json +4 -4
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
-
//
|
|
2565
|
-
//
|
|
2566
|
-
//
|
|
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) {
|
package/dist/logo-provenance.js
CHANGED
|
@@ -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} : {})}}} : {})};
|