@openpresentation/opf-pptx 0.11.9 → 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 +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 +289 -67
- package/dist/logo-provenance.js +4 -3
- package/dist/metric-provenance.js +6 -3
- 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/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,27 @@
|
|
|
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';
|
|
20
27
|
import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, writeNativeMasters} from './native-furniture.js';
|
|
@@ -27,11 +34,13 @@ import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from
|
|
|
27
34
|
import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
|
|
28
35
|
import {importBackground} from './background-import.js';
|
|
29
36
|
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';
|
|
37
|
+
import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, reconcileLanguage} from './script-fonts.js';
|
|
31
38
|
import { webpToPng, svgToPng, readLocalFile } from '#image-fallback';
|
|
32
39
|
import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
|
|
33
40
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
34
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";
|
|
35
44
|
import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
|
|
36
45
|
import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
|
|
37
46
|
import { unzipSync, zipSync } from "fflate";
|
|
@@ -40,6 +49,11 @@ import {
|
|
|
40
49
|
catalogs as bundledCatalogs,
|
|
41
50
|
validatePresentation
|
|
42
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";
|
|
43
57
|
|
|
44
58
|
export {checkPptxTypefaces, inventoryPptxTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
|
|
45
59
|
export {DEFAULT_SIGNAL_LIMITS, SIGNALS_VERSION, isMonospaceFamily} from './import-signals.js';
|
|
@@ -205,7 +219,7 @@ export async function toPptx(input, options = {}) {
|
|
|
205
219
|
if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
|
|
206
220
|
throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
|
|
207
221
|
}
|
|
208
|
-
const presentation = parseInput(input);
|
|
222
|
+
const presentation = resolveTemplateInput(parseInput(input), options);
|
|
209
223
|
assertValidBoundary(presentation);
|
|
210
224
|
options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement), svgRasters: new Map()};
|
|
211
225
|
|
|
@@ -238,9 +252,13 @@ export async function toPptx(input, options = {}) {
|
|
|
238
252
|
context.nativeFurniture = new Map();
|
|
239
253
|
context.hostDate = options.date;
|
|
240
254
|
context.codeTags = new Map();
|
|
255
|
+
context.captionTags = new Map();
|
|
256
|
+
context.footnoteTags = new Map();
|
|
241
257
|
context.metricTags = new Map();
|
|
258
|
+
context.metricDescriptions = new Map();
|
|
242
259
|
context.chartHeadings = new Map();
|
|
243
260
|
context.chartFonts = new Map();
|
|
261
|
+
context.chartPalettes = new Map();
|
|
244
262
|
context.chartex = new Map();
|
|
245
263
|
context.imageFormat = options.imageFormat ?? "compatible";
|
|
246
264
|
// Chartex export mode (FF-22b): 'auto' (default) writes native chartex parts for the constructs the native PowerPoint
|
|
@@ -327,7 +345,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
327
345
|
});
|
|
328
346
|
if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
|
|
329
347
|
if (core.description) imported.description = core.description;
|
|
330
|
-
if (core.author) imported.author = core.author;
|
|
348
|
+
if (core.author) imported.author = splitAuthors(core.author);
|
|
331
349
|
const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
|
|
332
350
|
const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
|
|
333
351
|
if (Object.keys(design).length) imported.design = design;
|
|
@@ -353,7 +371,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
353
371
|
const mediaRegistry = Object.create(null);
|
|
354
372
|
for (let index = 0; index < slidePaths.length; index += 1) {
|
|
355
373
|
furnitureContexts[index].mediaRegistry = mediaRegistry;
|
|
356
|
-
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]));
|
|
357
375
|
}
|
|
358
376
|
// Native sections (PowerPoint's own section list, `Default Section` = none)
|
|
359
377
|
// are reconciled with the footer text and the stored value in restoreDocumentProvenance.
|
|
@@ -396,7 +414,11 @@ export async function fromPptx(input, options = {}) {
|
|
|
396
414
|
report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
|
|
397
415
|
}
|
|
398
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);
|
|
399
420
|
restoreLogoFallback(imported, furnitureContexts);
|
|
421
|
+
applyRunColors(imported, slideProvenance, restoredGroups, report, options);
|
|
400
422
|
if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
|
|
401
423
|
languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
|
|
402
424
|
|
|
@@ -418,6 +440,24 @@ export async function fromPptx(input, options = {}) {
|
|
|
418
440
|
return {document: imported, signals};
|
|
419
441
|
}
|
|
420
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);
|
|
459
|
+
}
|
|
460
|
+
|
|
421
461
|
// A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the logo only when nothing else
|
|
422
462
|
// did: the stored document and slide design (or the organization) win and carry LogoSet variants, so a package exported
|
|
423
463
|
// without provenance still keeps its logo. A slide-level logo path restores slide design; every other path the deck's.
|
|
@@ -639,10 +679,32 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
639
679
|
item.text = current.map(paragraph => paragraph.text).join('\n');
|
|
640
680
|
}
|
|
641
681
|
}
|
|
642
|
-
const
|
|
643
|
-
|
|
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 ?? []}))
|
|
644
685
|
.filter((entry) => entry.payload);
|
|
645
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
|
+
}
|
|
646
708
|
if (blocks.length > 0) slide.blocks = blocks;
|
|
647
709
|
// Native bounds of each block in reference px, for content topology matching (document provenance).
|
|
648
710
|
// Which native shapes fed each field and block (import signals); never part of the document.
|
|
@@ -664,6 +726,12 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
664
726
|
const shapes = nativeContext.shapes;
|
|
665
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.'});
|
|
666
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
|
+
});
|
|
667
735
|
// Native shape keys (indexes in nativeTextShapes / nativePictures / frame order) for import signals.
|
|
668
736
|
const shapeKeys = new Map(shapes.map((shape, index) => [shape, `sp:${index}`]));
|
|
669
737
|
const sourcesOf = group => group.flatMap(shape => shapeKeys.has(shape) ? [shapeKeys.get(shape)] : []);
|
|
@@ -681,6 +749,9 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
681
749
|
const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
682
750
|
const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
683
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;
|
|
684
755
|
for(const group of [...headings.items,...plainText.items]) {
|
|
685
756
|
const item=importShape(group.shapes[0],dimensions,group.paragraphs,true);
|
|
686
757
|
if(item){
|
|
@@ -707,6 +778,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
707
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;
|
|
708
779
|
if (role) roles.set(shapeKeys.get(shape), role);
|
|
709
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; }
|
|
710
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;
|
|
711
783
|
const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
|
|
712
784
|
const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
|
|
@@ -718,12 +790,14 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
718
790
|
}
|
|
719
791
|
|
|
720
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}`});
|
|
721
795
|
const tables = frames.some(frame => frame['a:graphic']?.['a:graphicData']?.['a:tbl'])
|
|
722
796
|
? importTableFrames(slidePath, {
|
|
723
797
|
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) : [];
|
|
798
|
+
}, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions, {rtlDeck: options.rtlDeck === true}) : [];
|
|
725
799
|
for (const [index, frame] of frames.entries()) {
|
|
726
|
-
const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
|
|
800
|
+
const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index], chartReport(`charts.${index}`));
|
|
727
801
|
if (item) items.push({...item, sources: [`frame:${index}`]});
|
|
728
802
|
}
|
|
729
803
|
// A chartex chart is an mc:AlternateContent: the choice frame references the cx:chartSpace part; the fallback is a classic chart frame
|
|
@@ -731,8 +805,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
731
805
|
for (const [alternateIndex, alternate] of asArray(tree?.["mc:AlternateContent"]).entries()) {
|
|
732
806
|
const choice = asArray(alternate?.["mc:Choice"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
|
|
733
807
|
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;
|
|
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;
|
|
736
810
|
if (item && item.kind !== "unknown") items.push({...item, sources: [`alt:${alternateIndex}`]});
|
|
737
811
|
}
|
|
738
812
|
|
|
@@ -774,7 +848,7 @@ function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txB
|
|
|
774
848
|
};
|
|
775
849
|
}
|
|
776
850
|
|
|
777
|
-
function importGraphicFrame(entries, frame, slidePath, relationships, importedTable) {
|
|
851
|
+
function importGraphicFrame(entries, frame, slidePath, relationships, importedTable, report) {
|
|
778
852
|
const bounds = shapeBounds(frame["p:xfrm"]);
|
|
779
853
|
const name = scalarText(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]?.name).trim();
|
|
780
854
|
const graphicData = frame["a:graphic"]?.["a:graphicData"];
|
|
@@ -795,8 +869,8 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
|
|
|
795
869
|
const chartRelId = graphicData?.["c:chart"]?.["r:id"] ?? chartExRelId;
|
|
796
870
|
if (chartRelId) {
|
|
797
871
|
const chart = chartExRelId
|
|
798
|
-
? chartexFromRelationship(entries, relationships, chartExRelId)
|
|
799
|
-
: chartFromRelationship(entries, slidePath, relationships, chartRelId);
|
|
872
|
+
? chartexFromRelationship(entries, relationships, chartExRelId, report)
|
|
873
|
+
: chartFromRelationship(entries, slidePath, relationships, chartRelId, report);
|
|
800
874
|
return {
|
|
801
875
|
kind: "chart",
|
|
802
876
|
bounds,
|
|
@@ -879,11 +953,17 @@ function importPicture(entries, picture, slidePath, relationships, report) {
|
|
|
879
953
|
}
|
|
880
954
|
|
|
881
955
|
function readParagraphs(txBody) {
|
|
882
|
-
return asArray(txBody?.["a:p"])
|
|
956
|
+
return withDisplayedNumbers(asArray(txBody?.["a:p"])
|
|
883
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"])]);
|
|
884
963
|
const runs = [
|
|
885
964
|
...asArray(paragraph?.["a:r"]),
|
|
886
|
-
...asArray(paragraph?.["a:fld"])
|
|
965
|
+
...asArray(paragraph?.["a:fld"]),
|
|
966
|
+
...fallbackRuns
|
|
887
967
|
];
|
|
888
968
|
const texts = [];
|
|
889
969
|
const sizes = [];
|
|
@@ -893,13 +973,29 @@ function readParagraphs(txBody) {
|
|
|
893
973
|
const size = Number(run?.["a:rPr"]?.sz);
|
|
894
974
|
if (Number.isFinite(size)) sizes.push(size / 100);
|
|
895
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);
|
|
896
978
|
return {
|
|
897
979
|
text: texts.join(""),
|
|
898
980
|
bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buBlip"] !== undefined || props?.["a:buAutoNum"] !== undefined),
|
|
899
981
|
level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
|
|
900
|
-
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])
|
|
901
985
|
};
|
|
902
|
-
});
|
|
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("");
|
|
903
999
|
}
|
|
904
1000
|
|
|
905
1001
|
function shapePlaceholderType(shape) {
|
|
@@ -966,7 +1062,10 @@ function joinRich(a, b) {
|
|
|
966
1062
|
}
|
|
967
1063
|
return joined;
|
|
968
1064
|
}
|
|
969
|
-
|
|
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 = () => {}) {
|
|
970
1069
|
const lists = new Map();
|
|
971
1070
|
for (const item of items) {
|
|
972
1071
|
const match = /^OPF list (.+) line (\d+)$/.exec(item.name ?? '');
|
|
@@ -980,17 +1079,23 @@ function mergeOpfListShapes(items) {
|
|
|
980
1079
|
entries.sort((a, b) => a.line - b.line);
|
|
981
1080
|
if (!entries[0].item.paragraphs[0].bullet) continue;
|
|
982
1081
|
const paragraphs = [];
|
|
1082
|
+
const recorded = breaks?.get(path), applied = new Set();
|
|
983
1083
|
let bounds, size = 0;
|
|
984
|
-
for (const {item} of entries) {
|
|
1084
|
+
for (const {item, line} of entries) {
|
|
985
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]);
|
|
986
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 */ }
|
|
987
1090
|
else if (paragraph.maxFontSize < size - 0.01) {
|
|
988
1091
|
const text = paragraph.richText ?? paragraph.text;
|
|
989
|
-
|
|
1092
|
+
if (current.description === undefined) current.description = text;
|
|
1093
|
+
else { current.description = join(current.description, text); applied.add(line); }
|
|
990
1094
|
} else {
|
|
991
1095
|
const before = current.text;
|
|
992
|
-
current.text += paragraph.text;
|
|
993
|
-
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);
|
|
994
1099
|
}
|
|
995
1100
|
const b = item.visualBounds ?? item.bounds;
|
|
996
1101
|
if (b) {
|
|
@@ -999,6 +1104,7 @@ function mergeOpfListShapes(items) {
|
|
|
999
1104
|
}
|
|
1000
1105
|
consumed.add(item);
|
|
1001
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.`});
|
|
1002
1108
|
const first = entries[0].item;
|
|
1003
1109
|
merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined, sources: entries.flatMap(({item}) => item.sources ?? [])});
|
|
1004
1110
|
}
|
|
@@ -1025,17 +1131,23 @@ function mergeAdjacentBulletShapes(items) {
|
|
|
1025
1131
|
return result;
|
|
1026
1132
|
}
|
|
1027
1133
|
|
|
1028
|
-
function payloadFromSlideItem(item) {
|
|
1134
|
+
function payloadFromSlideItem(item, report) {
|
|
1029
1135
|
if (item.payload) return item.payload;
|
|
1030
1136
|
if (item.kind === "text") {
|
|
1031
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);
|
|
1032
1141
|
return {
|
|
1033
1142
|
type: "list",
|
|
1034
|
-
items: item.paragraphs.map((paragraph) =>
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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} : {})
|
|
1039
1151
|
};
|
|
1040
1152
|
}
|
|
1041
1153
|
return { type: "text", text: joinNativeParagraphs(item.paragraphs) };
|
|
@@ -1044,7 +1156,7 @@ function payloadFromSlideItem(item) {
|
|
|
1044
1156
|
return null;
|
|
1045
1157
|
}
|
|
1046
1158
|
|
|
1047
|
-
function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
1159
|
+
function chartFromRelationship(entries, slidePath, relationships, relId, report) {
|
|
1048
1160
|
const relationship = relationships.get(relId);
|
|
1049
1161
|
if (!relationship?.path || !entries[relationship.path]) return null;
|
|
1050
1162
|
const doc = parseRequiredXml(entries, relationship.path);
|
|
@@ -1061,7 +1173,13 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
1061
1173
|
|
|
1062
1174
|
const budget = { cells: 0 };
|
|
1063
1175
|
const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
|
|
1064
|
-
|
|
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));
|
|
1065
1183
|
const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
|
|
1066
1184
|
const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
|
|
1067
1185
|
const values = series.map((entry, index) => {
|
|
@@ -1082,13 +1200,13 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
1082
1200
|
]);
|
|
1083
1201
|
}
|
|
1084
1202
|
|
|
1085
|
-
return {
|
|
1203
|
+
return withOptions({
|
|
1086
1204
|
type: chartNode.type,
|
|
1087
1205
|
data: {
|
|
1088
1206
|
columns: [readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names],
|
|
1089
1207
|
rows
|
|
1090
1208
|
}
|
|
1091
|
-
};
|
|
1209
|
+
});
|
|
1092
1210
|
}
|
|
1093
1211
|
|
|
1094
1212
|
// Scatter charts share X values (c:xVal) across Y series (c:yVal). OPF keeps
|
|
@@ -1114,7 +1232,7 @@ function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
|
|
|
1114
1232
|
// A chartex part (cx:chartSpace): the series layoutIds name the kept OPF chart
|
|
1115
1233
|
// type and the cached dimensions restore the category-major data, under the
|
|
1116
1234
|
// same bounds as the classic chart cache.
|
|
1117
|
-
function chartexFromRelationship(entries, relationships, relId) {
|
|
1235
|
+
function chartexFromRelationship(entries, relationships, relId, report) {
|
|
1118
1236
|
const relationship = relationships.get(relId);
|
|
1119
1237
|
if (!relationship?.path || !entries[relationship.path]) return null;
|
|
1120
1238
|
const doc = parseRequiredXml(entries, relationship.path);
|
|
@@ -1122,6 +1240,7 @@ function chartexFromRelationship(entries, relationships, relId) {
|
|
|
1122
1240
|
heading: readChartCategoryHeading(entries, relationship.path),
|
|
1123
1241
|
limits: {points: MAX_CHART_CACHE_POINTS, cells: MAX_CHART_CACHE_CELLS},
|
|
1124
1242
|
path: relationship.path,
|
|
1243
|
+
report,
|
|
1125
1244
|
invalid: (message, location) => {
|
|
1126
1245
|
throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: location ?? relationship.path});
|
|
1127
1246
|
}
|
|
@@ -1306,6 +1425,30 @@ function parseInput(input) {
|
|
|
1306
1425
|
throw new OPFPptxError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
|
|
1307
1426
|
}
|
|
1308
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
|
+
|
|
1309
1452
|
function assertValidBoundary(presentation) {
|
|
1310
1453
|
const result = validatePresentation(presentation);
|
|
1311
1454
|
if (!result.valid) {
|
|
@@ -1489,7 +1632,7 @@ function configurePresentation(pptx, presentation, context) {
|
|
|
1489
1632
|
height: context.dimensions.heightInches
|
|
1490
1633
|
});
|
|
1491
1634
|
pptx.layout = context.layoutName;
|
|
1492
|
-
pptx.author = normalizeAuthor(presentation.author) ??
|
|
1635
|
+
pptx.author = normalizeAuthor(presentation.author) ?? DEFAULT_AUTHOR;
|
|
1493
1636
|
pptx.company = "OpenPresentation";
|
|
1494
1637
|
pptx.subject = presentation.description ?? "";
|
|
1495
1638
|
pptx.title = presentation.name ?? presentation.filename ?? "OPF Presentation";
|
|
@@ -1582,30 +1725,42 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1582
1725
|
...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
|
|
1583
1726
|
...nativeFontOptions(item.textStyle),
|
|
1584
1727
|
color: item.field === "tag" ? tagColor(itemContext) : itemContext.textColor,
|
|
1585
|
-
align: alignmentFor(item),
|
|
1728
|
+
align: physicalAlignment(alignmentFor(item), item.text.directions?.[0]),
|
|
1729
|
+
...(item.text.directions?.[0] === 'rtl' ? {rtlMode: true} : {}),
|
|
1586
1730
|
objectName,
|
|
1587
1731
|
breakLine: false
|
|
1588
1732
|
});
|
|
1589
1733
|
} 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);
|
|
1734
|
+
await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options,item.payload?.numbering!==undefined);
|
|
1591
1735
|
} else if (item.field === "text" && item.text?.richLines) {
|
|
1592
|
-
const
|
|
1736
|
+
const logicalAlignment=item.text.placement?.alignment??alignmentFor(item)??'left';
|
|
1593
1737
|
for(const [index,line] of item.text.richLines.entries()){
|
|
1594
1738
|
const runs=line.fragments.map(fragment=>{
|
|
1595
1739
|
const runColor=exportColor(fragment.run.color,itemContext,itemContext.colors.text);
|
|
1596
1740
|
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}};
|
|
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}};
|
|
1598
1742
|
});
|
|
1599
|
-
|
|
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;
|
|
1600
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;
|
|
1601
1748
|
if(runs.length)slide.addText(runs,{...textBoxOptions(area,itemContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
|
|
1602
1749
|
}
|
|
1603
1750
|
} 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)});
|
|
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])});
|
|
1605
1752
|
} else {
|
|
1606
|
-
|
|
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
|
+
}
|
|
1607
1760
|
}
|
|
1608
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}`}));
|
|
1609
1764
|
// Core composes furniture above all content and opf-render paints it last, so
|
|
1610
1765
|
// spTree order (PowerPoint's z-order) matches: header/footer parts come after
|
|
1611
1766
|
// every content item, and an overlapping footer stays visible over content.
|
|
@@ -1674,10 +1829,10 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
1674
1829
|
addTablePayload(slide, payload.table, region, context, options, path);
|
|
1675
1830
|
break;
|
|
1676
1831
|
case "code":
|
|
1677
|
-
addCodePayload(slide, payload.code, codeLayout, region, context, path);
|
|
1832
|
+
addCodePayload(slide, payload.code, codeLayout, region, context, path, options);
|
|
1678
1833
|
break;
|
|
1679
1834
|
case "metric":
|
|
1680
|
-
addMetricPayload(slide, payload.metric,metricLayout,context,path);
|
|
1835
|
+
addMetricPayload(slide, payload.metric,metricLayout,context,path,options);
|
|
1681
1836
|
break;
|
|
1682
1837
|
case "quote":
|
|
1683
1838
|
addQuotePayload(slide, payload.quote, quoteLayout, context, options, path);
|
|
@@ -1717,7 +1872,7 @@ function richLineRuns(line,color,native,context) {
|
|
|
1717
1872
|
const fallback=color.replace(/^#/,'');
|
|
1718
1873
|
return line.fragments.map(fragment=>{
|
|
1719
1874
|
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}};
|
|
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}};
|
|
1721
1876
|
});
|
|
1722
1877
|
}
|
|
1723
1878
|
// Every native line of a list, marker paragraph or not, is named for the list's
|
|
@@ -1729,7 +1884,10 @@ function richLineRuns(line,color,native,context) {
|
|
|
1729
1884
|
// PptxGenJS embeds the media and its relationship through one picture per slide (OPF bullet image); packaging removes
|
|
1730
1885
|
// that picture and points each marker paragraph at the relationship with a:buBlip. An icon that cannot be embedded
|
|
1731
1886
|
// keeps the character bullets, as the preview does.
|
|
1732
|
-
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.'});
|
|
1733
1891
|
let picture=false;
|
|
1734
1892
|
if(bulletImage){
|
|
1735
1893
|
const part=`ppt/slides/slide${slideIndex+1}.xml`;
|
|
@@ -1750,16 +1908,22 @@ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,s
|
|
|
1750
1908
|
const addLines=(text,box,color,native,withBullet)=>{
|
|
1751
1909
|
text.richLines.forEach((line,index)=>{
|
|
1752
1910
|
const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
|
|
1753
|
-
|
|
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);
|
|
1754
1917
|
const objectName=`OPF list ${path} line ${lineNumber++}`;
|
|
1755
|
-
|
|
1756
|
-
|
|
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};
|
|
1757
1921
|
const runs=richLineRuns(line,color,native,context);
|
|
1758
1922
|
if(!runs.length)runs.push({text:'',options:{}});
|
|
1759
1923
|
// Keep paragraph intent identical across runs. ZIP normalization below
|
|
1760
1924
|
// 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:
|
|
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});
|
|
1763
1927
|
});
|
|
1764
1928
|
};
|
|
1765
1929
|
addLines(entry.text,entry.textBox,context.colors.text,context.textColor,true);
|
|
@@ -1873,7 +2037,7 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
|
|
|
1873
2037
|
if (prefix) context.logoPlaceholderTags.set(prefix, {v: 1, role: 'placeholder', slide: `slides.${slideIndex}`, path: logo.path});
|
|
1874
2038
|
return;
|
|
1875
2039
|
}
|
|
1876
|
-
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'});
|
|
1877
2041
|
if (outcome.svg) context.svgPictures.set(`ppt/slides/slide${slideIndex + 1}.xml|${logoName()}`, outcome.svg);
|
|
1878
2042
|
slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
|
|
1879
2043
|
}
|
|
@@ -1929,9 +2093,14 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
|
|
|
1929
2093
|
// FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
|
|
1930
2094
|
// max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
|
|
1931
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).
|
|
1932
2099
|
function chartTextSize(context) {
|
|
1933
2100
|
const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
|
|
1934
|
-
|
|
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);
|
|
1935
2104
|
}
|
|
1936
2105
|
|
|
1937
2106
|
// Every c:txPr of a generated classic chart carries the chart text size: PptxGenJS writes no size on the legend (PowerPoint's
|
|
@@ -1950,6 +2119,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1950
2119
|
return;
|
|
1951
2120
|
}
|
|
1952
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);
|
|
1953
2125
|
|
|
1954
2126
|
// Keep the resolved palette surface (including alpha) explicit in native
|
|
1955
2127
|
// chart/plot areas, so inherited labels are assessed against their own panel.
|
|
@@ -1960,19 +2132,27 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1960
2132
|
const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
|
|
1961
2133
|
const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
|
|
1962
2134
|
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
|
|
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});
|
|
1964
2137
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
1965
|
-
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);
|
|
1966
2141
|
if (chartData.chartex) {
|
|
1967
2142
|
// The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
|
|
1968
2143
|
// PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
|
|
1969
2144
|
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});
|
|
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});
|
|
1971
2146
|
if (chartData.chartex.layoutId === 'regionMap') {
|
|
1972
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.`});
|
|
1973
2148
|
}
|
|
1974
2149
|
}
|
|
1975
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;
|
|
1976
2156
|
slide.addChart(chartData.type, chartData.series, {
|
|
1977
2157
|
objectName,
|
|
1978
2158
|
x: region.x,
|
|
@@ -1980,8 +2160,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1980
2160
|
w: region.w,
|
|
1981
2161
|
h: region.h,
|
|
1982
2162
|
showLegend: circular || chartData.series.length > (chartData.type === 'scatter' ? 2 : 1),
|
|
2163
|
+
...classicChartOptions(chartOptions, {labelColor, font: context.fonts.body, textSize}),
|
|
1983
2164
|
showTitle: false,
|
|
1984
|
-
chartColors
|
|
2165
|
+
chartColors,
|
|
1985
2166
|
chartArea: {fill:{...fill},roundedCorners:false},
|
|
1986
2167
|
// Paint alpha once in the chart area, rather than stacking two alpha fills.
|
|
1987
2168
|
plotArea: {fill:{color:null}},
|
|
@@ -2000,6 +2181,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2000
2181
|
valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
|
|
2001
2182
|
barDir: chartData.barDir,
|
|
2002
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' } : {}),
|
|
2003
2187
|
...(percent ? { valAxisLabelFormatCode: '0%' } : {}),
|
|
2004
2188
|
...(chartData.spec.markers === undefined ? {} : { lineDataSymbol: chartData.spec.markers ? 'circle' : 'none' }),
|
|
2005
2189
|
...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
|
|
@@ -2019,7 +2203,8 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
2019
2203
|
}
|
|
2020
2204
|
|
|
2021
2205
|
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
|
|
2206
|
+
scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path,
|
|
2207
|
+
...(context.direction === 'rtl' ? {direction: 'rtl'} : {})
|
|
2023
2208
|
});
|
|
2024
2209
|
const columnCount = layout.columnCount;
|
|
2025
2210
|
const rows = layout.rows.map(row => row.cells.map(cell => {
|
|
@@ -2068,7 +2253,7 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
2068
2253
|
...(rich ? {bold:false,italic:false} : {}),
|
|
2069
2254
|
lineSpacing: fit.lineHeight * 0.75,
|
|
2070
2255
|
paraSpaceAfter: 0,
|
|
2071
|
-
align: cellStyle.align ?? context.contentAlignment,
|
|
2256
|
+
align: physicalAlignment(cellStyle.align ?? context.contentAlignment, cell.direction),
|
|
2072
2257
|
valign: cellStyle.verticalAlign ?? 'top',
|
|
2073
2258
|
...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
|
|
2074
2259
|
...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
|
|
@@ -2127,7 +2312,8 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
2127
2312
|
const link = config.links?.[sourceLineIndex];
|
|
2128
2313
|
if (fit.sourceLines?.[index]?.boundary === 'hard') sourceLineIndex += 1;
|
|
2129
2314
|
if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.quote&&!config.keepEmpty) continue;
|
|
2130
|
-
|
|
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;
|
|
2131
2317
|
const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
|
|
2132
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;
|
|
2133
2319
|
if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
|
|
@@ -2146,6 +2332,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
2146
2332
|
...textBoxOptions(area, context, fit.fontSize * .75),
|
|
2147
2333
|
...nativeFontOptions(style),
|
|
2148
2334
|
color: lineColor, align: alignment,
|
|
2335
|
+
...(fit.directions?.[index]==='rtl'?{rtlMode:true}:{}),
|
|
2149
2336
|
// Reuse the frame's existing relationship to cover blank caption boxes.
|
|
2150
2337
|
// PptxGenJS also inherits it into runs; keep their native text styling.
|
|
2151
2338
|
hyperlink: config.hyperlink,
|
|
@@ -2214,7 +2401,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
2214
2401
|
|
|
2215
2402
|
const bulletImageName = () => 'OPF bullet image';
|
|
2216
2403
|
|
|
2217
|
-
function addCodePayload(slide, value, layout, region, context, path) {
|
|
2404
|
+
function addCodePayload(slide, value, layout, region, context, path, options) {
|
|
2218
2405
|
if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
|
|
2219
2406
|
for (const part of layout.parts) {
|
|
2220
2407
|
// XML 1.0 Char excludes controls and unpaired UTF-16 surrogates. The u flag
|
|
@@ -2225,6 +2412,7 @@ function addCodePayload(slide, value, layout, region, context, path) {
|
|
|
2225
2412
|
const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
|
|
2226
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});
|
|
2227
2414
|
context.codeTags.set(panelName,codeManifest(value,layout,group));
|
|
2415
|
+
const syntax = codeSyntax(value, layout, context, path, options);
|
|
2228
2416
|
slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
|
|
2229
2417
|
for (const [partIndex,part] of layout.parts.entries()) {
|
|
2230
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});
|
|
@@ -2232,19 +2420,37 @@ function addCodePayload(slide, value, layout, region, context, path) {
|
|
|
2232
2420
|
const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
|
|
2233
2421
|
const objectName = `OPF code ${group} ${part.role} line ${index+1}`;
|
|
2234
2422
|
context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
|
|
2235
|
-
|
|
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 = {
|
|
2236
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),
|
|
2237
2428
|
...nativeFontOptions(part.style),
|
|
2238
2429
|
color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
2239
2430
|
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
2240
|
-
}
|
|
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);
|
|
2241
2434
|
}
|
|
2242
2435
|
}
|
|
2243
2436
|
}
|
|
2244
2437
|
|
|
2245
|
-
|
|
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) {
|
|
2246
2449
|
if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
|
|
2247
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;
|
|
2248
2454
|
for (const [partIndex,part] of layout.parts.entries()) {
|
|
2249
2455
|
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
2250
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});
|
|
@@ -2262,11 +2468,17 @@ function addMetricPayload(slide,value,layout,context,path) {
|
|
|
2262
2468
|
slide.addText(part.text.slice(line.start,line.end),{
|
|
2263
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),
|
|
2264
2470
|
...nativeFontOptions(part.style),
|
|
2265
|
-
color:part.
|
|
2471
|
+
color:partColor(part),align:layout.alignment,...(part.fit.directions?.[index]==='rtl'?{rtlMode:true}:{}),fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
2266
2472
|
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
2267
2473
|
});
|
|
2268
2474
|
}
|
|
2269
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
|
+
}
|
|
2270
2482
|
}
|
|
2271
2483
|
|
|
2272
2484
|
function addQuotePayload(slide, value, layout, context, options, path) {
|
|
@@ -2492,6 +2704,9 @@ function textBoxOptions(region, context, fontSize) {
|
|
|
2492
2704
|
};
|
|
2493
2705
|
}
|
|
2494
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
|
+
|
|
2495
2710
|
function textRuns(value, context, fallbackFontSize) {
|
|
2496
2711
|
const runs = Array.isArray(value) ? value : [value];
|
|
2497
2712
|
return runs.map((run) => {
|
|
@@ -2978,13 +3193,14 @@ async function normalizePptxZip(raw, context) {
|
|
|
2978
3193
|
}
|
|
2979
3194
|
|
|
2980
3195
|
attachCodeTags(entries, context.codeTags);
|
|
2981
|
-
attachMetricTags(entries,context.metricTags);
|
|
3196
|
+
attachMetricTags(entries,context.metricTags,context.metricDescriptions);
|
|
2982
3197
|
attachCardTags(entries,context.cardTags);
|
|
2983
3198
|
attachMediaTags(entries,context.mediaTags);
|
|
2984
3199
|
attachHeadingTags(entries,context.headingTags);
|
|
2985
3200
|
attachPlainTextTags(entries,context.plainTextTags);
|
|
2986
3201
|
attachTimelineTags(entries,context.timelineTags);
|
|
2987
3202
|
attachQuoteTags(entries,context.quoteTags);
|
|
3203
|
+
attachAnnotationTags(entries,context);
|
|
2988
3204
|
attachFurnitureFields(entries,context.furnitureFields);
|
|
2989
3205
|
attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests,context.furnitureLogoTags);
|
|
2990
3206
|
// RR-11: the recorded footer shapes become PowerPoint's date, footer and slide-number placeholders.
|
|
@@ -3006,6 +3222,9 @@ async function normalizePptxZip(raw, context) {
|
|
|
3006
3222
|
// Normalize only our generated chart text styles; point/series fills stay intact.
|
|
3007
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>`)));
|
|
3008
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}));
|
|
3009
3228
|
}
|
|
3010
3229
|
}
|
|
3011
3230
|
applyChartFonts(entries,context.chartFonts,parseRelationships);
|
|
@@ -3302,6 +3521,9 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
3302
3521
|
if(!marker)return shape;
|
|
3303
3522
|
if(marker.picture&&bulletRelationship)return shape.replace(/<a:buChar\b[^>]*\/>/g,`<a:buBlip><a:blip r:embed="${bulletRelationship}"/></a:buBlip>`);
|
|
3304
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}"`}/>`);
|
|
3305
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}"/>`);
|
|
3306
3528
|
});
|
|
3307
3529
|
// PptxGenJS gives shapes no alternative text. An unavailable image's panel
|
|
@@ -3533,7 +3755,7 @@ function numericValue(value) {
|
|
|
3533
3755
|
|
|
3534
3756
|
function normalizeAuthor(author) {
|
|
3535
3757
|
if (typeof author === "string") return author;
|
|
3536
|
-
if (Array.isArray(author)) return author
|
|
3758
|
+
if (Array.isArray(author)) return joinAuthors(author);
|
|
3537
3759
|
return null;
|
|
3538
3760
|
}
|
|
3539
3761
|
|