@openpresentation/opf-pptx 0.11.1 → 0.11.3
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 +33 -8
- package/dist/chart-types.js +212 -0
- package/dist/chart-workbook.js +4 -2
- package/dist/chartex.js +375 -0
- package/dist/index.d.ts +18 -3
- package/dist/index.js +243 -101
- package/dist/script-fonts.js +1 -1
- package/dist/theme-colors.js +92 -6
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -3,6 +3,8 @@ 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';
|
|
7
|
+
import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
|
|
6
8
|
import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
7
9
|
import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
|
|
8
10
|
import {attachCardTags,importCardFrames} from './card-provenance.js';
|
|
@@ -19,7 +21,7 @@ import {dedupeMedia} from './media-dedupe.js';
|
|
|
19
21
|
import {placeWatermarks, importWatermark, watermarkName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
|
|
20
22
|
import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
|
|
21
23
|
import {importBackground} from './background-import.js';
|
|
22
|
-
import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
|
|
24
|
+
import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
|
|
23
25
|
import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
|
|
24
26
|
import { webpToPng } from '#image-fallback';
|
|
25
27
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
@@ -223,7 +225,14 @@ export async function toPptx(input, options = {}) {
|
|
|
223
225
|
context.metricTags = new Map();
|
|
224
226
|
context.chartHeadings = new Map();
|
|
225
227
|
context.chartFonts = new Map();
|
|
228
|
+
context.chartex = new Map();
|
|
226
229
|
context.imageFormat = options.imageFormat ?? "compatible";
|
|
230
|
+
// Native chartex parts are opt-in until the native PowerPoint check confirms them (FF-22b); the default keeps the
|
|
231
|
+
// clustered column export of the chartex chart types.
|
|
232
|
+
if (options.chartex !== undefined && !['native', 'fallback'].includes(options.chartex)) {
|
|
233
|
+
throw new OPFPptxError('invalid-chartex-mode', 'chartex must be native or fallback.', {path: 'options.chartex'});
|
|
234
|
+
}
|
|
235
|
+
context.chartexMode = options.chartex ?? 'fallback';
|
|
227
236
|
Object.assign(context, exportTheme(presentation, context));
|
|
228
237
|
context.reportedFontSchemes = new Set();
|
|
229
238
|
// Document references and metadata tags (FF-32, docs/document-roundtrip.md).
|
|
@@ -233,6 +242,8 @@ export async function toPptx(input, options = {}) {
|
|
|
233
242
|
report: diagnostic => options.onDiagnostic?.(diagnostic)
|
|
234
243
|
});
|
|
235
244
|
context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
|
|
245
|
+
context.masterBackground = masterBackground(context);
|
|
246
|
+
context.linkSentinels = linkSentinels(presentation);
|
|
236
247
|
const pptx = new PptxGenJS();
|
|
237
248
|
configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
|
|
238
249
|
|
|
@@ -610,6 +621,15 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
610
621
|
const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
|
|
611
622
|
if (item) items.push(item);
|
|
612
623
|
}
|
|
624
|
+
// A chartex chart is an mc:AlternateContent: the choice frame references the cx:chartSpace part; the fallback is a classic chart frame
|
|
625
|
+
// (this exporter) or a text shape (PowerPoint). The choice is read first and the fallback only when it names no OPF chart type.
|
|
626
|
+
for (const alternate of asArray(tree?.["mc:AlternateContent"])) {
|
|
627
|
+
const choice = asArray(alternate?.["mc:Choice"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
|
|
628
|
+
const fallback = asArray(alternate?.["mc:Fallback"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
|
|
629
|
+
const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships) : null;
|
|
630
|
+
const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships) : chosen;
|
|
631
|
+
if (item && item.kind !== "unknown") items.push(item);
|
|
632
|
+
}
|
|
613
633
|
|
|
614
634
|
for (const [index, picture] of nativeContext.pictures.entries()) {
|
|
615
635
|
if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index)) continue;
|
|
@@ -665,9 +685,12 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
|
|
|
665
685
|
};
|
|
666
686
|
}
|
|
667
687
|
|
|
668
|
-
const
|
|
688
|
+
const chartExRelId = graphicData?.uri === CHARTEX_GRAPHIC_DATA_URI ? graphicData?.["cx:chart"]?.["r:id"] : undefined;
|
|
689
|
+
const chartRelId = graphicData?.["c:chart"]?.["r:id"] ?? chartExRelId;
|
|
669
690
|
if (chartRelId) {
|
|
670
|
-
const chart =
|
|
691
|
+
const chart = chartExRelId
|
|
692
|
+
? chartexFromRelationship(entries, relationships, chartExRelId)
|
|
693
|
+
: chartFromRelationship(entries, slidePath, relationships, chartRelId);
|
|
671
694
|
return {
|
|
672
695
|
kind: "chart",
|
|
673
696
|
bounds,
|
|
@@ -851,6 +874,7 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
851
874
|
|
|
852
875
|
const budget = { cells: 0 };
|
|
853
876
|
const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
|
|
877
|
+
if (chartNode.type === 'scatter') return scatterFromSeries(entries, relationship.path, series, budget, cachePath);
|
|
854
878
|
const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
|
|
855
879
|
const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
|
|
856
880
|
const values = series.map((entry, index) => {
|
|
@@ -880,19 +904,48 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
880
904
|
};
|
|
881
905
|
}
|
|
882
906
|
|
|
907
|
+
// Scatter charts share X values (c:xVal) across Y series (c:yVal). OPF keeps
|
|
908
|
+
// them category-major: [point label, X, Y1, Y2, ...]; native charts carry no
|
|
909
|
+
// point labels, so points are numbered.
|
|
910
|
+
function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
|
|
911
|
+
// The same cache helpers as every other chart: bounded, indexed by c:pt@idx, with gaps kept as null (never plotted as 0).
|
|
912
|
+
const xRole = series[0]?.["c:xVal"];
|
|
913
|
+
const xs = cachedValues(xRole, cachePath(0, "c:xVal"), budget, numericCacheValue);
|
|
914
|
+
const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, "c:tx"), budget) ?? `Series ${index + 1}`);
|
|
915
|
+
const values = series.map((entry, index) => cachedValues(entry?.["c:yVal"], cachePath(index, "c:yVal"), budget, numericCacheValue));
|
|
916
|
+
const rowCount = values.reduce((count, row) => Math.max(count, row.length), xs.length);
|
|
917
|
+
if (rowCount === 0) return null;
|
|
918
|
+
if (rowCount * (series.length + 2) > MAX_CHART_CACHE_CELLS) {
|
|
919
|
+
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});
|
|
920
|
+
}
|
|
921
|
+
const rows = [];
|
|
922
|
+
// A chart without c:xVal plots against 1..n natively; a c:xVal gap stays a gap.
|
|
923
|
+
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)]);
|
|
924
|
+
return { type: "scatter", data: { columns: ["Point", readChartCategoryHeading(entries, chartPart) ?? "X", ...names], rows } };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// A chartex part (cx:chartSpace): the series layoutIds name the kept OPF chart
|
|
928
|
+
// type and the cached dimensions restore the category-major data, under the
|
|
929
|
+
// same bounds as the classic chart cache.
|
|
930
|
+
function chartexFromRelationship(entries, relationships, relId) {
|
|
931
|
+
const relationship = relationships.get(relId);
|
|
932
|
+
if (!relationship?.path || !entries[relationship.path]) return null;
|
|
933
|
+
const doc = parseRequiredXml(entries, relationship.path);
|
|
934
|
+
return chartFromChartex(doc, {
|
|
935
|
+
heading: readChartCategoryHeading(entries, relationship.path),
|
|
936
|
+
limits: {points: MAX_CHART_CACHE_POINTS, cells: MAX_CHART_CACHE_CELLS},
|
|
937
|
+
path: relationship.path,
|
|
938
|
+
invalid: (message, location) => {
|
|
939
|
+
throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: location ?? relationship.path});
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
883
944
|
function firstChartNode(plotArea) {
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
["c:doughnutChart", () => "doughnut"],
|
|
889
|
-
["c:areaChart", () => "area"],
|
|
890
|
-
["c:scatterChart", () => "scatter"],
|
|
891
|
-
["c:radarChart", () => "radar"]
|
|
892
|
-
];
|
|
893
|
-
for (const [key, type] of candidates) {
|
|
894
|
-
const node = asArray(plotArea[key])[0];
|
|
895
|
-
if (node) return { node, type: type(node) };
|
|
945
|
+
// Map the native construct back to the kept OPF chart type id (FF-22).
|
|
946
|
+
for (const element of NATIVE_CHART_ELEMENTS) {
|
|
947
|
+
const node = asArray(plotArea[`c:${element}`])[0];
|
|
948
|
+
if (node) return { node, type: chartTypeFromNative(element, node) };
|
|
896
949
|
}
|
|
897
950
|
return null;
|
|
898
951
|
}
|
|
@@ -1133,8 +1186,48 @@ function exportColor(entry, context, fallback) {
|
|
|
1133
1186
|
// PptxGenJS color for a document color reference: a theme scheme value when the
|
|
1134
1187
|
// reference names a scheme slot or role that the deck theme holds exactly,
|
|
1135
1188
|
// otherwise the resolved literal RRGGBB. Alpha stays in the caller's transparency.
|
|
1136
|
-
function nativeColor(reference, hex, context) {
|
|
1137
|
-
|
|
1189
|
+
function nativeColor(reference, hex, context, fallback) {
|
|
1190
|
+
if (fallback !== undefined && (reference === undefined || reference === null || reference === '')) return pptxColor(fallback);
|
|
1191
|
+
const value = schemeColorValue(reference, hex, context, {vendor: true});
|
|
1192
|
+
if (value) return value;
|
|
1193
|
+
// PptxGenJS cannot write hlink or folHlink: a reserved literal stands in and
|
|
1194
|
+
// is rewritten to the scheme color in the finished slide part.
|
|
1195
|
+
const link = schemeColorValue(reference, hex, context);
|
|
1196
|
+
return (link && context.linkSentinels?.[link]) ?? normalizeHex(hex);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// FF-24c: two literals no document color uses stand in for hlink and folHlink
|
|
1200
|
+
// (see nativeColor). When the document uses every candidate, those colors stay literal.
|
|
1201
|
+
const LINK_SENTINELS = [['FE01A0', 'FE01A1'], ['FE02B0', 'FE02B1'], ['FE03C0', 'FE03C1']];
|
|
1202
|
+
function linkSentinels(presentation) {
|
|
1203
|
+
const text = JSON.stringify(presentation).toUpperCase();
|
|
1204
|
+
const pair = LINK_SENTINELS.find(hexes => hexes.every(hex => !text.includes(hex)));
|
|
1205
|
+
return pair ? {hlink: pair[0], folHlink: pair[1]} : {};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function writeLinkSentinels(xml, sentinels) {
|
|
1209
|
+
const values = Object.entries(sentinels ?? {});
|
|
1210
|
+
if (!values.length) return xml;
|
|
1211
|
+
return xml.replace(/<a:srgbClr val="([0-9A-F]{6})"(\/>|>[\s\S]*?<\/a:srgbClr>)/g, (node, hex, rest) => {
|
|
1212
|
+
const link = values.find(([, value]) => value === hex)?.[0];
|
|
1213
|
+
return link ? `<a:schemeClr val="${link}"${rest.replace(/<\/a:srgbClr>$/, '</a:schemeClr>')}` : node;
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// PptxGenJS color option: a scheme value it can emit (tx1, bg2, ...) or RRGGBB.
|
|
1218
|
+
function pptxColor(value) {
|
|
1219
|
+
return /^(?:tx[12]|bg[12])$/.test(value) ? value : normalizeHex(value);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// Deck-level theme background for the slide master, so slides added in
|
|
1223
|
+
// PowerPoint match. The vendored layout already uses bg1, so only a theme
|
|
1224
|
+
// background on another slot (dark themes, light2, accents) changes the master.
|
|
1225
|
+
function masterBackground(context) {
|
|
1226
|
+
const deck = {...context, schemeOverride: false};
|
|
1227
|
+
const value = schemeBackgroundValue(deck.backgroundDefinition, deck);
|
|
1228
|
+
const fill = schemeBackgroundFill(deck.backgroundDefinition, deck);
|
|
1229
|
+
if (!value || value === 'bg1' || !fill || fill.includes('<a:alpha')) return null;
|
|
1230
|
+
return {fill, text: defaultTextSchemeValues(deck).text ?? normalizeHex(context.colors.text)};
|
|
1138
1231
|
}
|
|
1139
1232
|
|
|
1140
1233
|
// The theme part carries the deck-level scheme (slide overrides stay literal)
|
|
@@ -1225,7 +1318,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1225
1318
|
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.'});
|
|
1226
1319
|
}
|
|
1227
1320
|
}
|
|
1228
|
-
slide.color = slideContext.
|
|
1321
|
+
slide.color = slideContext.textColor;
|
|
1229
1322
|
if (opfSlide.hidden === true) slide.hidden = true;
|
|
1230
1323
|
|
|
1231
1324
|
const { widthInches, heightInches } = slideContext.dimensions;
|
|
@@ -1247,49 +1340,51 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1247
1340
|
if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
|
|
1248
1341
|
await addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options);
|
|
1249
1342
|
for (const item of geometry.items) {
|
|
1343
|
+
// Card text sits on a literal card fill, not the slide background: keep it literal.
|
|
1344
|
+
const itemContext = item.frameBox ? {...slideContext, textColor: slideContext.colors.text, mutedColor: slideContext.colors.mutedText} : slideContext;
|
|
1250
1345
|
const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
|
|
1251
1346
|
if (item.frameBox) {
|
|
1252
1347
|
const frame=item.frameBox;
|
|
1253
1348
|
context.cardTags.set(`OPF card ${item.path}`,item.path);
|
|
1254
1349
|
const paint=value=>({color:normalizeHex(value),transparency:/^#[0-9a-f]{8}$/i.test(value)?(1-parseInt(value.slice(7),16)/255)*100:0});
|
|
1255
|
-
const surface=
|
|
1350
|
+
const surface=itemContext.colorScheme[isDarkHex(itemContext.colors.background)?'dark2':'light2']??`#${itemContext.colors.surface}`;
|
|
1256
1351
|
slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
|
|
1257
1352
|
rectRadius:8*Math.min(widthInches,heightInches)/720,
|
|
1258
|
-
fill:paint(surface),line:{...paint(
|
|
1353
|
+
fill:paint(surface),line:{...paint(itemContext.colorScheme.accent5??`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
|
|
1259
1354
|
}
|
|
1260
1355
|
if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
|
|
1261
|
-
addMeasuredPayloadText(slide,item.value,item.box,
|
|
1356
|
+
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});
|
|
1262
1357
|
} else if (["title", "subtitle", "tag"].includes(item.field)) {
|
|
1263
1358
|
// Estimated-font headings use one native text box, which still needs a
|
|
1264
1359
|
// role tag. Role recovery must not depend on outline measurement support.
|
|
1265
1360
|
const objectName = `OPF heading ${item.path} line 0`;
|
|
1266
1361
|
context.headingTags.set(objectName,{v:1,group:item.path,field:item.field,line:0,count:1});
|
|
1267
1362
|
slide.addText(item.text.lines.join("\n"), {
|
|
1268
|
-
...textBoxOptions(region,
|
|
1363
|
+
...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
|
|
1269
1364
|
...nativeFontOptions(item.textStyle),
|
|
1270
|
-
color: item.field === "tag" ?
|
|
1365
|
+
color: item.field === "tag" ? itemContext.colors.accent : itemContext.textColor,
|
|
1271
1366
|
align: alignmentFor(item),
|
|
1272
1367
|
objectName,
|
|
1273
1368
|
breakLine: false
|
|
1274
1369
|
});
|
|
1275
1370
|
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
1276
|
-
addMeasuredList(slide,item.text,
|
|
1371
|
+
addMeasuredList(slide,item.text,itemContext,item.path);
|
|
1277
1372
|
} else if (item.field === "text" && item.text?.richLines) {
|
|
1278
1373
|
const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
|
|
1279
1374
|
for(const [index,line] of item.text.richLines.entries()){
|
|
1280
1375
|
const runs=line.fragments.map(fragment=>{
|
|
1281
|
-
const runColor=exportColor(fragment.run.color,
|
|
1282
|
-
const color=nativeColor(fragment.run.color,runColor,
|
|
1376
|
+
const runColor=exportColor(fragment.run.color,itemContext,itemContext.colors.text);
|
|
1377
|
+
const color=nativeColor(fragment.run.color,runColor,itemContext,itemContext.textColor);
|
|
1283
1378
|
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}};
|
|
1284
1379
|
});
|
|
1285
1380
|
const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
1286
1381
|
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};
|
|
1287
|
-
if(runs.length)slide.addText(runs,{...textBoxOptions(area,
|
|
1382
|
+
if(runs.length)slide.addText(runs,{...textBoxOptions(area,itemContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
|
|
1288
1383
|
}
|
|
1289
1384
|
} else if (item.field === "text" && typeof item.value === "string") {
|
|
1290
|
-
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region,
|
|
1385
|
+
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
|
|
1291
1386
|
} else {
|
|
1292
|
-
await addPayload(slide, presentation, item.payload, region, item.path, { ...
|
|
1387
|
+
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);
|
|
1293
1388
|
}
|
|
1294
1389
|
}
|
|
1295
1390
|
// Core composes furniture above all content and opf-render paints it last, so
|
|
@@ -1321,7 +1416,17 @@ function resolveSlideContext(presentation, slide, baseContext, options, slideInd
|
|
|
1321
1416
|
|| Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
|
|
1322
1417
|
throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
|
|
1323
1418
|
}
|
|
1324
|
-
|
|
1419
|
+
const slideContext = { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, variables: resolved.variables, imageFill: effective.design.imageFill ?? "fit" };
|
|
1420
|
+
// A slide-level color scheme (directly or through a slide theme) pins every
|
|
1421
|
+
// named color on that slide to literal sRGB; see schemeColorValue().
|
|
1422
|
+
const slideScheme = themeSlotColors(resolved.colorScheme), deckScheme = baseContext.themeColors ?? {};
|
|
1423
|
+
slideContext.schemeOverride = slide.design?.colorScheme !== undefined
|
|
1424
|
+
|| Object.keys({...slideScheme, ...deckScheme}).some(slot => slideScheme[slot] !== deckScheme[slot]);
|
|
1425
|
+
// Default and muted text follow the theme only where the background does.
|
|
1426
|
+
const text = defaultTextSchemeValues(slideContext);
|
|
1427
|
+
slideContext.textColor = text.text ?? resolved.colors.text;
|
|
1428
|
+
slideContext.mutedColor = text.muted ?? resolved.colors.mutedText;
|
|
1429
|
+
return slideContext;
|
|
1325
1430
|
}
|
|
1326
1431
|
|
|
1327
1432
|
function fieldToType(field) {
|
|
@@ -1389,10 +1494,10 @@ function addTextPayload(slide, value, region, context) {
|
|
|
1389
1494
|
slide.addText(stringifyText(value), textBoxOptions(region, context, 18));
|
|
1390
1495
|
}
|
|
1391
1496
|
|
|
1392
|
-
function richLineRuns(line,color,context) {
|
|
1497
|
+
function richLineRuns(line,color,native,context) {
|
|
1393
1498
|
const fallback=color.replace(/^#/,'');
|
|
1394
1499
|
return line.fragments.map(fragment=>{
|
|
1395
|
-
const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context);
|
|
1500
|
+
const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context,native);
|
|
1396
1501
|
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}};
|
|
1397
1502
|
});
|
|
1398
1503
|
}
|
|
@@ -1404,14 +1509,14 @@ function richLineRuns(line,color,context) {
|
|
|
1404
1509
|
function addMeasuredList(slide,fit,context,path) {
|
|
1405
1510
|
let lineNumber=0;
|
|
1406
1511
|
for(const entry of fit.listEntries){
|
|
1407
|
-
const addLines=(text,box,color,withBullet)=>{
|
|
1512
|
+
const addLines=(text,box,color,native,withBullet)=>{
|
|
1408
1513
|
text.richLines.forEach((line,index)=>{
|
|
1409
1514
|
const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
|
|
1410
1515
|
const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
|
|
1411
1516
|
const objectName=`OPF list ${path} line ${lineNumber++}`;
|
|
1412
|
-
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:
|
|
1517
|
+
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor)});
|
|
1413
1518
|
const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
|
|
1414
|
-
const runs=richLineRuns(line,color,context);
|
|
1519
|
+
const runs=richLineRuns(line,color,native,context);
|
|
1415
1520
|
if(!runs.length)runs.push({text:'',options:{}});
|
|
1416
1521
|
// Keep paragraph intent identical across runs. ZIP normalization below
|
|
1417
1522
|
// removes the duplicate paragraph-property nodes emitted by PptxGenJS.
|
|
@@ -1419,8 +1524,8 @@ function addMeasuredList(slide,fit,context,path) {
|
|
|
1419
1524
|
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});
|
|
1420
1525
|
});
|
|
1421
1526
|
};
|
|
1422
|
-
addLines(entry.text,entry.textBox,context.colors.text,true);
|
|
1423
|
-
if(entry.description)addLines(entry.description,entry.descriptionBox,context.colors.mutedText,false);
|
|
1527
|
+
addLines(entry.text,entry.textBox,context.colors.text,context.textColor,true);
|
|
1528
|
+
if(entry.description)addLines(entry.description,entry.descriptionBox,context.colors.mutedText,context.mutedColor,false);
|
|
1424
1529
|
}
|
|
1425
1530
|
}
|
|
1426
1531
|
|
|
@@ -1440,7 +1545,7 @@ function addListPayload(slide, items, region, context) {
|
|
|
1440
1545
|
options: {
|
|
1441
1546
|
bullet: { type: "bullet", indent: 14 + level * 14 },
|
|
1442
1547
|
breakLine: index < list.length - 1 || Boolean(isPlainObject(item) && item.description),
|
|
1443
|
-
color: context.
|
|
1548
|
+
color: context.textColor,
|
|
1444
1549
|
fontFace: context.fonts.body,
|
|
1445
1550
|
fontSize: 15,
|
|
1446
1551
|
hanging: 4 + level * 10
|
|
@@ -1451,7 +1556,7 @@ function addListPayload(slide, items, region, context) {
|
|
|
1451
1556
|
text: stringifyText(item.description),
|
|
1452
1557
|
options: {
|
|
1453
1558
|
breakLine: index < list.length - 1,
|
|
1454
|
-
color: context.
|
|
1559
|
+
color: context.mutedColor,
|
|
1455
1560
|
fontFace: context.fonts.body,
|
|
1456
1561
|
fontSize: 11,
|
|
1457
1562
|
margin: [0, 0, 0, 18 + level * 14]
|
|
@@ -1546,14 +1651,14 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
|
|
|
1546
1651
|
}
|
|
1547
1652
|
|
|
1548
1653
|
function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
|
|
1549
|
-
const chartData = toPptxChartData(chart);
|
|
1654
|
+
const chartData = toPptxChartData(chart, context.chartexMode);
|
|
1550
1655
|
if (!chartData.series) {
|
|
1551
1656
|
// Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
|
|
1552
1657
|
options.onDiagnostic?.({code: "chart-data-unplottable", path, message: chartData.message, reason: chartData.reason});
|
|
1553
1658
|
addPlaceholderPayload(slide, "Chart", chartData.summary, region, context);
|
|
1554
1659
|
return;
|
|
1555
1660
|
}
|
|
1556
|
-
|
|
1661
|
+
for (const {adaptation, message} of chartData.adaptations ?? []) options.onDiagnostic?.({code: "chart-data-adapted", path, message, adaptation});
|
|
1557
1662
|
|
|
1558
1663
|
// Keep the resolved palette surface (including alpha) explicit in native
|
|
1559
1664
|
// chart/plot areas, so inherited labels are assessed against their own panel.
|
|
@@ -1563,17 +1668,28 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1563
1668
|
const fill = {color:normalizeHex(panelFill),transparency};
|
|
1564
1669
|
const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
|
|
1565
1670
|
const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
|
|
1566
|
-
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor});
|
|
1671
|
+
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec});
|
|
1567
1672
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
1673
|
+
const palette = CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`)));
|
|
1674
|
+
if (chartData.chartex) {
|
|
1675
|
+
// The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
|
|
1676
|
+
// PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
|
|
1677
|
+
const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
|
|
1678
|
+
context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette});
|
|
1679
|
+
if (chartData.chartex.layoutId === 'regionMap') {
|
|
1680
|
+
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: PowerPoint matches the category names to regions and fetches their shapes from Bing Maps when the deck is opened online, so the map shows no regions offline or where Office map data is disabled. The clustered column fallback shows the same values in other readers.`});
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
const percent = chartData.spec.grouping === 'percentStacked';
|
|
1568
1684
|
slide.addChart(chartData.type, chartData.series, {
|
|
1569
1685
|
objectName,
|
|
1570
1686
|
x: region.x,
|
|
1571
1687
|
y: region.y,
|
|
1572
1688
|
w: region.w,
|
|
1573
1689
|
h: region.h,
|
|
1574
|
-
showLegend: circular || chartData.series.length > 1,
|
|
1690
|
+
showLegend: circular || chartData.series.length > (chartData.type === 'scatter' ? 2 : 1),
|
|
1575
1691
|
showTitle: false,
|
|
1576
|
-
chartColors:
|
|
1692
|
+
chartColors: palette,
|
|
1577
1693
|
chartArea: {fill:{...fill},roundedCorners:false},
|
|
1578
1694
|
// Paint alpha once in the chart area, rather than stacking two alpha fills.
|
|
1579
1695
|
plotArea: {fill:{color:null}},
|
|
@@ -1589,7 +1705,12 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1589
1705
|
showValue: false,
|
|
1590
1706
|
valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
|
|
1591
1707
|
barDir: chartData.barDir,
|
|
1592
|
-
barGrouping: chartData.barGrouping
|
|
1708
|
+
barGrouping: chartData.barGrouping,
|
|
1709
|
+
...(percent ? { valAxisLabelFormatCode: '0%' } : {}),
|
|
1710
|
+
...(chartData.spec.markers === undefined ? {} : { lineDataSymbol: chartData.spec.markers ? 'circle' : 'none' }),
|
|
1711
|
+
...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
|
|
1712
|
+
// ScatterWithMarkers: markers only, no connecting line.
|
|
1713
|
+
...(chartData.type === 'scatter' ? { lineSize: 0, lineDataSymbol: 'circle' } : {})
|
|
1593
1714
|
});
|
|
1594
1715
|
}
|
|
1595
1716
|
|
|
@@ -1612,8 +1733,13 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1612
1733
|
const cellStyle = cell.style ?? {};
|
|
1613
1734
|
const defaultFillHex = header ? context.colors.accent : context.colors.surface;
|
|
1614
1735
|
const baseFill = exportColor(cellStyle.fill ?? defaultFillHex, context, defaultFillHex);
|
|
1736
|
+
// FF-24c: engine chrome resolves from scheme roles (header accent1, body surface), so it follows the deck theme like a named fill.
|
|
1737
|
+
const fillReference = cellStyle.fill ?? (header ? 'accent1' : 'surface');
|
|
1738
|
+
const fillValue = schemeColorValue(fillReference, baseFill, context);
|
|
1615
1739
|
const inheritedText = textColorForFill(`#${baseFill}`, header ? "#FFFFFF" : `#${context.colors.text}`);
|
|
1616
1740
|
const baseColor = exportColor(cellStyle.color ?? inheritedText.replace(/^#/, ""), context, inheritedText.replace(/^#/, ""));
|
|
1741
|
+
// 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.
|
|
1742
|
+
const defaultText = cellStyle.color === undefined ? tableTextSchemeValue(fillValue, baseColor, context) : undefined;
|
|
1617
1743
|
const alpha = value => value.length === 8 ? (1 - parseInt(value.slice(6), 16) / 255) * 100 : 0;
|
|
1618
1744
|
const text = stringifyText(cell.value), style = cell.textStyle;
|
|
1619
1745
|
const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
|
|
@@ -1622,7 +1748,7 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1622
1748
|
const fragment = fragments.find(item => item.runIndex === index);
|
|
1623
1749
|
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);
|
|
1624
1750
|
const rawColor = run.color !== undefined && run.color !== '' ? exportColor(run.color, context, baseColor) : baseColor;
|
|
1625
|
-
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;
|
|
1751
|
+
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;
|
|
1626
1752
|
const runOptions = {
|
|
1627
1753
|
...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
|
|
1628
1754
|
underline:run.underline ? {style:'sng',color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
|
|
@@ -1652,17 +1778,17 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1652
1778
|
valign: cellStyle.verticalAlign ?? 'top',
|
|
1653
1779
|
...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
|
|
1654
1780
|
...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
|
|
1655
|
-
color: nativeColor(cellStyle.color, baseColor, context),
|
|
1781
|
+
color: nativeColor(cellStyle.color, baseColor, context, defaultText),
|
|
1656
1782
|
// Rich runs carry resolved alpha. A translucent cell default would
|
|
1657
1783
|
// overwrite an explicit opaque run because PptxGenJS inherits falsy 0.
|
|
1658
1784
|
...(cellStyle.color && !rich ? {transparency:alpha(baseColor)} : {}),
|
|
1659
|
-
fill: { color: nativeColor(
|
|
1785
|
+
fill: { color: nativeColor(fillReference, baseFill, context), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
|
|
1660
1786
|
},
|
|
1661
1787
|
};
|
|
1662
1788
|
}));
|
|
1663
1789
|
const objectName = `OPF table ${context.tableHeaders.size + 1}`;
|
|
1664
1790
|
context.tableHeaders.set(objectName, hasHeaders);
|
|
1665
|
-
if (layout.rows.some(row => row.cells.some(cell => Object.keys(cell.style ?? {}).length))) context.tableCells.set(objectName, {layout, scale, defaultBorder:{color:"
|
|
1791
|
+
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}});
|
|
1666
1792
|
slide.addTable(rows, {
|
|
1667
1793
|
objectName,
|
|
1668
1794
|
x: region.x,
|
|
@@ -1678,7 +1804,7 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1678
1804
|
fontFace: context.fonts.body,
|
|
1679
1805
|
fontSize: 15 * scale * 0.75,
|
|
1680
1806
|
color: context.colors.text,
|
|
1681
|
-
border: { type: "solid", color: context.colors.border, pt: 0.75 },
|
|
1807
|
+
border: { type: "solid", color: nativeColor("accent5", context.colors.border, context), pt: 0.75 },
|
|
1682
1808
|
margin: [6 * scale, 7.5 * scale, 3 * scale, 7.5 * scale],
|
|
1683
1809
|
valign: "top"
|
|
1684
1810
|
});
|
|
@@ -1720,7 +1846,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
1720
1846
|
else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
|
|
1721
1847
|
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};
|
|
1722
1848
|
// A run-level link keeps the muted, non-underlined furniture look of the preview (hlinkClr=tx, u=none).
|
|
1723
|
-
const lineColor =
|
|
1849
|
+
const lineColor = pptxColor(config.color ?? context.textColor);
|
|
1724
1850
|
slide.addText(link?.href && line ? [{text: line, options: {hyperlink: {url: link.href}, color: lineColor, underline: {style: 'none'}}}] : line, {
|
|
1725
1851
|
...textBoxOptions(area, context, fit.fontSize * .75),
|
|
1726
1852
|
...nativeFontOptions(style),
|
|
@@ -1768,7 +1894,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
1768
1894
|
if(marker) staticDates.set(index,marker);
|
|
1769
1895
|
}
|
|
1770
1896
|
}
|
|
1771
|
-
addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.
|
|
1897
|
+
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});
|
|
1772
1898
|
}
|
|
1773
1899
|
}
|
|
1774
1900
|
const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
|
|
@@ -1823,7 +1949,7 @@ function addMetricPayload(slide,value,layout,context,path) {
|
|
|
1823
1949
|
slide.addText(part.text.slice(line.start,line.end),{
|
|
1824
1950
|
...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),
|
|
1825
1951
|
...nativeFontOptions(part.style),
|
|
1826
|
-
color:part.role==='value'?context.colors.accent:context.
|
|
1952
|
+
color:part.role==='value'?context.colors.accent:context.textColor,align:layout.alignment,fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
1827
1953
|
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
1828
1954
|
});
|
|
1829
1955
|
}
|
|
@@ -1836,7 +1962,7 @@ function addQuotePayload(slide, layout, context, options, path) {
|
|
|
1836
1962
|
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});
|
|
1837
1963
|
addMeasuredPayloadText(slide,part.text,part.box,context,options,{
|
|
1838
1964
|
path:part.path,fit:part.fit,textStyle:part.style,diagnosticsHandled:true,
|
|
1839
|
-
color:part.role==='footer'?context.
|
|
1965
|
+
color:part.role==='footer'?context.mutedColor:context.textColor,
|
|
1840
1966
|
});
|
|
1841
1967
|
}
|
|
1842
1968
|
}
|
|
@@ -1972,7 +2098,7 @@ function addPlaceholderPayload(slide, label, description, region, context) {
|
|
|
1972
2098
|
margin: 0,
|
|
1973
2099
|
fontFace: context.fonts.body,
|
|
1974
2100
|
fontSize: 11,
|
|
1975
|
-
color: context.
|
|
2101
|
+
color: context.mutedColor,
|
|
1976
2102
|
fit: "shrink",
|
|
1977
2103
|
valign: "mid",
|
|
1978
2104
|
align: "center"
|
|
@@ -2040,7 +2166,7 @@ function textBoxOptions(region, context, fontSize) {
|
|
|
2040
2166
|
lineSpacingMultiple: 1.22,
|
|
2041
2167
|
fontFace: context.fonts.body,
|
|
2042
2168
|
fontSize,
|
|
2043
|
-
color: context.
|
|
2169
|
+
color: context.textColor,
|
|
2044
2170
|
breakLine: false,
|
|
2045
2171
|
fit: "shrink",
|
|
2046
2172
|
valign: "top"
|
|
@@ -2054,7 +2180,7 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
2054
2180
|
return {
|
|
2055
2181
|
text: run,
|
|
2056
2182
|
options: {
|
|
2057
|
-
color: context.
|
|
2183
|
+
color: context.textColor,
|
|
2058
2184
|
fontFace: context.fonts.body,
|
|
2059
2185
|
fontSize: fallbackFontSize
|
|
2060
2186
|
}
|
|
@@ -2065,9 +2191,9 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
2065
2191
|
options: {
|
|
2066
2192
|
bold: run?.bold,
|
|
2067
2193
|
italic: run?.italic,
|
|
2068
|
-
underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context) } : undefined,
|
|
2194
|
+
underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor) } : undefined,
|
|
2069
2195
|
strike: run?.strikethrough ? "sngStrike" : undefined,
|
|
2070
|
-
color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context),
|
|
2196
|
+
color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor),
|
|
2071
2197
|
fontFace: run?.fontFamily ?? context.fonts.body,
|
|
2072
2198
|
fontSize: run?.fontSize ?? fallbackFontSize,
|
|
2073
2199
|
superscript: run?.superscript,
|
|
@@ -2085,7 +2211,7 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
2085
2211
|
* into equal-width bins and exported as a column chart of the counts, every other type plots the values against
|
|
2086
2212
|
* their row numbers. `adapted` names that transformation so it is reported, never silent.
|
|
2087
2213
|
*/
|
|
2088
|
-
function toPptxChartData(chart) {
|
|
2214
|
+
function toPptxChartData(chart, chartexMode = 'fallback') {
|
|
2089
2215
|
const data = chart?.data;
|
|
2090
2216
|
const unplottable = (reason, summary, message) => ({reason, summary, message: `${message} No native chart was exported; a placeholder frame stands in for it.`});
|
|
2091
2217
|
if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) {
|
|
@@ -2093,7 +2219,20 @@ function toPptxChartData(chart) {
|
|
|
2093
2219
|
}
|
|
2094
2220
|
if (data.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
|
|
2095
2221
|
if (data.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
|
|
2096
|
-
const
|
|
2222
|
+
const resolved = resolveChartType(chart.type).spec;
|
|
2223
|
+
// A chartex type (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) has no classic construct. With
|
|
2224
|
+
// `chartex: 'native'` it is written as its cx:chartSpace part and the clustered column chart of the same data is its
|
|
2225
|
+
// mc:Fallback (src/chartex.js); by default (pending the native PowerPoint check, FF-22b) it exports as the clustered
|
|
2226
|
+
// column chart alone and reports chartex-fallback, never silently.
|
|
2227
|
+
const native = chartexMode === 'native';
|
|
2228
|
+
const chartex = resolved.family === 'chartex' && native ? resolved : null;
|
|
2229
|
+
const spec = resolved.family === 'chartex' ? CHARTEX_FALLBACK : resolved;
|
|
2230
|
+
const typeName = stringifyText(chart.type);
|
|
2231
|
+
const adaptations = [];
|
|
2232
|
+
if (resolved.family === 'chartex' && !native) {
|
|
2233
|
+
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.`});
|
|
2234
|
+
}
|
|
2235
|
+
const mapped = {type: spec.pptx, spec, chartex, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined};
|
|
2097
2236
|
if (data.columns.length === 1) {
|
|
2098
2237
|
const heading = stringifyText(data.columns[0]);
|
|
2099
2238
|
const cell = (row) => Array.isArray(row) ? row[0] : row;
|
|
@@ -2104,13 +2243,15 @@ function toPptxChartData(chart) {
|
|
|
2104
2243
|
}
|
|
2105
2244
|
const skipped = data.rows.length - points.length;
|
|
2106
2245
|
const skippedNote = skipped ? ` (${skipped} non-numeric ${skipped === 1 ? "cell was" : "cells were"} skipped)` : "";
|
|
2107
|
-
if (String(chart.type ?? "").toLowerCase() === "histogram") {
|
|
2246
|
+
if (!native && String(chart.type ?? "").toLowerCase() === "histogram") {
|
|
2108
2247
|
const bins = histogramBins(points.map((point) => point.value));
|
|
2109
2248
|
return {
|
|
2110
|
-
type: "bar", barDir: "col", barGrouping: "clustered", heading: "Bin",
|
|
2249
|
+
type: "bar", spec: CHARTEX_FALLBACK, barDir: "col", barGrouping: "clustered", heading: "Bin",
|
|
2111
2250
|
series: [{name: "Frequency", labels: bins.map((bin) => bin.label), values: bins.map((bin) => bin.count)}],
|
|
2112
|
-
|
|
2113
|
-
|
|
2251
|
+
adaptations: [{
|
|
2252
|
+
adaptation: "histogram-binned",
|
|
2253
|
+
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.`
|
|
2254
|
+
}]
|
|
2114
2255
|
};
|
|
2115
2256
|
}
|
|
2116
2257
|
// Rows keep their own row numbers, so a skipped cell leaves a gap in the numbering.
|
|
@@ -2119,26 +2260,39 @@ function toPptxChartData(chart) {
|
|
|
2119
2260
|
const series = mapped.type === "scatter"
|
|
2120
2261
|
? [{name: "Row", labels, values: points.map((point) => point.row)}, {name: heading, labels, values: numbers}]
|
|
2121
2262
|
: [{name: heading, labels, values: numbers}];
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
};
|
|
2263
|
+
// A native histogram or Pareto chart bins the values themselves (PowerPoint's automatic bins); only its classic fallback plots them against row numbers.
|
|
2264
|
+
if (chartex?.binning) return {...mapped, series, heading: "Row", hasCategories: false, adaptations};
|
|
2265
|
+
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.`});
|
|
2266
|
+
return {...mapped, series, heading: "Row", hasCategories: true, adaptations};
|
|
2126
2267
|
}
|
|
2127
2268
|
|
|
2128
2269
|
const labels = data.rows.map((row) => stringifyText(row?.[0]));
|
|
2129
|
-
|
|
2270
|
+
let series = data.columns.slice(1).map((name, seriesIndex) => ({
|
|
2130
2271
|
name: stringifyText(name),
|
|
2131
2272
|
labels,
|
|
2132
2273
|
values: data.rows.map((row) => numericValue(row?.[seriesIndex + 1]))
|
|
2133
2274
|
}));
|
|
2134
|
-
|
|
2135
|
-
|
|
2275
|
+
const plotted = spec.family === 'circular' ? 1 : chartex ? chartex.series : Infinity;
|
|
2276
|
+
if (series.length > plotted) {
|
|
2277
|
+
// A pie, doughnut or single-series chartex construct plots one series; name the ones left out instead of dropping them silently.
|
|
2278
|
+
const dropped = series.slice(plotted).map((entry) => `'${entry.name}'`);
|
|
2279
|
+
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.`});
|
|
2280
|
+
series = series.slice(0, plotted);
|
|
2281
|
+
}
|
|
2282
|
+
if (spec.family === 'xy' && series.length === 1) {
|
|
2283
|
+
// [Point, X, Y...] carries its own X column; a lone value column is plotted against the row numbers.
|
|
2284
|
+
series = [{name: 'X', labels, values: data.rows.map((_, index) => index + 1)}, ...series];
|
|
2285
|
+
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.`});
|
|
2286
|
+
}
|
|
2287
|
+
return {...mapped, series, hasCategories: true, adaptations};
|
|
2136
2288
|
}
|
|
2137
2289
|
|
|
2290
|
+
|
|
2138
2291
|
/**
|
|
2139
2292
|
* Equal-width bins (Sturges' count, at most 50) from the minimum to the maximum; every bin but the last is [low, high).
|
|
2140
2293
|
* Arithmetic stays finite for any finite input (values of +-1e308 and denormals included): the bin edges are
|
|
2141
2294
|
* computed by interpolating the edges rather than from a width, and a value is placed by comparing it with the edges.
|
|
2295
|
+
* Used by the default (fallback) histogram export only; the native chartex histogram bins in PowerPoint.
|
|
2142
2296
|
*/
|
|
2143
2297
|
function histogramBins(values) {
|
|
2144
2298
|
const min = values.reduce((a, b) => Math.min(a, b)), max = values.reduce((a, b) => Math.max(a, b));
|
|
@@ -2154,34 +2308,12 @@ function histogramBins(values) {
|
|
|
2154
2308
|
// Labels carry six significant digits, and more when that would make two bins read alike.
|
|
2155
2309
|
for (let precision = 6; ; precision++) {
|
|
2156
2310
|
const format = (value) => String(Number(value.toPrecision(precision)));
|
|
2157
|
-
const labels = bins.map((bin) => min === max ? format(min) : `${format(bin.low)}
|
|
2311
|
+
const labels = bins.map((bin) => min === max ? format(min) : `${format(bin.low)}–${format(bin.high)}`);
|
|
2158
2312
|
if (new Set(labels).size === labels.length) return bins.map((bin, index) => ({...bin, label: labels[index]}));
|
|
2159
2313
|
if (precision === 17) return bins.map((bin, index) => ({...bin, label: `${labels[index]} (bin ${index + 1})`}));
|
|
2160
2314
|
}
|
|
2161
2315
|
}
|
|
2162
2316
|
|
|
2163
|
-
function mapChartType(type) {
|
|
2164
|
-
const normalized = String(type ?? "").toLowerCase();
|
|
2165
|
-
if (normalized.includes("pie")) return { type: "pie" };
|
|
2166
|
-
if (normalized.includes("doughnut") || normalized.includes("donut")) return { type: "doughnut" };
|
|
2167
|
-
if (normalized.includes("area")) return { type: "area" };
|
|
2168
|
-
if (normalized.includes("line") || normalized.includes("sparkline")) return { type: "line" };
|
|
2169
|
-
if (normalized.includes("scatter")) return { type: "scatter" };
|
|
2170
|
-
if (normalized.includes("radar")) return { type: "radar" };
|
|
2171
|
-
if (normalized.includes("bar")) {
|
|
2172
|
-
return {
|
|
2173
|
-
type: "bar",
|
|
2174
|
-
barDir: "bar",
|
|
2175
|
-
barGrouping: normalized.includes("stacked") ? "stacked" : "clustered"
|
|
2176
|
-
};
|
|
2177
|
-
}
|
|
2178
|
-
return {
|
|
2179
|
-
type: "bar",
|
|
2180
|
-
barDir: "col",
|
|
2181
|
-
barGrouping: normalized.includes("stacked") ? "stacked" : "clustered"
|
|
2182
|
-
};
|
|
2183
|
-
}
|
|
2184
|
-
|
|
2185
2317
|
async function resolveImage(asset, presentation, options, path) {
|
|
2186
2318
|
const assetObject = dereferenceAsset(asset, presentation, path);
|
|
2187
2319
|
const src = typeof assetObject === "string" ? assetObject : assetObject?.src;
|
|
@@ -2400,14 +2532,21 @@ async function normalizePptxZip(raw, context) {
|
|
|
2400
2532
|
if(!context.chartHeadings.has(name))continue;
|
|
2401
2533
|
const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
|
|
2402
2534
|
if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
|
|
2403
|
-
const {heading,labelColor}=context.chartHeadings.get(name);
|
|
2535
|
+
const {heading,labelColor,spec}=context.chartHeadings.get(name);
|
|
2404
2536
|
if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
|
|
2537
|
+
entries[chartPart]=encodeText(applyChartConstruct(decodeText(entries[chartPart]),spec));
|
|
2405
2538
|
// PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
|
|
2406
2539
|
// Normalize only our generated chart text styles; point/series fills stay intact.
|
|
2407
2540
|
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>`)));
|
|
2408
2541
|
}
|
|
2409
2542
|
}
|
|
2410
2543
|
applyChartFonts(entries,context.chartFonts,parseRelationships);
|
|
2544
|
+
// Chartex charts: the native cx:chartSpace part, its style parts and the mc:AlternateContent frame (the classic chart above is the fallback).
|
|
2545
|
+
try {
|
|
2546
|
+
attachChartexParts(entries, context.chartex, parseRelationships);
|
|
2547
|
+
} catch (error) {
|
|
2548
|
+
throw new OPFPptxError('packaging-failed', `Chartex chart parts could not be attached: ${errorMessage(error)}`);
|
|
2549
|
+
}
|
|
2411
2550
|
applyPitchFamilies(entries,context.fontPitch);
|
|
2412
2551
|
const imageSources = new Map();
|
|
2413
2552
|
for (const [part, bytes] of Object.entries(entries)) {
|
|
@@ -2457,7 +2596,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
2457
2596
|
if (!slide) continue;
|
|
2458
2597
|
context.partSlides.set(part, Number(slide[1]) - 1);
|
|
2459
2598
|
for (const relationship of parseRelationships(entries, part).values()) {
|
|
2460
|
-
if (/\/(?:chart|notesSlide)$/.test(relationship.type)) context.partSlides.set(relationship.path, Number(slide[1]) - 1);
|
|
2599
|
+
if (/\/(?:chart|chartEx|notesSlide)$/.test(relationship.type)) context.partSlides.set(relationship.path, Number(slide[1]) - 1);
|
|
2461
2600
|
}
|
|
2462
2601
|
}
|
|
2463
2602
|
const output = {};
|
|
@@ -2559,8 +2698,11 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
2559
2698
|
xml = xml.replace('</Types>', `${overrides}</Types>`);
|
|
2560
2699
|
}
|
|
2561
2700
|
if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path)) xml = themeMasterBulletFonts(xml);
|
|
2701
|
+
if (context.masterBackground && path === 'ppt/slideMasters/slideMaster1.xml') xml = writeMasterBackground(xml, context.masterBackground);
|
|
2702
|
+
if (context.masterBackground && /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path)) xml = inheritLayoutBackground(xml);
|
|
2562
2703
|
if (path === 'ppt/theme/theme1.xml') xml = writeThemeColors(xml, {colors: context.themeColors, schemeName: context.schemeName, themeName: context.themeName});
|
|
2563
2704
|
if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
|
|
2705
|
+
xml = writeLinkSentinels(xml, context.linkSentinels);
|
|
2564
2706
|
const fill = context.backgroundFills.get(path);
|
|
2565
2707
|
if (typeof fill === 'string') xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`);
|
|
2566
2708
|
else if (fill?.image) {
|
|
@@ -2631,10 +2773,10 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
2631
2773
|
for (const [edge, native] of [['left','lnL'],['right','lnR'],['top','lnT'],['bottom','lnB']]) {
|
|
2632
2774
|
const border = style.borders?.[edge];
|
|
2633
2775
|
if (!border) continue;
|
|
2634
|
-
const borderHex = exportColor(border.color,
|
|
2776
|
+
const slideContext = table.context, borderHex = exportColor(border.color, slideContext, slideContext.colors.border);
|
|
2635
2777
|
const borderOpacity = borderHex.length === 8 ? parseInt(borderHex.slice(6), 16) / 255 : 1;
|
|
2636
|
-
const borderScheme = borderOpacity === 1 ? schemeColorValue(border.color, borderHex,
|
|
2637
|
-
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},
|
|
2778
|
+
const borderScheme = borderOpacity === 1 ? schemeColorValue(border.color, borderHex, slideContext) : undefined;
|
|
2779
|
+
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);
|
|
2638
2780
|
const dash = {solid:'solid',dash:'dash',dot:'sysDot'}[border.dash ?? 'solid'];
|
|
2639
2781
|
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}>`;
|
|
2640
2782
|
const existing = new RegExp(`<a:${native}\\b[^>]*>[\\s\\S]*?<\\/a:${native}>`);
|
|
@@ -2670,7 +2812,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
2670
2812
|
const marker=context.listMarkers.get(shape.match(/name="(OPF list [^"]* line \d+)"/)?.[1]);
|
|
2671
2813
|
if(!marker)return shape;
|
|
2672
2814
|
const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
2673
|
-
return shape.replace(/<a:buSzPct val="100000"\/>/g,`<a:buClr
|
|
2815
|
+
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}"/>`);
|
|
2674
2816
|
});
|
|
2675
2817
|
// PptxGenJS gives shapes no alternative text. An unavailable image's panel
|
|
2676
2818
|
// carries the accessible name the preview gives its group.
|