@openpresentation/opf-pptx 0.11.9 → 0.12.1
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 +22 -3
- 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 +259 -52
- package/dist/document-provenance.js +77 -5
- package/dist/index.d.ts +17 -1
- package/dist/index.js +304 -67
- package/dist/logo-provenance.js +4 -3
- package/dist/master-themes.js +49 -0
- package/dist/metric-provenance.js +6 -3
- package/dist/native-text-style.js +17 -1
- package/dist/numbered-list.js +114 -0
- package/dist/package-fonts.js +6 -4
- package/dist/run-colors.js +118 -0
- package/dist/script-fonts.js +150 -17
- package/dist/table-cell-import.js +3 -1
- package/dist/table-import.js +3 -2
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
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';
|
|
6
|
+
import {giveNotesMastersOwnThemes} from './master-themes.js';
|
|
5
7
|
import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
|
|
6
8
|
import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,NATIVE_CHART_ELEMENTS} from './chart-types.js';
|
|
7
9
|
import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
|
|
10
|
+
import {applyDataLabels,chartOptionsFromClassic,chartTargetFor,classicChartOptions,reportChartOptionDiagnostics,resolveChartOptionsFor} from './chart-options.js';
|
|
8
11
|
import {attachCodeTags, attachTextTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
9
12
|
import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
|
|
10
13
|
import {attachCardTags,importCardFrames} from './card-provenance.js';
|
|
11
14
|
import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTextFingerprint} from './media-provenance.js';
|
|
15
|
+
// RR-34: captions and footnote areas as tagged text boxes; citation markers round-trip through the tags and the marker runs.
|
|
16
|
+
import {addCaption, addFootnotes} from './annotation-export.js';
|
|
17
|
+
import {attachAnnotationTags, captionValue, importAnnotations, restoreCitations} from './annotation-provenance.js';
|
|
12
18
|
import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
|
|
13
19
|
import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
|
|
14
20
|
import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
|
|
15
21
|
import {attachQuoteTags,quoteManifest,importQuoteGroups} from './quote-provenance.js';
|
|
16
22
|
import {attachFurnitureTags, furnitureManifest, importFurniture, manifestPartIndex, staticDateFallback} from './furniture-provenance.js';
|
|
17
|
-
import {
|
|
23
|
+
import {restoreRunColors} from './run-colors.js';
|
|
24
|
+
import {joinWrappedText} from './content-topology.js';
|
|
25
|
+
import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListBreaks, DEFAULT_AUTHOR} from './document-provenance.js';
|
|
18
26
|
import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
|
|
19
27
|
import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
|
|
20
28
|
import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, writeNativeMasters} from './native-furniture.js';
|
|
@@ -27,11 +35,13 @@ import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from
|
|
|
27
35
|
import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
|
|
28
36
|
import {importBackground} from './background-import.js';
|
|
29
37
|
import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
|
|
30
|
-
import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
|
|
38
|
+
import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, reconcileLanguage, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
|
|
31
39
|
import { webpToPng, svgToPng, readLocalFile } from '#image-fallback';
|
|
32
40
|
import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
|
|
33
41
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
34
42
|
import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
|
|
43
|
+
// chartPaletteForFill ships with core RR-29 (opf#270); an older published core still loads and clamps each colour on its own.
|
|
44
|
+
import * as opfComposition from "@openpresentation/opf/composition";
|
|
35
45
|
import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
|
|
36
46
|
import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
|
|
37
47
|
import { unzipSync, zipSync } from "fflate";
|
|
@@ -40,6 +50,11 @@ import {
|
|
|
40
50
|
catalogs as bundledCatalogs,
|
|
41
51
|
validatePresentation
|
|
42
52
|
} from "@openpresentation/opf";
|
|
53
|
+
// Optional core exports (RR-07 syntax colours, trend marks) are read from the namespace so an older published core still loads.
|
|
54
|
+
|
|
55
|
+
// Optional core exports are read from the namespace so an older published core still loads;
|
|
56
|
+
// resolveVariables ships with core RR-32.
|
|
57
|
+
import * as opfCore from "@openpresentation/opf";
|
|
43
58
|
|
|
44
59
|
export {checkPptxTypefaces, inventoryPptxTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
|
|
45
60
|
export {DEFAULT_SIGNAL_LIMITS, SIGNALS_VERSION, isMonospaceFamily} from './import-signals.js';
|
|
@@ -205,7 +220,7 @@ export async function toPptx(input, options = {}) {
|
|
|
205
220
|
if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
|
|
206
221
|
throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
|
|
207
222
|
}
|
|
208
|
-
const presentation = parseInput(input);
|
|
223
|
+
const presentation = resolveTemplateInput(parseInput(input), options);
|
|
209
224
|
assertValidBoundary(presentation);
|
|
210
225
|
options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement), svgRasters: new Map()};
|
|
211
226
|
|
|
@@ -238,9 +253,13 @@ export async function toPptx(input, options = {}) {
|
|
|
238
253
|
context.nativeFurniture = new Map();
|
|
239
254
|
context.hostDate = options.date;
|
|
240
255
|
context.codeTags = new Map();
|
|
256
|
+
context.captionTags = new Map();
|
|
257
|
+
context.footnoteTags = new Map();
|
|
241
258
|
context.metricTags = new Map();
|
|
259
|
+
context.metricDescriptions = new Map();
|
|
242
260
|
context.chartHeadings = new Map();
|
|
243
261
|
context.chartFonts = new Map();
|
|
262
|
+
context.chartPalettes = new Map();
|
|
244
263
|
context.chartex = new Map();
|
|
245
264
|
context.imageFormat = options.imageFormat ?? "compatible";
|
|
246
265
|
// Chartex export mode (FF-22b): 'auto' (default) writes native chartex parts for the constructs the native PowerPoint
|
|
@@ -327,7 +346,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
327
346
|
});
|
|
328
347
|
if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
|
|
329
348
|
if (core.description) imported.description = core.description;
|
|
330
|
-
if (core.author) imported.author = core.author;
|
|
349
|
+
if (core.author) imported.author = splitAuthors(core.author);
|
|
331
350
|
const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
|
|
332
351
|
const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
|
|
333
352
|
if (Object.keys(design).length) imported.design = design;
|
|
@@ -353,7 +372,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
353
372
|
const mediaRegistry = Object.create(null);
|
|
354
373
|
for (let index = 0; index < slidePaths.length; index += 1) {
|
|
355
374
|
furnitureContexts[index].mediaRegistry = mediaRegistry;
|
|
356
|
-
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
|
|
375
|
+
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage)}, furniture.slides[index], furnitureContexts[index]));
|
|
357
376
|
}
|
|
358
377
|
// Native sections (PowerPoint's own section list, `Default Section` = none)
|
|
359
378
|
// are reconciled with the footer text and the stored value in restoreDocumentProvenance.
|
|
@@ -396,7 +415,11 @@ export async function fromPptx(input, options = {}) {
|
|
|
396
415
|
report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
|
|
397
416
|
}
|
|
398
417
|
if (slideProvenance.length !== imported.slides.length) throw new OPFPptxError("invalid-import-opf", "Slide provenance does not match the imported slides.");
|
|
418
|
+
// RR-34: marker runs become cite/footnote on the runs before them, and the references list is rebuilt
|
|
419
|
+
// from the stored record and the footnote boxes (an edited note keeps its edited text).
|
|
420
|
+
restoreCitations(imported, furnitureContexts.map(context => context.annotations?.notes ?? []), report);
|
|
399
421
|
restoreLogoFallback(imported, furnitureContexts);
|
|
422
|
+
applyRunColors(imported, slideProvenance, restoredGroups, report, options);
|
|
400
423
|
if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
|
|
401
424
|
languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
|
|
402
425
|
|
|
@@ -418,6 +441,24 @@ export async function fromPptx(input, options = {}) {
|
|
|
418
441
|
return {document: imported, signals};
|
|
419
442
|
}
|
|
420
443
|
|
|
444
|
+
// RR-08: run colours named by a ColorRef (variable, scheme slot, role) come back as that name where the document's own
|
|
445
|
+
// colour resolution still gives the run's colour; see run-colors.js.
|
|
446
|
+
function applyRunColors(imported, slideProvenance, restoredGroups, report, options) {
|
|
447
|
+
const restoredContent = new Set(restoredGroups.filter(group => group.applied && group.contentPaths).map(group => group.contentPaths.slide));
|
|
448
|
+
const info = slideProvenance.map((entry, index) => entry.structure === "untagged" ? undefined : {record: entry.record, structure: entry.structure === "match", content: restoredContent.has(index)});
|
|
449
|
+
const contexts = new Map();
|
|
450
|
+
const resolve = (slide, name) => {
|
|
451
|
+
try {
|
|
452
|
+
const key = slide.design ?? imported.design ?? null;
|
|
453
|
+
if (!contexts.has(key)) contexts.set(key, resolvePresentationContext(slide.design ? {...imported, design: {...imported.design, ...slide.design}} : imported, options));
|
|
454
|
+
return resolveColorRefValue(name, colorContext(contexts.get(key)));
|
|
455
|
+
} catch {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
restoreRunColors(imported.slides, info, resolve, report);
|
|
460
|
+
}
|
|
461
|
+
|
|
421
462
|
// A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the logo only when nothing else
|
|
422
463
|
// did: the stored document and slide design (or the organization) win and carry LogoSet variants, so a package exported
|
|
423
464
|
// without provenance still keeps its logo. A slide-level logo path restores slide design; every other path the deck's.
|
|
@@ -639,10 +680,32 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
639
680
|
item.text = current.map(paragraph => paragraph.text).join('\n');
|
|
640
681
|
}
|
|
641
682
|
}
|
|
642
|
-
const
|
|
643
|
-
|
|
683
|
+
const listBreaks = slideListBreaks(entries, slidePath, slideRoot, relationships);
|
|
684
|
+
const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items, listBreaks, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})))
|
|
685
|
+
.map((item) => ({payload: payloadFromSlideItem(item, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? []}))
|
|
644
686
|
.filter((entry) => entry.payload);
|
|
645
687
|
const blocks = content.map((entry) => entry.payload);
|
|
688
|
+
// RR-34: a tagged caption re-attaches to the block whose native media shape it names (never by position).
|
|
689
|
+
const annotations = nativeContext.annotations;
|
|
690
|
+
if (annotations?.captions.size) {
|
|
691
|
+
const tree = slideRoot['p:cSld']?.['p:spTree'];
|
|
692
|
+
const shapeName = node => scalarText(node?.['p:nvSpPr']?.['p:cNvPr']?.name ?? node?.['p:nvPicPr']?.['p:cNvPr']?.name ?? node?.['p:nvGraphicFramePr']?.['p:cNvPr']?.name).trim();
|
|
693
|
+
const keyByName = new Map();
|
|
694
|
+
nativeContext.shapes.forEach((shape, index) => keyByName.set(shapeName(shape), `sp:${index}`));
|
|
695
|
+
nativeContext.pictures.forEach((picture, index) => keyByName.set(shapeName(picture), `pic:${index}`));
|
|
696
|
+
asArray(tree?.['p:graphicFrame']).forEach((frame, index) => keyByName.set(shapeName(frame), `frame:${index}`));
|
|
697
|
+
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}`); });
|
|
698
|
+
for (const [mediaName, caption] of annotations.captions) {
|
|
699
|
+
const key = keyByName.get(mediaName);
|
|
700
|
+
const entry = key === undefined ? undefined : content.find(item => item.sources.includes(key));
|
|
701
|
+
if (!entry || !['image', 'chart', 'table', 'video'].includes(inferPayloadKind(entry.payload))) {
|
|
702
|
+
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.`});
|
|
703
|
+
blocks.push({type: 'text', text: caption.value});
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
entry.payload.caption = captionValue(caption);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
646
709
|
if (blocks.length > 0) slide.blocks = blocks;
|
|
647
710
|
// Native bounds of each block in reference px, for content topology matching (document provenance).
|
|
648
711
|
// Which native shapes fed each field and block (import signals); never part of the document.
|
|
@@ -664,6 +727,12 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
664
727
|
const shapes = nativeContext.shapes;
|
|
665
728
|
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.'});
|
|
666
729
|
const paragraphs = nativeContext.paragraphs;
|
|
730
|
+
// FF-45: OMML equations (a14:m math zones) have no OPF model. Their fallback text is imported as plain text; the equation
|
|
731
|
+
// layout (fractions, radicals, limits, matrices) is lost, and that loss is diagnosed per shape instead of passing silently.
|
|
732
|
+
paragraphs.forEach((list, index) => {
|
|
733
|
+
const zones = list.reduce((count, paragraph) => count + (paragraph.math?.zones ?? 0), 0);
|
|
734
|
+
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.`});
|
|
735
|
+
});
|
|
667
736
|
// Native shape keys (indexes in nativeTextShapes / nativePictures / frame order) for import signals.
|
|
668
737
|
const shapeKeys = new Map(shapes.map((shape, index) => [shape, `sp:${index}`]));
|
|
669
738
|
const sourcesOf = group => group.flatMap(shape => shapeKeys.has(shape) ? [shapeKeys.get(shape)] : []);
|
|
@@ -681,6 +750,9 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
681
750
|
const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
682
751
|
const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
683
752
|
const quotes=importQuoteGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote`}));
|
|
753
|
+
// RR-34: tagged caption and footnote lines are not content; captions re-attach to their media block and notes rebuild cite/footnote.
|
|
754
|
+
const annotations=importAnnotations({shapes,paragraphs,relationships,entries,slideIndex,readBody,report:diagnostic=>options.onDiagnostic?.(diagnostic)});
|
|
755
|
+
nativeContext.annotations=annotations;
|
|
684
756
|
for(const group of [...headings.items,...plainText.items]) {
|
|
685
757
|
const item=importShape(group.shapes[0],dimensions,group.paragraphs,true);
|
|
686
758
|
if(item){
|
|
@@ -707,6 +779,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
707
779
|
: code.consumed.has(shape) ? 'code' : metric.consumed.has(shape) ? 'metric' : media.consumed.has(shape) ? 'media' : timelines.consumed.has(shape) ? 'timeline' : quotes.consumed.has(shape) ? 'quote' : undefined;
|
|
708
780
|
if (role) roles.set(shapeKeys.get(shape), role);
|
|
709
781
|
if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
|
|
782
|
+
if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
|
|
710
783
|
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;
|
|
711
784
|
const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
|
|
712
785
|
const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
|
|
@@ -718,12 +791,14 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
718
791
|
}
|
|
719
792
|
|
|
720
793
|
const frames = asArray(tree?.["p:graphicFrame"]);
|
|
794
|
+
// RR-35: what a chart's axis titles, legend and data labels cannot express in OPF is reported on the chart's path.
|
|
795
|
+
const chartReport = label => diagnostic => options.onDiagnostic?.({code: 'chart-option-adapted', ...diagnostic, path: `slides.${slideIndex}.${label}.${diagnostic.option}`});
|
|
721
796
|
const tables = frames.some(frame => frame['a:graphic']?.['a:graphicData']?.['a:tbl'])
|
|
722
797
|
? importTableFrames(slidePath, {
|
|
723
798
|
part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
|
|
724
|
-
}, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions) : [];
|
|
799
|
+
}, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions, {rtlDeck: options.rtlDeck === true}) : [];
|
|
725
800
|
for (const [index, frame] of frames.entries()) {
|
|
726
|
-
const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
|
|
801
|
+
const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index], chartReport(`charts.${index}`));
|
|
727
802
|
if (item) items.push({...item, sources: [`frame:${index}`]});
|
|
728
803
|
}
|
|
729
804
|
// A chartex chart is an mc:AlternateContent: the choice frame references the cx:chartSpace part; the fallback is a classic chart frame
|
|
@@ -731,8 +806,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
731
806
|
for (const [alternateIndex, alternate] of asArray(tree?.["mc:AlternateContent"]).entries()) {
|
|
732
807
|
const choice = asArray(alternate?.["mc:Choice"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
|
|
733
808
|
const fallback = asArray(alternate?.["mc:Fallback"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
|
|
734
|
-
const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships) : null;
|
|
735
|
-
const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships) : chosen;
|
|
809
|
+
const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships, undefined, chartReport(`charts.alt${alternateIndex}`)) : null;
|
|
810
|
+
const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships, undefined, chartReport(`charts.alt${alternateIndex}`)) : chosen;
|
|
736
811
|
if (item && item.kind !== "unknown") items.push({...item, sources: [`alt:${alternateIndex}`]});
|
|
737
812
|
}
|
|
738
813
|
|
|
@@ -774,7 +849,7 @@ function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txB
|
|
|
774
849
|
};
|
|
775
850
|
}
|
|
776
851
|
|
|
777
|
-
function importGraphicFrame(entries, frame, slidePath, relationships, importedTable) {
|
|
852
|
+
function importGraphicFrame(entries, frame, slidePath, relationships, importedTable, report) {
|
|
778
853
|
const bounds = shapeBounds(frame["p:xfrm"]);
|
|
779
854
|
const name = scalarText(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]?.name).trim();
|
|
780
855
|
const graphicData = frame["a:graphic"]?.["a:graphicData"];
|
|
@@ -795,8 +870,8 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
|
|
|
795
870
|
const chartRelId = graphicData?.["c:chart"]?.["r:id"] ?? chartExRelId;
|
|
796
871
|
if (chartRelId) {
|
|
797
872
|
const chart = chartExRelId
|
|
798
|
-
? chartexFromRelationship(entries, relationships, chartExRelId)
|
|
799
|
-
: chartFromRelationship(entries, slidePath, relationships, chartRelId);
|
|
873
|
+
? chartexFromRelationship(entries, relationships, chartExRelId, report)
|
|
874
|
+
: chartFromRelationship(entries, slidePath, relationships, chartRelId, report);
|
|
800
875
|
return {
|
|
801
876
|
kind: "chart",
|
|
802
877
|
bounds,
|
|
@@ -879,11 +954,17 @@ function importPicture(entries, picture, slidePath, relationships, report) {
|
|
|
879
954
|
}
|
|
880
955
|
|
|
881
956
|
function readParagraphs(txBody) {
|
|
882
|
-
return asArray(txBody?.["a:p"])
|
|
957
|
+
return withDisplayedNumbers(asArray(txBody?.["a:p"])
|
|
883
958
|
.map((paragraph) => {
|
|
959
|
+
// FF-45: an a14:m math zone (OMML equation) inside mc:AlternateContent has no OPF model; its mc:Fallback runs are read as
|
|
960
|
+
// text (or, without them, the equation's m:t text), and `math` records the zones so the importer can diagnose the loss.
|
|
961
|
+
const alternates = asArray(paragraph?.["mc:AlternateContent"]);
|
|
962
|
+
const zones = alternates.flatMap(alternate => asArray(alternate?.["mc:Choice"])).filter(choice => choice?.["a14:m"] !== undefined);
|
|
963
|
+
const fallbackRuns = alternates.flatMap(alternate => asArray(alternate?.["mc:Fallback"])).flatMap(fallback => [...asArray(fallback?.["a:r"]), ...asArray(fallback?.["a:fld"])]);
|
|
884
964
|
const runs = [
|
|
885
965
|
...asArray(paragraph?.["a:r"]),
|
|
886
|
-
...asArray(paragraph?.["a:fld"])
|
|
966
|
+
...asArray(paragraph?.["a:fld"]),
|
|
967
|
+
...fallbackRuns
|
|
887
968
|
];
|
|
888
969
|
const texts = [];
|
|
889
970
|
const sizes = [];
|
|
@@ -893,13 +974,29 @@ function readParagraphs(txBody) {
|
|
|
893
974
|
const size = Number(run?.["a:rPr"]?.sz);
|
|
894
975
|
if (Number.isFinite(size)) sizes.push(size / 100);
|
|
895
976
|
}
|
|
977
|
+
const linear = zones.map(choice => keyedMathText(choice["a14:m"])).join("");
|
|
978
|
+
if (zones.length && !fallbackRuns.some(run => scalarText(run?.["a:t"]))) texts.push(linear);
|
|
896
979
|
return {
|
|
897
980
|
text: texts.join(""),
|
|
898
981
|
bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buBlip"] !== undefined || props?.["a:buAutoNum"] !== undefined),
|
|
899
982
|
level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
|
|
900
|
-
maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0
|
|
983
|
+
maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0,
|
|
984
|
+
...(zones.length ? {math: {zones: zones.length, text: linear}} : {}),
|
|
985
|
+
...autoNumberOf(asArray(paragraph?.["a:pPr"])[0])
|
|
901
986
|
};
|
|
902
|
-
});
|
|
987
|
+
}));
|
|
988
|
+
}
|
|
989
|
+
// RR-33: the native auto-number of a paragraph's properties, when its last bullet element is one.
|
|
990
|
+
function autoNumberOf(properties) {
|
|
991
|
+
if (properties?.["a:buAutoNum"] === undefined) return {};
|
|
992
|
+
const attributes = asArray(properties["a:buAutoNum"])[0] ?? {};
|
|
993
|
+
return {autoNum: {type: attributes.type, startAt: attributes.startAt}};
|
|
994
|
+
}
|
|
995
|
+
/** The m:t text of a keyed OMML tree (an a14:m zone) in document order: the equation's linear reading without its layout. */
|
|
996
|
+
function keyedMathText(node) {
|
|
997
|
+
if (Array.isArray(node)) return node.map(keyedMathText).join("");
|
|
998
|
+
if (!node || typeof node !== "object") return "";
|
|
999
|
+
return Object.entries(node).map(([key, value]) => key === "m:t" ? asArray(value).map(scalarText).join("") : keyedMathText(value)).join("");
|
|
903
1000
|
}
|
|
904
1001
|
|
|
905
1002
|
function shapePlaceholderType(shape) {
|
|
@@ -966,7 +1063,10 @@ function joinRich(a, b) {
|
|
|
966
1063
|
}
|
|
967
1064
|
return joined;
|
|
968
1065
|
}
|
|
969
|
-
|
|
1066
|
+
// `breaks` (slideListBreaks) holds the whitespace of the hard breaks the export recorded, by list path and line number: the
|
|
1067
|
+
// continuation line after a break joins with it, so a newline inside an item (or a description) returns. A blank line is a
|
|
1068
|
+
// native shape with no text: it is never a description. Without a record the lines join without a separator, as before.
|
|
1069
|
+
function mergeOpfListShapes(items, breaks = null, report = () => {}) {
|
|
970
1070
|
const lists = new Map();
|
|
971
1071
|
for (const item of items) {
|
|
972
1072
|
const match = /^OPF list (.+) line (\d+)$/.exec(item.name ?? '');
|
|
@@ -980,17 +1080,23 @@ function mergeOpfListShapes(items) {
|
|
|
980
1080
|
entries.sort((a, b) => a.line - b.line);
|
|
981
1081
|
if (!entries[0].item.paragraphs[0].bullet) continue;
|
|
982
1082
|
const paragraphs = [];
|
|
1083
|
+
const recorded = breaks?.get(path), applied = new Set();
|
|
983
1084
|
let bounds, size = 0;
|
|
984
|
-
for (const {item} of entries) {
|
|
1085
|
+
for (const {item, line} of entries) {
|
|
985
1086
|
const [paragraph] = item.paragraphs, current = paragraphs.at(-1);
|
|
1087
|
+
const gap = recorded?.get(line);
|
|
1088
|
+
const join = (a, b) => gap === undefined ? joinRich(a, b) : joinWrappedText([a, b], [gap]);
|
|
986
1089
|
if (paragraph.bullet) { paragraphs.push({...paragraph}); size = paragraph.maxFontSize; }
|
|
1090
|
+
else if (paragraph.text === '') { /* a blank line of the entry: no text, no description */ }
|
|
987
1091
|
else if (paragraph.maxFontSize < size - 0.01) {
|
|
988
1092
|
const text = paragraph.richText ?? paragraph.text;
|
|
989
|
-
|
|
1093
|
+
if (current.description === undefined) current.description = text;
|
|
1094
|
+
else { current.description = join(current.description, text); applied.add(line); }
|
|
990
1095
|
} else {
|
|
991
1096
|
const before = current.text;
|
|
992
|
-
current.text += paragraph.text;
|
|
993
|
-
if (current.richText !== undefined || paragraph.richText !== undefined) current.richText =
|
|
1097
|
+
current.text += (gap === undefined ? '' : Array.isArray(gap) ? gap[0] : gap) + paragraph.text;
|
|
1098
|
+
if (current.richText !== undefined || paragraph.richText !== undefined) current.richText = join(current.richText ?? before, paragraph.richText ?? paragraph.text);
|
|
1099
|
+
applied.add(line);
|
|
994
1100
|
}
|
|
995
1101
|
const b = item.visualBounds ?? item.bounds;
|
|
996
1102
|
if (b) {
|
|
@@ -999,6 +1105,7 @@ function mergeOpfListShapes(items) {
|
|
|
999
1105
|
}
|
|
1000
1106
|
consumed.add(item);
|
|
1001
1107
|
}
|
|
1108
|
+
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.`});
|
|
1002
1109
|
const first = entries[0].item;
|
|
1003
1110
|
merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined, sources: entries.flatMap(({item}) => item.sources ?? [])});
|
|
1004
1111
|
}
|
|
@@ -1025,17 +1132,23 @@ function mergeAdjacentBulletShapes(items) {
|
|
|
1025
1132
|
return result;
|
|
1026
1133
|
}
|
|
1027
1134
|
|
|
1028
|
-
function payloadFromSlideItem(item) {
|
|
1135
|
+
function payloadFromSlideItem(item, report) {
|
|
1029
1136
|
if (item.payload) return item.payload;
|
|
1030
1137
|
if (item.kind === "text") {
|
|
1031
1138
|
if (item.paragraphs.some(p=>p.bullet)) {
|
|
1139
|
+
// RR-33: native auto-numbers come back as `numbering` and the entry `start` values that restore their numbers.
|
|
1140
|
+
const {numbering, starts, diagnostics} = deriveListNumbering(item.paragraphs);
|
|
1141
|
+
for (const diagnostic of diagnostics) report?.(diagnostic);
|
|
1032
1142
|
return {
|
|
1033
1143
|
type: "list",
|
|
1034
|
-
items: item.paragraphs.map((paragraph) =>
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1144
|
+
items: item.paragraphs.map((paragraph, index) => {
|
|
1145
|
+
const start = starts.get(index) === undefined ? {} : {start: starts.get(index)};
|
|
1146
|
+
return paragraph.level > 0
|
|
1147
|
+
? { text: paragraph.richText ?? paragraph.text, level: paragraph.level, ...(paragraph.description !== undefined ? {description: paragraph.description} : {}), ...start }
|
|
1148
|
+
: paragraph.description !== undefined ? { text: paragraph.richText ?? paragraph.text, description: paragraph.description, ...start }
|
|
1149
|
+
: start.start !== undefined ? { text: paragraph.richText ?? paragraph.text, ...start } : (paragraph.richText ?? paragraph.text);
|
|
1150
|
+
}),
|
|
1151
|
+
...(numbering !== undefined ? {numbering} : {})
|
|
1039
1152
|
};
|
|
1040
1153
|
}
|
|
1041
1154
|
return { type: "text", text: joinNativeParagraphs(item.paragraphs) };
|
|
@@ -1044,7 +1157,7 @@ function payloadFromSlideItem(item) {
|
|
|
1044
1157
|
return null;
|
|
1045
1158
|
}
|
|
1046
1159
|
|
|
1047
|
-
function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
1160
|
+
function chartFromRelationship(entries, slidePath, relationships, relId, report) {
|
|
1048
1161
|
const relationship = relationships.get(relId);
|
|
1049
1162
|
if (!relationship?.path || !entries[relationship.path]) return null;
|
|
1050
1163
|
const doc = parseRequiredXml(entries, relationship.path);
|
|
@@ -1061,7 +1174,13 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
1061
1174
|
|
|
1062
1175
|
const budget = { cells: 0 };
|
|
1063
1176
|
const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
|
|
1064
|
-
|
|
1177
|
+
// RR-35: axis titles, legend position and data labels read back into the chart's option fields.
|
|
1178
|
+
const withOptions = chart => {
|
|
1179
|
+
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'});
|
|
1180
|
+
for (const note of notes) report?.(note);
|
|
1181
|
+
return chart && Object.keys(options).length ? {...chart, ...options} : chart;
|
|
1182
|
+
};
|
|
1183
|
+
if (chartNode.type === 'scatter') return withOptions(scatterFromSeries(entries, relationship.path, series, budget, cachePath));
|
|
1065
1184
|
const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
|
|
1066
1185
|
const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
|
|
1067
1186
|
const values = series.map((entry, index) => {
|
|
@@ -1082,13 +1201,13 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
1082
1201
|
]);
|
|
1083
1202
|
}
|
|
1084
1203
|
|
|
1085
|
-
return {
|
|
1204
|
+
return withOptions({
|
|
1086
1205
|
type: chartNode.type,
|
|
1087
1206
|
data: {
|
|
1088
1207
|
columns: [readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names],
|
|
1089
1208
|
rows
|
|
1090
1209
|
}
|
|
1091
|
-
};
|
|
1210
|
+
});
|
|
1092
1211
|
}
|
|
1093
1212
|
|
|
1094
1213
|
// Scatter charts share X values (c:xVal) across Y series (c:yVal). OPF keeps
|
|
@@ -1114,7 +1233,7 @@ function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
|
|
|
1114
1233
|
// A chartex part (cx:chartSpace): the series layoutIds name the kept OPF chart
|
|
1115
1234
|
// type and the cached dimensions restore the category-major data, under the
|
|
1116
1235
|
// same bounds as the classic chart cache.
|
|
1117
|
-
function chartexFromRelationship(entries, relationships, relId) {
|
|
1236
|
+
function chartexFromRelationship(entries, relationships, relId, report) {
|
|
1118
1237
|
const relationship = relationships.get(relId);
|
|
1119
1238
|
if (!relationship?.path || !entries[relationship.path]) return null;
|
|
1120
1239
|
const doc = parseRequiredXml(entries, relationship.path);
|
|
@@ -1122,6 +1241,7 @@ function chartexFromRelationship(entries, relationships, relId) {
|
|
|
1122
1241
|
heading: readChartCategoryHeading(entries, relationship.path),
|
|
1123
1242
|
limits: {points: MAX_CHART_CACHE_POINTS, cells: MAX_CHART_CACHE_CELLS},
|
|
1124
1243
|
path: relationship.path,
|
|
1244
|
+
report,
|
|
1125
1245
|
invalid: (message, location) => {
|
|
1126
1246
|
throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: location ?? relationship.path});
|
|
1127
1247
|
}
|
|
@@ -1306,6 +1426,30 @@ function parseInput(input) {
|
|
|
1306
1426
|
throw new OPFPptxError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
|
|
1307
1427
|
}
|
|
1308
1428
|
|
|
1429
|
+
// Template variables (core resolveVariables, RR-32): a deck that uses content variables, or a template, is resolved to a
|
|
1430
|
+
// concrete deck before export, so the PPTX holds exactly the text, numbers, dates and images the preview shows. A template
|
|
1431
|
+
// exports with each unfilled variable's example (reported as variable-example-used); a normal deck with an unfilled
|
|
1432
|
+
// required variable is refused. Decks without content variables are returned untouched. The package stores the resolved
|
|
1433
|
+
// deck, not the template form, so fromPptx returns the filled deck.
|
|
1434
|
+
function resolveTemplateInput(presentation, options) {
|
|
1435
|
+
if (typeof opfCore.resolveVariables !== "function" || !isPlainObject(presentation)) return presentation;
|
|
1436
|
+
const values = options.variables;
|
|
1437
|
+
if (values !== undefined && !isPlainObject(values)) {
|
|
1438
|
+
throw new OPFPptxError("invalid-variables", "The variables option must be an object keyed by variable id.", {path: "options.variables"});
|
|
1439
|
+
}
|
|
1440
|
+
const template = opfCore.isTemplate(presentation);
|
|
1441
|
+
if (!template && !opfCore.hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
|
|
1442
|
+
const result = opfCore.resolveVariables(presentation, values ?? {}, {examples: template});
|
|
1443
|
+
const errors = result.diagnostics.filter(entry => entry.severity === "error");
|
|
1444
|
+
if (errors.length) {
|
|
1445
|
+
throw new OPFPptxError(errors.some(entry => entry.code === "variable-unfilled") ? "unfilled-variables" : "invalid-variables", errors[0].message, {issues: errors, path: errors[0].path});
|
|
1446
|
+
}
|
|
1447
|
+
for (const entry of result.diagnostics) {
|
|
1448
|
+
if (entry.code === "variable-example-used") options.onDiagnostic?.({code: "variable-example-used", path: entry.path, message: entry.message, id: entry.id});
|
|
1449
|
+
}
|
|
1450
|
+
return result.presentation;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1309
1453
|
function assertValidBoundary(presentation) {
|
|
1310
1454
|
const result = validatePresentation(presentation);
|
|
1311
1455
|
if (!result.valid) {
|
|
@@ -1489,7 +1633,7 @@ function configurePresentation(pptx, presentation, context) {
|
|
|
1489
1633
|
height: context.dimensions.heightInches
|
|
1490
1634
|
});
|
|
1491
1635
|
pptx.layout = context.layoutName;
|
|
1492
|
-
pptx.author = normalizeAuthor(presentation.author) ??
|
|
1636
|
+
pptx.author = normalizeAuthor(presentation.author) ?? DEFAULT_AUTHOR;
|
|
1493
1637
|
pptx.company = "OpenPresentation";
|
|
1494
1638
|
pptx.subject = presentation.description ?? "";
|
|
1495
1639
|
pptx.title = presentation.name ?? presentation.filename ?? "OPF Presentation";
|
|
@@ -1582,30 +1726,42 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1582
1726
|
...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
|
|
1583
1727
|
...nativeFontOptions(item.textStyle),
|
|
1584
1728
|
color: item.field === "tag" ? tagColor(itemContext) : itemContext.textColor,
|
|
1585
|
-
align: alignmentFor(item),
|
|
1729
|
+
align: physicalAlignment(alignmentFor(item), item.text.directions?.[0]),
|
|
1730
|
+
...(item.text.directions?.[0] === 'rtl' ? {rtlMode: true} : {}),
|
|
1586
1731
|
objectName,
|
|
1587
1732
|
breakLine: false
|
|
1588
1733
|
});
|
|
1589
1734
|
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
1590
|
-
await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options);
|
|
1735
|
+
await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options,item.payload?.numbering!==undefined);
|
|
1591
1736
|
} else if (item.field === "text" && item.text?.richLines) {
|
|
1592
|
-
const
|
|
1737
|
+
const logicalAlignment=item.text.placement?.alignment??alignmentFor(item)??'left';
|
|
1593
1738
|
for(const [index,line] of item.text.richLines.entries()){
|
|
1594
1739
|
const runs=line.fragments.map(fragment=>{
|
|
1595
1740
|
const runColor=exportColor(fragment.run.color,itemContext,itemContext.colors.text);
|
|
1596
1741
|
const color=nativeColor(fragment.run.color,runColor,itemContext,itemContext.textColor);
|
|
1597
|
-
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}};
|
|
1742
|
+
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}};
|
|
1598
1743
|
});
|
|
1599
|
-
|
|
1744
|
+
// Logical alignment (RR-05): a right-to-left paragraph starts at the right edge; every wrapped line shares its paragraph's direction.
|
|
1745
|
+
const placed=item.text.placement?.lines[index],alignment=placed?.alignment??physicalAlignment(logicalAlignment,item.text.directions?.[index]),factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
1600
1746
|
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};
|
|
1747
|
+
// PptxGenJS reads the paragraph direction from the first run's options, not from the shape options.
|
|
1748
|
+
if(item.text.directions?.[index]==='rtl')for(const run of runs)run.options.rtlMode=true;
|
|
1601
1749
|
if(runs.length)slide.addText(runs,{...textBoxOptions(area,itemContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
|
|
1602
1750
|
}
|
|
1603
1751
|
} else if (item.field === "text" && typeof item.value === "string") {
|
|
1604
|
-
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
|
|
1752
|
+
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])});
|
|
1605
1753
|
} else {
|
|
1606
|
-
|
|
1754
|
+
// 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.
|
|
1755
|
+
const mediaNames = item.caption ? new Set([...context.imagePlacements.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
|
|
1756
|
+
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);
|
|
1757
|
+
if (item.caption) {
|
|
1758
|
+
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));
|
|
1759
|
+
addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
|
|
1760
|
+
}
|
|
1607
1761
|
}
|
|
1608
1762
|
}
|
|
1763
|
+
// RR-34: the footnote area core reserved above the footer band (rule plus one tagged text box per listed line).
|
|
1764
|
+
addFootnotes(slide, geometry.footnotes, slideIndex, slideContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: `slides.${slideIndex}`}));
|
|
1609
1765
|
// Core composes furniture above all content and opf-render paints it last, so
|
|
1610
1766
|
// spTree order (PowerPoint's z-order) matches: header/footer parts come after
|
|
1611
1767
|
// every content item, and an overlapping footer stays visible over content.
|
|
@@ -1674,10 +1830,10 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
1674
1830
|
addTablePayload(slide, payload.table, region, context, options, path);
|
|
1675
1831
|
break;
|
|
1676
1832
|
case "code":
|
|
1677
|
-
addCodePayload(slide, payload.code, codeLayout, region, context, path);
|
|
1833
|
+
addCodePayload(slide, payload.code, codeLayout, region, context, path, options);
|
|
1678
1834
|
break;
|
|
1679
1835
|
case "metric":
|
|
1680
|
-
addMetricPayload(slide, payload.metric,metricLayout,context,path);
|
|
1836
|
+
addMetricPayload(slide, payload.metric,metricLayout,context,path,options);
|
|
1681
1837
|
break;
|
|
1682
1838
|
case "quote":
|
|
1683
1839
|
addQuotePayload(slide, payload.quote, quoteLayout, context, options, path);
|
|
@@ -1717,7 +1873,7 @@ function richLineRuns(line,color,native,context) {
|
|
|
1717
1873
|
const fallback=color.replace(/^#/,'');
|
|
1718
1874
|
return line.fragments.map(fragment=>{
|
|
1719
1875
|
const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context,native);
|
|
1720
|
-
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}};
|
|
1876
|
+
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}};
|
|
1721
1877
|
});
|
|
1722
1878
|
}
|
|
1723
1879
|
// Every native line of a list, marker paragraph or not, is named for the list's
|
|
@@ -1729,7 +1885,10 @@ function richLineRuns(line,color,native,context) {
|
|
|
1729
1885
|
// PptxGenJS embeds the media and its relationship through one picture per slide (OPF bullet image); packaging removes
|
|
1730
1886
|
// that picture and points each marker paragraph at the relationship with a:buBlip. An icon that cannot be embedded
|
|
1731
1887
|
// keeps the character bullets, as the preview does.
|
|
1732
|
-
async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,slideIndex,options) {
|
|
1888
|
+
async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,slideIndex,options,numbered=false) {
|
|
1889
|
+
// RR-33: a numbered list writes native auto-numbers (a:buAutoNum) at core's marker geometry. Core older than the numbering
|
|
1890
|
+
// release composes bullet markers, so nothing numbered can be written; say so instead of exporting bullets silently.
|
|
1891
|
+
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.'});
|
|
1733
1892
|
let picture=false;
|
|
1734
1893
|
if(bulletImage){
|
|
1735
1894
|
const part=`ppt/slides/slide${slideIndex+1}.xml`;
|
|
@@ -1750,16 +1909,22 @@ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,s
|
|
|
1750
1909
|
const addLines=(text,box,color,native,withBullet)=>{
|
|
1751
1910
|
text.richLines.forEach((line,index)=>{
|
|
1752
1911
|
const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
|
|
1753
|
-
|
|
1912
|
+
// 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
|
|
1913
|
+
// is rtl with its text aligned to the right; marL/indent are the start-side margin and hanging indent in PowerPoint.
|
|
1914
|
+
const entryRtl=entry.direction==='rtl';
|
|
1915
|
+
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};
|
|
1916
|
+
const lineDirection=(text.directions?.[index])??entry.direction;
|
|
1917
|
+
const lineAlign=physicalAlignment('left',lineDirection);
|
|
1754
1918
|
const objectName=`OPF list ${path} line ${lineNumber++}`;
|
|
1755
|
-
|
|
1756
|
-
|
|
1919
|
+
const number=first?entry.marker.number:undefined;
|
|
1920
|
+
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor),picture:number?false:picture});
|
|
1921
|
+
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};
|
|
1757
1922
|
const runs=richLineRuns(line,color,native,context);
|
|
1758
1923
|
if(!runs.length)runs.push({text:'',options:{}});
|
|
1759
1924
|
// Keep paragraph intent identical across runs. ZIP normalization below
|
|
1760
1925
|
// removes the duplicate paragraph-property nodes emitted by PptxGenJS.
|
|
1761
|
-
for(const run of runs)Object.assign(run.options,paragraph);
|
|
1762
|
-
slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:nativeFontOptions(entry.marker.style).fontFace,objectName,align:
|
|
1926
|
+
for(const run of runs)Object.assign(run.options,paragraph,lineDirection==='rtl'?{rtlMode:true}:{});
|
|
1927
|
+
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});
|
|
1763
1928
|
});
|
|
1764
1929
|
};
|
|
1765
1930
|
addLines(entry.text,entry.textBox,context.colors.text,context.textColor,true);
|
|
@@ -1873,7 +2038,7 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
|
|
|
1873
2038
|
if (prefix) context.logoPlaceholderTags.set(prefix, {v: 1, role: 'placeholder', slide: `slides.${slideIndex}`, path: logo.path});
|
|
1874
2039
|
return;
|
|
1875
2040
|
}
|
|
1876
|
-
context.logos.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box: region, path: logo.path, variant: logo.variant});
|
|
2041
|
+
context.logos.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box: region, path: logo.path, variant: logo.variant, anchor: logo.anchor ?? 'left'});
|
|
1877
2042
|
if (outcome.svg) context.svgPictures.set(`ppt/slides/slide${slideIndex + 1}.xml|${logoName()}`, outcome.svg);
|
|
1878
2043
|
slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
|
|
1879
2044
|
}
|
|
@@ -1929,9 +2094,14 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
|
|
|
1929
2094
|
// FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
|
|
1930
2095
|
// max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
|
|
1931
2096
|
// 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`).
|
|
2097
|
+
// 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
|
|
2098
|
+
// product: the floor rounds up (core `snapFontSizeUp`) and an unfloored request of 14 px rounds down (`snapFontSizeDown`).
|
|
2099
|
+
// Math.round differed from the preview by 0.01 pt on slide sizes whose scale is not a whole number (A4, Letter, 16:10).
|
|
1932
2100
|
function chartTextSize(context) {
|
|
1933
2101
|
const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
|
|
1934
|
-
|
|
2102
|
+
const floor = context.composition?.minFontSize ?? 16;
|
|
2103
|
+
const hundredths = Math.max(14, floor) * scale * 75;
|
|
2104
|
+
return floor >= 14 ? Math.ceil(hundredths - 1e-6) : Math.floor(hundredths + 1e-6);
|
|
1935
2105
|
}
|
|
1936
2106
|
|
|
1937
2107
|
// Every c:txPr of a generated classic chart carries the chart text size: PptxGenJS writes no size on the legend (PowerPoint's
|
|
@@ -1950,6 +2120,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1950
2120
|
return;
|
|
1951
2121
|
}
|
|
1952
2122
|
for (const {adaptation, message} of chartData.adaptations ?? []) options.onDiagnostic?.({code: "chart-data-adapted", path, message, adaptation});
|
|
2123
|
+
// RR-35: axis titles, legend position and data labels (core resolves them against what the chart type can show).
|
|
2124
|
+
const chartOptions = resolveChartOptionsFor(chart);
|
|
2125
|
+
reportChartOptionDiagnostics(chartOptions, path, options.onDiagnostic);
|
|
1953
2126
|
|
|
1954
2127
|
// Keep the resolved palette surface (including alpha) explicit in native
|
|
1955
2128
|
// chart/plot areas, so inherited labels are assessed against their own panel.
|
|
@@ -1960,19 +2133,27 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1960
2133
|
const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
|
|
1961
2134
|
const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
|
|
1962
2135
|
const textSize = chartTextSize(context);
|
|
1963
|
-
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec,textSize
|
|
2136
|
+
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec,textSize,
|
|
2137
|
+
options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chart.data.rows.length});
|
|
1964
2138
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
1965
|
-
const
|
|
2139
|
+
const preferredPalette = CHART_COLORS.map(color=>`#${color}`);
|
|
2140
|
+
const palette = (typeof opfComposition.chartPaletteForFill === "function" ? opfComposition.chartPaletteForFill(panelFill, preferredPalette) : preferredPalette.map(color=>chartColorForFill(panelFill,color))).map(color=>normalizeHex(color));
|
|
2141
|
+
context.chartPalettes.set(objectName,palette);
|
|
1966
2142
|
if (chartData.chartex) {
|
|
1967
2143
|
// The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
|
|
1968
2144
|
// PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
|
|
1969
2145
|
const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
|
|
1970
|
-
context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette, textSize});
|
|
2146
|
+
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});
|
|
1971
2147
|
if (chartData.chartex.layoutId === 'regionMap') {
|
|
1972
2148
|
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.`});
|
|
1973
2149
|
}
|
|
1974
2150
|
}
|
|
1975
2151
|
const percent = chartData.spec.grouping === 'percentStacked';
|
|
2152
|
+
// RR-36: a series has one colour, as the preview draws it (opf-render paints series j with palette colour j; only pie,
|
|
2153
|
+
// doughnut and treemap slices take a colour per category). PptxGenJS writes a c:dPt per point, cycling the palette, for a
|
|
2154
|
+
// bar chart with one series whenever chartColors is a custom array of more than one colour, so PowerPoint drew each
|
|
2155
|
+
// column of a single-series column or bar chart in a different colour. One colour for that series writes no c:dPt.
|
|
2156
|
+
const chartColors = chartData.type === 'bar' && chartData.series.length === 1 ? palette.slice(0, 1) : palette;
|
|
1976
2157
|
slide.addChart(chartData.type, chartData.series, {
|
|
1977
2158
|
objectName,
|
|
1978
2159
|
x: region.x,
|
|
@@ -1980,8 +2161,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1980
2161
|
w: region.w,
|
|
1981
2162
|
h: region.h,
|
|
1982
2163
|
showLegend: circular || chartData.series.length > (chartData.type === 'scatter' ? 2 : 1),
|
|
2164
|
+
...classicChartOptions(chartOptions, {labelColor, font: context.fonts.body, textSize}),
|
|
1983
2165
|
showTitle: false,
|
|
1984
|
-
chartColors
|
|
2166
|
+
chartColors,
|
|
1985
2167
|
chartArea: {fill:{...fill},roundedCorners:false},
|
|
1986
2168
|
// Paint alpha once in the chart area, rather than stacking two alpha fills.
|
|
1987
2169
|
plotArea: {fill:{color:null}},
|
|
@@ -2000,6 +2182,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2000
2182
|
valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
|
|
2001
2183
|
barDir: chartData.barDir,
|
|
2002
2184
|
barGrouping: chartData.barGrouping,
|
|
2185
|
+
// Right to left (RR-05): column, line and area charts reverse their categories (c:catAx orientation maxMin), which also moves the
|
|
2186
|
+
// value axis to the right, as the preview draws them. Bar charts keep their vertical category axis.
|
|
2187
|
+
...(context.direction === 'rtl' && ((chartData.type === 'bar' && chartData.barDir === 'col') || chartData.type === 'line' || chartData.type === 'area') ? { catAxisOrientation: 'maxMin' } : {}),
|
|
2003
2188
|
...(percent ? { valAxisLabelFormatCode: '0%' } : {}),
|
|
2004
2189
|
...(chartData.spec.markers === undefined ? {} : { lineDataSymbol: chartData.spec.markers ? 'circle' : 'none' }),
|
|
2005
2190
|
...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
|
|
@@ -2019,7 +2204,8 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
2019
2204
|
}
|
|
2020
2205
|
|
|
2021
2206
|
const layout = layoutTable(table, {x:region.x*96,y:region.y*96,width:region.w*96,height:region.h*96}, {
|
|
2022
|
-
scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path
|
|
2207
|
+
scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path,
|
|
2208
|
+
...(context.direction === 'rtl' ? {direction: 'rtl'} : {})
|
|
2023
2209
|
});
|
|
2024
2210
|
const columnCount = layout.columnCount;
|
|
2025
2211
|
const rows = layout.rows.map(row => row.cells.map(cell => {
|
|
@@ -2068,7 +2254,7 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
2068
2254
|
...(rich ? {bold:false,italic:false} : {}),
|
|
2069
2255
|
lineSpacing: fit.lineHeight * 0.75,
|
|
2070
2256
|
paraSpaceAfter: 0,
|
|
2071
|
-
align: cellStyle.align ?? context.contentAlignment,
|
|
2257
|
+
align: physicalAlignment(cellStyle.align ?? context.contentAlignment, cell.direction),
|
|
2072
2258
|
valign: cellStyle.verticalAlign ?? 'top',
|
|
2073
2259
|
...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
|
|
2074
2260
|
...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
|
|
@@ -2127,7 +2313,8 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
2127
2313
|
const link = config.links?.[sourceLineIndex];
|
|
2128
2314
|
if (fit.sourceLines?.[index]?.boundary === 'hard') sourceLineIndex += 1;
|
|
2129
2315
|
if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.quote&&!config.keepEmpty) continue;
|
|
2130
|
-
|
|
2316
|
+
// 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.
|
|
2317
|
+
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;
|
|
2131
2318
|
const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
|
|
2132
2319
|
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;
|
|
2133
2320
|
if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
|
|
@@ -2146,6 +2333,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
2146
2333
|
...textBoxOptions(area, context, fit.fontSize * .75),
|
|
2147
2334
|
...nativeFontOptions(style),
|
|
2148
2335
|
color: lineColor, align: alignment,
|
|
2336
|
+
...(fit.directions?.[index]==='rtl'?{rtlMode:true}:{}),
|
|
2149
2337
|
// Reuse the frame's existing relationship to cover blank caption boxes.
|
|
2150
2338
|
// PptxGenJS also inherits it into runs; keep their native text styling.
|
|
2151
2339
|
hyperlink: config.hyperlink,
|
|
@@ -2214,7 +2402,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
2214
2402
|
|
|
2215
2403
|
const bulletImageName = () => 'OPF bullet image';
|
|
2216
2404
|
|
|
2217
|
-
function addCodePayload(slide, value, layout, region, context, path) {
|
|
2405
|
+
function addCodePayload(slide, value, layout, region, context, path, options) {
|
|
2218
2406
|
if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
|
|
2219
2407
|
for (const part of layout.parts) {
|
|
2220
2408
|
// XML 1.0 Char excludes controls and unpaired UTF-16 surrogates. The u flag
|
|
@@ -2225,6 +2413,7 @@ function addCodePayload(slide, value, layout, region, context, path) {
|
|
|
2225
2413
|
const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
|
|
2226
2414
|
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});
|
|
2227
2415
|
context.codeTags.set(panelName,codeManifest(value,layout,group));
|
|
2416
|
+
const syntax = codeSyntax(value, layout, context, path, options);
|
|
2228
2417
|
slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
|
|
2229
2418
|
for (const [partIndex,part] of layout.parts.entries()) {
|
|
2230
2419
|
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});
|
|
@@ -2232,19 +2421,37 @@ function addCodePayload(slide, value, layout, region, context, path) {
|
|
|
2232
2421
|
const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
|
|
2233
2422
|
const objectName = `OPF code ${group} ${part.role} line ${index+1}`;
|
|
2234
2423
|
context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
|
|
2235
|
-
|
|
2424
|
+
// RR-07: the same token ranges and palette the preview paints; the text of the runs is the line text, unchanged.
|
|
2425
|
+
const runs = syntax && part.role==='body' ? opfCore.codeLineRuns(syntax.tokens,line.start,line.end,part.text) : undefined;
|
|
2426
|
+
const lineText = part.text.slice(line.start,line.end);
|
|
2427
|
+
const lineOptions = {
|
|
2236
2428
|
...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),
|
|
2237
2429
|
...nativeFontOptions(part.style),
|
|
2238
2430
|
color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
2239
2431
|
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
2240
|
-
}
|
|
2432
|
+
};
|
|
2433
|
+
// 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.
|
|
2434
|
+
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);
|
|
2241
2435
|
}
|
|
2242
2436
|
}
|
|
2243
2437
|
}
|
|
2244
2438
|
|
|
2245
|
-
|
|
2439
|
+
// Token ranges and palette for the code body, from core (the preview calls the same functions); undefined for plain code.
|
|
2440
|
+
function codeSyntax(value, layout, context, path, options) {
|
|
2441
|
+
const body = layout.parts.find(part => part.role === 'body');
|
|
2442
|
+
const language = typeof value?.language === 'string' ? value.language : layout.parts.find(part => part.role === 'language')?.text;
|
|
2443
|
+
if (!body || !language) return undefined;
|
|
2444
|
+
if (typeof opfCore.tokenizeCode !== 'function') return undefined;
|
|
2445
|
+
const tokens = opfCore.tokenizeCode(body.text, language);
|
|
2446
|
+
return tokens.length ? {tokens, palette: opfCore.codeSyntaxPaletteForScheme(context.colorScheme)} : undefined;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
function addMetricPayload(slide,value,layout,context,path,options) {
|
|
2246
2450
|
if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
|
|
2247
2451
|
const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
|
|
2452
|
+
// RR-07: a trend colours the trend and delta text and adds one native arrow shape, from core's accepted geometry.
|
|
2453
|
+
const trendMark=typeof opfCore.metricTrendMark==='function'?opfCore.metricTrendMark(layout,{background:`#${normalizeHex(context.colors.background)}`}):undefined;
|
|
2454
|
+
const partColor=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color.slice(1):part.role==='value'?context.colors.accent:context.textColor;
|
|
2248
2455
|
for (const [partIndex,part] of layout.parts.entries()) {
|
|
2249
2456
|
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
2250
2457
|
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});
|
|
@@ -2262,11 +2469,17 @@ function addMetricPayload(slide,value,layout,context,path) {
|
|
|
2262
2469
|
slide.addText(part.text.slice(line.start,line.end),{
|
|
2263
2470
|
...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),
|
|
2264
2471
|
...nativeFontOptions(part.style),
|
|
2265
|
-
color:part.
|
|
2472
|
+
color:partColor(part),align:layout.alignment,...(part.fit.directions?.[index]==='rtl'?{rtlMode:true}:{}),fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
2266
2473
|
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
2267
2474
|
});
|
|
2268
2475
|
}
|
|
2269
2476
|
}
|
|
2477
|
+
if(trendMark) {
|
|
2478
|
+
const objectName=`OPF metric ${group} trend mark`;
|
|
2479
|
+
context.metricTags.set(objectName,{v:1,group,role:'mark'});
|
|
2480
|
+
context.metricDescriptions.set(objectName,trendMark.ariaLabel);
|
|
2481
|
+
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});
|
|
2482
|
+
}
|
|
2270
2483
|
}
|
|
2271
2484
|
|
|
2272
2485
|
function addQuotePayload(slide, value, layout, context, options, path) {
|
|
@@ -2492,6 +2705,9 @@ function textBoxOptions(region, context, fontSize) {
|
|
|
2492
2705
|
};
|
|
2493
2706
|
}
|
|
2494
2707
|
|
|
2708
|
+
// The private text helpers annotation-export.js writes captions and footnote lines with (RR-34).
|
|
2709
|
+
const exportHelpers = {textBoxOptions, nativeFontOptions, exportColor, nativeColor};
|
|
2710
|
+
|
|
2495
2711
|
function textRuns(value, context, fallbackFontSize) {
|
|
2496
2712
|
const runs = Array.isArray(value) ? value : [value];
|
|
2497
2713
|
return runs.map((run) => {
|
|
@@ -2978,13 +3194,14 @@ async function normalizePptxZip(raw, context) {
|
|
|
2978
3194
|
}
|
|
2979
3195
|
|
|
2980
3196
|
attachCodeTags(entries, context.codeTags);
|
|
2981
|
-
attachMetricTags(entries,context.metricTags);
|
|
3197
|
+
attachMetricTags(entries,context.metricTags,context.metricDescriptions);
|
|
2982
3198
|
attachCardTags(entries,context.cardTags);
|
|
2983
3199
|
attachMediaTags(entries,context.mediaTags);
|
|
2984
3200
|
attachHeadingTags(entries,context.headingTags);
|
|
2985
3201
|
attachPlainTextTags(entries,context.plainTextTags);
|
|
2986
3202
|
attachTimelineTags(entries,context.timelineTags);
|
|
2987
3203
|
attachQuoteTags(entries,context.quoteTags);
|
|
3204
|
+
attachAnnotationTags(entries,context);
|
|
2988
3205
|
attachFurnitureFields(entries,context.furnitureFields);
|
|
2989
3206
|
attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests,context.furnitureLogoTags);
|
|
2990
3207
|
// RR-11: the recorded footer shapes become PowerPoint's date, footer and slide-number placeholders.
|
|
@@ -3006,6 +3223,9 @@ async function normalizePptxZip(raw, context) {
|
|
|
3006
3223
|
// Normalize only our generated chart text styles; point/series fills stay intact.
|
|
3007
3224
|
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>`)));
|
|
3008
3225
|
entries[chartPart]=encodeText(applyChartTextSize(decodeText(entries[chartPart]),textSize));
|
|
3226
|
+
// RR-35: data labels last, so their own text colours (contrast inside a mark) are not normalized away.
|
|
3227
|
+
const {options:chartOptions,kind:optionKind,pointCount}=context.chartHeadings.get(name);
|
|
3228
|
+
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}));
|
|
3009
3229
|
}
|
|
3010
3230
|
}
|
|
3011
3231
|
applyChartFonts(entries,context.chartFonts,parseRelationships);
|
|
@@ -3103,6 +3323,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
3103
3323
|
}
|
|
3104
3324
|
|
|
3105
3325
|
writeNativeFurnitureMasters(output, context);
|
|
3326
|
+
giveNotesMastersOwnThemes(output);
|
|
3106
3327
|
finalizeFontsUsed(output);
|
|
3107
3328
|
// Document references record evidence from the final normalized parts.
|
|
3108
3329
|
if (context.documentProvenance) {
|
|
@@ -3181,6 +3402,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
3181
3402
|
if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path)) xml = themeMasterBulletFonts(xml);
|
|
3182
3403
|
if (context.masterBackground && path === 'ppt/slideMasters/slideMaster1.xml') xml = writeMasterBackground(xml, context.masterBackground);
|
|
3183
3404
|
if (context.masterBackground && /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path)) xml = inheritLayoutBackground(xml);
|
|
3405
|
+
if (path === 'ppt/presentation.xml') xml = presentationInSchemaOrder(xml);
|
|
3184
3406
|
if (path === 'ppt/theme/theme1.xml') xml = writeThemeColors(xml, {colors: context.themeColors, schemeName: context.schemeName, themeName: context.themeName});
|
|
3185
3407
|
if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
|
|
3186
3408
|
xml = writeLinkSentinels(xml, context.linkSentinels);
|
|
@@ -3302,6 +3524,9 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
3302
3524
|
if(!marker)return shape;
|
|
3303
3525
|
if(marker.picture&&bulletRelationship)return shape.replace(/<a:buChar\b[^>]*\/>/g,`<a:buBlip><a:blip r:embed="${bulletRelationship}"/></a:buBlip>`);
|
|
3304
3526
|
const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
3527
|
+
// RR-33: an auto-number takes the measured size, colour and family like a character bullet, and startAt only when it is not 1.
|
|
3528
|
+
const autoNumber=/<a:buSzPct val="100000"\/><a:buFont typeface="\+mj-lt"\/><a:buAutoNum type="([A-Za-z]+)" startAt="(\d+)"\/>/;
|
|
3529
|
+
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}"`}/>`);
|
|
3305
3530
|
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}"/>`);
|
|
3306
3531
|
});
|
|
3307
3532
|
// PptxGenJS gives shapes no alternative text. An unavailable image's panel
|
|
@@ -3322,6 +3547,8 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
3322
3547
|
}
|
|
3323
3548
|
// theme1.xml: FF-24 colors and names above, then FF-07 fonts here; they touch disjoint elements.
|
|
3324
3549
|
if (context.scriptFonts) xml = partScriptFonts(path, xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
|
|
3550
|
+
else if (/^ppt\/theme\/theme\d+\.xml$/.test(path)) xml = themeEastAsianFromLatin(xml);
|
|
3551
|
+
if (/^ppt\/(?:slides\/slide|notesSlides\/notesSlide)\d+\.xml$/.test(path)) xml = stripRunScriptFonts(xml);
|
|
3325
3552
|
return encodeText(normalizePartReferences(xml, renameMaps));
|
|
3326
3553
|
}
|
|
3327
3554
|
return bytes;
|
|
@@ -3340,6 +3567,16 @@ function themeMasterBulletFonts(xml) {
|
|
|
3340
3567
|
bodyStyle.split(VENDOR_MASTER_BULLET_FONT).join(THEME_MINOR_BULLET_FONT));
|
|
3341
3568
|
}
|
|
3342
3569
|
|
|
3570
|
+
// FF-05: PptxGenJS 4.0.1 writes p:notesMasterIdLst after p:sldIdLst, but CT_Presentation is a sequence
|
|
3571
|
+
// (sldMasterIdLst, notesMasterIdLst, handoutMasterIdLst, sldIdLst, sldSz, ...). PowerPoint reads the out-of-order
|
|
3572
|
+
// list as no notes master and lists a default-theme font (Aptos) in Presentation.Fonts; schema order restores
|
|
3573
|
+
// the exporter's own notes master. Only the list's position changes.
|
|
3574
|
+
function presentationInSchemaOrder(xml) {
|
|
3575
|
+
const notes = /<p:notesMasterIdLst>[\s\S]*?<\/p:notesMasterIdLst>/.exec(xml)?.[0];
|
|
3576
|
+
if (!notes || xml.indexOf(notes) < xml.indexOf('</p:sldMasterIdLst>')) return xml;
|
|
3577
|
+
return xml.replace(notes, '').replace('</p:sldMasterIdLst>', `</p:sldMasterIdLst>${notes}`);
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3343
3580
|
function isXmlPart(path) {
|
|
3344
3581
|
return path.endsWith(".xml") || path.endsWith(".rels") || path === "[Content_Types].xml";
|
|
3345
3582
|
}
|
|
@@ -3533,7 +3770,7 @@ function numericValue(value) {
|
|
|
3533
3770
|
|
|
3534
3771
|
function normalizeAuthor(author) {
|
|
3535
3772
|
if (typeof author === "string") return author;
|
|
3536
|
-
if (Array.isArray(author)) return author
|
|
3773
|
+
if (Array.isArray(author)) return joinAuthors(author);
|
|
3537
3774
|
return null;
|
|
3538
3775
|
}
|
|
3539
3776
|
|