@openpresentation/opf-pptx 0.11.8 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -9
- package/dist/annotation-export.js +83 -0
- package/dist/annotation-provenance.js +246 -0
- package/dist/body-text-import.js +4 -1
- package/dist/chart-options.js +276 -0
- package/dist/chartex.js +42 -11
- package/dist/code-provenance.js +34 -10
- package/dist/content-topology.js +280 -53
- package/dist/document-provenance.js +83 -10
- 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 +302 -4
- package/dist/index.js +486 -102
- package/dist/logo-provenance.js +8 -6
- package/dist/metric-provenance.js +6 -3
- package/dist/native-furniture.js +297 -0
- package/dist/native-text-style.js +17 -1
- package/dist/numbered-list.js +114 -0
- package/dist/run-colors.js +118 -0
- package/dist/script-fonts.js +90 -14
- package/dist/slide-image-provenance.js +6 -4
- package/dist/svg-image.js +354 -0
- package/dist/table-cell-import.js +3 -1
- package/dist/table-import.js +3 -2
- package/dist/watermark-provenance.js +4 -3
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import {nativeBodyReader, joinNativeParagraphs} from './body-text-import.js';
|
|
2
|
+
import {autoNumScheme, deriveListNumbering, withDisplayedNumbers} from './numbered-list.js';
|
|
2
3
|
import {isFaceStyleSuffix} from './font-weights.js';
|
|
3
4
|
import {importTableFrames} from './table-import.js';
|
|
4
5
|
import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilies} from './package-fonts.js';
|
|
5
6
|
import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
|
|
6
7
|
import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,NATIVE_CHART_ELEMENTS} from './chart-types.js';
|
|
7
8
|
import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
|
|
9
|
+
import {applyDataLabels,chartOptionsFromClassic,chartTargetFor,classicChartOptions,reportChartOptionDiagnostics,resolveChartOptionsFor} from './chart-options.js';
|
|
8
10
|
import {attachCodeTags, attachTextTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
9
11
|
import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
|
|
10
12
|
import {attachCardTags,importCardFrames} from './card-provenance.js';
|
|
11
13
|
import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTextFingerprint} from './media-provenance.js';
|
|
14
|
+
// RR-34: captions and footnote areas as tagged text boxes; citation markers round-trip through the tags and the marker runs.
|
|
15
|
+
import {addCaption, addFootnotes} from './annotation-export.js';
|
|
16
|
+
import {attachAnnotationTags, captionValue, importAnnotations, restoreCitations} from './annotation-provenance.js';
|
|
12
17
|
import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
|
|
13
18
|
import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
|
|
14
19
|
import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
|
|
15
20
|
import {attachQuoteTags,quoteManifest,importQuoteGroups} from './quote-provenance.js';
|
|
16
21
|
import {attachFurnitureTags, furnitureManifest, importFurniture, manifestPartIndex, staticDateFallback} from './furniture-provenance.js';
|
|
17
|
-
import {
|
|
22
|
+
import {restoreRunColors} from './run-colors.js';
|
|
23
|
+
import {joinWrappedText} from './content-topology.js';
|
|
24
|
+
import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListBreaks, DEFAULT_AUTHOR} from './document-provenance.js';
|
|
18
25
|
import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
|
|
19
26
|
import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
|
|
27
|
+
import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, writeNativeMasters} from './native-furniture.js';
|
|
20
28
|
import {importImageOrientation} from './image-import.js';
|
|
29
|
+
import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
|
|
21
30
|
import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
|
|
22
31
|
import {dedupeMedia} from './media-dedupe.js';
|
|
23
32
|
import {placeWatermarks, importWatermark, watermarkName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
|
|
@@ -25,10 +34,13 @@ import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from
|
|
|
25
34
|
import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
|
|
26
35
|
import {importBackground} from './background-import.js';
|
|
27
36
|
import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
|
|
28
|
-
import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
|
|
29
|
-
import { webpToPng } from '#image-fallback';
|
|
37
|
+
import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, reconcileLanguage} from './script-fonts.js';
|
|
38
|
+
import { webpToPng, svgToPng, readLocalFile } from '#image-fallback';
|
|
39
|
+
import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
|
|
30
40
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
31
41
|
import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
|
|
42
|
+
// chartPaletteForFill ships with core RR-29 (opf#270); an older published core still loads and clamps each colour on its own.
|
|
43
|
+
import * as opfComposition from "@openpresentation/opf/composition";
|
|
32
44
|
import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
|
|
33
45
|
import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
|
|
34
46
|
import { unzipSync, zipSync } from "fflate";
|
|
@@ -37,8 +49,14 @@ import {
|
|
|
37
49
|
catalogs as bundledCatalogs,
|
|
38
50
|
validatePresentation
|
|
39
51
|
} from "@openpresentation/opf";
|
|
52
|
+
// Optional core exports (RR-07 syntax colours, trend marks) are read from the namespace so an older published core still loads.
|
|
53
|
+
|
|
54
|
+
// Optional core exports are read from the namespace so an older published core still loads;
|
|
55
|
+
// resolveVariables ships with core RR-32.
|
|
56
|
+
import * as opfCore from "@openpresentation/opf";
|
|
40
57
|
|
|
41
58
|
export {checkPptxTypefaces, inventoryPptxTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
|
|
59
|
+
export {DEFAULT_SIGNAL_LIMITS, SIGNALS_VERSION, isMonospaceFamily} from './import-signals.js';
|
|
42
60
|
|
|
43
61
|
export const packageName = "@openpresentation/opf-pptx";
|
|
44
62
|
|
|
@@ -201,9 +219,9 @@ export async function toPptx(input, options = {}) {
|
|
|
201
219
|
if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
|
|
202
220
|
throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
|
|
203
221
|
}
|
|
204
|
-
const presentation = parseInput(input);
|
|
222
|
+
const presentation = resolveTemplateInput(parseInput(input), options);
|
|
205
223
|
assertValidBoundary(presentation);
|
|
206
|
-
options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement)};
|
|
224
|
+
options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement), svgRasters: new Map()};
|
|
207
225
|
|
|
208
226
|
const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
|
|
209
227
|
context.listMarkers = new Map();
|
|
@@ -211,6 +229,7 @@ export async function toPptx(input, options = {}) {
|
|
|
211
229
|
context.tableHeaders = new Map();
|
|
212
230
|
context.tableCells = new Map();
|
|
213
231
|
context.imagePlacements = new Map();
|
|
232
|
+
context.svgPictures = new Map();
|
|
214
233
|
context.pictureText = new Map();
|
|
215
234
|
context.slideImages = new Map();
|
|
216
235
|
context.watermarks = new Map();
|
|
@@ -230,10 +249,16 @@ export async function toPptx(input, options = {}) {
|
|
|
230
249
|
context.furnitureLogoTags = new Map();
|
|
231
250
|
context.furnitureFields = new Map();
|
|
232
251
|
context.furnitureManifests = new Map();
|
|
252
|
+
context.nativeFurniture = new Map();
|
|
253
|
+
context.hostDate = options.date;
|
|
233
254
|
context.codeTags = new Map();
|
|
255
|
+
context.captionTags = new Map();
|
|
256
|
+
context.footnoteTags = new Map();
|
|
234
257
|
context.metricTags = new Map();
|
|
258
|
+
context.metricDescriptions = new Map();
|
|
235
259
|
context.chartHeadings = new Map();
|
|
236
260
|
context.chartFonts = new Map();
|
|
261
|
+
context.chartPalettes = new Map();
|
|
237
262
|
context.chartex = new Map();
|
|
238
263
|
context.imageFormat = options.imageFormat ?? "compatible";
|
|
239
264
|
// Chartex export mode (FF-22b): 'auto' (default) writes native chartex parts for the constructs the native PowerPoint
|
|
@@ -289,6 +314,8 @@ export async function toPptx(input, options = {}) {
|
|
|
289
314
|
}
|
|
290
315
|
|
|
291
316
|
export async function fromPptx(input, options = {}) {
|
|
317
|
+
// Opt-in raw shape signals (import-signals.js). Validated first so a bad option fails before any work.
|
|
318
|
+
const signalLimits = normalizeSignalOptions(options.signals, (code, message, details) => new OPFPptxError(code, message, details));
|
|
292
319
|
const entries = readPptxZip(input);
|
|
293
320
|
const presentationDoc = parseRequiredXml(entries, "ppt/presentation.xml");
|
|
294
321
|
const presentationRoot = presentationDoc["p:presentation"];
|
|
@@ -318,16 +345,21 @@ export async function fromPptx(input, options = {}) {
|
|
|
318
345
|
});
|
|
319
346
|
if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
|
|
320
347
|
if (core.description) imported.description = core.description;
|
|
321
|
-
if (core.author) imported.author = core.author;
|
|
348
|
+
if (core.author) imported.author = splitAuthors(core.author);
|
|
322
349
|
const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
|
|
323
350
|
const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
|
|
324
351
|
if (Object.keys(design).length) imported.design = design;
|
|
325
352
|
|
|
353
|
+
// Layouts and the master are read once however many slides inherit a placeholder from them (RR-11).
|
|
354
|
+
const cached = read => { const memo = new Map(); return path => { if (!memo.has(path)) memo.set(path, read(path)); return memo.get(path); }; };
|
|
355
|
+
const cachedRelationships = cached(path => parseRelationships(entries, path)), cachedPart = cached(path => parseOptionalXml(entries, path));
|
|
326
356
|
const furnitureContexts = slidePaths.map((slidePath, index) => {
|
|
327
357
|
const root = parseRequiredXml(entries, slidePath)['p:sld'];
|
|
328
358
|
if (!root) throw new OPFPptxError('invalid-pptx', `PPTX slide is not a PresentationML slide: ${slidePath}.`, {path: slidePath});
|
|
329
359
|
const tree = root['p:cSld']?.['p:spTree'], relationships = parseRelationships(entries, slidePath);
|
|
330
360
|
return {root, relationships, shapes: nativeTextShapes(tree), pictures: nativePictures(tree),
|
|
361
|
+
// RR-11: native footer placeholders resolve their position through the layout and master.
|
|
362
|
+
path: slidePath, slideWidth: Number(presentationRoot['p:sldSz']?.cx), relationshipsOf: cachedRelationships, readPart: cachedPart,
|
|
331
363
|
paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
|
|
332
364
|
readPicture: picture => importPicture(entries, picture, slidePath, relationships,
|
|
333
365
|
diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))};
|
|
@@ -339,7 +371,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
339
371
|
const mediaRegistry = Object.create(null);
|
|
340
372
|
for (let index = 0; index < slidePaths.length; index += 1) {
|
|
341
373
|
furnitureContexts[index].mediaRegistry = mediaRegistry;
|
|
342
|
-
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
|
|
374
|
+
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage)}, furniture.slides[index], furnitureContexts[index]));
|
|
343
375
|
}
|
|
344
376
|
// Native sections (PowerPoint's own section list, `Default Section` = none)
|
|
345
377
|
// are reconciled with the footer text and the stored value in restoreDocumentProvenance.
|
|
@@ -366,6 +398,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
366
398
|
// Layout-structure recovery (FF-29) reads the stored OPF_SLIDE_V1 record from
|
|
367
399
|
// here instead of writing a second slide tag.
|
|
368
400
|
let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
|
|
401
|
+
let restoredGroups = [];
|
|
369
402
|
try {
|
|
370
403
|
const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
|
|
371
404
|
// Host catalogs format stored socials exactly as export did (FF-34).
|
|
@@ -376,11 +409,16 @@ export async function fromPptx(input, options = {}) {
|
|
|
376
409
|
restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
|
|
377
410
|
imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
|
|
378
411
|
slideProvenance = restored.slides;
|
|
412
|
+
restoredGroups = restored.groups;
|
|
379
413
|
} catch (error) {
|
|
380
414
|
report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
|
|
381
415
|
}
|
|
382
416
|
if (slideProvenance.length !== imported.slides.length) throw new OPFPptxError("invalid-import-opf", "Slide provenance does not match the imported slides.");
|
|
417
|
+
// RR-34: marker runs become cite/footnote on the runs before them, and the references list is rebuilt
|
|
418
|
+
// from the stored record and the footnote boxes (an edited note keeps its edited text).
|
|
419
|
+
restoreCitations(imported, furnitureContexts.map(context => context.annotations?.notes ?? []), report);
|
|
383
420
|
restoreLogoFallback(imported, furnitureContexts);
|
|
421
|
+
applyRunColors(imported, slideProvenance, restoredGroups, report, options);
|
|
384
422
|
if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
|
|
385
423
|
languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
|
|
386
424
|
|
|
@@ -392,7 +430,32 @@ export async function fromPptx(input, options = {}) {
|
|
|
392
430
|
});
|
|
393
431
|
}
|
|
394
432
|
|
|
395
|
-
return imported;
|
|
433
|
+
if (!signalLimits) return imported;
|
|
434
|
+
// Where document provenance rebuilt the authored structure, flat block indexes map to their rebuilt paths.
|
|
435
|
+
const contentPaths = slidePaths.map(() => null);
|
|
436
|
+
for (const group of restoredGroups) if (group.applied && group.contentPaths?.paths) contentPaths[group.contentPaths.slide] = group.contentPaths.paths;
|
|
437
|
+
const archive = {part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]};
|
|
438
|
+
const signals = extractSignals({archive, presentationRoot, slidePaths, themeFacts: themeFactsFor(slidePaths[0], archive), limits: signalLimits,
|
|
439
|
+
recorded: furnitureContexts.map(context => context.signalSources), slideProvenance, contentPaths});
|
|
440
|
+
return {document: imported, signals};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// RR-08: run colours named by a ColorRef (variable, scheme slot, role) come back as that name where the document's own
|
|
444
|
+
// colour resolution still gives the run's colour; see run-colors.js.
|
|
445
|
+
function applyRunColors(imported, slideProvenance, restoredGroups, report, options) {
|
|
446
|
+
const restoredContent = new Set(restoredGroups.filter(group => group.applied && group.contentPaths).map(group => group.contentPaths.slide));
|
|
447
|
+
const info = slideProvenance.map((entry, index) => entry.structure === "untagged" ? undefined : {record: entry.record, structure: entry.structure === "match", content: restoredContent.has(index)});
|
|
448
|
+
const contexts = new Map();
|
|
449
|
+
const resolve = (slide, name) => {
|
|
450
|
+
try {
|
|
451
|
+
const key = slide.design ?? imported.design ?? null;
|
|
452
|
+
if (!contexts.has(key)) contexts.set(key, resolvePresentationContext(slide.design ? {...imported, design: {...imported.design, ...slide.design}} : imported, options));
|
|
453
|
+
return resolveColorRefValue(name, colorContext(contexts.get(key)));
|
|
454
|
+
} catch {
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
restoreRunColors(imported.slides, info, resolve, report);
|
|
396
459
|
}
|
|
397
460
|
|
|
398
461
|
// A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the logo only when nothing else
|
|
@@ -616,12 +679,37 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
616
679
|
item.text = current.map(paragraph => paragraph.text).join('\n');
|
|
617
680
|
}
|
|
618
681
|
}
|
|
619
|
-
const
|
|
620
|
-
|
|
682
|
+
const listBreaks = slideListBreaks(entries, slidePath, slideRoot, relationships);
|
|
683
|
+
const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items, listBreaks, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})))
|
|
684
|
+
.map((item) => ({payload: payloadFromSlideItem(item, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? []}))
|
|
621
685
|
.filter((entry) => entry.payload);
|
|
622
686
|
const blocks = content.map((entry) => entry.payload);
|
|
687
|
+
// RR-34: a tagged caption re-attaches to the block whose native media shape it names (never by position).
|
|
688
|
+
const annotations = nativeContext.annotations;
|
|
689
|
+
if (annotations?.captions.size) {
|
|
690
|
+
const tree = slideRoot['p:cSld']?.['p:spTree'];
|
|
691
|
+
const shapeName = node => scalarText(node?.['p:nvSpPr']?.['p:cNvPr']?.name ?? node?.['p:nvPicPr']?.['p:cNvPr']?.name ?? node?.['p:nvGraphicFramePr']?.['p:cNvPr']?.name).trim();
|
|
692
|
+
const keyByName = new Map();
|
|
693
|
+
nativeContext.shapes.forEach((shape, index) => keyByName.set(shapeName(shape), `sp:${index}`));
|
|
694
|
+
nativeContext.pictures.forEach((picture, index) => keyByName.set(shapeName(picture), `pic:${index}`));
|
|
695
|
+
asArray(tree?.['p:graphicFrame']).forEach((frame, index) => keyByName.set(shapeName(frame), `frame:${index}`));
|
|
696
|
+
asArray(tree?.['mc:AlternateContent']).forEach((alternate, index) => { for (const node of [...asArray(alternate?.['mc:Choice']), ...asArray(alternate?.['mc:Fallback'])]) if (node?.['p:graphicFrame']) keyByName.set(shapeName(node['p:graphicFrame']), `alt:${index}`); });
|
|
697
|
+
for (const [mediaName, caption] of annotations.captions) {
|
|
698
|
+
const key = keyByName.get(mediaName);
|
|
699
|
+
const entry = key === undefined ? undefined : content.find(item => item.sources.includes(key));
|
|
700
|
+
if (!entry || !['image', 'chart', 'table', 'video'].includes(inferPayloadKind(entry.payload))) {
|
|
701
|
+
options.onDiagnostic?.({code: 'caption-detached', path: `slides.${slideIndex}`, message: `The caption of ${caption.path} no longer has its media shape (${mediaName}); its text is imported as an ordinary text block.`});
|
|
702
|
+
blocks.push({type: 'text', text: caption.value});
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
entry.payload.caption = captionValue(caption);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
623
708
|
if (blocks.length > 0) slide.blocks = blocks;
|
|
624
709
|
// Native bounds of each block in reference px, for content topology matching (document provenance).
|
|
710
|
+
// Which native shapes fed each field and block (import signals); never part of the document.
|
|
711
|
+
nativeContext.signalSources = {tag: tagItem?.sources ?? [], title: titleItem?.sources ?? [], subtitle: subtitleItem?.sources ?? [],
|
|
712
|
+
blocks: content.map(entry => ({sources: entry.sources, type: entry.payload.type})), roles: nativeContext.signalRoles ?? new Map()};
|
|
625
713
|
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
714
|
|
|
627
715
|
const notes = readSlideNotes(entries, relationships);
|
|
@@ -638,6 +726,17 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
638
726
|
const shapes = nativeContext.shapes;
|
|
639
727
|
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
728
|
const paragraphs = nativeContext.paragraphs;
|
|
729
|
+
// FF-45: OMML equations (a14:m math zones) have no OPF model. Their fallback text is imported as plain text; the equation
|
|
730
|
+
// layout (fractions, radicals, limits, matrices) is lost, and that loss is diagnosed per shape instead of passing silently.
|
|
731
|
+
paragraphs.forEach((list, index) => {
|
|
732
|
+
const zones = list.reduce((count, paragraph) => count + (paragraph.math?.zones ?? 0), 0);
|
|
733
|
+
if (zones) options.onDiagnostic?.({code: 'math-equation-flattened', path: `slides.${slideIndex}`, message: `Native shape ${index} holds ${zones} OMML equation${zones === 1 ? '' : 's'} (a14:m); OPF has no equation model, so its fallback text is imported as plain text and the equation layout is not represented.`});
|
|
734
|
+
});
|
|
735
|
+
// Native shape keys (indexes in nativeTextShapes / nativePictures / frame order) for import signals.
|
|
736
|
+
const shapeKeys = new Map(shapes.map((shape, index) => [shape, `sp:${index}`]));
|
|
737
|
+
const sourcesOf = group => group.flatMap(shape => shapeKeys.has(shape) ? [shapeKeys.get(shape)] : []);
|
|
738
|
+
const roles = new Map();
|
|
739
|
+
nativeContext.signalRoles = roles;
|
|
641
740
|
const readBody = nativeBodyReader(slidePath, {
|
|
642
741
|
part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
|
|
643
742
|
}, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}));
|
|
@@ -650,29 +749,36 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
650
749
|
const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
651
750
|
const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
652
751
|
const quotes=importQuoteGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote`}));
|
|
752
|
+
// RR-34: tagged caption and footnote lines are not content; captions re-attach to their media block and notes rebuild cite/footnote.
|
|
753
|
+
const annotations=importAnnotations({shapes,paragraphs,relationships,entries,slideIndex,readBody,report:diagnostic=>options.onDiagnostic?.(diagnostic)});
|
|
754
|
+
nativeContext.annotations=annotations;
|
|
653
755
|
for(const group of [...headings.items,...plainText.items]) {
|
|
654
756
|
const item=importShape(group.shapes[0],dimensions,group.paragraphs,true);
|
|
655
757
|
if(item){
|
|
656
758
|
const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
|
|
657
759
|
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});
|
|
760
|
+
items.push({...item,heading:group.field,sourceText:!group.field,sources:sourcesOf(group.shapes)});
|
|
659
761
|
}
|
|
660
762
|
}
|
|
661
763
|
for(const group of timelines.items){
|
|
662
764
|
const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
|
|
663
765
|
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});
|
|
766
|
+
items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload,sources:sourcesOf(group.shapes)});
|
|
665
767
|
}
|
|
666
768
|
for(const group of quotes.items){
|
|
667
769
|
const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
|
|
668
770
|
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});
|
|
771
|
+
items.push({kind:'quote',bounds:union,payload:group.payload,sources:sourcesOf(group.shapes)});
|
|
670
772
|
}
|
|
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});
|
|
773
|
+
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])});
|
|
774
|
+
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])});
|
|
775
|
+
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
776
|
for (const [index,shape] of shapes.entries()) {
|
|
777
|
+
const role = furniture.text.has(index) ? 'furniture' : nativeContext.slideImageShapes?.has(index) ? 'slide-image' : nativeContext.logoPlaceholderShapes?.has(index) ? 'logo' : cards.has(shape) ? 'card-frame'
|
|
778
|
+
: 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;
|
|
779
|
+
if (role) roles.set(shapeKeys.get(shape), role);
|
|
675
780
|
if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
|
|
781
|
+
if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
|
|
676
782
|
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
783
|
const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
|
|
678
784
|
const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
|
|
@@ -680,33 +786,36 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
680
786
|
// A damaged/edited furniture group falls back to current native text,
|
|
681
787
|
// including cleared text boxes, without inventing a title or shape label.
|
|
682
788
|
if (item && (furniture.taggedText.has(index) || media.captionShapes.has(shape))) item.sourceText = true;
|
|
683
|
-
if (item) items.push(item);
|
|
789
|
+
if (item) items.push({...item, sources: [`sp:${index}`]});
|
|
684
790
|
}
|
|
685
791
|
|
|
686
792
|
const frames = asArray(tree?.["p:graphicFrame"]);
|
|
793
|
+
// RR-35: what a chart's axis titles, legend and data labels cannot express in OPF is reported on the chart's path.
|
|
794
|
+
const chartReport = label => diagnostic => options.onDiagnostic?.({code: 'chart-option-adapted', ...diagnostic, path: `slides.${slideIndex}.${label}.${diagnostic.option}`});
|
|
687
795
|
const tables = frames.some(frame => frame['a:graphic']?.['a:graphicData']?.['a:tbl'])
|
|
688
796
|
? importTableFrames(slidePath, {
|
|
689
797
|
part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
|
|
690
|
-
}, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions) : [];
|
|
798
|
+
}, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions, {rtlDeck: options.rtlDeck === true}) : [];
|
|
691
799
|
for (const [index, frame] of frames.entries()) {
|
|
692
|
-
const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
|
|
693
|
-
if (item) items.push(item);
|
|
800
|
+
const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index], chartReport(`charts.${index}`));
|
|
801
|
+
if (item) items.push({...item, sources: [`frame:${index}`]});
|
|
694
802
|
}
|
|
695
803
|
// A chartex chart is an mc:AlternateContent: the choice frame references the cx:chartSpace part; the fallback is a classic chart frame
|
|
696
804
|
// (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"])) {
|
|
805
|
+
for (const [alternateIndex, alternate] of asArray(tree?.["mc:AlternateContent"]).entries()) {
|
|
698
806
|
const choice = asArray(alternate?.["mc:Choice"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
|
|
699
807
|
const fallback = asArray(alternate?.["mc:Fallback"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
|
|
700
|
-
const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships) : null;
|
|
701
|
-
const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships) : chosen;
|
|
702
|
-
if (item && item.kind !== "unknown") items.push(item);
|
|
808
|
+
const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships, undefined, chartReport(`charts.alt${alternateIndex}`)) : null;
|
|
809
|
+
const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships, undefined, chartReport(`charts.alt${alternateIndex}`)) : chosen;
|
|
810
|
+
if (item && item.kind !== "unknown") items.push({...item, sources: [`alt:${alternateIndex}`]});
|
|
703
811
|
}
|
|
704
812
|
|
|
705
813
|
for (const [index, picture] of nativeContext.pictures.entries()) {
|
|
706
|
-
|
|
814
|
+
const pictureRole = furniture.pictures.has(index) ? 'furniture' : nativeContext.slideImagePictures?.has(index) ? 'slide-image' : nativeContext.watermarkPictures?.has(index) ? 'watermark' : nativeContext.logoPictures?.has(index) ? 'logo' : undefined;
|
|
815
|
+
if (pictureRole) { roles.set(`pic:${index}`, pictureRole); continue; }
|
|
707
816
|
const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
|
|
708
817
|
const item = importPicture(entries, picture, slidePath, relationships, report);
|
|
709
|
-
if (item) items.push(item);
|
|
818
|
+
if (item) items.push({...item, sources: [`pic:${index}`]});
|
|
710
819
|
}
|
|
711
820
|
|
|
712
821
|
return items;
|
|
@@ -739,7 +848,7 @@ function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txB
|
|
|
739
848
|
};
|
|
740
849
|
}
|
|
741
850
|
|
|
742
|
-
function importGraphicFrame(entries, frame, slidePath, relationships, importedTable) {
|
|
851
|
+
function importGraphicFrame(entries, frame, slidePath, relationships, importedTable, report) {
|
|
743
852
|
const bounds = shapeBounds(frame["p:xfrm"]);
|
|
744
853
|
const name = scalarText(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]?.name).trim();
|
|
745
854
|
const graphicData = frame["a:graphic"]?.["a:graphicData"];
|
|
@@ -760,8 +869,8 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
|
|
|
760
869
|
const chartRelId = graphicData?.["c:chart"]?.["r:id"] ?? chartExRelId;
|
|
761
870
|
if (chartRelId) {
|
|
762
871
|
const chart = chartExRelId
|
|
763
|
-
? chartexFromRelationship(entries, relationships, chartExRelId)
|
|
764
|
-
: chartFromRelationship(entries, slidePath, relationships, chartRelId);
|
|
872
|
+
? chartexFromRelationship(entries, relationships, chartExRelId, report)
|
|
873
|
+
: chartFromRelationship(entries, slidePath, relationships, chartRelId, report);
|
|
765
874
|
return {
|
|
766
875
|
kind: "chart",
|
|
767
876
|
bounds,
|
|
@@ -800,7 +909,19 @@ function importPicture(entries, picture, slidePath, relationships, report) {
|
|
|
800
909
|
const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
|
|
801
910
|
const relationship = relationships.get(relId);
|
|
802
911
|
let bytes = relationship?.path ? entries[relationship.path] : null;
|
|
803
|
-
|
|
912
|
+
// A picture PowerPoint stores as SVG carries the SVG beside its PNG fallback: the SVG is the image.
|
|
913
|
+
let svg = null;
|
|
914
|
+
const svgRelationship = relationships.get(svgBlipRelationship(picture["p:blipFill"]?.["a:blip"]));
|
|
915
|
+
if (svgRelationship && svgRelationship.targetMode !== "External" && entries[svgRelationship.path]) {
|
|
916
|
+
const prepared = prepareSvg(entries[svgRelationship.path]);
|
|
917
|
+
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).`});
|
|
918
|
+
else {
|
|
919
|
+
svg = prepared.bytes;
|
|
920
|
+
if (prepared.removed.length) report({code: "svg-sanitized", message: `The SVG had ${prepared.removed.join(", ")} removed on import.`});
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
// 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.
|
|
924
|
+
if (!bytes && !svg) {
|
|
804
925
|
return {
|
|
805
926
|
kind: "unknown",
|
|
806
927
|
bounds,
|
|
@@ -813,7 +934,7 @@ function importPicture(entries, picture, slidePath, relationships, report) {
|
|
|
813
934
|
if (crop && ['l','r','t','b'].some(key => Number(crop[key] ?? 0) !== 0)) {
|
|
814
935
|
report({code: 'unsupported-image-crop', message: 'Native picture crop is not represented by the imported OPF asset; the full image was retained.'});
|
|
815
936
|
}
|
|
816
|
-
bytes = importImageOrientation(bytes, picture["p:spPr"]?.["a:xfrm"], report);
|
|
937
|
+
bytes = importImageOrientation(svg ?? bytes, picture["p:spPr"]?.["a:xfrm"], report);
|
|
817
938
|
|
|
818
939
|
return {
|
|
819
940
|
kind: "image",
|
|
@@ -823,7 +944,7 @@ function importPicture(entries, picture, slidePath, relationships, report) {
|
|
|
823
944
|
payload: {
|
|
824
945
|
type: "image",
|
|
825
946
|
image: {
|
|
826
|
-
src: `data:${rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship
|
|
947
|
+
src: `data:${svg ? "image/svg+xml" : rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship?.path)};base64,${bytesToBase64(bytes)}`,
|
|
827
948
|
...(alt ? { alt } : {}),
|
|
828
949
|
...(title && alt ? { title } : {})
|
|
829
950
|
}
|
|
@@ -832,11 +953,17 @@ function importPicture(entries, picture, slidePath, relationships, report) {
|
|
|
832
953
|
}
|
|
833
954
|
|
|
834
955
|
function readParagraphs(txBody) {
|
|
835
|
-
return asArray(txBody?.["a:p"])
|
|
956
|
+
return withDisplayedNumbers(asArray(txBody?.["a:p"])
|
|
836
957
|
.map((paragraph) => {
|
|
958
|
+
// FF-45: an a14:m math zone (OMML equation) inside mc:AlternateContent has no OPF model; its mc:Fallback runs are read as
|
|
959
|
+
// text (or, without them, the equation's m:t text), and `math` records the zones so the importer can diagnose the loss.
|
|
960
|
+
const alternates = asArray(paragraph?.["mc:AlternateContent"]);
|
|
961
|
+
const zones = alternates.flatMap(alternate => asArray(alternate?.["mc:Choice"])).filter(choice => choice?.["a14:m"] !== undefined);
|
|
962
|
+
const fallbackRuns = alternates.flatMap(alternate => asArray(alternate?.["mc:Fallback"])).flatMap(fallback => [...asArray(fallback?.["a:r"]), ...asArray(fallback?.["a:fld"])]);
|
|
837
963
|
const runs = [
|
|
838
964
|
...asArray(paragraph?.["a:r"]),
|
|
839
|
-
...asArray(paragraph?.["a:fld"])
|
|
965
|
+
...asArray(paragraph?.["a:fld"]),
|
|
966
|
+
...fallbackRuns
|
|
840
967
|
];
|
|
841
968
|
const texts = [];
|
|
842
969
|
const sizes = [];
|
|
@@ -846,13 +973,29 @@ function readParagraphs(txBody) {
|
|
|
846
973
|
const size = Number(run?.["a:rPr"]?.sz);
|
|
847
974
|
if (Number.isFinite(size)) sizes.push(size / 100);
|
|
848
975
|
}
|
|
976
|
+
const linear = zones.map(choice => keyedMathText(choice["a14:m"])).join("");
|
|
977
|
+
if (zones.length && !fallbackRuns.some(run => scalarText(run?.["a:t"]))) texts.push(linear);
|
|
849
978
|
return {
|
|
850
979
|
text: texts.join(""),
|
|
851
980
|
bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buBlip"] !== undefined || props?.["a:buAutoNum"] !== undefined),
|
|
852
981
|
level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
|
|
853
|
-
maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0
|
|
982
|
+
maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0,
|
|
983
|
+
...(zones.length ? {math: {zones: zones.length, text: linear}} : {}),
|
|
984
|
+
...autoNumberOf(asArray(paragraph?.["a:pPr"])[0])
|
|
854
985
|
};
|
|
855
|
-
});
|
|
986
|
+
}));
|
|
987
|
+
}
|
|
988
|
+
// RR-33: the native auto-number of a paragraph's properties, when its last bullet element is one.
|
|
989
|
+
function autoNumberOf(properties) {
|
|
990
|
+
if (properties?.["a:buAutoNum"] === undefined) return {};
|
|
991
|
+
const attributes = asArray(properties["a:buAutoNum"])[0] ?? {};
|
|
992
|
+
return {autoNum: {type: attributes.type, startAt: attributes.startAt}};
|
|
993
|
+
}
|
|
994
|
+
/** The m:t text of a keyed OMML tree (an a14:m zone) in document order: the equation's linear reading without its layout. */
|
|
995
|
+
function keyedMathText(node) {
|
|
996
|
+
if (Array.isArray(node)) return node.map(keyedMathText).join("");
|
|
997
|
+
if (!node || typeof node !== "object") return "";
|
|
998
|
+
return Object.entries(node).map(([key, value]) => key === "m:t" ? asArray(value).map(scalarText).join("") : keyedMathText(value)).join("");
|
|
856
999
|
}
|
|
857
1000
|
|
|
858
1001
|
function shapePlaceholderType(shape) {
|
|
@@ -919,7 +1062,10 @@ function joinRich(a, b) {
|
|
|
919
1062
|
}
|
|
920
1063
|
return joined;
|
|
921
1064
|
}
|
|
922
|
-
|
|
1065
|
+
// `breaks` (slideListBreaks) holds the whitespace of the hard breaks the export recorded, by list path and line number: the
|
|
1066
|
+
// continuation line after a break joins with it, so a newline inside an item (or a description) returns. A blank line is a
|
|
1067
|
+
// native shape with no text: it is never a description. Without a record the lines join without a separator, as before.
|
|
1068
|
+
function mergeOpfListShapes(items, breaks = null, report = () => {}) {
|
|
923
1069
|
const lists = new Map();
|
|
924
1070
|
for (const item of items) {
|
|
925
1071
|
const match = /^OPF list (.+) line (\d+)$/.exec(item.name ?? '');
|
|
@@ -933,17 +1079,23 @@ function mergeOpfListShapes(items) {
|
|
|
933
1079
|
entries.sort((a, b) => a.line - b.line);
|
|
934
1080
|
if (!entries[0].item.paragraphs[0].bullet) continue;
|
|
935
1081
|
const paragraphs = [];
|
|
1082
|
+
const recorded = breaks?.get(path), applied = new Set();
|
|
936
1083
|
let bounds, size = 0;
|
|
937
|
-
for (const {item} of entries) {
|
|
1084
|
+
for (const {item, line} of entries) {
|
|
938
1085
|
const [paragraph] = item.paragraphs, current = paragraphs.at(-1);
|
|
1086
|
+
const gap = recorded?.get(line);
|
|
1087
|
+
const join = (a, b) => gap === undefined ? joinRich(a, b) : joinWrappedText([a, b], [gap]);
|
|
939
1088
|
if (paragraph.bullet) { paragraphs.push({...paragraph}); size = paragraph.maxFontSize; }
|
|
1089
|
+
else if (paragraph.text === '') { /* a blank line of the entry: no text, no description */ }
|
|
940
1090
|
else if (paragraph.maxFontSize < size - 0.01) {
|
|
941
1091
|
const text = paragraph.richText ?? paragraph.text;
|
|
942
|
-
|
|
1092
|
+
if (current.description === undefined) current.description = text;
|
|
1093
|
+
else { current.description = join(current.description, text); applied.add(line); }
|
|
943
1094
|
} else {
|
|
944
1095
|
const before = current.text;
|
|
945
|
-
current.text += paragraph.text;
|
|
946
|
-
if (current.richText !== undefined || paragraph.richText !== undefined) current.richText =
|
|
1096
|
+
current.text += (gap === undefined ? '' : Array.isArray(gap) ? gap[0] : gap) + paragraph.text;
|
|
1097
|
+
if (current.richText !== undefined || paragraph.richText !== undefined) current.richText = join(current.richText ?? before, paragraph.richText ?? paragraph.text);
|
|
1098
|
+
applied.add(line);
|
|
947
1099
|
}
|
|
948
1100
|
const b = item.visualBounds ?? item.bounds;
|
|
949
1101
|
if (b) {
|
|
@@ -952,8 +1104,9 @@ function mergeOpfListShapes(items) {
|
|
|
952
1104
|
}
|
|
953
1105
|
consumed.add(item);
|
|
954
1106
|
}
|
|
1107
|
+
if (recorded && [...recorded.keys()].some(line => !applied.has(line))) report({code: 'list-line-break-changed', message: `A hard line break recorded at export inside the list ${path} no longer has the continuation line it belonged to (a line was edited away, or became an entry), so that break was not restored; the list keeps the lines it has.`});
|
|
955
1108
|
const first = entries[0].item;
|
|
956
|
-
merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined});
|
|
1109
|
+
merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined, sources: entries.flatMap(({item}) => item.sources ?? [])});
|
|
957
1110
|
}
|
|
958
1111
|
return items.flatMap(item => merged.has(item) ? [merged.get(item)] : consumed.has(item) ? [] : [item]);
|
|
959
1112
|
}
|
|
@@ -971,23 +1124,30 @@ function mergeAdjacentBulletShapes(items) {
|
|
|
971
1124
|
&&item.bounds.y-(previous.bounds.y+previous.bounds.h)<.3){
|
|
972
1125
|
previous.paragraphs.push(...item.paragraphs);
|
|
973
1126
|
previous.text+='\n'+item.text;
|
|
1127
|
+
previous.sources=[...(previous.sources??[]),...(item.sources??[])];
|
|
974
1128
|
previous.bounds.h=item.bounds.y+item.bounds.h-previous.bounds.y;
|
|
975
1129
|
}else result.push({...item,paragraphs:item.paragraphs?[...item.paragraphs]:undefined,bounds:item.bounds?{...item.bounds}:undefined});
|
|
976
1130
|
}
|
|
977
1131
|
return result;
|
|
978
1132
|
}
|
|
979
1133
|
|
|
980
|
-
function payloadFromSlideItem(item) {
|
|
1134
|
+
function payloadFromSlideItem(item, report) {
|
|
981
1135
|
if (item.payload) return item.payload;
|
|
982
1136
|
if (item.kind === "text") {
|
|
983
1137
|
if (item.paragraphs.some(p=>p.bullet)) {
|
|
1138
|
+
// RR-33: native auto-numbers come back as `numbering` and the entry `start` values that restore their numbers.
|
|
1139
|
+
const {numbering, starts, diagnostics} = deriveListNumbering(item.paragraphs);
|
|
1140
|
+
for (const diagnostic of diagnostics) report?.(diagnostic);
|
|
984
1141
|
return {
|
|
985
1142
|
type: "list",
|
|
986
|
-
items: item.paragraphs.map((paragraph) =>
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1143
|
+
items: item.paragraphs.map((paragraph, index) => {
|
|
1144
|
+
const start = starts.get(index) === undefined ? {} : {start: starts.get(index)};
|
|
1145
|
+
return paragraph.level > 0
|
|
1146
|
+
? { text: paragraph.richText ?? paragraph.text, level: paragraph.level, ...(paragraph.description !== undefined ? {description: paragraph.description} : {}), ...start }
|
|
1147
|
+
: paragraph.description !== undefined ? { text: paragraph.richText ?? paragraph.text, description: paragraph.description, ...start }
|
|
1148
|
+
: start.start !== undefined ? { text: paragraph.richText ?? paragraph.text, ...start } : (paragraph.richText ?? paragraph.text);
|
|
1149
|
+
}),
|
|
1150
|
+
...(numbering !== undefined ? {numbering} : {})
|
|
991
1151
|
};
|
|
992
1152
|
}
|
|
993
1153
|
return { type: "text", text: joinNativeParagraphs(item.paragraphs) };
|
|
@@ -996,7 +1156,7 @@ function payloadFromSlideItem(item) {
|
|
|
996
1156
|
return null;
|
|
997
1157
|
}
|
|
998
1158
|
|
|
999
|
-
function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
1159
|
+
function chartFromRelationship(entries, slidePath, relationships, relId, report) {
|
|
1000
1160
|
const relationship = relationships.get(relId);
|
|
1001
1161
|
if (!relationship?.path || !entries[relationship.path]) return null;
|
|
1002
1162
|
const doc = parseRequiredXml(entries, relationship.path);
|
|
@@ -1013,7 +1173,13 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
1013
1173
|
|
|
1014
1174
|
const budget = { cells: 0 };
|
|
1015
1175
|
const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
|
|
1016
|
-
|
|
1176
|
+
// RR-35: axis titles, legend position and data labels read back into the chart's option fields.
|
|
1177
|
+
const withOptions = chart => {
|
|
1178
|
+
const {options, notes} = chartOptionsFromClassic(doc["c:chartSpace"], {chartNode: chartNode.node, target: chartTargetFor(chartNode.type), seriesCount: series.length, circular: chartNode.type === 'pie' || chartNode.type === 'doughnut', scatter: chartNode.type === 'scatter'});
|
|
1179
|
+
for (const note of notes) report?.(note);
|
|
1180
|
+
return chart && Object.keys(options).length ? {...chart, ...options} : chart;
|
|
1181
|
+
};
|
|
1182
|
+
if (chartNode.type === 'scatter') return withOptions(scatterFromSeries(entries, relationship.path, series, budget, cachePath));
|
|
1017
1183
|
const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
|
|
1018
1184
|
const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
|
|
1019
1185
|
const values = series.map((entry, index) => {
|
|
@@ -1034,13 +1200,13 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
1034
1200
|
]);
|
|
1035
1201
|
}
|
|
1036
1202
|
|
|
1037
|
-
return {
|
|
1203
|
+
return withOptions({
|
|
1038
1204
|
type: chartNode.type,
|
|
1039
1205
|
data: {
|
|
1040
1206
|
columns: [readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names],
|
|
1041
1207
|
rows
|
|
1042
1208
|
}
|
|
1043
|
-
};
|
|
1209
|
+
});
|
|
1044
1210
|
}
|
|
1045
1211
|
|
|
1046
1212
|
// Scatter charts share X values (c:xVal) across Y series (c:yVal). OPF keeps
|
|
@@ -1066,7 +1232,7 @@ function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
|
|
|
1066
1232
|
// A chartex part (cx:chartSpace): the series layoutIds name the kept OPF chart
|
|
1067
1233
|
// type and the cached dimensions restore the category-major data, under the
|
|
1068
1234
|
// same bounds as the classic chart cache.
|
|
1069
|
-
function chartexFromRelationship(entries, relationships, relId) {
|
|
1235
|
+
function chartexFromRelationship(entries, relationships, relId, report) {
|
|
1070
1236
|
const relationship = relationships.get(relId);
|
|
1071
1237
|
if (!relationship?.path || !entries[relationship.path]) return null;
|
|
1072
1238
|
const doc = parseRequiredXml(entries, relationship.path);
|
|
@@ -1074,6 +1240,7 @@ function chartexFromRelationship(entries, relationships, relId) {
|
|
|
1074
1240
|
heading: readChartCategoryHeading(entries, relationship.path),
|
|
1075
1241
|
limits: {points: MAX_CHART_CACHE_POINTS, cells: MAX_CHART_CACHE_CELLS},
|
|
1076
1242
|
path: relationship.path,
|
|
1243
|
+
report,
|
|
1077
1244
|
invalid: (message, location) => {
|
|
1078
1245
|
throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: location ?? relationship.path});
|
|
1079
1246
|
}
|
|
@@ -1258,6 +1425,30 @@ function parseInput(input) {
|
|
|
1258
1425
|
throw new OPFPptxError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
|
|
1259
1426
|
}
|
|
1260
1427
|
|
|
1428
|
+
// Template variables (core resolveVariables, RR-32): a deck that uses content variables, or a template, is resolved to a
|
|
1429
|
+
// concrete deck before export, so the PPTX holds exactly the text, numbers, dates and images the preview shows. A template
|
|
1430
|
+
// exports with each unfilled variable's example (reported as variable-example-used); a normal deck with an unfilled
|
|
1431
|
+
// required variable is refused. Decks without content variables are returned untouched. The package stores the resolved
|
|
1432
|
+
// deck, not the template form, so fromPptx returns the filled deck.
|
|
1433
|
+
function resolveTemplateInput(presentation, options) {
|
|
1434
|
+
if (typeof opfCore.resolveVariables !== "function" || !isPlainObject(presentation)) return presentation;
|
|
1435
|
+
const values = options.variables;
|
|
1436
|
+
if (values !== undefined && !isPlainObject(values)) {
|
|
1437
|
+
throw new OPFPptxError("invalid-variables", "The variables option must be an object keyed by variable id.", {path: "options.variables"});
|
|
1438
|
+
}
|
|
1439
|
+
const template = opfCore.isTemplate(presentation);
|
|
1440
|
+
if (!template && !opfCore.hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
|
|
1441
|
+
const result = opfCore.resolveVariables(presentation, values ?? {}, {examples: template});
|
|
1442
|
+
const errors = result.diagnostics.filter(entry => entry.severity === "error");
|
|
1443
|
+
if (errors.length) {
|
|
1444
|
+
throw new OPFPptxError(errors.some(entry => entry.code === "variable-unfilled") ? "unfilled-variables" : "invalid-variables", errors[0].message, {issues: errors, path: errors[0].path});
|
|
1445
|
+
}
|
|
1446
|
+
for (const entry of result.diagnostics) {
|
|
1447
|
+
if (entry.code === "variable-example-used") options.onDiagnostic?.({code: "variable-example-used", path: entry.path, message: entry.message, id: entry.id});
|
|
1448
|
+
}
|
|
1449
|
+
return result.presentation;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1261
1452
|
function assertValidBoundary(presentation) {
|
|
1262
1453
|
const result = validatePresentation(presentation);
|
|
1263
1454
|
if (!result.valid) {
|
|
@@ -1441,7 +1632,7 @@ function configurePresentation(pptx, presentation, context) {
|
|
|
1441
1632
|
height: context.dimensions.heightInches
|
|
1442
1633
|
});
|
|
1443
1634
|
pptx.layout = context.layoutName;
|
|
1444
|
-
pptx.author = normalizeAuthor(presentation.author) ??
|
|
1635
|
+
pptx.author = normalizeAuthor(presentation.author) ?? DEFAULT_AUTHOR;
|
|
1445
1636
|
pptx.company = "OpenPresentation";
|
|
1446
1637
|
pptx.subject = presentation.description ?? "";
|
|
1447
1638
|
pptx.title = presentation.name ?? presentation.filename ?? "OPF Presentation";
|
|
@@ -1467,7 +1658,8 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1467
1658
|
}
|
|
1468
1659
|
if (backgroundDefinition?.type === 'image') {
|
|
1469
1660
|
const imagePath = `${backgroundPath}.image`;
|
|
1470
|
-
|
|
1661
|
+
// An SVG background is its PNG raster at the slide's size: a slide background picture fill carries no SVG.
|
|
1662
|
+
const outcome = { box: { w: slideContext.dimensions.widthInches, h: slideContext.dimensions.heightInches }, cover: (backgroundDefinition.image?.fit ?? 'cover') !== 'contain' };
|
|
1471
1663
|
const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath, outcome);
|
|
1472
1664
|
if (resolved) {
|
|
1473
1665
|
// PptxGenJS embeds the raster and its relationship; packaging replaces
|
|
@@ -1497,7 +1689,10 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1497
1689
|
// Core composition resolves one alignment per composed item for every engine
|
|
1498
1690
|
// (FF-29); the design fallback keeps cores published before item.alignment working.
|
|
1499
1691
|
const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
|
|
1500
|
-
const
|
|
1692
|
+
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) };
|
|
1693
|
+
const geometry = composeSlide(opfSlide, composeOptions);
|
|
1694
|
+
// The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
|
|
1695
|
+
if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
|
|
1501
1696
|
for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
|
|
1502
1697
|
// The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
|
|
1503
1698
|
recordContentTopology(context.documentProvenance, opfSlide, slideIndex, geometry.items);
|
|
@@ -1530,30 +1725,42 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1530
1725
|
...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
|
|
1531
1726
|
...nativeFontOptions(item.textStyle),
|
|
1532
1727
|
color: item.field === "tag" ? tagColor(itemContext) : itemContext.textColor,
|
|
1533
|
-
align: alignmentFor(item),
|
|
1728
|
+
align: physicalAlignment(alignmentFor(item), item.text.directions?.[0]),
|
|
1729
|
+
...(item.text.directions?.[0] === 'rtl' ? {rtlMode: true} : {}),
|
|
1534
1730
|
objectName,
|
|
1535
1731
|
breakLine: false
|
|
1536
1732
|
});
|
|
1537
1733
|
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
1538
|
-
await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options);
|
|
1734
|
+
await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options,item.payload?.numbering!==undefined);
|
|
1539
1735
|
} else if (item.field === "text" && item.text?.richLines) {
|
|
1540
|
-
const
|
|
1736
|
+
const logicalAlignment=item.text.placement?.alignment??alignmentFor(item)??'left';
|
|
1541
1737
|
for(const [index,line] of item.text.richLines.entries()){
|
|
1542
1738
|
const runs=line.fragments.map(fragment=>{
|
|
1543
1739
|
const runColor=exportColor(fragment.run.color,itemContext,itemContext.colors.text);
|
|
1544
1740
|
const color=nativeColor(fragment.run.color,runColor,itemContext,itemContext.textColor);
|
|
1545
|
-
return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
|
|
1741
|
+
return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:(fragment.nominalSize??fragment.fontSize)*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/(fragment.nominalSize??fragment.fontSize)*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
|
|
1546
1742
|
});
|
|
1547
|
-
|
|
1743
|
+
// Logical alignment (RR-05): a right-to-left paragraph starts at the right edge; every wrapped line shares its paragraph's direction.
|
|
1744
|
+
const placed=item.text.placement?.lines[index],alignment=placed?.alignment??physicalAlignment(logicalAlignment,item.text.directions?.[index]),factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
1548
1745
|
const area=placed?{...region,x:(placed.x+line.width*factor-item.box.width*factor)/96,y:placed.y/96,h:placed.height/96}:{...region,y:region.y+line.y/96,h:line.height/96};
|
|
1746
|
+
// PptxGenJS reads the paragraph direction from the first run's options, not from the shape options.
|
|
1747
|
+
if(item.text.directions?.[index]==='rtl')for(const run of runs)run.options.rtlMode=true;
|
|
1549
1748
|
if(runs.length)slide.addText(runs,{...textBoxOptions(area,itemContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
|
|
1550
1749
|
}
|
|
1551
1750
|
} else if (item.field === "text" && typeof item.value === "string") {
|
|
1552
|
-
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
|
|
1751
|
+
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:physicalAlignment(alignmentFor(item), item.text.directions?.[0])});
|
|
1553
1752
|
} else {
|
|
1554
|
-
|
|
1753
|
+
// RR-34: a captioned item draws its media in item.box; the caption band follows as tagged text boxes linked to the media shape by name.
|
|
1754
|
+
const mediaNames = item.caption ? new Set([...context.imagePlacements.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
|
|
1755
|
+
await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: alignmentFor(item) ?? "left", direction: geometry.direction }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
|
|
1756
|
+
if (item.caption) {
|
|
1757
|
+
const mediaName = item.field === 'video' ? `OPF media ${item.path} frame` : [...context.imagePlacements.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()].find(name => !mediaNames.has(name));
|
|
1758
|
+
addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
|
|
1759
|
+
}
|
|
1555
1760
|
}
|
|
1556
1761
|
}
|
|
1762
|
+
// RR-34: the footnote area core reserved above the footer band (rule plus one tagged text box per listed line).
|
|
1763
|
+
addFootnotes(slide, geometry.footnotes, slideIndex, slideContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: `slides.${slideIndex}`}));
|
|
1557
1764
|
// Core composes furniture above all content and opf-render paints it last, so
|
|
1558
1765
|
// spTree order (PowerPoint's z-order) matches: header/footer parts come after
|
|
1559
1766
|
// every content item, and an overlapping footer stays visible over content.
|
|
@@ -1622,10 +1829,10 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
1622
1829
|
addTablePayload(slide, payload.table, region, context, options, path);
|
|
1623
1830
|
break;
|
|
1624
1831
|
case "code":
|
|
1625
|
-
addCodePayload(slide, payload.code, codeLayout, region, context, path);
|
|
1832
|
+
addCodePayload(slide, payload.code, codeLayout, region, context, path, options);
|
|
1626
1833
|
break;
|
|
1627
1834
|
case "metric":
|
|
1628
|
-
addMetricPayload(slide, payload.metric,metricLayout,context,path);
|
|
1835
|
+
addMetricPayload(slide, payload.metric,metricLayout,context,path,options);
|
|
1629
1836
|
break;
|
|
1630
1837
|
case "quote":
|
|
1631
1838
|
addQuotePayload(slide, payload.quote, quoteLayout, context, options, path);
|
|
@@ -1665,7 +1872,7 @@ function richLineRuns(line,color,native,context) {
|
|
|
1665
1872
|
const fallback=color.replace(/^#/,'');
|
|
1666
1873
|
return line.fragments.map(fragment=>{
|
|
1667
1874
|
const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context,native);
|
|
1668
|
-
return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
|
|
1875
|
+
return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:(fragment.nominalSize??fragment.fontSize)*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/(fragment.nominalSize??fragment.fontSize)*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
|
|
1669
1876
|
});
|
|
1670
1877
|
}
|
|
1671
1878
|
// Every native line of a list, marker paragraph or not, is named for the list's
|
|
@@ -1677,13 +1884,17 @@ function richLineRuns(line,color,native,context) {
|
|
|
1677
1884
|
// PptxGenJS embeds the media and its relationship through one picture per slide (OPF bullet image); packaging removes
|
|
1678
1885
|
// that picture and points each marker paragraph at the relationship with a:buBlip. An icon that cannot be embedded
|
|
1679
1886
|
// keeps the character bullets, as the preview does.
|
|
1680
|
-
async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,slideIndex,options) {
|
|
1887
|
+
async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,slideIndex,options,numbered=false) {
|
|
1888
|
+
// RR-33: a numbered list writes native auto-numbers (a:buAutoNum) at core's marker geometry. Core older than the numbering
|
|
1889
|
+
// release composes bullet markers, so nothing numbered can be written; say so instead of exporting bullets silently.
|
|
1890
|
+
if(numbered&&!fit.listEntries.some(entry=>entry.marker.number))options.onDiagnostic?.({code:'numbering-unsupported-core',path,message:'The installed @openpresentation/opf does not compose numbered lists (numbering), so the list is exported with bullets. Use a core release that supports numbering.'});
|
|
1681
1891
|
let picture=false;
|
|
1682
1892
|
if(bulletImage){
|
|
1683
1893
|
const part=`ppt/slides/slide${slideIndex+1}.xml`;
|
|
1684
1894
|
if(context.bulletImages.has(part))picture=context.bulletImages.get(part);
|
|
1685
1895
|
else{
|
|
1686
|
-
|
|
1896
|
+
// A picture bullet (a:buBlip) can only reference a raster: an SVG icon is its PNG fallback.
|
|
1897
|
+
const outcome={box:{w:.5,h:.5}};
|
|
1687
1898
|
const resolved=await resolveImage(bulletImage.source,presentation,options,bulletImage.path,outcome);
|
|
1688
1899
|
if(resolved){
|
|
1689
1900
|
slide.addImage({...resolved,objectName:bulletImageName(),x:0,y:0,w:.1,h:.1,altText:''});
|
|
@@ -1697,16 +1908,22 @@ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,s
|
|
|
1697
1908
|
const addLines=(text,box,color,native,withBullet)=>{
|
|
1698
1909
|
text.richLines.forEach((line,index)=>{
|
|
1699
1910
|
const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
|
|
1700
|
-
|
|
1911
|
+
// Right to left (RR-05): the bullet column is at the right, so the first line's box extends right to hold it and the paragraph
|
|
1912
|
+
// is rtl with its text aligned to the right; marL/indent are the start-side margin and hanging indent in PowerPoint.
|
|
1913
|
+
const entryRtl=entry.direction==='rtl';
|
|
1914
|
+
const region={x:(entryRtl?box.x:box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
|
|
1915
|
+
const lineDirection=(text.directions?.[index])??entry.direction;
|
|
1916
|
+
const lineAlign=physicalAlignment('left',lineDirection);
|
|
1701
1917
|
const objectName=`OPF list ${path} line ${lineNumber++}`;
|
|
1702
|
-
|
|
1703
|
-
|
|
1918
|
+
const number=first?entry.marker.number:undefined;
|
|
1919
|
+
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor),picture:number?false:picture});
|
|
1920
|
+
const paragraph=first?{bullet:number?{type:'number',style:autoNumScheme(number.style,number.suffix),startAt:number.value,indent:entry.marker.indent*.75}:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
|
|
1704
1921
|
const runs=richLineRuns(line,color,native,context);
|
|
1705
1922
|
if(!runs.length)runs.push({text:'',options:{}});
|
|
1706
1923
|
// Keep paragraph intent identical across runs. ZIP normalization below
|
|
1707
1924
|
// removes the duplicate paragraph-property nodes emitted by PptxGenJS.
|
|
1708
|
-
for(const run of runs)Object.assign(run.options,paragraph);
|
|
1709
|
-
slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:nativeFontOptions(entry.marker.style).fontFace,objectName,align:
|
|
1925
|
+
for(const run of runs)Object.assign(run.options,paragraph,lineDirection==='rtl'?{rtlMode:true}:{});
|
|
1926
|
+
slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:nativeFontOptions(entry.marker.style).fontFace,objectName,align:lineAlign,fit:'none',wrap:false,lineSpacingMultiple:1,...paragraph});
|
|
1710
1927
|
});
|
|
1711
1928
|
};
|
|
1712
1929
|
addLines(entry.text,entry.textBox,context.colors.text,context.textColor,true);
|
|
@@ -1754,13 +1971,15 @@ function addListPayload(slide, items, region, context) {
|
|
|
1754
1971
|
}
|
|
1755
1972
|
|
|
1756
1973
|
async function addImagePayload(slide, presentation, asset, region, path, context, options) {
|
|
1757
|
-
const
|
|
1974
|
+
const outcome = { box: region, cover: context.imageFill === 'crop' };
|
|
1975
|
+
const resolved = await resolveImage(asset, presentation, options, path, outcome);
|
|
1758
1976
|
if (!resolved) {
|
|
1759
1977
|
addImagePlaceholder(slide, presentation, asset, region, path, context, options);
|
|
1760
1978
|
return;
|
|
1761
1979
|
}
|
|
1762
1980
|
const objectName = `OPF image ${context.imagePlacements.size + 1}`;
|
|
1763
1981
|
context.imagePlacements.set(objectName, { region, mode: context.imageFill, path });
|
|
1982
|
+
if (outcome.svg) context.svgPictures.set(objectName, outcome.svg);
|
|
1764
1983
|
context.pictureText.set(objectName, pictureText(assetAlt(asset, presentation), asset, presentation));
|
|
1765
1984
|
slide.addImage({
|
|
1766
1985
|
...resolved,
|
|
@@ -1789,16 +2008,18 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
|
|
|
1789
2008
|
notExported('The watermark needs an image source (a string, or an object with src); no watermark was exported for this slide.');
|
|
1790
2009
|
return;
|
|
1791
2010
|
}
|
|
1792
|
-
const
|
|
2011
|
+
const {widthInches, heightInches} = slideContext.dimensions;
|
|
2012
|
+
const box = watermarkBox(widthInches, heightInches);
|
|
2013
|
+
const outcome = {box};
|
|
1793
2014
|
const resolved = await resolveImage(asset, presentation, options, path, outcome);
|
|
1794
2015
|
if (!resolved) {
|
|
1795
2016
|
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
2017
|
return;
|
|
1797
2018
|
}
|
|
1798
|
-
const {widthInches, heightInches} = slideContext.dimensions;
|
|
1799
|
-
const box = watermarkBox(widthInches, heightInches);
|
|
1800
2019
|
const opacity = watermarkOpacity(watermark);
|
|
1801
2020
|
context.watermarks.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box, opacity, path});
|
|
2021
|
+
// Opacity is an a:alphaModFix on the blip, which PowerPoint applies to an SVG picture (native check 2026-10-01).
|
|
2022
|
+
if (outcome.svg) context.svgPictures.set(`ppt/slides/slide${slideIndex + 1}.xml|${watermarkName()}`, outcome.svg);
|
|
1802
2023
|
slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
|
|
1803
2024
|
}
|
|
1804
2025
|
|
|
@@ -1808,7 +2029,7 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
|
|
|
1808
2029
|
// PptxGenJS embeds the bytes. An unresolved source draws the preview's "Image unavailable" panel in the logo box.
|
|
1809
2030
|
async function addLogo(slide, presentation, logo, slideIndex, slideContext, context, options) {
|
|
1810
2031
|
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 = {};
|
|
2032
|
+
const outcome = {box: region};
|
|
1812
2033
|
const resolved = await resolveImage(logo.source, presentation, options, logo.path, outcome);
|
|
1813
2034
|
if (!resolved) {
|
|
1814
2035
|
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.'});
|
|
@@ -1816,7 +2037,8 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
|
|
|
1816
2037
|
if (prefix) context.logoPlaceholderTags.set(prefix, {v: 1, role: 'placeholder', slide: `slides.${slideIndex}`, path: logo.path});
|
|
1817
2038
|
return;
|
|
1818
2039
|
}
|
|
1819
|
-
context.logos.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box: region, path: logo.path, variant: logo.variant});
|
|
2040
|
+
context.logos.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box: region, path: logo.path, variant: logo.variant, anchor: logo.anchor ?? 'left'});
|
|
2041
|
+
if (outcome.svg) context.svgPictures.set(`ppt/slides/slide${slideIndex + 1}.xml|${logoName()}`, outcome.svg);
|
|
1820
2042
|
slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
|
|
1821
2043
|
}
|
|
1822
2044
|
|
|
@@ -1824,7 +2046,8 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
|
|
|
1824
2046
|
// Crop/fit and treatments are written after PptxGenJS embeds the bytes.
|
|
1825
2047
|
async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
|
|
1826
2048
|
const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
|
|
1827
|
-
const
|
|
2049
|
+
const outcome = { box, cover: image.fill === 'crop' };
|
|
2050
|
+
const resolved = await resolveImage(image.value, presentation, options, image.sourcePath, outcome);
|
|
1828
2051
|
if (!resolved) {
|
|
1829
2052
|
addImagePlaceholder(slide, presentation, image.value, box, image.sourcePath, context, options);
|
|
1830
2053
|
return;
|
|
@@ -1849,6 +2072,14 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
|
|
|
1849
2072
|
// FF-53: a root `image` with the slide image's source is the slide image (core `replacesContent`); the
|
|
1850
2073
|
// manifest records that so an unchanged picture imports back as both design.slideImage and slide.image.
|
|
1851
2074
|
context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects, content: image.replacesContent === true });
|
|
2075
|
+
// PowerPoint applies opacity (a:alphaModFix), grayscale (a:grayscl) and the border (a:ln) to an SVG picture (native check
|
|
2076
|
+
// 2026-10-01). A duotone recolor and a non-rectangular mask are not confirmed on an SVG picture: with either the SVG stays its
|
|
2077
|
+
// PNG raster, so the effect applies as in the preview.
|
|
2078
|
+
if (outcome.svg) {
|
|
2079
|
+
const treated = [effects.recolor?.type === 'duotone' && 'duotone recolor', effects.shape && effects.shape.preset !== 'rect' && 'shape'].filter(Boolean);
|
|
2080
|
+
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.` });
|
|
2081
|
+
else context.svgPictures.set(objectName, outcome.svg);
|
|
2082
|
+
}
|
|
1852
2083
|
const slideImageAlt = image.alt ?? assetAlt(image.value, presentation);
|
|
1853
2084
|
context.pictureText.set(objectName, pictureText(slideImageAlt, image.value, presentation));
|
|
1854
2085
|
slide.addImage({ ...resolved, objectName, ...box, altText: slideImageAlt });
|
|
@@ -1862,9 +2093,14 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
|
|
|
1862
2093
|
// FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
|
|
1863
2094
|
// max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
|
|
1864
2095
|
// the readability floor: 12 pt on a 13.33 x 7.5 in slide. The export writes that size, in hundredths of a point (DrawingML `sz`).
|
|
2096
|
+
// RR-16: core composes every font size on PowerPoint's 0.01 pt grid (1/75 px), so the preview draws the grid size, not the raw
|
|
2097
|
+
// product: the floor rounds up (core `snapFontSizeUp`) and an unfloored request of 14 px rounds down (`snapFontSizeDown`).
|
|
2098
|
+
// Math.round differed from the preview by 0.01 pt on slide sizes whose scale is not a whole number (A4, Letter, 16:10).
|
|
1865
2099
|
function chartTextSize(context) {
|
|
1866
2100
|
const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
|
|
1867
|
-
|
|
2101
|
+
const floor = context.composition?.minFontSize ?? 16;
|
|
2102
|
+
const hundredths = Math.max(14, floor) * scale * 75;
|
|
2103
|
+
return floor >= 14 ? Math.ceil(hundredths - 1e-6) : Math.floor(hundredths + 1e-6);
|
|
1868
2104
|
}
|
|
1869
2105
|
|
|
1870
2106
|
// Every c:txPr of a generated classic chart carries the chart text size: PptxGenJS writes no size on the legend (PowerPoint's
|
|
@@ -1883,6 +2119,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1883
2119
|
return;
|
|
1884
2120
|
}
|
|
1885
2121
|
for (const {adaptation, message} of chartData.adaptations ?? []) options.onDiagnostic?.({code: "chart-data-adapted", path, message, adaptation});
|
|
2122
|
+
// RR-35: axis titles, legend position and data labels (core resolves them against what the chart type can show).
|
|
2123
|
+
const chartOptions = resolveChartOptionsFor(chart);
|
|
2124
|
+
reportChartOptionDiagnostics(chartOptions, path, options.onDiagnostic);
|
|
1886
2125
|
|
|
1887
2126
|
// Keep the resolved palette surface (including alpha) explicit in native
|
|
1888
2127
|
// chart/plot areas, so inherited labels are assessed against their own panel.
|
|
@@ -1893,19 +2132,27 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1893
2132
|
const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
|
|
1894
2133
|
const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
|
|
1895
2134
|
const textSize = chartTextSize(context);
|
|
1896
|
-
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec,textSize
|
|
2135
|
+
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec,textSize,
|
|
2136
|
+
options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chart.data.rows.length});
|
|
1897
2137
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
1898
|
-
const
|
|
2138
|
+
const preferredPalette = CHART_COLORS.map(color=>`#${color}`);
|
|
2139
|
+
const palette = (typeof opfComposition.chartPaletteForFill === "function" ? opfComposition.chartPaletteForFill(panelFill, preferredPalette) : preferredPalette.map(color=>chartColorForFill(panelFill,color))).map(color=>normalizeHex(color));
|
|
2140
|
+
context.chartPalettes.set(objectName,palette);
|
|
1899
2141
|
if (chartData.chartex) {
|
|
1900
2142
|
// The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
|
|
1901
2143
|
// PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
|
|
1902
2144
|
const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
|
|
1903
|
-
context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette, textSize});
|
|
2145
|
+
context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette, textSize, options: chartOptions});
|
|
1904
2146
|
if (chartData.chartex.layoutId === 'regionMap') {
|
|
1905
2147
|
options.onDiagnostic?.({code: "chart-map-geodata", path, message: `The '${stringifyText(chart.type)}' chart is exported as a native PowerPoint map (chartex regionMap) without cached geography (no cx:geoCache; provider data is never fabricated): PowerPoint must fetch the region shapes from its online map service when the deck is opened, and until it does it shows "There was a problem getting the information for your map chart" and draws nothing. The clustered column fallback shows the same values in readers without chartex support.`});
|
|
1906
2148
|
}
|
|
1907
2149
|
}
|
|
1908
2150
|
const percent = chartData.spec.grouping === 'percentStacked';
|
|
2151
|
+
// RR-36: a series has one colour, as the preview draws it (opf-render paints series j with palette colour j; only pie,
|
|
2152
|
+
// doughnut and treemap slices take a colour per category). PptxGenJS writes a c:dPt per point, cycling the palette, for a
|
|
2153
|
+
// bar chart with one series whenever chartColors is a custom array of more than one colour, so PowerPoint drew each
|
|
2154
|
+
// column of a single-series column or bar chart in a different colour. One colour for that series writes no c:dPt.
|
|
2155
|
+
const chartColors = chartData.type === 'bar' && chartData.series.length === 1 ? palette.slice(0, 1) : palette;
|
|
1909
2156
|
slide.addChart(chartData.type, chartData.series, {
|
|
1910
2157
|
objectName,
|
|
1911
2158
|
x: region.x,
|
|
@@ -1913,8 +2160,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1913
2160
|
w: region.w,
|
|
1914
2161
|
h: region.h,
|
|
1915
2162
|
showLegend: circular || chartData.series.length > (chartData.type === 'scatter' ? 2 : 1),
|
|
2163
|
+
...classicChartOptions(chartOptions, {labelColor, font: context.fonts.body, textSize}),
|
|
1916
2164
|
showTitle: false,
|
|
1917
|
-
chartColors
|
|
2165
|
+
chartColors,
|
|
1918
2166
|
chartArea: {fill:{...fill},roundedCorners:false},
|
|
1919
2167
|
// Paint alpha once in the chart area, rather than stacking two alpha fills.
|
|
1920
2168
|
plotArea: {fill:{color:null}},
|
|
@@ -1933,6 +2181,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1933
2181
|
valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
|
|
1934
2182
|
barDir: chartData.barDir,
|
|
1935
2183
|
barGrouping: chartData.barGrouping,
|
|
2184
|
+
// Right to left (RR-05): column, line and area charts reverse their categories (c:catAx orientation maxMin), which also moves the
|
|
2185
|
+
// value axis to the right, as the preview draws them. Bar charts keep their vertical category axis.
|
|
2186
|
+
...(context.direction === 'rtl' && ((chartData.type === 'bar' && chartData.barDir === 'col') || chartData.type === 'line' || chartData.type === 'area') ? { catAxisOrientation: 'maxMin' } : {}),
|
|
1936
2187
|
...(percent ? { valAxisLabelFormatCode: '0%' } : {}),
|
|
1937
2188
|
...(chartData.spec.markers === undefined ? {} : { lineDataSymbol: chartData.spec.markers ? 'circle' : 'none' }),
|
|
1938
2189
|
...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
|
|
@@ -1952,7 +2203,8 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1952
2203
|
}
|
|
1953
2204
|
|
|
1954
2205
|
const layout = layoutTable(table, {x:region.x*96,y:region.y*96,width:region.w*96,height:region.h*96}, {
|
|
1955
|
-
scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path
|
|
2206
|
+
scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path,
|
|
2207
|
+
...(context.direction === 'rtl' ? {direction: 'rtl'} : {})
|
|
1956
2208
|
});
|
|
1957
2209
|
const columnCount = layout.columnCount;
|
|
1958
2210
|
const rows = layout.rows.map(row => row.cells.map(cell => {
|
|
@@ -2001,7 +2253,7 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
2001
2253
|
...(rich ? {bold:false,italic:false} : {}),
|
|
2002
2254
|
lineSpacing: fit.lineHeight * 0.75,
|
|
2003
2255
|
paraSpaceAfter: 0,
|
|
2004
|
-
align: cellStyle.align ?? context.contentAlignment,
|
|
2256
|
+
align: physicalAlignment(cellStyle.align ?? context.contentAlignment, cell.direction),
|
|
2005
2257
|
valign: cellStyle.verticalAlign ?? 'top',
|
|
2006
2258
|
...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
|
|
2007
2259
|
...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
|
|
@@ -2060,7 +2312,8 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
2060
2312
|
const link = config.links?.[sourceLineIndex];
|
|
2061
2313
|
if (fit.sourceLines?.[index]?.boundary === 'hard') sourceLineIndex += 1;
|
|
2062
2314
|
if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.quote&&!config.keepEmpty) continue;
|
|
2063
|
-
|
|
2315
|
+
// Logical alignment (RR-05): a right-to-left line starts at the right edge; furniture and metrics pass physical alignments and report no line directions.
|
|
2316
|
+
const placed=fit.placement?.lines[index],alignment=placed?.alignment??physicalAlignment(fit.placement?.alignment??config.align??context.contentAlignment??'left',fit.directions?.[index]),factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
2064
2317
|
const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
|
|
2065
2318
|
const objectName=config.heading?`OPF heading ${config.path} line ${index}`:config.timeline?`OPF timeline ${config.timeline.group} part ${config.timeline.part} line ${index}`:config.quote?`OPF quote ${config.quote.group} part ${config.quote.part} line ${index}`:config.sourceText?`OPF text ${config.path} line ${index}`:config.objectName?`${config.objectName} line ${index}`:undefined;
|
|
2066
2319
|
if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
|
|
@@ -2079,6 +2332,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
2079
2332
|
...textBoxOptions(area, context, fit.fontSize * .75),
|
|
2080
2333
|
...nativeFontOptions(style),
|
|
2081
2334
|
color: lineColor, align: alignment,
|
|
2335
|
+
...(fit.directions?.[index]==='rtl'?{rtlMode:true}:{}),
|
|
2082
2336
|
// Reuse the frame's existing relationship to cover blank caption boxes.
|
|
2083
2337
|
// PptxGenJS also inherits it into runs; keep their native text styling.
|
|
2084
2338
|
hyperlink: config.hyperlink,
|
|
@@ -2090,6 +2344,17 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
2090
2344
|
}
|
|
2091
2345
|
}
|
|
2092
2346
|
|
|
2347
|
+
// The default footer band core composes for a deck with no footer of its own: where a footer added natively lands.
|
|
2348
|
+
function defaultFooterParts(composeOptions) {
|
|
2349
|
+
try {
|
|
2350
|
+
const slide = {design: {footer: {left: {date: '2026-01-01'}, center: {text: 'Footer'}, right: {slideNumber: true}}}};
|
|
2351
|
+
// 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.
|
|
2352
|
+
return (composeSlide(slide, {...composeOptions, textMeasurement: undefined}).furniture?.parts ?? []).filter(part => part.kind === 'footer' && part.type === 'text');
|
|
2353
|
+
} catch {
|
|
2354
|
+
return undefined;
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2093
2358
|
async function addFurniture(slide,presentation,source,layout,context,options,slideIndex) {
|
|
2094
2359
|
if(!layout){
|
|
2095
2360
|
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 +2363,8 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
2098
2363
|
return;
|
|
2099
2364
|
}
|
|
2100
2365
|
const staticDates = new Map();
|
|
2366
|
+
// RR-11: the first footer text, date and slide number that fit one line are native placeholders (src/native-furniture.js).
|
|
2367
|
+
const natives = nativeFurnitureParts(layout);
|
|
2101
2368
|
for(const [index,part]of layout.parts.entries()){
|
|
2102
2369
|
if(part.type==='image'){
|
|
2103
2370
|
const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
|
|
@@ -2125,15 +2392,16 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
2125
2392
|
}
|
|
2126
2393
|
}
|
|
2127
2394
|
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});
|
|
2395
|
+
if(natives.has(index))context.nativeFurniture.set(`OPF furniture ${slideIndex} part ${index} line 0`,natives.get(index));
|
|
2128
2396
|
}
|
|
2129
2397
|
}
|
|
2130
|
-
const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
|
|
2398
|
+
const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates, natives);
|
|
2131
2399
|
if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
|
|
2132
2400
|
}
|
|
2133
2401
|
|
|
2134
2402
|
const bulletImageName = () => 'OPF bullet image';
|
|
2135
2403
|
|
|
2136
|
-
function addCodePayload(slide, value, layout, region, context, path) {
|
|
2404
|
+
function addCodePayload(slide, value, layout, region, context, path, options) {
|
|
2137
2405
|
if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
|
|
2138
2406
|
for (const part of layout.parts) {
|
|
2139
2407
|
// XML 1.0 Char excludes controls and unpaired UTF-16 surrogates. The u flag
|
|
@@ -2144,6 +2412,7 @@ function addCodePayload(slide, value, layout, region, context, path) {
|
|
|
2144
2412
|
const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
|
|
2145
2413
|
for (const part of layout.parts) if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
|
|
2146
2414
|
context.codeTags.set(panelName,codeManifest(value,layout,group));
|
|
2415
|
+
const syntax = codeSyntax(value, layout, context, path, options);
|
|
2147
2416
|
slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
|
|
2148
2417
|
for (const [partIndex,part] of layout.parts.entries()) {
|
|
2149
2418
|
if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
|
|
@@ -2151,19 +2420,37 @@ function addCodePayload(slide, value, layout, region, context, path) {
|
|
|
2151
2420
|
const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
|
|
2152
2421
|
const objectName = `OPF code ${group} ${part.role} line ${index+1}`;
|
|
2153
2422
|
context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
|
|
2154
|
-
|
|
2423
|
+
// RR-07: the same token ranges and palette the preview paints; the text of the runs is the line text, unchanged.
|
|
2424
|
+
const runs = syntax && part.role==='body' ? opfCore.codeLineRuns(syntax.tokens,line.start,line.end,part.text) : undefined;
|
|
2425
|
+
const lineText = part.text.slice(line.start,line.end);
|
|
2426
|
+
const lineOptions = {
|
|
2155
2427
|
...textBoxOptions({x:part.box.x/96,y:(part.box.y+index*part.fit.lineHeight)/96,w:part.box.width/96,h:part.fit.lineHeight/96},context,part.fit.fontSize*.75),
|
|
2156
2428
|
...nativeFontOptions(part.style),
|
|
2157
2429
|
color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
2158
2430
|
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
2159
|
-
}
|
|
2431
|
+
};
|
|
2432
|
+
// A run's own options replace the line's, so every run repeats the font, size, alignment and tab stops and differs only in colour.
|
|
2433
|
+
slide.addText(runs?.some(run=>run.kind) ? runs.map(run=>({text:part.text.slice(run.start,run.end),options:{...lineOptions,color:syntax.palette[run.kind??'plain'].slice(1)}})) : lineText,lineOptions);
|
|
2160
2434
|
}
|
|
2161
2435
|
}
|
|
2162
2436
|
}
|
|
2163
2437
|
|
|
2164
|
-
|
|
2438
|
+
// Token ranges and palette for the code body, from core (the preview calls the same functions); undefined for plain code.
|
|
2439
|
+
function codeSyntax(value, layout, context, path, options) {
|
|
2440
|
+
const body = layout.parts.find(part => part.role === 'body');
|
|
2441
|
+
const language = typeof value?.language === 'string' ? value.language : layout.parts.find(part => part.role === 'language')?.text;
|
|
2442
|
+
if (!body || !language) return undefined;
|
|
2443
|
+
if (typeof opfCore.tokenizeCode !== 'function') return undefined;
|
|
2444
|
+
const tokens = opfCore.tokenizeCode(body.text, language);
|
|
2445
|
+
return tokens.length ? {tokens, palette: opfCore.codeSyntaxPaletteForScheme(context.colorScheme)} : undefined;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
function addMetricPayload(slide,value,layout,context,path,options) {
|
|
2165
2449
|
if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
|
|
2166
2450
|
const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
|
|
2451
|
+
// RR-07: a trend colours the trend and delta text and adds one native arrow shape, from core's accepted geometry.
|
|
2452
|
+
const trendMark=typeof opfCore.metricTrendMark==='function'?opfCore.metricTrendMark(layout,{background:`#${normalizeHex(context.colors.background)}`}):undefined;
|
|
2453
|
+
const partColor=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color.slice(1):part.role==='value'?context.colors.accent:context.textColor;
|
|
2167
2454
|
for (const [partIndex,part] of layout.parts.entries()) {
|
|
2168
2455
|
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
2169
2456
|
if (invalid) throw new OPFPptxError('invalid-metric-text',`Metric text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before exporting.`,{path:part.path});
|
|
@@ -2181,11 +2468,17 @@ function addMetricPayload(slide,value,layout,context,path) {
|
|
|
2181
2468
|
slide.addText(part.text.slice(line.start,line.end),{
|
|
2182
2469
|
...textBoxOptions({x:(anchor-part.box.width*factor)/96,y:(origin.baseline-part.fit.fontSize)/96,w:part.box.width/96,h:part.fit.lineHeight/96},context,part.fit.fontSize*.75),
|
|
2183
2470
|
...nativeFontOptions(part.style),
|
|
2184
|
-
color:part.
|
|
2471
|
+
color:partColor(part),align:layout.alignment,...(part.fit.directions?.[index]==='rtl'?{rtlMode:true}:{}),fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
2185
2472
|
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
2186
2473
|
});
|
|
2187
2474
|
}
|
|
2188
2475
|
}
|
|
2476
|
+
if(trendMark) {
|
|
2477
|
+
const objectName=`OPF metric ${group} trend mark`;
|
|
2478
|
+
context.metricTags.set(objectName,{v:1,group,role:'mark'});
|
|
2479
|
+
context.metricDescriptions.set(objectName,trendMark.ariaLabel);
|
|
2480
|
+
slide.addShape(trendMark.shape,{objectName,x:trendMark.box.x/96,y:trendMark.box.y/96,w:trendMark.box.width/96,h:trendMark.box.height/96,fill:{color:trendMark.color.slice(1)},line:{transparency:100},altText:trendMark.ariaLabel});
|
|
2481
|
+
}
|
|
2189
2482
|
}
|
|
2190
2483
|
|
|
2191
2484
|
function addQuotePayload(slide, value, layout, context, options, path) {
|
|
@@ -2411,6 +2704,9 @@ function textBoxOptions(region, context, fontSize) {
|
|
|
2411
2704
|
};
|
|
2412
2705
|
}
|
|
2413
2706
|
|
|
2707
|
+
// The private text helpers annotation-export.js writes captions and footnote lines with (RR-34).
|
|
2708
|
+
const exportHelpers = {textBoxOptions, nativeFontOptions, exportColor, nativeColor};
|
|
2709
|
+
|
|
2414
2710
|
function textRuns(value, context, fallbackFontSize) {
|
|
2415
2711
|
const runs = Array.isArray(value) ? value : [value];
|
|
2416
2712
|
return runs.map((run) => {
|
|
@@ -2558,17 +2854,32 @@ function histogramBins(values) {
|
|
|
2558
2854
|
}
|
|
2559
2855
|
}
|
|
2560
2856
|
|
|
2561
|
-
// Embedded bytes must be a raster the exporter can measure and PptxGenJS can embed (PNG, JPEG, GIF, WebP)
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
-
//
|
|
2565
|
-
//
|
|
2566
|
-
//
|
|
2857
|
+
// Embedded bytes must be a raster the exporter can measure and PptxGenJS can embed (PNG, JPEG, GIF, WebP), or an SVG,
|
|
2858
|
+
// which exports as a native SVG picture over a PNG fallback raster (see resolveSvgImage). Bytes that are no readable
|
|
2859
|
+
// image would otherwise fail deep inside packaging. Like an unresolved asset they are not exported as a picture: the
|
|
2860
|
+
// caller draws the preview's placeholder (or omits the picture) and one `unresolved-asset` diagnostic names the cause;
|
|
2861
|
+
// `strictAssets` keeps the `unsupported-image-dimensions` error. `outcome.reported` tells a caller that the diagnostic
|
|
2862
|
+
// was already emitted. `outcome.box` (inches, in) sizes an SVG's fallback raster; `outcome.svg` (out) carries the SVG to
|
|
2863
|
+
// attach to the picture the caller adds. Local paths are read by PptxGenJS and checked after embedding.
|
|
2567
2864
|
async function resolveImage(asset, presentation, options, path, outcome = {}) {
|
|
2568
2865
|
const resolved = await resolveImageSource(asset, presentation, options, path);
|
|
2866
|
+
// A local SVG file is read here (PptxGenJS cannot size it); a raster path is read by PptxGenJS itself.
|
|
2867
|
+
if (resolved?.path && /\.svg$/i.test(resolved.path)) {
|
|
2868
|
+
let file;
|
|
2869
|
+
try { file = await readLocalFile(resolved.path); }
|
|
2870
|
+
catch (error) {
|
|
2871
|
+
if (options.strictAssets) throw new OPFPptxError("invalid-svg-image", `The SVG file could not be read (${errorMessage(error)}).`, { path, reason: "svg-unreadable" });
|
|
2872
|
+
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.` });
|
|
2873
|
+
outcome.reported = true;
|
|
2874
|
+
return null;
|
|
2875
|
+
}
|
|
2876
|
+
return resolveSvgImage(file, options, path, outcome);
|
|
2877
|
+
}
|
|
2569
2878
|
if (!resolved?.data) return resolved;
|
|
2570
2879
|
const bytes = dataUriBytes(resolved.data);
|
|
2571
2880
|
if (bytes && rasterMetadata(bytes)) return resolved;
|
|
2881
|
+
const svg = svgDataUriBytes(resolved.data);
|
|
2882
|
+
if (svg) return resolveSvgImage(svg, options, path, outcome);
|
|
2572
2883
|
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
2884
|
if (options.strictAssets) throw new OPFPptxError("unsupported-image-dimensions", `Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. ${message}`, { path });
|
|
2574
2885
|
options.onDiagnostic?.({ code: "unresolved-asset", path, reason: "unsupported-format", message });
|
|
@@ -2576,6 +2887,44 @@ async function resolveImage(asset, presentation, options, path, outcome = {}) {
|
|
|
2576
2887
|
return null;
|
|
2577
2888
|
}
|
|
2578
2889
|
|
|
2890
|
+
// An SVG picture is PowerPoint 2016's native form: the SVG itself (sanitized, never executed or fetched) plus a PNG
|
|
2891
|
+
// fallback raster drawn from it at 192 dpi of the displayed size by options.svgRasterizer (default: opf-render's resvg,
|
|
2892
|
+
// deterministic, with its bundled fonts). The caller adds the PNG as the picture and attaches `outcome.svg` at packaging.
|
|
2893
|
+
// A malformed SVG, an SVG without an intrinsic size or without a rasterizer exports like any unreadable image.
|
|
2894
|
+
async function resolveSvgImage(source, options, path, outcome) {
|
|
2895
|
+
const unresolved = (code, reason, message) => {
|
|
2896
|
+
if (options.strictAssets) throw new OPFPptxError(code, message, { path, reason });
|
|
2897
|
+
options.onDiagnostic?.({ code: "unresolved-asset", path, reason, message: `${message} The image exports as the unavailable-image placeholder.` });
|
|
2898
|
+
outcome.reported = true;
|
|
2899
|
+
return null;
|
|
2900
|
+
};
|
|
2901
|
+
if (source.error) return unresolved("invalid-svg-image", "svg-malformed", source.error);
|
|
2902
|
+
const prepared = prepareSvg(source);
|
|
2903
|
+
if (prepared.error) return unresolved("invalid-svg-image", prepared.error.reason, prepared.error.message);
|
|
2904
|
+
if (prepared.removed.length) {
|
|
2905
|
+
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.` });
|
|
2906
|
+
}
|
|
2907
|
+
const scale = svgRasterScale(prepared, outcome.box, outcome.cover === true);
|
|
2908
|
+
const width = Math.max(1, Math.round(prepared.width * scale)), height = Math.max(1, Math.round(prepared.height * scale));
|
|
2909
|
+
const cacheKey = `${scale}|${prepared.text}`;
|
|
2910
|
+
let png = options.svgRasters.get(cacheKey);
|
|
2911
|
+
if (!png) {
|
|
2912
|
+
try {
|
|
2913
|
+
const rasterize = options.svgRasterizer ?? ((svg, hint) => svgToPng(svg, hint));
|
|
2914
|
+
png = await rasterize(prepared.text, { width, height, scale, text: prepared.hasText });
|
|
2915
|
+
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" });
|
|
2916
|
+
} catch (error) {
|
|
2917
|
+
const reason = error?.code === "svg-rasterizer-unavailable" ? "svg-rasterizer-unavailable" : "svg-render-failed";
|
|
2918
|
+
return unresolved(reason, reason, reason === "svg-rasterizer-unavailable"
|
|
2919
|
+
? "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)."
|
|
2920
|
+
: `The SVG image could not be rasterized for its PNG fallback (${errorMessage(error)}).`);
|
|
2921
|
+
}
|
|
2922
|
+
options.svgRasters.set(cacheKey, png);
|
|
2923
|
+
}
|
|
2924
|
+
outcome.svg = { bytes: prepared.bytes, width: prepared.width, height: prepared.height };
|
|
2925
|
+
return { data: `data:image/png;base64,${bytesToBase64(png)}` };
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2579
2928
|
function dataUriMediaType(uri) {
|
|
2580
2929
|
return /^data:([^;,]+)/i.exec(uri)?.[1]?.toLowerCase();
|
|
2581
2930
|
}
|
|
@@ -2812,6 +3161,27 @@ function isDarkHex(value) {
|
|
|
2812
3161
|
return (red * 299 + green * 587 + blue * 114) / 1000 < 128;
|
|
2813
3162
|
}
|
|
2814
3163
|
|
|
3164
|
+
// The master, layout and notes master half of the native header/footer (RR-11): placeholders and p:hf on the finished parts.
|
|
3165
|
+
// Every deck gets them (flags off when no slide uses a type), so Insert > Header & Footer works on a deck with no footer too.
|
|
3166
|
+
function writeNativeFurnitureMasters(output, context) {
|
|
3167
|
+
const size = /<p:sldSz\b[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/.exec(decodeText(output['ppt/presentation.xml'][0]));
|
|
3168
|
+
const defaults = new Map((context.defaultFooterOptions ? defaultFooterParts(context.defaultFooterOptions) ?? [] : []).flatMap(part => {
|
|
3169
|
+
const ph = {date: 'dt', text: 'ftr', slideNumber: 'sldNum'}[part.field];
|
|
3170
|
+
const geometry = ph && defaultPlaceholderGeometry(part);
|
|
3171
|
+
return geometry ? [[ph, geometry]] : [];
|
|
3172
|
+
}));
|
|
3173
|
+
const info = {used: context.nativePlaceholders.used, first: context.nativePlaceholders.first, names: new Set(context.nativeFurniture.keys()), defaults,
|
|
3174
|
+
slideSize: {width: size ? Number(size[1]) : 12192000, height: size ? Number(size[2]) : 6858000}, dateText: nativeDateText(context.hostDate)};
|
|
3175
|
+
const paths = Object.keys(output);
|
|
3176
|
+
try {
|
|
3177
|
+
// The placeholders carry the deck's language and direction like every other generated part (partScriptFonts is idempotent).
|
|
3178
|
+
const written = (path, bytes) => { output[path] = [context.scriptFonts ? encodeText(partScriptFonts(path, decodeText(bytes), context.scriptFonts, 0)) : bytes, output[path][1]]; };
|
|
3179
|
+
writeNativeMasters(paths, path => output[path][0], written, info);
|
|
3180
|
+
} catch (error) {
|
|
3181
|
+
throw new OPFPptxError('packaging-failed', 'Native header/footer placeholders could not be written.', {cause: errorMessage(error)});
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
|
|
2815
3185
|
async function normalizePptxZip(raw, context) {
|
|
2816
3186
|
let entries;
|
|
2817
3187
|
try {
|
|
@@ -2823,15 +3193,18 @@ async function normalizePptxZip(raw, context) {
|
|
|
2823
3193
|
}
|
|
2824
3194
|
|
|
2825
3195
|
attachCodeTags(entries, context.codeTags);
|
|
2826
|
-
attachMetricTags(entries,context.metricTags);
|
|
3196
|
+
attachMetricTags(entries,context.metricTags,context.metricDescriptions);
|
|
2827
3197
|
attachCardTags(entries,context.cardTags);
|
|
2828
3198
|
attachMediaTags(entries,context.mediaTags);
|
|
2829
3199
|
attachHeadingTags(entries,context.headingTags);
|
|
2830
3200
|
attachPlainTextTags(entries,context.plainTextTags);
|
|
2831
3201
|
attachTimelineTags(entries,context.timelineTags);
|
|
2832
3202
|
attachQuoteTags(entries,context.quoteTags);
|
|
3203
|
+
attachAnnotationTags(entries,context);
|
|
2833
3204
|
attachFurnitureFields(entries,context.furnitureFields);
|
|
2834
3205
|
attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests,context.furnitureLogoTags);
|
|
3206
|
+
// RR-11: the recorded footer shapes become PowerPoint's date, footer and slide-number placeholders.
|
|
3207
|
+
context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture);
|
|
2835
3208
|
// The "Image unavailable" panel of an unresolved logo: identity only, so import does not read it as content.
|
|
2836
3209
|
attachTextTags(entries,context.logoPlaceholderTags,LOGO_TAG,'opfLogoPlaceholder','logo placeholder');
|
|
2837
3210
|
for(const [part,bytes]of Object.entries(entries)){
|
|
@@ -2849,6 +3222,9 @@ async function normalizePptxZip(raw, context) {
|
|
|
2849
3222
|
// Normalize only our generated chart text styles; point/series fills stay intact.
|
|
2850
3223
|
entries[chartPart]=encodeText(decodeText(entries[chartPart]).replace(/<c:txPr>[\s\S]*?<\/c:txPr>/g,properties=>properties.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/g,()=>`<a:solidFill><a:srgbClr val="${labelColor}"/></a:solidFill>`)));
|
|
2851
3224
|
entries[chartPart]=encodeText(applyChartTextSize(decodeText(entries[chartPart]),textSize));
|
|
3225
|
+
// RR-35: data labels last, so their own text colours (contrast inside a mark) are not normalized away.
|
|
3226
|
+
const {options:chartOptions,kind:optionKind,pointCount}=context.chartHeadings.get(name);
|
|
3227
|
+
if(chartOptions?.dataLabels)entries[chartPart]=encodeText(applyDataLabels(decodeText(entries[chartPart]),{resolved:chartOptions,kind:optionKind,palette:context.chartPalettes.get(name),labelColor,font:context.chartFonts.get(name)?.body??'Arial',textSize,pointCount}));
|
|
2852
3228
|
}
|
|
2853
3229
|
}
|
|
2854
3230
|
applyChartFonts(entries,context.chartFonts,parseRelationships);
|
|
@@ -2878,10 +3254,14 @@ async function normalizePptxZip(raw, context) {
|
|
|
2878
3254
|
const backgroundId = background && decodeText(bytes).match(/<p:bg>[\s\S]*?<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
2879
3255
|
if (backgroundId) imageSources.set(relationships.get(backgroundId)?.path, background.path);
|
|
2880
3256
|
}
|
|
3257
|
+
// SVG pictures: the media part, its relationship and the asvg:svgBlip extension beside the PNG fallback blip. Fitting then
|
|
3258
|
+
// uses the SVG's own proportions (the fallback raster only approximates them at integer pixels).
|
|
3259
|
+
const svgSizes = attachSvgPictures(entries, context.svgPictures);
|
|
2881
3260
|
const imageMetadata = new Map();
|
|
2882
3261
|
for (const [part, bytes] of Object.entries(entries)) {
|
|
2883
3262
|
if (!part.startsWith('ppt/media/')) continue;
|
|
2884
3263
|
let metadata = rasterMetadata(bytes);
|
|
3264
|
+
if (metadata && svgSizes.has(part)) metadata = {...metadata, ...svgSizes.get(part)};
|
|
2885
3265
|
if (metadata?.mediaType === 'image/webp' && context.imageFormat === 'compatible') {
|
|
2886
3266
|
try {
|
|
2887
3267
|
if (metadata.width * metadata.height > 40_000_000) throw new Error('Image dimensions exceed the 40 megapixel conversion limit.');
|
|
@@ -2941,6 +3321,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
2941
3321
|
}];
|
|
2942
3322
|
}
|
|
2943
3323
|
|
|
3324
|
+
writeNativeFurnitureMasters(output, context);
|
|
2944
3325
|
finalizeFontsUsed(output);
|
|
2945
3326
|
// Document references record evidence from the final normalized parts.
|
|
2946
3327
|
if (context.documentProvenance) {
|
|
@@ -3140,6 +3521,9 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
3140
3521
|
if(!marker)return shape;
|
|
3141
3522
|
if(marker.picture&&bulletRelationship)return shape.replace(/<a:buChar\b[^>]*\/>/g,`<a:buBlip><a:blip r:embed="${bulletRelationship}"/></a:buBlip>`);
|
|
3142
3523
|
const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
3524
|
+
// RR-33: an auto-number takes the measured size, colour and family like a character bullet, and startAt only when it is not 1.
|
|
3525
|
+
const autoNumber=/<a:buSzPct val="100000"\/><a:buFont typeface="\+mj-lt"\/><a:buAutoNum type="([A-Za-z]+)" startAt="(\d+)"\/>/;
|
|
3526
|
+
if(autoNumber.test(shape))return shape.replace(autoNumber,(_,type,startAt)=>`<a:buClr>${solidColorXml(marker.color)}</a:buClr><a:buSzPts val="${Math.round(marker.fontSize*100)}"/><a:buFont typeface="${family}"/><a:buAutoNum type="${type}"${startAt==='1'?'':` startAt="${startAt}"`}/>`);
|
|
3143
3527
|
return shape.replace(/<a:buSzPct val="100000"\/>/g,`<a:buClr>${solidColorXml(marker.color)}</a:buClr><a:buSzPts val="${Math.round(marker.fontSize*100)}"/><a:buFont typeface="${family}"/>`);
|
|
3144
3528
|
});
|
|
3145
3529
|
// PptxGenJS gives shapes no alternative text. An unavailable image's panel
|
|
@@ -3371,7 +3755,7 @@ function numericValue(value) {
|
|
|
3371
3755
|
|
|
3372
3756
|
function normalizeAuthor(author) {
|
|
3373
3757
|
if (typeof author === "string") return author;
|
|
3374
|
-
if (Array.isArray(author)) return author
|
|
3758
|
+
if (Array.isArray(author)) return joinAuthors(author);
|
|
3375
3759
|
return null;
|
|
3376
3760
|
}
|
|
3377
3761
|
|