@openpresentation/opf-pptx 0.11.0 → 0.11.2
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/DEPENDENCY-NOTES.md +1 -1
- package/README.md +16 -7
- package/dist/chart-types.js +183 -0
- package/dist/chart-workbook.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +228 -95
- package/dist/media-dedupe.js +44 -0
- package/dist/theme-colors.js +92 -6
- package/dist/watermark-provenance.js +146 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import {isFaceStyleSuffix} from './font-weights.js';
|
|
|
3
3
|
import {importTableFrames} from './table-import.js';
|
|
4
4
|
import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilies} from './package-fonts.js';
|
|
5
5
|
import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
|
|
6
|
+
import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,NATIVE_CHART_ELEMENTS} from './chart-types.js';
|
|
6
7
|
import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
7
8
|
import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
|
|
8
9
|
import {attachCardTags,importCardFrames} from './card-provenance.js';
|
|
@@ -15,9 +16,11 @@ import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, r
|
|
|
15
16
|
import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
|
|
16
17
|
import {importImageOrientation} from './image-import.js';
|
|
17
18
|
import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
|
|
19
|
+
import {dedupeMedia} from './media-dedupe.js';
|
|
20
|
+
import {placeWatermarks, importWatermark, watermarkName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
|
|
18
21
|
import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
|
|
19
22
|
import {importBackground} from './background-import.js';
|
|
20
|
-
import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
|
|
23
|
+
import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
|
|
21
24
|
import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
|
|
22
25
|
import { webpToPng } from '#image-fallback';
|
|
23
26
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
@@ -205,6 +208,7 @@ export async function toPptx(input, options = {}) {
|
|
|
205
208
|
context.tableCells = new Map();
|
|
206
209
|
context.imagePlacements = new Map();
|
|
207
210
|
context.slideImages = new Map();
|
|
211
|
+
context.watermarks = new Map();
|
|
208
212
|
context.backgroundFills = new Map();
|
|
209
213
|
context.notesWithCarriageReturns = new Map();
|
|
210
214
|
context.cardTags = new Map();
|
|
@@ -230,6 +234,8 @@ export async function toPptx(input, options = {}) {
|
|
|
230
234
|
report: diagnostic => options.onDiagnostic?.(diagnostic)
|
|
231
235
|
});
|
|
232
236
|
context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
|
|
237
|
+
context.masterBackground = masterBackground(context);
|
|
238
|
+
context.linkSentinels = linkSentinels(presentation);
|
|
233
239
|
const pptx = new PptxGenJS();
|
|
234
240
|
configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
|
|
235
241
|
|
|
@@ -309,6 +315,12 @@ export async function fromPptx(input, options = {}) {
|
|
|
309
315
|
furnitureContexts[index].mediaRegistry = mediaRegistry;
|
|
310
316
|
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
|
|
311
317
|
}
|
|
318
|
+
// A watermark carried identically by every slide is the deck's design.watermark.
|
|
319
|
+
const carried = imported.slides.map(slide => slide.design?.watermark);
|
|
320
|
+
if (carried.length && carried[0] && carried.every(value => JSON.stringify(value) === JSON.stringify(carried[0]))) {
|
|
321
|
+
imported.design = {...imported.design, watermark: carried[0]};
|
|
322
|
+
for (const slide of imported.slides) { delete slide.design.watermark; if (!Object.keys(slide.design).length) delete slide.design; }
|
|
323
|
+
}
|
|
312
324
|
// Conflicting media asset IDs fall back to their current native URLs during import.
|
|
313
325
|
for (const context of furnitureContexts) for (const [id, asset] of Object.entries(context.mediaAssets ?? {})) if (!Object.hasOwn(imported.assets ?? {}, id)) imported.assets = {...imported.assets, [id]: asset};
|
|
314
326
|
// Report after the slides so slide diagnostics keep their established order.
|
|
@@ -502,6 +514,15 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
502
514
|
if (slideImage.design) slide.design = {...slide.design, ...slideImage.design};
|
|
503
515
|
nativeContext.slideImagePictures = slideImage.consumed;
|
|
504
516
|
nativeContext.slideImageShapes = slideImage.consumedShapes;
|
|
517
|
+
const watermarkPath = `slides.${slideIndex}.design.watermark`;
|
|
518
|
+
const watermark = importWatermark(nativeContext.pictures, relationships, entries, slideIndex,
|
|
519
|
+
picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
|
|
520
|
+
// The recorded fit is re-derived from design.watermark.
|
|
521
|
+
if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: watermarkPath});
|
|
522
|
+
}),
|
|
523
|
+
diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
|
|
524
|
+
if (watermark.design) slide.design = {...slide.design, ...watermark.design};
|
|
525
|
+
nativeContext.watermarkPictures = watermark.consumed;
|
|
505
526
|
|
|
506
527
|
const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
|
|
507
528
|
.sort(comparePositionedItems);
|
|
@@ -594,7 +615,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
594
615
|
}
|
|
595
616
|
|
|
596
617
|
for (const [index, picture] of nativeContext.pictures.entries()) {
|
|
597
|
-
if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index)) continue;
|
|
618
|
+
if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index)) continue;
|
|
598
619
|
const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
|
|
599
620
|
const item = importPicture(entries, picture, slidePath, relationships, report);
|
|
600
621
|
if (item) items.push(item);
|
|
@@ -833,6 +854,7 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
833
854
|
|
|
834
855
|
const budget = { cells: 0 };
|
|
835
856
|
const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
|
|
857
|
+
if (chartNode.type === 'scatter') return scatterFromSeries(entries, relationship.path, series, budget, cachePath);
|
|
836
858
|
const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
|
|
837
859
|
const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
|
|
838
860
|
const values = series.map((entry, index) => {
|
|
@@ -862,19 +884,31 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
862
884
|
};
|
|
863
885
|
}
|
|
864
886
|
|
|
887
|
+
// Scatter charts share X values (c:xVal) across Y series (c:yVal). OPF keeps
|
|
888
|
+
// them category-major: [point label, X, Y1, Y2, ...]; native charts carry no
|
|
889
|
+
// point labels, so points are numbered.
|
|
890
|
+
function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
|
|
891
|
+
// The same cache helpers as every other chart: bounded, indexed by c:pt@idx, with gaps kept as null (never plotted as 0).
|
|
892
|
+
const xRole = series[0]?.["c:xVal"];
|
|
893
|
+
const xs = cachedValues(xRole, cachePath(0, "c:xVal"), budget, numericCacheValue);
|
|
894
|
+
const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, "c:tx"), budget) ?? `Series ${index + 1}`);
|
|
895
|
+
const values = series.map((entry, index) => cachedValues(entry?.["c:yVal"], cachePath(index, "c:yVal"), budget, numericCacheValue));
|
|
896
|
+
const rowCount = values.reduce((count, row) => Math.max(count, row.length), xs.length);
|
|
897
|
+
if (rowCount === 0) return null;
|
|
898
|
+
if (rowCount * (series.length + 2) > MAX_CHART_CACHE_CELLS) {
|
|
899
|
+
throw new OPFPptxError('invalid-chart-cache', 'Chart cache exceeds the 1,000,000-cell import limit; reduce its rows or series before importing.', {path: chartPart});
|
|
900
|
+
}
|
|
901
|
+
const rows = [];
|
|
902
|
+
// A chart without c:xVal plots against 1..n natively; a c:xVal gap stays a gap.
|
|
903
|
+
for (let index = 0; index < rowCount; index += 1) rows.push([String(index + 1), xRole === undefined ? index + 1 : xs[index] ?? null, ...values.map((row) => row[index] ?? null)]);
|
|
904
|
+
return { type: "scatter", data: { columns: ["Point", readChartCategoryHeading(entries, chartPart) ?? "X", ...names], rows } };
|
|
905
|
+
}
|
|
906
|
+
|
|
865
907
|
function firstChartNode(plotArea) {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
["c:doughnutChart", () => "doughnut"],
|
|
871
|
-
["c:areaChart", () => "area"],
|
|
872
|
-
["c:scatterChart", () => "scatter"],
|
|
873
|
-
["c:radarChart", () => "radar"]
|
|
874
|
-
];
|
|
875
|
-
for (const [key, type] of candidates) {
|
|
876
|
-
const node = asArray(plotArea[key])[0];
|
|
877
|
-
if (node) return { node, type: type(node) };
|
|
908
|
+
// Map the native construct back to the kept OPF chart type id (FF-22).
|
|
909
|
+
for (const element of NATIVE_CHART_ELEMENTS) {
|
|
910
|
+
const node = asArray(plotArea[`c:${element}`])[0];
|
|
911
|
+
if (node) return { node, type: chartTypeFromNative(element, node) };
|
|
878
912
|
}
|
|
879
913
|
return null;
|
|
880
914
|
}
|
|
@@ -1115,8 +1149,48 @@ function exportColor(entry, context, fallback) {
|
|
|
1115
1149
|
// PptxGenJS color for a document color reference: a theme scheme value when the
|
|
1116
1150
|
// reference names a scheme slot or role that the deck theme holds exactly,
|
|
1117
1151
|
// otherwise the resolved literal RRGGBB. Alpha stays in the caller's transparency.
|
|
1118
|
-
function nativeColor(reference, hex, context) {
|
|
1119
|
-
|
|
1152
|
+
function nativeColor(reference, hex, context, fallback) {
|
|
1153
|
+
if (fallback !== undefined && (reference === undefined || reference === null || reference === '')) return pptxColor(fallback);
|
|
1154
|
+
const value = schemeColorValue(reference, hex, context, {vendor: true});
|
|
1155
|
+
if (value) return value;
|
|
1156
|
+
// PptxGenJS cannot write hlink or folHlink: a reserved literal stands in and
|
|
1157
|
+
// is rewritten to the scheme color in the finished slide part.
|
|
1158
|
+
const link = schemeColorValue(reference, hex, context);
|
|
1159
|
+
return (link && context.linkSentinels?.[link]) ?? normalizeHex(hex);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// FF-24c: two literals no document color uses stand in for hlink and folHlink
|
|
1163
|
+
// (see nativeColor). When the document uses every candidate, those colors stay literal.
|
|
1164
|
+
const LINK_SENTINELS = [['FE01A0', 'FE01A1'], ['FE02B0', 'FE02B1'], ['FE03C0', 'FE03C1']];
|
|
1165
|
+
function linkSentinels(presentation) {
|
|
1166
|
+
const text = JSON.stringify(presentation).toUpperCase();
|
|
1167
|
+
const pair = LINK_SENTINELS.find(hexes => hexes.every(hex => !text.includes(hex)));
|
|
1168
|
+
return pair ? {hlink: pair[0], folHlink: pair[1]} : {};
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function writeLinkSentinels(xml, sentinels) {
|
|
1172
|
+
const values = Object.entries(sentinels ?? {});
|
|
1173
|
+
if (!values.length) return xml;
|
|
1174
|
+
return xml.replace(/<a:srgbClr val="([0-9A-F]{6})"(\/>|>[\s\S]*?<\/a:srgbClr>)/g, (node, hex, rest) => {
|
|
1175
|
+
const link = values.find(([, value]) => value === hex)?.[0];
|
|
1176
|
+
return link ? `<a:schemeClr val="${link}"${rest.replace(/<\/a:srgbClr>$/, '</a:schemeClr>')}` : node;
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// PptxGenJS color option: a scheme value it can emit (tx1, bg2, ...) or RRGGBB.
|
|
1181
|
+
function pptxColor(value) {
|
|
1182
|
+
return /^(?:tx[12]|bg[12])$/.test(value) ? value : normalizeHex(value);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Deck-level theme background for the slide master, so slides added in
|
|
1186
|
+
// PowerPoint match. The vendored layout already uses bg1, so only a theme
|
|
1187
|
+
// background on another slot (dark themes, light2, accents) changes the master.
|
|
1188
|
+
function masterBackground(context) {
|
|
1189
|
+
const deck = {...context, schemeOverride: false};
|
|
1190
|
+
const value = schemeBackgroundValue(deck.backgroundDefinition, deck);
|
|
1191
|
+
const fill = schemeBackgroundFill(deck.backgroundDefinition, deck);
|
|
1192
|
+
if (!value || value === 'bg1' || !fill || fill.includes('<a:alpha')) return null;
|
|
1193
|
+
return {fill, text: defaultTextSchemeValues(deck).text ?? normalizeHex(context.colors.text)};
|
|
1120
1194
|
}
|
|
1121
1195
|
|
|
1122
1196
|
// The theme part carries the deck-level scheme (slide overrides stay literal)
|
|
@@ -1207,7 +1281,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1207
1281
|
options.onDiagnostic?.({code: 'unresolved-asset', path: imagePath, message: 'The background image needs an embedded raster, a declared asset or a host imageResolver; the slide background color was exported instead.'});
|
|
1208
1282
|
}
|
|
1209
1283
|
}
|
|
1210
|
-
slide.color = slideContext.
|
|
1284
|
+
slide.color = slideContext.textColor;
|
|
1211
1285
|
if (opfSlide.hidden === true) slide.hidden = true;
|
|
1212
1286
|
|
|
1213
1287
|
const { widthInches, heightInches } = slideContext.dimensions;
|
|
@@ -1225,51 +1299,55 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1225
1299
|
const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
|
|
1226
1300
|
const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment, titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, textMeasurement: options.textMeasurement, date: options.date, socialPlatforms: socialPlatformRecords(presentation, options) });
|
|
1227
1301
|
for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
|
|
1302
|
+
// The preview paints background, slide image (and its overlay), then design.watermark, then content.
|
|
1228
1303
|
if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
|
|
1304
|
+
await addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options);
|
|
1229
1305
|
for (const item of geometry.items) {
|
|
1306
|
+
// Card text sits on a literal card fill, not the slide background: keep it literal.
|
|
1307
|
+
const itemContext = item.frameBox ? {...slideContext, textColor: slideContext.colors.text, mutedColor: slideContext.colors.mutedText} : slideContext;
|
|
1230
1308
|
const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
|
|
1231
1309
|
if (item.frameBox) {
|
|
1232
1310
|
const frame=item.frameBox;
|
|
1233
1311
|
context.cardTags.set(`OPF card ${item.path}`,item.path);
|
|
1234
1312
|
const paint=value=>({color:normalizeHex(value),transparency:/^#[0-9a-f]{8}$/i.test(value)?(1-parseInt(value.slice(7),16)/255)*100:0});
|
|
1235
|
-
const surface=
|
|
1313
|
+
const surface=itemContext.colorScheme[isDarkHex(itemContext.colors.background)?'dark2':'light2']??`#${itemContext.colors.surface}`;
|
|
1236
1314
|
slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
|
|
1237
1315
|
rectRadius:8*Math.min(widthInches,heightInches)/720,
|
|
1238
|
-
fill:paint(surface),line:{...paint(
|
|
1316
|
+
fill:paint(surface),line:{...paint(itemContext.colorScheme.accent5??`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
|
|
1239
1317
|
}
|
|
1240
1318
|
if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
|
|
1241
|
-
addMeasuredPayloadText(slide,item.value,item.box,
|
|
1319
|
+
addMeasuredPayloadText(slide,item.value,item.box,itemContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:alignmentFor(item),diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?itemContext.colors.accent:itemContext.textColor});
|
|
1242
1320
|
} else if (["title", "subtitle", "tag"].includes(item.field)) {
|
|
1243
1321
|
// Estimated-font headings use one native text box, which still needs a
|
|
1244
1322
|
// role tag. Role recovery must not depend on outline measurement support.
|
|
1245
1323
|
const objectName = `OPF heading ${item.path} line 0`;
|
|
1246
1324
|
context.headingTags.set(objectName,{v:1,group:item.path,field:item.field,line:0,count:1});
|
|
1247
1325
|
slide.addText(item.text.lines.join("\n"), {
|
|
1248
|
-
...textBoxOptions(region,
|
|
1326
|
+
...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
|
|
1249
1327
|
...nativeFontOptions(item.textStyle),
|
|
1250
|
-
color: item.field === "tag" ?
|
|
1328
|
+
color: item.field === "tag" ? itemContext.colors.accent : itemContext.textColor,
|
|
1251
1329
|
align: alignmentFor(item),
|
|
1252
1330
|
objectName,
|
|
1253
1331
|
breakLine: false
|
|
1254
1332
|
});
|
|
1255
1333
|
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
1256
|
-
addMeasuredList(slide,item.text,
|
|
1334
|
+
addMeasuredList(slide,item.text,itemContext,item.path);
|
|
1257
1335
|
} else if (item.field === "text" && item.text?.richLines) {
|
|
1258
1336
|
const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
|
|
1259
1337
|
for(const [index,line] of item.text.richLines.entries()){
|
|
1260
1338
|
const runs=line.fragments.map(fragment=>{
|
|
1261
|
-
const runColor=exportColor(fragment.run.color,
|
|
1262
|
-
const color=nativeColor(fragment.run.color,runColor,
|
|
1339
|
+
const runColor=exportColor(fragment.run.color,itemContext,itemContext.colors.text);
|
|
1340
|
+
const color=nativeColor(fragment.run.color,runColor,itemContext,itemContext.textColor);
|
|
1263
1341
|
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}};
|
|
1264
1342
|
});
|
|
1265
1343
|
const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
1266
1344
|
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};
|
|
1267
|
-
if(runs.length)slide.addText(runs,{...textBoxOptions(area,
|
|
1345
|
+
if(runs.length)slide.addText(runs,{...textBoxOptions(area,itemContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
|
|
1268
1346
|
}
|
|
1269
1347
|
} else if (item.field === "text" && typeof item.value === "string") {
|
|
1270
|
-
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region,
|
|
1348
|
+
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
|
|
1271
1349
|
} else {
|
|
1272
|
-
await addPayload(slide, presentation, item.payload, region, item.path, { ...
|
|
1350
|
+
await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: alignmentFor(item) ?? "left" }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
|
|
1273
1351
|
}
|
|
1274
1352
|
}
|
|
1275
1353
|
// Core composes furniture above all content and opf-render paints it last, so
|
|
@@ -1301,7 +1379,17 @@ function resolveSlideContext(presentation, slide, baseContext, options, slideInd
|
|
|
1301
1379
|
|| Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
|
|
1302
1380
|
throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
|
|
1303
1381
|
}
|
|
1304
|
-
|
|
1382
|
+
const slideContext = { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, variables: resolved.variables, imageFill: effective.design.imageFill ?? "fit" };
|
|
1383
|
+
// A slide-level color scheme (directly or through a slide theme) pins every
|
|
1384
|
+
// named color on that slide to literal sRGB; see schemeColorValue().
|
|
1385
|
+
const slideScheme = themeSlotColors(resolved.colorScheme), deckScheme = baseContext.themeColors ?? {};
|
|
1386
|
+
slideContext.schemeOverride = slide.design?.colorScheme !== undefined
|
|
1387
|
+
|| Object.keys({...slideScheme, ...deckScheme}).some(slot => slideScheme[slot] !== deckScheme[slot]);
|
|
1388
|
+
// Default and muted text follow the theme only where the background does.
|
|
1389
|
+
const text = defaultTextSchemeValues(slideContext);
|
|
1390
|
+
slideContext.textColor = text.text ?? resolved.colors.text;
|
|
1391
|
+
slideContext.mutedColor = text.muted ?? resolved.colors.mutedText;
|
|
1392
|
+
return slideContext;
|
|
1305
1393
|
}
|
|
1306
1394
|
|
|
1307
1395
|
function fieldToType(field) {
|
|
@@ -1369,10 +1457,10 @@ function addTextPayload(slide, value, region, context) {
|
|
|
1369
1457
|
slide.addText(stringifyText(value), textBoxOptions(region, context, 18));
|
|
1370
1458
|
}
|
|
1371
1459
|
|
|
1372
|
-
function richLineRuns(line,color,context) {
|
|
1460
|
+
function richLineRuns(line,color,native,context) {
|
|
1373
1461
|
const fallback=color.replace(/^#/,'');
|
|
1374
1462
|
return line.fragments.map(fragment=>{
|
|
1375
|
-
const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context);
|
|
1463
|
+
const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context,native);
|
|
1376
1464
|
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}};
|
|
1377
1465
|
});
|
|
1378
1466
|
}
|
|
@@ -1384,14 +1472,14 @@ function richLineRuns(line,color,context) {
|
|
|
1384
1472
|
function addMeasuredList(slide,fit,context,path) {
|
|
1385
1473
|
let lineNumber=0;
|
|
1386
1474
|
for(const entry of fit.listEntries){
|
|
1387
|
-
const addLines=(text,box,color,withBullet)=>{
|
|
1475
|
+
const addLines=(text,box,color,native,withBullet)=>{
|
|
1388
1476
|
text.richLines.forEach((line,index)=>{
|
|
1389
1477
|
const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
|
|
1390
1478
|
const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
|
|
1391
1479
|
const objectName=`OPF list ${path} line ${lineNumber++}`;
|
|
1392
|
-
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:
|
|
1480
|
+
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor)});
|
|
1393
1481
|
const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
|
|
1394
|
-
const runs=richLineRuns(line,color,context);
|
|
1482
|
+
const runs=richLineRuns(line,color,native,context);
|
|
1395
1483
|
if(!runs.length)runs.push({text:'',options:{}});
|
|
1396
1484
|
// Keep paragraph intent identical across runs. ZIP normalization below
|
|
1397
1485
|
// removes the duplicate paragraph-property nodes emitted by PptxGenJS.
|
|
@@ -1399,8 +1487,8 @@ function addMeasuredList(slide,fit,context,path) {
|
|
|
1399
1487
|
slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:nativeFontOptions(entry.marker.style).fontFace,objectName,align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,...paragraph});
|
|
1400
1488
|
});
|
|
1401
1489
|
};
|
|
1402
|
-
addLines(entry.text,entry.textBox,context.colors.text,true);
|
|
1403
|
-
if(entry.description)addLines(entry.description,entry.descriptionBox,context.colors.mutedText,false);
|
|
1490
|
+
addLines(entry.text,entry.textBox,context.colors.text,context.textColor,true);
|
|
1491
|
+
if(entry.description)addLines(entry.description,entry.descriptionBox,context.colors.mutedText,context.mutedColor,false);
|
|
1404
1492
|
}
|
|
1405
1493
|
}
|
|
1406
1494
|
|
|
@@ -1420,7 +1508,7 @@ function addListPayload(slide, items, region, context) {
|
|
|
1420
1508
|
options: {
|
|
1421
1509
|
bullet: { type: "bullet", indent: 14 + level * 14 },
|
|
1422
1510
|
breakLine: index < list.length - 1 || Boolean(isPlainObject(item) && item.description),
|
|
1423
|
-
color: context.
|
|
1511
|
+
color: context.textColor,
|
|
1424
1512
|
fontFace: context.fonts.body,
|
|
1425
1513
|
fontSize: 15,
|
|
1426
1514
|
hanging: 4 + level * 10
|
|
@@ -1431,7 +1519,7 @@ function addListPayload(slide, items, region, context) {
|
|
|
1431
1519
|
text: stringifyText(item.description),
|
|
1432
1520
|
options: {
|
|
1433
1521
|
breakLine: index < list.length - 1,
|
|
1434
|
-
color: context.
|
|
1522
|
+
color: context.mutedColor,
|
|
1435
1523
|
fontFace: context.fonts.body,
|
|
1436
1524
|
fontSize: 11,
|
|
1437
1525
|
margin: [0, 0, 0, 18 + level * 14]
|
|
@@ -1463,6 +1551,33 @@ async function addImagePayload(slide, presentation, asset, region, path, context
|
|
|
1463
1551
|
return objectName;
|
|
1464
1552
|
}
|
|
1465
1553
|
|
|
1554
|
+
// design.watermark: one native picture per slide, added after the slide image
|
|
1555
|
+
// (and its overlay) and before all content, the preview's paint order. The
|
|
1556
|
+
// slide's own design.watermark replaces the deck's, and false suppresses it.
|
|
1557
|
+
// Frame, fit and opacity are written after PptxGenJS embeds the bytes.
|
|
1558
|
+
async function addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options) {
|
|
1559
|
+
const local = opfSlide.design?.watermark !== undefined;
|
|
1560
|
+
const watermark = local ? opfSlide.design.watermark : presentation.design?.watermark;
|
|
1561
|
+
if (watermark === undefined || watermark === null || watermark === false) return;
|
|
1562
|
+
const path = local ? `slides.${slideIndex}.design.watermark` : 'design.watermark';
|
|
1563
|
+
const notExported = message => options.onDiagnostic?.({code: 'watermark-not-exported', path, message});
|
|
1564
|
+
const asset = isPlainObject(watermark) || typeof watermark === 'string' ? watermark : null;
|
|
1565
|
+
if (asset === null || (isPlainObject(asset) && typeof asset.src !== 'string')) {
|
|
1566
|
+
notExported('The watermark needs an image source (a string, or an object with src); no watermark was exported for this slide.');
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
const resolved = await resolveImage(asset, presentation, options, path);
|
|
1570
|
+
if (!resolved) {
|
|
1571
|
+
options.onDiagnostic?.({code: 'unresolved-asset', path, message: 'The watermark image needs an embedded raster, a declared asset or a host imageResolver; no watermark was exported for this slide.'});
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
const {widthInches, heightInches} = slideContext.dimensions;
|
|
1575
|
+
const box = watermarkBox(widthInches, heightInches);
|
|
1576
|
+
const opacity = watermarkOpacity(watermark);
|
|
1577
|
+
context.watermarks.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box, opacity, path});
|
|
1578
|
+
slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1466
1581
|
// design.slideImage: one native picture at the shared frame, beneath content.
|
|
1467
1582
|
// Crop/fit and treatments are written after PptxGenJS embeds the bytes.
|
|
1468
1583
|
async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
|
|
@@ -1506,7 +1621,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1506
1621
|
addPlaceholderPayload(slide, "Chart", chartData.summary, region, context);
|
|
1507
1622
|
return;
|
|
1508
1623
|
}
|
|
1509
|
-
|
|
1624
|
+
for (const {adaptation, message} of chartData.adaptations ?? []) options.onDiagnostic?.({code: "chart-data-adapted", path, message, adaptation});
|
|
1510
1625
|
|
|
1511
1626
|
// Keep the resolved palette surface (including alpha) explicit in native
|
|
1512
1627
|
// chart/plot areas, so inherited labels are assessed against their own panel.
|
|
@@ -1516,15 +1631,16 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1516
1631
|
const fill = {color:normalizeHex(panelFill),transparency};
|
|
1517
1632
|
const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
|
|
1518
1633
|
const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
|
|
1519
|
-
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor});
|
|
1634
|
+
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec});
|
|
1520
1635
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
1636
|
+
const percent = chartData.spec.grouping === 'percentStacked';
|
|
1521
1637
|
slide.addChart(chartData.type, chartData.series, {
|
|
1522
1638
|
objectName,
|
|
1523
1639
|
x: region.x,
|
|
1524
1640
|
y: region.y,
|
|
1525
1641
|
w: region.w,
|
|
1526
1642
|
h: region.h,
|
|
1527
|
-
showLegend: circular || chartData.series.length > 1,
|
|
1643
|
+
showLegend: circular || chartData.series.length > (chartData.type === 'scatter' ? 2 : 1),
|
|
1528
1644
|
showTitle: false,
|
|
1529
1645
|
chartColors: CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`))),
|
|
1530
1646
|
chartArea: {fill:{...fill},roundedCorners:false},
|
|
@@ -1542,7 +1658,12 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1542
1658
|
showValue: false,
|
|
1543
1659
|
valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
|
|
1544
1660
|
barDir: chartData.barDir,
|
|
1545
|
-
barGrouping: chartData.barGrouping
|
|
1661
|
+
barGrouping: chartData.barGrouping,
|
|
1662
|
+
...(percent ? { valAxisLabelFormatCode: '0%' } : {}),
|
|
1663
|
+
...(chartData.spec.markers === undefined ? {} : { lineDataSymbol: chartData.spec.markers ? 'circle' : 'none' }),
|
|
1664
|
+
...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
|
|
1665
|
+
// ScatterWithMarkers: markers only, no connecting line.
|
|
1666
|
+
...(chartData.type === 'scatter' ? { lineSize: 0, lineDataSymbol: 'circle' } : {})
|
|
1546
1667
|
});
|
|
1547
1668
|
}
|
|
1548
1669
|
|
|
@@ -1565,8 +1686,13 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1565
1686
|
const cellStyle = cell.style ?? {};
|
|
1566
1687
|
const defaultFillHex = header ? context.colors.accent : context.colors.surface;
|
|
1567
1688
|
const baseFill = exportColor(cellStyle.fill ?? defaultFillHex, context, defaultFillHex);
|
|
1689
|
+
// FF-24c: engine chrome resolves from scheme roles (header accent1, body surface), so it follows the deck theme like a named fill.
|
|
1690
|
+
const fillReference = cellStyle.fill ?? (header ? 'accent1' : 'surface');
|
|
1691
|
+
const fillValue = schemeColorValue(fillReference, baseFill, context);
|
|
1568
1692
|
const inheritedText = textColorForFill(`#${baseFill}`, header ? "#FFFFFF" : `#${context.colors.text}`);
|
|
1569
1693
|
const baseColor = exportColor(cellStyle.color ?? inheritedText.replace(/^#/, ""), context, inheritedText.replace(/^#/, ""));
|
|
1694
|
+
// Default cell text follows the theme only with a theme-referenced fill: paired slot on a light or dark fill, light1/dark1 on an accent fill.
|
|
1695
|
+
const defaultText = cellStyle.color === undefined ? tableTextSchemeValue(fillValue, baseColor, context) : undefined;
|
|
1570
1696
|
const alpha = value => value.length === 8 ? (1 - parseInt(value.slice(6), 16) / 255) * 100 : 0;
|
|
1571
1697
|
const text = stringifyText(cell.value), style = cell.textStyle;
|
|
1572
1698
|
const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
|
|
@@ -1575,7 +1701,7 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1575
1701
|
const fragment = fragments.find(item => item.runIndex === index);
|
|
1576
1702
|
const runStyle = fragment?.style ?? resolveTextStyle({...style,fontFamily:run.fontFamily ?? style.fontFamily,fontWeight:run.bold === undefined ? style.fontWeight : run.bold ? 700 : 400,italic:run.italic ?? style.italic}, options.textMeasurement);
|
|
1577
1703
|
const rawColor = run.color !== undefined && run.color !== '' ? exportColor(run.color, context, baseColor) : baseColor;
|
|
1578
|
-
const color = nativeColor(run.color !== undefined && run.color !== '' ? run.color : cellStyle.color, rawColor, context), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
|
|
1704
|
+
const color = nativeColor(run.color !== undefined && run.color !== '' ? run.color : cellStyle.color, rawColor, context, defaultText), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
|
|
1579
1705
|
const runOptions = {
|
|
1580
1706
|
...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
|
|
1581
1707
|
underline:run.underline ? {style:'sng',color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
|
|
@@ -1605,17 +1731,17 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1605
1731
|
valign: cellStyle.verticalAlign ?? 'top',
|
|
1606
1732
|
...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
|
|
1607
1733
|
...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
|
|
1608
|
-
color: nativeColor(cellStyle.color, baseColor, context),
|
|
1734
|
+
color: nativeColor(cellStyle.color, baseColor, context, defaultText),
|
|
1609
1735
|
// Rich runs carry resolved alpha. A translucent cell default would
|
|
1610
1736
|
// overwrite an explicit opaque run because PptxGenJS inherits falsy 0.
|
|
1611
1737
|
...(cellStyle.color && !rich ? {transparency:alpha(baseColor)} : {}),
|
|
1612
|
-
fill: { color: nativeColor(
|
|
1738
|
+
fill: { color: nativeColor(fillReference, baseFill, context), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
|
|
1613
1739
|
},
|
|
1614
1740
|
};
|
|
1615
1741
|
}));
|
|
1616
1742
|
const objectName = `OPF table ${context.tableHeaders.size + 1}`;
|
|
1617
1743
|
context.tableHeaders.set(objectName, hasHeaders);
|
|
1618
|
-
if (layout.rows.some(row => row.cells.some(cell => Object.keys(cell.style ?? {}).length))) context.tableCells.set(objectName, {layout, scale, defaultBorder:{color:"
|
|
1744
|
+
if (layout.rows.some(row => row.cells.some(cell => Object.keys(cell.style ?? {}).length))) context.tableCells.set(objectName, {layout, scale, context, defaultBorder:{color:"accent5",width:1/scale}});
|
|
1619
1745
|
slide.addTable(rows, {
|
|
1620
1746
|
objectName,
|
|
1621
1747
|
x: region.x,
|
|
@@ -1631,7 +1757,7 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1631
1757
|
fontFace: context.fonts.body,
|
|
1632
1758
|
fontSize: 15 * scale * 0.75,
|
|
1633
1759
|
color: context.colors.text,
|
|
1634
|
-
border: { type: "solid", color: context.colors.border, pt: 0.75 },
|
|
1760
|
+
border: { type: "solid", color: nativeColor("accent5", context.colors.border, context), pt: 0.75 },
|
|
1635
1761
|
margin: [6 * scale, 7.5 * scale, 3 * scale, 7.5 * scale],
|
|
1636
1762
|
valign: "top"
|
|
1637
1763
|
});
|
|
@@ -1673,7 +1799,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
1673
1799
|
else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
|
|
1674
1800
|
const area=placed?{x:(placed.x+placed.width*factor-box.width*factor)/96,y:(placed.baseline-fit.fontSize)/96,w:box.width/96,h:placed.height/96}:{x:box.x/96,y:(box.y+index*fit.lineHeight)/96,w:box.width/96,h:fit.lineHeight/96};
|
|
1675
1801
|
// A run-level link keeps the muted, non-underlined furniture look of the preview (hlinkClr=tx, u=none).
|
|
1676
|
-
const lineColor =
|
|
1802
|
+
const lineColor = pptxColor(config.color ?? context.textColor);
|
|
1677
1803
|
slide.addText(link?.href && line ? [{text: line, options: {hyperlink: {url: link.href}, color: lineColor, underline: {style: 'none'}}}] : line, {
|
|
1678
1804
|
...textBoxOptions(area, context, fit.fontSize * .75),
|
|
1679
1805
|
...nativeFontOptions(style),
|
|
@@ -1721,7 +1847,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
1721
1847
|
if(marker) staticDates.set(index,marker);
|
|
1722
1848
|
}
|
|
1723
1849
|
}
|
|
1724
|
-
addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.
|
|
1850
|
+
addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.mutedColor,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:index},liveFields,links:part.links});
|
|
1725
1851
|
}
|
|
1726
1852
|
}
|
|
1727
1853
|
const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
|
|
@@ -1776,7 +1902,7 @@ function addMetricPayload(slide,value,layout,context,path) {
|
|
|
1776
1902
|
slide.addText(part.text.slice(line.start,line.end),{
|
|
1777
1903
|
...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),
|
|
1778
1904
|
...nativeFontOptions(part.style),
|
|
1779
|
-
color:part.role==='value'?context.colors.accent:context.
|
|
1905
|
+
color:part.role==='value'?context.colors.accent:context.textColor,align:layout.alignment,fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
1780
1906
|
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
1781
1907
|
});
|
|
1782
1908
|
}
|
|
@@ -1789,7 +1915,7 @@ function addQuotePayload(slide, layout, context, options, path) {
|
|
|
1789
1915
|
if (!part.fit) throw new OPFPptxError('layout-overflow', 'Quote content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
|
|
1790
1916
|
addMeasuredPayloadText(slide,part.text,part.box,context,options,{
|
|
1791
1917
|
path:part.path,fit:part.fit,textStyle:part.style,diagnosticsHandled:true,
|
|
1792
|
-
color:part.role==='footer'?context.
|
|
1918
|
+
color:part.role==='footer'?context.mutedColor:context.textColor,
|
|
1793
1919
|
});
|
|
1794
1920
|
}
|
|
1795
1921
|
}
|
|
@@ -1925,7 +2051,7 @@ function addPlaceholderPayload(slide, label, description, region, context) {
|
|
|
1925
2051
|
margin: 0,
|
|
1926
2052
|
fontFace: context.fonts.body,
|
|
1927
2053
|
fontSize: 11,
|
|
1928
|
-
color: context.
|
|
2054
|
+
color: context.mutedColor,
|
|
1929
2055
|
fit: "shrink",
|
|
1930
2056
|
valign: "mid",
|
|
1931
2057
|
align: "center"
|
|
@@ -1993,7 +2119,7 @@ function textBoxOptions(region, context, fontSize) {
|
|
|
1993
2119
|
lineSpacingMultiple: 1.22,
|
|
1994
2120
|
fontFace: context.fonts.body,
|
|
1995
2121
|
fontSize,
|
|
1996
|
-
color: context.
|
|
2122
|
+
color: context.textColor,
|
|
1997
2123
|
breakLine: false,
|
|
1998
2124
|
fit: "shrink",
|
|
1999
2125
|
valign: "top"
|
|
@@ -2007,7 +2133,7 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
2007
2133
|
return {
|
|
2008
2134
|
text: run,
|
|
2009
2135
|
options: {
|
|
2010
|
-
color: context.
|
|
2136
|
+
color: context.textColor,
|
|
2011
2137
|
fontFace: context.fonts.body,
|
|
2012
2138
|
fontSize: fallbackFontSize
|
|
2013
2139
|
}
|
|
@@ -2018,9 +2144,9 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
2018
2144
|
options: {
|
|
2019
2145
|
bold: run?.bold,
|
|
2020
2146
|
italic: run?.italic,
|
|
2021
|
-
underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context) } : undefined,
|
|
2147
|
+
underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor) } : undefined,
|
|
2022
2148
|
strike: run?.strikethrough ? "sngStrike" : undefined,
|
|
2023
|
-
color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context),
|
|
2149
|
+
color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor),
|
|
2024
2150
|
fontFace: run?.fontFamily ?? context.fonts.body,
|
|
2025
2151
|
fontSize: run?.fontSize ?? fallbackFontSize,
|
|
2026
2152
|
superscript: run?.superscript,
|
|
@@ -2046,7 +2172,16 @@ function toPptxChartData(chart) {
|
|
|
2046
2172
|
}
|
|
2047
2173
|
if (data.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
|
|
2048
2174
|
if (data.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
|
|
2049
|
-
const
|
|
2175
|
+
const resolved = resolveChartType(chart.type).spec;
|
|
2176
|
+
// Chartex types (treemap, box & whisker, waterfall, ...) have no classic construct here: they export as a clustered column chart, never silently.
|
|
2177
|
+
const chartex = resolved.family === 'chartex';
|
|
2178
|
+
const spec = chartex ? CHARTEX_FALLBACK : resolved;
|
|
2179
|
+
const typeName = stringifyText(chart.type);
|
|
2180
|
+
const adaptations = [];
|
|
2181
|
+
if (chartex) {
|
|
2182
|
+
adaptations.push({adaptation: "chartex-fallback", message: `The '${typeName}' chart is a PowerPoint extension (chartex) chart that this exporter does not write; its data is exported as a native clustered column chart instead.`});
|
|
2183
|
+
}
|
|
2184
|
+
const mapped = {type: spec.pptx, spec, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined};
|
|
2050
2185
|
if (data.columns.length === 1) {
|
|
2051
2186
|
const heading = stringifyText(data.columns[0]);
|
|
2052
2187
|
const cell = (row) => Array.isArray(row) ? row[0] : row;
|
|
@@ -2060,10 +2195,12 @@ function toPptxChartData(chart) {
|
|
|
2060
2195
|
if (String(chart.type ?? "").toLowerCase() === "histogram") {
|
|
2061
2196
|
const bins = histogramBins(points.map((point) => point.value));
|
|
2062
2197
|
return {
|
|
2063
|
-
type: "bar", barDir: "col", barGrouping: "clustered", heading: "Bin",
|
|
2198
|
+
type: "bar", spec: CHARTEX_FALLBACK, barDir: "col", barGrouping: "clustered", heading: "Bin",
|
|
2064
2199
|
series: [{name: "Frequency", labels: bins.map((bin) => bin.label), values: bins.map((bin) => bin.count)}],
|
|
2065
|
-
|
|
2066
|
-
|
|
2200
|
+
adaptations: [{
|
|
2201
|
+
adaptation: "histogram-binned",
|
|
2202
|
+
message: `The histogram's single data column '${heading}' (${points.length} values${skippedNote}) was binned into ${bins.length} equal-width bins and exported as a column chart of the counts; PowerPoint's own histogram chart is not exported, and the binned counts do not restore the raw values on re-import.`
|
|
2203
|
+
}]
|
|
2067
2204
|
};
|
|
2068
2205
|
}
|
|
2069
2206
|
// Rows keep their own row numbers, so a skipped cell leaves a gap in the numbering.
|
|
@@ -2072,22 +2209,31 @@ function toPptxChartData(chart) {
|
|
|
2072
2209
|
const series = mapped.type === "scatter"
|
|
2073
2210
|
? [{name: "Row", labels, values: points.map((point) => point.row)}, {name: heading, labels, values: numbers}]
|
|
2074
2211
|
: [{name: heading, labels, values: numbers}];
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
message: `The chart's single data column '${heading}' has no category column, so its ${points.length} values${skippedNote} are plotted against their row numbers.`
|
|
2078
|
-
};
|
|
2212
|
+
adaptations.push({adaptation: "row-numbers", message: `The chart's single data column '${heading}' has no category column, so its ${points.length} values${skippedNote} are plotted against their row numbers.`});
|
|
2213
|
+
return {...mapped, series, heading: "Row", adaptations};
|
|
2079
2214
|
}
|
|
2080
2215
|
|
|
2081
2216
|
const labels = data.rows.map((row) => stringifyText(row?.[0]));
|
|
2082
|
-
|
|
2217
|
+
let series = data.columns.slice(1).map((name, seriesIndex) => ({
|
|
2083
2218
|
name: stringifyText(name),
|
|
2084
2219
|
labels,
|
|
2085
2220
|
values: data.rows.map((row) => numericValue(row?.[seriesIndex + 1]))
|
|
2086
2221
|
}));
|
|
2087
|
-
|
|
2088
|
-
|
|
2222
|
+
if (spec.family === 'circular' && series.length > 1) {
|
|
2223
|
+
// A pie or doughnut plots one series; name the ones left out instead of dropping them silently.
|
|
2224
|
+
const dropped = series.slice(1).map((entry) => `'${entry.name}'`);
|
|
2225
|
+
adaptations.push({adaptation: "series-dropped", message: `The ${typeName} chart plots one series, so its first series '${series[0].name}' is exported and the other ${dropped.length} (${dropped.join(", ")}) ${dropped.length === 1 ? "is" : "are"} not.`});
|
|
2226
|
+
series = series.slice(0, 1);
|
|
2227
|
+
}
|
|
2228
|
+
if (spec.family === 'xy' && series.length === 1) {
|
|
2229
|
+
// [Point, X, Y...] carries its own X column; a lone value column is plotted against the row numbers.
|
|
2230
|
+
series = [{name: 'X', labels, values: data.rows.map((_, index) => index + 1)}, ...series];
|
|
2231
|
+
adaptations.push({adaptation: "row-numbers", message: `The scatter chart has no X column (a point label column and one value column), so its ${data.rows.length} values are plotted against their row numbers.`});
|
|
2232
|
+
}
|
|
2233
|
+
return {type: spec.pptx, spec, series, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined, adaptations};
|
|
2089
2234
|
}
|
|
2090
2235
|
|
|
2236
|
+
|
|
2091
2237
|
/**
|
|
2092
2238
|
* Equal-width bins (Sturges' count, at most 50) from the minimum to the maximum; every bin but the last is [low, high).
|
|
2093
2239
|
* Arithmetic stays finite for any finite input (values of +-1e308 and denormals included): the bin edges are
|
|
@@ -2113,28 +2259,6 @@ function histogramBins(values) {
|
|
|
2113
2259
|
}
|
|
2114
2260
|
}
|
|
2115
2261
|
|
|
2116
|
-
function mapChartType(type) {
|
|
2117
|
-
const normalized = String(type ?? "").toLowerCase();
|
|
2118
|
-
if (normalized.includes("pie")) return { type: "pie" };
|
|
2119
|
-
if (normalized.includes("doughnut") || normalized.includes("donut")) return { type: "doughnut" };
|
|
2120
|
-
if (normalized.includes("area")) return { type: "area" };
|
|
2121
|
-
if (normalized.includes("line") || normalized.includes("sparkline")) return { type: "line" };
|
|
2122
|
-
if (normalized.includes("scatter")) return { type: "scatter" };
|
|
2123
|
-
if (normalized.includes("radar")) return { type: "radar" };
|
|
2124
|
-
if (normalized.includes("bar")) {
|
|
2125
|
-
return {
|
|
2126
|
-
type: "bar",
|
|
2127
|
-
barDir: "bar",
|
|
2128
|
-
barGrouping: normalized.includes("stacked") ? "stacked" : "clustered"
|
|
2129
|
-
};
|
|
2130
|
-
}
|
|
2131
|
-
return {
|
|
2132
|
-
type: "bar",
|
|
2133
|
-
barDir: "col",
|
|
2134
|
-
barGrouping: normalized.includes("stacked") ? "stacked" : "clustered"
|
|
2135
|
-
};
|
|
2136
|
-
}
|
|
2137
|
-
|
|
2138
2262
|
async function resolveImage(asset, presentation, options, path) {
|
|
2139
2263
|
const assetObject = dereferenceAsset(asset, presentation, path);
|
|
2140
2264
|
const src = typeof assetObject === "string" ? assetObject : assetObject?.src;
|
|
@@ -2353,8 +2477,9 @@ async function normalizePptxZip(raw, context) {
|
|
|
2353
2477
|
if(!context.chartHeadings.has(name))continue;
|
|
2354
2478
|
const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
|
|
2355
2479
|
if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
|
|
2356
|
-
const {heading,labelColor}=context.chartHeadings.get(name);
|
|
2480
|
+
const {heading,labelColor,spec}=context.chartHeadings.get(name);
|
|
2357
2481
|
if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
|
|
2482
|
+
entries[chartPart]=encodeText(applyChartConstruct(decodeText(entries[chartPart]),spec));
|
|
2358
2483
|
// PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
|
|
2359
2484
|
// Normalize only our generated chart text styles; point/series fills stay intact.
|
|
2360
2485
|
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>`)));
|
|
@@ -2368,7 +2493,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
2368
2493
|
const relationships = parseRelationships(entries, part);
|
|
2369
2494
|
for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
|
|
2370
2495
|
const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
2371
|
-
const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name);
|
|
2496
|
+
const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : undefined);
|
|
2372
2497
|
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
2373
2498
|
if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
|
|
2374
2499
|
}
|
|
@@ -2398,6 +2523,11 @@ async function normalizePptxZip(raw, context) {
|
|
|
2398
2523
|
placeSlideImages(entries, context.slideImages, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
|
|
2399
2524
|
throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
|
|
2400
2525
|
});
|
|
2526
|
+
placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
|
|
2527
|
+
throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
|
|
2528
|
+
});
|
|
2529
|
+
// A picture repeated across slides embeds once (after fitting, which needs each slide's own relationship).
|
|
2530
|
+
dedupeMedia(entries, imageMetadata, resolveRelationshipTarget);
|
|
2401
2531
|
// Charts and notes follow the language and fonts of the slide they belong to.
|
|
2402
2532
|
context.partSlides = new Map();
|
|
2403
2533
|
for (const part of Object.keys(entries)) {
|
|
@@ -2507,8 +2637,11 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
2507
2637
|
xml = xml.replace('</Types>', `${overrides}</Types>`);
|
|
2508
2638
|
}
|
|
2509
2639
|
if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path)) xml = themeMasterBulletFonts(xml);
|
|
2640
|
+
if (context.masterBackground && path === 'ppt/slideMasters/slideMaster1.xml') xml = writeMasterBackground(xml, context.masterBackground);
|
|
2641
|
+
if (context.masterBackground && /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path)) xml = inheritLayoutBackground(xml);
|
|
2510
2642
|
if (path === 'ppt/theme/theme1.xml') xml = writeThemeColors(xml, {colors: context.themeColors, schemeName: context.schemeName, themeName: context.themeName});
|
|
2511
2643
|
if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
|
|
2644
|
+
xml = writeLinkSentinels(xml, context.linkSentinels);
|
|
2512
2645
|
const fill = context.backgroundFills.get(path);
|
|
2513
2646
|
if (typeof fill === 'string') xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`);
|
|
2514
2647
|
else if (fill?.image) {
|
|
@@ -2579,10 +2712,10 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
2579
2712
|
for (const [edge, native] of [['left','lnL'],['right','lnR'],['top','lnT'],['bottom','lnB']]) {
|
|
2580
2713
|
const border = style.borders?.[edge];
|
|
2581
2714
|
if (!border) continue;
|
|
2582
|
-
const borderHex = exportColor(border.color,
|
|
2715
|
+
const slideContext = table.context, borderHex = exportColor(border.color, slideContext, slideContext.colors.border);
|
|
2583
2716
|
const borderOpacity = borderHex.length === 8 ? parseInt(borderHex.slice(6), 16) / 255 : 1;
|
|
2584
|
-
const borderScheme = borderOpacity === 1 ? schemeColorValue(border.color, borderHex,
|
|
2585
|
-
const fill = border.width === 0 ? '<a:noFill/>' : borderScheme ? `<a:solidFill><a:schemeClr val="${borderScheme}"/></a:solidFill>` : nativeBackgroundFill({type:'solid',color:'#' + borderHex.slice(0, 6), opacity: borderOpacity},{width:1,height:1},
|
|
2717
|
+
const borderScheme = borderOpacity === 1 ? schemeColorValue(border.color, borderHex, slideContext) : undefined;
|
|
2718
|
+
const fill = border.width === 0 ? '<a:noFill/>' : borderScheme ? `<a:solidFill><a:schemeClr val="${borderScheme}"/></a:solidFill>` : nativeBackgroundFill({type:'solid',color:'#' + borderHex.slice(0, 6), opacity: borderOpacity},{width:1,height:1}, slideContext.colors.border);
|
|
2586
2719
|
const dash = {solid:'solid',dash:'dash',dot:'sysDot'}[border.dash ?? 'solid'];
|
|
2587
2720
|
const line = `<a:${native} w="${Math.round(border.width * table.scale * 9525)}" cap="flat" cmpd="sng" algn="ctr">${fill}<a:prstDash val="${dash}"/></a:${native}>`;
|
|
2588
2721
|
const existing = new RegExp(`<a:${native}\\b[^>]*>[\\s\\S]*?<\\/a:${native}>`);
|
|
@@ -2618,7 +2751,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
2618
2751
|
const marker=context.listMarkers.get(shape.match(/name="(OPF list [^"]* line \d+)"/)?.[1]);
|
|
2619
2752
|
if(!marker)return shape;
|
|
2620
2753
|
const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
2621
|
-
return shape.replace(/<a:buSzPct val="100000"\/>/g,`<a:buClr
|
|
2754
|
+
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}"/>`);
|
|
2622
2755
|
});
|
|
2623
2756
|
// PptxGenJS gives shapes no alternative text. An unavailable image's panel
|
|
2624
2757
|
// carries the accessible name the preview gives its group.
|