@openpresentation/opf-pptx 0.11.2 → 0.11.4
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 +32 -8
- package/dist/chart-types.js +38 -9
- package/dist/chart-workbook.js +4 -2
- package/dist/chartex.js +375 -0
- package/dist/index.d.ts +18 -3
- package/dist/index.js +104 -27
- package/dist/quote-provenance.js +89 -0
- package/dist/script-fonts.js +12 -12
- package/dist/slide-image-provenance.js +7 -1
- package/dist/typeface-inventory.js +11 -1
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -39,13 +39,28 @@ export interface MediaProvenanceDiagnostic { code: "media-provenance-omitted"; p
|
|
|
39
39
|
export interface ChartDataUnplottableDiagnostic { code: "chart-data-unplottable"; path: string; message: string; reason: "data-not-inline" | "no-rows" | "no-columns" | "single-column-not-numeric" }
|
|
40
40
|
/** Content with no PowerPoint export (an empty table, an unsupported payload) is replaced by a plain-language placeholder frame. */
|
|
41
41
|
export interface ContentPlaceholderDiagnostic { code: "content-placeholder"; path: string; message: string; reason: "table-has-no-rows" | "unsupported-payload" }
|
|
42
|
-
/** The chart data was reshaped to export a native chart: a histogram's values were binned, or a single value column was plotted against row numbers. */
|
|
43
42
|
export interface WatermarkNotExportedDiagnostic { code: "watermark-not-exported"; path: string; message: string }
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The chart data was reshaped to export a native chart: a single value column was plotted against row numbers; a one-series construct
|
|
45
|
+
* (pie, doughnut, and with `chartex: "native"` treemap, histogram, pareto, waterfall, funnel, map) kept only its first series; by default
|
|
46
|
+
* a chartex chart type was written as a clustered column chart (`chartex-fallback`) and a one-column histogram was binned into counts
|
|
47
|
+
* (`histogram-binned`).
|
|
48
|
+
*/
|
|
49
|
+
export interface ChartDataAdaptedDiagnostic { code: "chart-data-adapted"; path: string; message: string; adaptation: "histogram-binned" | "row-numbers" | "series-dropped" | "chartex-fallback" }
|
|
50
|
+
/** With `chartex: "native"`, a map chart (`world`) is exported as a chartex regionMap without cached geography: PowerPoint fetches the region shapes from Bing Maps online, so the map may show no regions offline. */
|
|
51
|
+
export interface ChartMapGeodataDiagnostic { code: "chart-map-geodata"; path: string; message: string }
|
|
45
52
|
|
|
46
53
|
export interface ToPptxOptions {
|
|
47
54
|
/** Default compatible converts WebP to a static PNG. Preserve embeds original WebP bytes. */
|
|
48
55
|
imageFormat?: "compatible" | "preserve";
|
|
56
|
+
/**
|
|
57
|
+
* How the chartex chart types (treemap, histogram, pareto, box-and-whisker, waterfall, funnel, world) are exported.
|
|
58
|
+
* "fallback" (default) writes the clustered column chart of the data and reports `chart-data-adapted` (`chartex-fallback`,
|
|
59
|
+
* `histogram-binned`). "native" writes Office 2016 chartex parts (`cx:chartSpace`, style parts, an `mc:AlternateContent`
|
|
60
|
+
* frame with the clustered column chart as fallback). Native chartex is opt-in pending native PowerPoint confirmation (FF-22b);
|
|
61
|
+
* the default flips once that check passes. Import of chartex parts is always on.
|
|
62
|
+
*/
|
|
63
|
+
chartex?: "native" | "fallback";
|
|
49
64
|
/**
|
|
50
65
|
* OPF_DOCUMENT_V1 / OPF_SLIDE_V1 customer-data tags that let fromPptx restore
|
|
51
66
|
* catalog references, layout ids and authoring metadata (docs/document-roundtrip.md).
|
|
@@ -58,7 +73,7 @@ export interface ToPptxOptions {
|
|
|
58
73
|
/** Match preview/pagination clearance around supplied vector text outlines; default 1. */
|
|
59
74
|
textRasterPadding?: number;
|
|
60
75
|
/** Layout diagnostics, `media-provenance-omitted` when video data cannot be stored, plus `unresolved-font-scheme` (once per reference path) when a font-scheme id matches no record and the default `aptos` scheme is used as the base. */
|
|
61
|
-
onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ContentPlaceholderDiagnostic | WatermarkNotExportedDiagnostic) => void;
|
|
76
|
+
onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ContentPlaceholderDiagnostic | WatermarkNotExportedDiagnostic) => void;
|
|
62
77
|
baseDir?: string;
|
|
63
78
|
compressionLevel?: number;
|
|
64
79
|
imageResolver?: (src: string, context: ImageResolverContext) => ImageResolverResult | Promise<ImageResolverResult | null | undefined> | null | undefined;
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ 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
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';
|
|
7
8
|
import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
8
9
|
import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
|
|
9
10
|
import {attachCardTags,importCardFrames} from './card-provenance.js';
|
|
@@ -11,6 +12,7 @@ import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTex
|
|
|
11
12
|
import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
|
|
12
13
|
import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
|
|
13
14
|
import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
|
|
15
|
+
import {attachQuoteTags,quoteManifest,importQuoteGroups} from './quote-provenance.js';
|
|
14
16
|
import {attachFurnitureTags, furnitureManifest, importFurniture, staticDateFallback} from './furniture-provenance.js';
|
|
15
17
|
import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, restoreDocumentProvenance} from './document-provenance.js';
|
|
16
18
|
import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
|
|
@@ -217,6 +219,7 @@ export async function toPptx(input, options = {}) {
|
|
|
217
219
|
context.headingTags = new Map();
|
|
218
220
|
context.plainTextTags = new Map();
|
|
219
221
|
context.timelineTags = new Map();
|
|
222
|
+
context.quoteTags = new Map();
|
|
220
223
|
context.furnitureTags = new Map();
|
|
221
224
|
context.furnitureFields = new Map();
|
|
222
225
|
context.furnitureManifests = new Map();
|
|
@@ -224,7 +227,14 @@ export async function toPptx(input, options = {}) {
|
|
|
224
227
|
context.metricTags = new Map();
|
|
225
228
|
context.chartHeadings = new Map();
|
|
226
229
|
context.chartFonts = new Map();
|
|
230
|
+
context.chartex = new Map();
|
|
227
231
|
context.imageFormat = options.imageFormat ?? "compatible";
|
|
232
|
+
// Native chartex parts are opt-in until the native PowerPoint check confirms them (FF-22b); the default keeps the
|
|
233
|
+
// clustered column export of the chartex chart types.
|
|
234
|
+
if (options.chartex !== undefined && !['native', 'fallback'].includes(options.chartex)) {
|
|
235
|
+
throw new OPFPptxError('invalid-chartex-mode', 'chartex must be native or fallback.', {path: 'options.chartex'});
|
|
236
|
+
}
|
|
237
|
+
context.chartexMode = options.chartex ?? 'fallback';
|
|
228
238
|
Object.assign(context, exportTheme(presentation, context));
|
|
229
239
|
context.reportedFontSchemes = new Set();
|
|
230
240
|
// Document references and metadata tags (FF-32, docs/document-roundtrip.md).
|
|
@@ -512,6 +522,7 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
512
522
|
}),
|
|
513
523
|
diagnostic => options.onDiagnostic?.({...diagnostic, path: slideImagePath}));
|
|
514
524
|
if (slideImage.design) slide.design = {...slide.design, ...slideImage.design};
|
|
525
|
+
if (slideImage.image) slide.image = slideImage.image;
|
|
515
526
|
nativeContext.slideImagePictures = slideImage.consumed;
|
|
516
527
|
nativeContext.slideImageShapes = slideImage.consumedShapes;
|
|
517
528
|
const watermarkPath = `slides.${slideIndex}.design.watermark`;
|
|
@@ -576,6 +587,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
576
587
|
const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
577
588
|
const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
578
589
|
const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
590
|
+
const quotes=importQuoteGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote`}));
|
|
579
591
|
for(const group of [...headings.items,...plainText.items]) {
|
|
580
592
|
const item=importShape(group.shapes[0],dimensions,group.paragraphs,true);
|
|
581
593
|
if(item){
|
|
@@ -589,12 +601,17 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
589
601
|
let union;for(const bound of bounds){if(!union)union={...bound};else{const x=Math.min(union.x,bound.x),y=Math.min(union.y,bound.y);union={x,y,w:Math.max(union.x+union.w,bound.x+bound.w)-x,h:Math.max(union.y+union.h,bound.y+bound.h)-y};}}
|
|
590
602
|
items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload});
|
|
591
603
|
}
|
|
604
|
+
for(const group of quotes.items){
|
|
605
|
+
const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
|
|
606
|
+
let union;for(const bound of bounds){if(!union)union={...bound};else{const x=Math.min(union.x,bound.x),y=Math.min(union.y,bound.y);union={x,y,w:Math.max(union.x+union.w,bound.x+bound.w)-x,h:Math.max(union.y+union.h,bound.y+bound.h)-y};}}
|
|
607
|
+
items.push({kind:'quote',bounds:union,payload:group.payload});
|
|
608
|
+
}
|
|
592
609
|
for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
|
|
593
610
|
for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
|
|
594
611
|
for (const item of media.items) items.push({kind:'media',sourceText:item.payload.type==='text',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
|
|
595
612
|
for (const [index,shape] of shapes.entries()) {
|
|
596
613
|
if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index)) continue;
|
|
597
|
-
if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||media.consumed.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)) continue;
|
|
614
|
+
if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||media.consumed.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)||quotes.consumed.has(shape)) continue;
|
|
598
615
|
const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
|
|
599
616
|
const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
|
|
600
617
|
if (item && ordinaryBody) item.readNativeBody = () => readBody(index);
|
|
@@ -613,6 +630,15 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
613
630
|
const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
|
|
614
631
|
if (item) items.push(item);
|
|
615
632
|
}
|
|
633
|
+
// A chartex chart is an mc:AlternateContent: the choice frame references the cx:chartSpace part; the fallback is a classic chart frame
|
|
634
|
+
// (this exporter) or a text shape (PowerPoint). The choice is read first and the fallback only when it names no OPF chart type.
|
|
635
|
+
for (const alternate of asArray(tree?.["mc:AlternateContent"])) {
|
|
636
|
+
const choice = asArray(alternate?.["mc:Choice"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
|
|
637
|
+
const fallback = asArray(alternate?.["mc:Fallback"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
|
|
638
|
+
const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships) : null;
|
|
639
|
+
const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships) : chosen;
|
|
640
|
+
if (item && item.kind !== "unknown") items.push(item);
|
|
641
|
+
}
|
|
616
642
|
|
|
617
643
|
for (const [index, picture] of nativeContext.pictures.entries()) {
|
|
618
644
|
if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index)) continue;
|
|
@@ -668,9 +694,12 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
|
|
|
668
694
|
};
|
|
669
695
|
}
|
|
670
696
|
|
|
671
|
-
const
|
|
697
|
+
const chartExRelId = graphicData?.uri === CHARTEX_GRAPHIC_DATA_URI ? graphicData?.["cx:chart"]?.["r:id"] : undefined;
|
|
698
|
+
const chartRelId = graphicData?.["c:chart"]?.["r:id"] ?? chartExRelId;
|
|
672
699
|
if (chartRelId) {
|
|
673
|
-
const chart =
|
|
700
|
+
const chart = chartExRelId
|
|
701
|
+
? chartexFromRelationship(entries, relationships, chartExRelId)
|
|
702
|
+
: chartFromRelationship(entries, slidePath, relationships, chartRelId);
|
|
674
703
|
return {
|
|
675
704
|
kind: "chart",
|
|
676
705
|
bounds,
|
|
@@ -904,6 +933,23 @@ function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
|
|
|
904
933
|
return { type: "scatter", data: { columns: ["Point", readChartCategoryHeading(entries, chartPart) ?? "X", ...names], rows } };
|
|
905
934
|
}
|
|
906
935
|
|
|
936
|
+
// A chartex part (cx:chartSpace): the series layoutIds name the kept OPF chart
|
|
937
|
+
// type and the cached dimensions restore the category-major data, under the
|
|
938
|
+
// same bounds as the classic chart cache.
|
|
939
|
+
function chartexFromRelationship(entries, relationships, relId) {
|
|
940
|
+
const relationship = relationships.get(relId);
|
|
941
|
+
if (!relationship?.path || !entries[relationship.path]) return null;
|
|
942
|
+
const doc = parseRequiredXml(entries, relationship.path);
|
|
943
|
+
return chartFromChartex(doc, {
|
|
944
|
+
heading: readChartCategoryHeading(entries, relationship.path),
|
|
945
|
+
limits: {points: MAX_CHART_CACHE_POINTS, cells: MAX_CHART_CACHE_CELLS},
|
|
946
|
+
path: relationship.path,
|
|
947
|
+
invalid: (message, location) => {
|
|
948
|
+
throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: location ?? relationship.path});
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
907
953
|
function firstChartNode(plotArea) {
|
|
908
954
|
// Map the native construct back to the kept OPF chart type id (FF-22).
|
|
909
955
|
for (const element of NATIVE_CHART_ELEMENTS) {
|
|
@@ -1424,7 +1470,7 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
1424
1470
|
addMetricPayload(slide, payload.metric,metricLayout,context,path);
|
|
1425
1471
|
break;
|
|
1426
1472
|
case "quote":
|
|
1427
|
-
addQuotePayload(slide, quoteLayout, context, options, path);
|
|
1473
|
+
addQuotePayload(slide, payload.quote, quoteLayout, context, options, path);
|
|
1428
1474
|
break;
|
|
1429
1475
|
case "timeline":
|
|
1430
1476
|
addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
|
|
@@ -1604,7 +1650,9 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
|
|
|
1604
1650
|
: image.recolor?.type === 'duotone' ? { type: 'duotone', dark: paint(image.recolor.dark, '000000'), light: paint(image.recolor.light, 'FFFFFF') } : null,
|
|
1605
1651
|
overlay: image.overlay ? { ...paint(image.overlay.color, context.colors.text), opacity: image.overlay.opacity, box: image.overlay.box, shape: image.overlay.shape } : null,
|
|
1606
1652
|
};
|
|
1607
|
-
|
|
1653
|
+
// FF-53: a root `image` with the slide image's source is the slide image (core `replacesContent`); the
|
|
1654
|
+
// manifest records that so an unchanged picture imports back as both design.slideImage and slide.image.
|
|
1655
|
+
context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects, content: image.replacesContent === true });
|
|
1608
1656
|
slide.addImage({ ...resolved, objectName, ...box, altText: image.alt ?? assetAlt(image.value, presentation) });
|
|
1609
1657
|
// The overlay scrim is a separate native shape directly above the picture.
|
|
1610
1658
|
if (effects.overlay) {
|
|
@@ -1614,7 +1662,7 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
|
|
|
1614
1662
|
}
|
|
1615
1663
|
|
|
1616
1664
|
function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
|
|
1617
|
-
const chartData = toPptxChartData(chart);
|
|
1665
|
+
const chartData = toPptxChartData(chart, context.chartexMode);
|
|
1618
1666
|
if (!chartData.series) {
|
|
1619
1667
|
// Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
|
|
1620
1668
|
options.onDiagnostic?.({code: "chart-data-unplottable", path, message: chartData.message, reason: chartData.reason});
|
|
@@ -1633,6 +1681,16 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1633
1681
|
const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
|
|
1634
1682
|
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec});
|
|
1635
1683
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
1684
|
+
const palette = CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`)));
|
|
1685
|
+
if (chartData.chartex) {
|
|
1686
|
+
// The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
|
|
1687
|
+
// PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
|
|
1688
|
+
const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
|
|
1689
|
+
context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette});
|
|
1690
|
+
if (chartData.chartex.layoutId === 'regionMap') {
|
|
1691
|
+
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.`});
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1636
1694
|
const percent = chartData.spec.grouping === 'percentStacked';
|
|
1637
1695
|
slide.addChart(chartData.type, chartData.series, {
|
|
1638
1696
|
objectName,
|
|
@@ -1642,7 +1700,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1642
1700
|
h: region.h,
|
|
1643
1701
|
showLegend: circular || chartData.series.length > (chartData.type === 'scatter' ? 2 : 1),
|
|
1644
1702
|
showTitle: false,
|
|
1645
|
-
chartColors:
|
|
1703
|
+
chartColors: palette,
|
|
1646
1704
|
chartArea: {fill:{...fill},roundedCorners:false},
|
|
1647
1705
|
// Paint alpha once in the chart area, rather than stacking two alpha fills.
|
|
1648
1706
|
plotArea: {fill:{color:null}},
|
|
@@ -1785,12 +1843,13 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
1785
1843
|
for (const [index, line] of fit.lines.entries()) {
|
|
1786
1844
|
const link = config.links?.[sourceLineIndex];
|
|
1787
1845
|
if (fit.sourceLines?.[index]?.boundary === 'hard') sourceLineIndex += 1;
|
|
1788
|
-
if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.keepEmpty) continue;
|
|
1846
|
+
if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.quote&&!config.keepEmpty) continue;
|
|
1789
1847
|
const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
1790
1848
|
const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
|
|
1791
|
-
const objectName=config.heading?`OPF heading ${config.path} line ${index}`:config.timeline?`OPF timeline ${config.timeline.group} part ${config.timeline.part} line ${index}`:config.sourceText?`OPF text ${config.path} line ${index}`:config.objectName?`${config.objectName} line ${index}`:undefined;
|
|
1849
|
+
const objectName=config.heading?`OPF heading ${config.path} line ${index}`:config.timeline?`OPF timeline ${config.timeline.group} part ${config.timeline.part} line ${index}`:config.quote?`OPF quote ${config.quote.group} part ${config.quote.part} line ${index}`:config.sourceText?`OPF text ${config.path} line ${index}`:config.objectName?`${config.objectName} line ${index}`:undefined;
|
|
1792
1850
|
if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
|
|
1793
1851
|
else if(config.timeline)context.timelineTags.set(objectName,{v:1,role:'text',group:config.timeline.group,part:config.timeline.part,line:index,count:fit.lines.length,...boundary,...(config.timeline.part===0&&index===0?{anchor:config.timeline.anchor}:{})});
|
|
1852
|
+
else if(config.quote)context.quoteTags.set(objectName,{v:1,role:'text',group:config.quote.group,part:config.quote.part,line:index,count:fit.lines.length,...boundary,...(config.quote.part===0&&index===0?{anchor:config.quote.anchor}:{})});
|
|
1794
1853
|
else if(config.furniture){
|
|
1795
1854
|
context.furnitureTags.set(objectName,{v:1,role:'text',...config.furniture,line:index,count:fit.lines.length,...boundary});
|
|
1796
1855
|
if(config.liveFields?.[index]?.length)context.furnitureFields.set(objectName,{text:line,fields:config.liveFields[index]});
|
|
@@ -1909,13 +1968,16 @@ function addMetricPayload(slide,value,layout,context,path) {
|
|
|
1909
1968
|
}
|
|
1910
1969
|
}
|
|
1911
1970
|
|
|
1912
|
-
function addQuotePayload(slide, layout, context, options, path) {
|
|
1971
|
+
function addQuotePayload(slide, value, layout, context, options, path) {
|
|
1913
1972
|
if (!layout) throw new OPFPptxError('missing-quote-layout', 'Quote export requires a coordinated core build with shared quote geometry.', {path});
|
|
1914
|
-
for (const part of layout.parts) {
|
|
1915
|
-
|
|
1973
|
+
for (const part of layout.parts) 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});
|
|
1974
|
+
// Every line shape is tagged (OPF_QUOTE_V1) so an unchanged export re-imports as a quote payload (FF-57).
|
|
1975
|
+
const group=String(context.quoteTags.size),anchor=quoteManifest(value,layout);
|
|
1976
|
+
for (const [index,part] of layout.parts.entries()) {
|
|
1916
1977
|
addMeasuredPayloadText(slide,part.text,part.box,context,options,{
|
|
1917
1978
|
path:part.path,fit:part.fit,textStyle:part.style,diagnosticsHandled:true,
|
|
1918
1979
|
color:part.role==='footer'?context.mutedColor:context.textColor,
|
|
1980
|
+
quote:{group,part:index,anchor},
|
|
1919
1981
|
});
|
|
1920
1982
|
}
|
|
1921
1983
|
}
|
|
@@ -2164,7 +2226,7 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
2164
2226
|
* into equal-width bins and exported as a column chart of the counts, every other type plots the values against
|
|
2165
2227
|
* their row numbers. `adapted` names that transformation so it is reported, never silent.
|
|
2166
2228
|
*/
|
|
2167
|
-
function toPptxChartData(chart) {
|
|
2229
|
+
function toPptxChartData(chart, chartexMode = 'fallback') {
|
|
2168
2230
|
const data = chart?.data;
|
|
2169
2231
|
const unplottable = (reason, summary, message) => ({reason, summary, message: `${message} No native chart was exported; a placeholder frame stands in for it.`});
|
|
2170
2232
|
if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) {
|
|
@@ -2173,15 +2235,19 @@ function toPptxChartData(chart) {
|
|
|
2173
2235
|
if (data.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
|
|
2174
2236
|
if (data.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
|
|
2175
2237
|
const resolved = resolveChartType(chart.type).spec;
|
|
2176
|
-
//
|
|
2177
|
-
|
|
2178
|
-
|
|
2238
|
+
// A chartex type (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) has no classic construct. With
|
|
2239
|
+
// `chartex: 'native'` it is written as its cx:chartSpace part and the clustered column chart of the same data is its
|
|
2240
|
+
// mc:Fallback (src/chartex.js); by default (pending the native PowerPoint check, FF-22b) it exports as the clustered
|
|
2241
|
+
// column chart alone and reports chartex-fallback, never silently.
|
|
2242
|
+
const native = chartexMode === 'native';
|
|
2243
|
+
const chartex = resolved.family === 'chartex' && native ? resolved : null;
|
|
2244
|
+
const spec = resolved.family === 'chartex' ? CHARTEX_FALLBACK : resolved;
|
|
2179
2245
|
const typeName = stringifyText(chart.type);
|
|
2180
2246
|
const adaptations = [];
|
|
2181
|
-
if (chartex) {
|
|
2247
|
+
if (resolved.family === 'chartex' && !native) {
|
|
2182
2248
|
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
2249
|
}
|
|
2184
|
-
const mapped = {type: spec.pptx, spec, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined};
|
|
2250
|
+
const mapped = {type: spec.pptx, spec, chartex, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined};
|
|
2185
2251
|
if (data.columns.length === 1) {
|
|
2186
2252
|
const heading = stringifyText(data.columns[0]);
|
|
2187
2253
|
const cell = (row) => Array.isArray(row) ? row[0] : row;
|
|
@@ -2192,7 +2258,7 @@ function toPptxChartData(chart) {
|
|
|
2192
2258
|
}
|
|
2193
2259
|
const skipped = data.rows.length - points.length;
|
|
2194
2260
|
const skippedNote = skipped ? ` (${skipped} non-numeric ${skipped === 1 ? "cell was" : "cells were"} skipped)` : "";
|
|
2195
|
-
if (String(chart.type ?? "").toLowerCase() === "histogram") {
|
|
2261
|
+
if (!native && String(chart.type ?? "").toLowerCase() === "histogram") {
|
|
2196
2262
|
const bins = histogramBins(points.map((point) => point.value));
|
|
2197
2263
|
return {
|
|
2198
2264
|
type: "bar", spec: CHARTEX_FALLBACK, barDir: "col", barGrouping: "clustered", heading: "Bin",
|
|
@@ -2209,8 +2275,10 @@ function toPptxChartData(chart) {
|
|
|
2209
2275
|
const series = mapped.type === "scatter"
|
|
2210
2276
|
? [{name: "Row", labels, values: points.map((point) => point.row)}, {name: heading, labels, values: numbers}]
|
|
2211
2277
|
: [{name: heading, labels, values: numbers}];
|
|
2278
|
+
// A native histogram or Pareto chart bins the values themselves (PowerPoint's automatic bins); only its classic fallback plots them against row numbers.
|
|
2279
|
+
if (chartex?.binning) return {...mapped, series, heading: "Row", hasCategories: false, adaptations};
|
|
2212
2280
|
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};
|
|
2281
|
+
return {...mapped, series, heading: "Row", hasCategories: true, adaptations};
|
|
2214
2282
|
}
|
|
2215
2283
|
|
|
2216
2284
|
const labels = data.rows.map((row) => stringifyText(row?.[0]));
|
|
@@ -2219,18 +2287,19 @@ function toPptxChartData(chart) {
|
|
|
2219
2287
|
labels,
|
|
2220
2288
|
values: data.rows.map((row) => numericValue(row?.[seriesIndex + 1]))
|
|
2221
2289
|
}));
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2290
|
+
const plotted = spec.family === 'circular' ? 1 : chartex ? chartex.series : Infinity;
|
|
2291
|
+
if (series.length > plotted) {
|
|
2292
|
+
// A pie, doughnut or single-series chartex construct plots one series; name the ones left out instead of dropping them silently.
|
|
2293
|
+
const dropped = series.slice(plotted).map((entry) => `'${entry.name}'`);
|
|
2225
2294
|
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,
|
|
2295
|
+
series = series.slice(0, plotted);
|
|
2227
2296
|
}
|
|
2228
2297
|
if (spec.family === 'xy' && series.length === 1) {
|
|
2229
2298
|
// [Point, X, Y...] carries its own X column; a lone value column is plotted against the row numbers.
|
|
2230
2299
|
series = [{name: 'X', labels, values: data.rows.map((_, index) => index + 1)}, ...series];
|
|
2231
2300
|
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
2301
|
}
|
|
2233
|
-
return {
|
|
2302
|
+
return {...mapped, series, hasCategories: true, adaptations};
|
|
2234
2303
|
}
|
|
2235
2304
|
|
|
2236
2305
|
|
|
@@ -2238,6 +2307,7 @@ function toPptxChartData(chart) {
|
|
|
2238
2307
|
* Equal-width bins (Sturges' count, at most 50) from the minimum to the maximum; every bin but the last is [low, high).
|
|
2239
2308
|
* Arithmetic stays finite for any finite input (values of +-1e308 and denormals included): the bin edges are
|
|
2240
2309
|
* computed by interpolating the edges rather than from a width, and a value is placed by comparing it with the edges.
|
|
2310
|
+
* Used by the default (fallback) histogram export only; the native chartex histogram bins in PowerPoint.
|
|
2241
2311
|
*/
|
|
2242
2312
|
function histogramBins(values) {
|
|
2243
2313
|
const min = values.reduce((a, b) => Math.min(a, b)), max = values.reduce((a, b) => Math.max(a, b));
|
|
@@ -2253,7 +2323,7 @@ function histogramBins(values) {
|
|
|
2253
2323
|
// Labels carry six significant digits, and more when that would make two bins read alike.
|
|
2254
2324
|
for (let precision = 6; ; precision++) {
|
|
2255
2325
|
const format = (value) => String(Number(value.toPrecision(precision)));
|
|
2256
|
-
const labels = bins.map((bin) => min === max ? format(min) : `${format(bin.low)}
|
|
2326
|
+
const labels = bins.map((bin) => min === max ? format(min) : `${format(bin.low)}–${format(bin.high)}`);
|
|
2257
2327
|
if (new Set(labels).size === labels.length) return bins.map((bin, index) => ({...bin, label: labels[index]}));
|
|
2258
2328
|
if (precision === 17) return bins.map((bin, index) => ({...bin, label: `${labels[index]} (bin ${index + 1})`}));
|
|
2259
2329
|
}
|
|
@@ -2467,6 +2537,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
2467
2537
|
attachHeadingTags(entries,context.headingTags);
|
|
2468
2538
|
attachPlainTextTags(entries,context.plainTextTags);
|
|
2469
2539
|
attachTimelineTags(entries,context.timelineTags);
|
|
2540
|
+
attachQuoteTags(entries,context.quoteTags);
|
|
2470
2541
|
attachFurnitureFields(entries,context.furnitureFields);
|
|
2471
2542
|
attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
|
|
2472
2543
|
for(const [part,bytes]of Object.entries(entries)){
|
|
@@ -2486,6 +2557,12 @@ async function normalizePptxZip(raw, context) {
|
|
|
2486
2557
|
}
|
|
2487
2558
|
}
|
|
2488
2559
|
applyChartFonts(entries,context.chartFonts,parseRelationships);
|
|
2560
|
+
// Chartex charts: the native cx:chartSpace part, its style parts and the mc:AlternateContent frame (the classic chart above is the fallback).
|
|
2561
|
+
try {
|
|
2562
|
+
attachChartexParts(entries, context.chartex, parseRelationships);
|
|
2563
|
+
} catch (error) {
|
|
2564
|
+
throw new OPFPptxError('packaging-failed', `Chartex chart parts could not be attached: ${errorMessage(error)}`);
|
|
2565
|
+
}
|
|
2489
2566
|
applyPitchFamilies(entries,context.fontPitch);
|
|
2490
2567
|
const imageSources = new Map();
|
|
2491
2568
|
for (const [part, bytes] of Object.entries(entries)) {
|
|
@@ -2535,7 +2612,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
2535
2612
|
if (!slide) continue;
|
|
2536
2613
|
context.partSlides.set(part, Number(slide[1]) - 1);
|
|
2537
2614
|
for (const relationship of parseRelationships(entries, part).values()) {
|
|
2538
|
-
if (/\/(?:chart|notesSlide)$/.test(relationship.type)) context.partSlides.set(relationship.path, Number(slide[1]) - 1);
|
|
2615
|
+
if (/\/(?:chart|chartEx|notesSlide)$/.test(relationship.type)) context.partSlides.set(relationship.path, Number(slide[1]) - 1);
|
|
2539
2616
|
}
|
|
2540
2617
|
}
|
|
2541
2618
|
const output = {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
+
import {validatePresentation} from '@openpresentation/opf';
|
|
3
|
+
import {attachTextTags,decodeTextTag} from './code-provenance.js';
|
|
4
|
+
import {sourceLineParagraphs} from './text-provenance.js';
|
|
5
|
+
|
|
6
|
+
// FF-57: a `quote` payload exports as native text lines (a body part and an optional footer part). Each line shape carries an
|
|
7
|
+
// OPF_QUOTE_V1 tag, so an unchanged export imports back as {quote: ...} instead of loose text blocks. Like timelines, only topology
|
|
8
|
+
// and line boundaries are stored: every word comes from the current native text, so a cleared or edited quote cannot bring back
|
|
9
|
+
// old words, and a group that no longer matches its manifest degrades to ordinary text blocks with a diagnostic.
|
|
10
|
+
const TAG='OPF_QUOTE_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
11
|
+
const SEPARATOR=' - ',FIELDS=['attribution','source'],FOOTERS=['["attribution"]','["source"]','["attribution","source"]'];
|
|
12
|
+
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
|
|
13
|
+
const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
|
|
14
|
+
export const attachQuoteTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfQuote','quote');
|
|
15
|
+
|
|
16
|
+
// The manifest rides on the first body line. `footer` lists which fields the footer part joins (core writes "attribution - source",
|
|
17
|
+
// skipping an absent field) and, when both are present, where the separator starts, so an unchanged footer splits back exactly.
|
|
18
|
+
export function quoteManifest(value,layout) {
|
|
19
|
+
const shorthand=typeof value==='string',fields=shorthand?[]:FIELDS.filter(field=>value[field]);
|
|
20
|
+
return {shorthand,...(fields.length?{footer:{fields,...(fields.length===2?{split:value.attribution.length}:{})}}:{}),
|
|
21
|
+
parts:layout.parts.map(part=>({role:part.role,lines:part.fit.sourceLines.length}))};
|
|
22
|
+
}
|
|
23
|
+
function validateManifest(manifest) {
|
|
24
|
+
if(!manifest||typeof manifest.shorthand!=='boolean'||!Array.isArray(manifest.parts)||manifest.parts.length<1||manifest.parts.length>2)throw Error('Invalid quote topology.');
|
|
25
|
+
const roles=manifest.parts.map(part=>part?.role);
|
|
26
|
+
if(roles[0]!=='body'||(roles.length===2&&roles[1]!=='footer'))throw Error('Invalid quote parts.');
|
|
27
|
+
let count=0;
|
|
28
|
+
for(const part of manifest.parts){
|
|
29
|
+
if(!Number.isSafeInteger(part.lines)||part.lines<1||part.lines>10000)throw Error('Invalid quote line count.');
|
|
30
|
+
count+=part.lines;
|
|
31
|
+
}
|
|
32
|
+
if(count>10000)throw Error('Too many quote source lines.');
|
|
33
|
+
const footer=manifest.footer;
|
|
34
|
+
if(roles.length===2){
|
|
35
|
+
if(manifest.shorthand||!footer||!FOOTERS.includes(JSON.stringify(footer.fields)))throw Error('Invalid quote footer fields.');
|
|
36
|
+
if(footer.fields.length===2?!Number.isSafeInteger(footer.split)||footer.split<1:footer.split!==undefined)throw Error('Invalid quote footer split.');
|
|
37
|
+
}else if(footer!==undefined)throw Error('Invalid quote footer.');
|
|
38
|
+
return count;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const unwrap=text=>text.length>=2&&text.startsWith('"')&&text.endsWith('"')?text.slice(1,-1):text;
|
|
42
|
+
// Split the current footer text back into fields. A footer edited so the recorded separator no longer matches splits at its
|
|
43
|
+
// first separator; one with none keeps everything as the attribution.
|
|
44
|
+
function footerFields(text,footer,report) {
|
|
45
|
+
if(footer.fields.length===1)return {[footer.fields[0]]:text};
|
|
46
|
+
const at=text.startsWith(SEPARATOR,footer.split)?footer.split:text.indexOf(SEPARATOR);
|
|
47
|
+
if(at<0){
|
|
48
|
+
report({code:'quote-footer-merged',message:'The quote footer no longer separates attribution from source; the current text is kept as the attribution.'});
|
|
49
|
+
return {attribution:text};
|
|
50
|
+
}
|
|
51
|
+
return {attribution:text.slice(0,at),source:text.slice(at+SEPARATOR.length)};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function importQuoteGroups(shapes,paragraphs,relationships,entries,report) {
|
|
55
|
+
const groups=new Map(),consumed=new Set(),items=[];
|
|
56
|
+
for(const [index,shape]of shapes.entries()){
|
|
57
|
+
const tags=[];let unreadable=false;
|
|
58
|
+
for(const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])){
|
|
59
|
+
const rel=relationships.get(link['r:id']);if(rel?.type!==REL||rel.targetMode==='External'||!entries[rel.path])continue;
|
|
60
|
+
try{tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag']));}catch{unreadable=true;}
|
|
61
|
+
}
|
|
62
|
+
for(const tag of tags.filter(tag=>tag.name?.toUpperCase()===TAG))try{
|
|
63
|
+
const data=decodeTextTag(tag.val);if(data?.v!==1||typeof data.group!=='string'||!/^\d{1,12}$/.test(data.group))throw Error('Invalid quote identity.');
|
|
64
|
+
const group=groups.get(data.group)??[];group.push({shape,index,data,ambiguous:unreadable||tags.filter(tag=>/^OPF_/i.test(tag.name)).length!==1});groups.set(data.group,group);
|
|
65
|
+
}catch{report({code:'invalid-quote-provenance',message:'Invalid quote tags; ordinary import retains current native text.'});}
|
|
66
|
+
}
|
|
67
|
+
for(const group of groups.values())try{
|
|
68
|
+
const anchors=group.filter(item=>item.data.anchor!==undefined);if(anchors.length!==1)throw Error('Missing or duplicated quote anchor.');
|
|
69
|
+
const anchor=anchors[0],manifest=anchor.data.anchor,count=validateManifest(manifest);
|
|
70
|
+
if(anchor.data.role!=='text'||anchor.data.part!==0||anchor.data.line!==0||group.length!==count||new Set(group.map(item=>item.shape)).size!==count)throw Error('Incomplete or duplicated quote lines.');
|
|
71
|
+
const ordered=manifest.parts.map(part=>Array(part.lines));
|
|
72
|
+
for(const item of group){
|
|
73
|
+
if(item.ambiguous)throw Error('Multiple source identities on one quote shape.');
|
|
74
|
+
const {part,line}=item.data;
|
|
75
|
+
if(item.data.role!=='text'||!Number.isSafeInteger(part)||!Number.isSafeInteger(line)||part<0||line<0||!manifest.parts[part]||line>=manifest.parts[part].lines||item.data.count!==manifest.parts[part].lines||ordered[part][line])throw Error('Ambiguous quote source line.');
|
|
76
|
+
ordered[part][line]=item;
|
|
77
|
+
}
|
|
78
|
+
const [body,footer]=ordered.map(lines=>sourceLineParagraphs(lines,paragraphs)[0].text),local=[];
|
|
79
|
+
let quote={text:unwrap(body)};
|
|
80
|
+
if(footer)quote={...quote,...footerFields(footer,manifest.footer,diagnostic=>local.push(diagnostic))};
|
|
81
|
+
if(manifest.shorthand&&Object.keys(quote).length===1)quote=quote.text;
|
|
82
|
+
if(!validatePresentation({slides:[{quote}]}).valid)throw Error('Current native text does not form a valid quote.');
|
|
83
|
+
for(const item of group)consumed.add(item.shape);
|
|
84
|
+
items.push({shapes:group.map(item=>item.shape),payload:{type:'quote',quote}});
|
|
85
|
+
for(const diagnostic of local)report(diagnostic);
|
|
86
|
+
report({code:'quote-import-reflow',message:'Quote text, attribution and source were recovered from the current native text. Native formatting, positions and font theme are not reconstructed; review the reflowed quote.'});
|
|
87
|
+
}catch(error){report({code:'invalid-quote-provenance',message:`${error.message} Ordinary import retains current native text without restoring old source words.`});}
|
|
88
|
+
return {consumed,items};
|
|
89
|
+
}
|
package/dist/script-fonts.js
CHANGED
|
@@ -64,23 +64,23 @@ const escapeAttribute = value => String(value).replace(/[&<>"']/g, char => ({"&"
|
|
|
64
64
|
* language's own per-script supplement. Only the supplement's script entry
|
|
65
65
|
* changes; the rest of the vendored per-script list is FF-08's call.
|
|
66
66
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* script
|
|
71
|
-
*
|
|
72
|
-
*
|
|
67
|
+
* FF-49: a slot is written only when a script font is actually selected for it,
|
|
68
|
+
* that is when the resolver's source for that slot is not `latin`: the design
|
|
69
|
+
* font scheme's explicit `eastAsian`/`complexScript`, the scheme's own script
|
|
70
|
+
* family, or the language's script font (Model C). Every other slot keeps the
|
|
71
|
+
* vendored empty typeface, exactly as Office's own themes leave it, so PowerPoint
|
|
72
|
+
* picks its per-language default for script text typed later and the package
|
|
73
|
+
* names nothing the author did not select (core script-font-model.md, "Theme
|
|
74
|
+
* slots (FF-49)"). Latin, Cyrillic and Greek decks keep both slots empty, and a
|
|
75
|
+
* Japanese deck writes `ea` only.
|
|
73
76
|
*/
|
|
74
77
|
export function themeScriptFonts(xml, plan) {
|
|
75
78
|
const {heading, body, supplement} = plan.deck;
|
|
76
79
|
for (const [tag, slots, family] of [["majorFont", heading, supplement?.heading], ["minorFont", body, supplement?.body]]) {
|
|
77
80
|
xml = xml.replace(new RegExp(`<a:${tag}>[\\s\\S]*?</a:${tag}>`), block => {
|
|
78
|
-
// With no script-specific choice the slot repeats the theme's own latin
|
|
79
|
-
// face exactly as written, as the run slots do.
|
|
80
|
-
const latin = /<a:latin typeface="([^"]*)"/.exec(block)?.[1];
|
|
81
81
|
for (const [element, slot] of SCRIPT_SLOTS) {
|
|
82
|
-
if (plan.deck.
|
|
83
|
-
const face =
|
|
82
|
+
if (plan.deck.sources[slot] === "latin") continue;
|
|
83
|
+
const face = escapeAttribute(slots[slot]);
|
|
84
84
|
block = block.replace(new RegExp(`<a:${element}\\b[^>]*/>`), `<a:${element} typeface="${face}"/>`);
|
|
85
85
|
}
|
|
86
86
|
if (supplement && family) {
|
|
@@ -158,7 +158,7 @@ export function partScriptFonts(path, xml, plan, slideIndex) {
|
|
|
158
158
|
xml = xml.replace(/<p:(sp|graphicFrame)>[\s\S]*?<\/p:\1>/g, shape =>
|
|
159
159
|
runScriptFonts(shape, resolved, /<p:cNvPr\b[^>]*\bname="OPF heading /.test(shape)));
|
|
160
160
|
if (plan.rtl) xml = paragraphRtl(xml, plan.deck.direction);
|
|
161
|
-
} else if (/^ppt\/(?:charts\/chart
|
|
161
|
+
} else if (/^ppt\/(?:charts\/chart(?:Ex)?|notesSlides\/notesSlide)\d+\.xml$/.test(path)) {
|
|
162
162
|
xml = runScriptFonts(xml, resolved, false);
|
|
163
163
|
if (plan.rtl && path.startsWith("ppt/notesSlides/")) xml = paragraphRtl(xml, plan.deck.direction);
|
|
164
164
|
} else if (plan.rtl && /^ppt\/(?:slideMasters\/slideMaster\d+|slideLayouts\/slideLayout\d+|notesMasters\/notesMaster\d+|presentation)\.xml$/.test(path)) {
|
|
@@ -71,6 +71,7 @@ export function placeSlideImages(entries, images, metadataFor, fail) {
|
|
|
71
71
|
}
|
|
72
72
|
const node = parser.parse(picture)['p:pic'];
|
|
73
73
|
manifests.set(name, {v:1, slide:image.slide, position:image.treatment.position, fill:image.fill, treatment:image.treatment,
|
|
74
|
+
...(image.content === true ? {content:true} : {}),
|
|
74
75
|
properties:node['p:spPr'], blipFill:blipFillIdentity(node['p:blipFill'])});
|
|
75
76
|
return picture;
|
|
76
77
|
});
|
|
@@ -129,6 +130,7 @@ export function importSlideImage(pictures, shapes, relationships, entries, slide
|
|
|
129
130
|
if (unreadable || others || own.length !== 1) throw Error('Ambiguous slide image identity.');
|
|
130
131
|
const manifest = decodeTextTag(own[0].val);
|
|
131
132
|
if (manifest?.v !== 1 || manifest.slide !== `slides.${slideIndex}` || !['crop', 'fit'].includes(manifest.fill) || !validTreatment(manifest.treatment, manifest.position)) throw Error('Invalid slide image manifest.');
|
|
133
|
+
if (manifest.content !== undefined && manifest.content !== true) throw Error('Invalid slide image content flag.');
|
|
132
134
|
if (picture['p:nvPicPr']?.['p:cNvPr']?.name !== slideImageName(manifest.slide)) throw Error('Slide image identity changed.');
|
|
133
135
|
if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(manifest.blipFill)) throw Error('Slide image geometry or effects changed.');
|
|
134
136
|
const item = readPicture(picture);
|
|
@@ -144,7 +146,11 @@ export function importSlideImage(pictures, shapes, relationships, entries, slide
|
|
|
144
146
|
report({code:'invalid-slide-image-provenance', message:'The tagged OPF slide image overlay is missing, edited or ambiguous. The slide image is recovered without its overlay; any remaining native shape is imported as ordinary content.'});
|
|
145
147
|
} else consumedShapes.add(overlay);
|
|
146
148
|
}
|
|
147
|
-
|
|
149
|
+
// FF-53: a picture that replaced the slide's root `image` (same source as the slide image) restores that
|
|
150
|
+
// payload too, so the round trip keeps `slide.image` with its alt text as well as design.slideImage.
|
|
151
|
+
const alt = item.payload.image.alt;
|
|
152
|
+
const content = manifest.content === true ? {image: {src, ...(typeof alt === 'string' && alt ? {alt} : {})}} : {};
|
|
153
|
+
return {consumed, consumedShapes, design: {slideImage: {...treatment, src}, ...(manifest.fill === 'fit' ? {imageFill: 'fit'} : {})}, ...content};
|
|
148
154
|
} catch {
|
|
149
155
|
invalid();
|
|
150
156
|
return {consumed, consumedShapes};
|
|
@@ -9,7 +9,7 @@ import {unzipSync} from 'fflate';
|
|
|
9
9
|
// rich-text `rFont`) and the docProps/app.xml "Fonts Used" list.
|
|
10
10
|
// checkPptxTypefaces() then applies the owner font policy: a package names only
|
|
11
11
|
// the fonts its author chose, plus theme references that resolve to them, empty
|
|
12
|
-
// theme script slots (FF-
|
|
12
|
+
// theme script slots when no script font was selected (as in Office's themes; FF-49 checks a selected slot through options.themeScripts) and the documented theme script supplements.
|
|
13
13
|
|
|
14
14
|
const decoder = new TextDecoder();
|
|
15
15
|
const NESTED_PACKAGE = /\.(?:xlsx|xlsm|docx|pptx)$/i;
|
|
@@ -96,8 +96,18 @@ export function checkPptxTypefaces(input, options = {}) {
|
|
|
96
96
|
const violations = [];
|
|
97
97
|
const fail = (entry, reason, details = {}) => violations.push({reason, part: entry.part, element: entry.element, typeface: entry.typeface, ...details});
|
|
98
98
|
const emptyAllowed = entry => allowEmpty && entry.theme && (entry.element === 'ea' || entry.element === 'cs');
|
|
99
|
+
// FF-49: options.themeScripts names the East Asian / complex-script families the author selected for the
|
|
100
|
+
// presentation theme, {major: {ea, cs}, minor: {ea, cs}}. A selected slot must carry exactly that family (an
|
|
101
|
+
// empty or different one is a violation); a slot with nothing selected may stay empty, as in Office's own themes.
|
|
102
|
+
const themeSlot = entry => entry.theme && !entry.part.includes('!/') && (entry.element === 'ea' || entry.element === 'cs')
|
|
103
|
+
? options.themeScripts?.[entry.theme]?.[entry.element] || '' : '';
|
|
99
104
|
const pitches = new Map();
|
|
100
105
|
for (const entry of inventory.typefaces) {
|
|
106
|
+
const wanted = themeSlot(entry);
|
|
107
|
+
if (wanted && entry.typeface !== wanted) {
|
|
108
|
+
fail(entry, 'theme-script-slot', {expected: wanted});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
101
111
|
const reference = THEME_REFERENCE.exec(entry.typeface);
|
|
102
112
|
if (reference) {
|
|
103
113
|
if (entry.resolved === null) fail(entry, 'unresolved-theme-reference');
|