@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/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
- export interface ChartDataAdaptedDiagnostic { code: "chart-data-adapted"; path: string; message: string; adaptation: "histogram-binned" | "row-numbers" }
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 chartRelId = graphicData?.["c:chart"]?.["r:id"];
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 = chartFromRelationship(entries, slidePath, relationships, chartRelId);
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
- context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects });
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: CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`))),
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
- 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});
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
- // Chartex types (treemap, box & whisker, waterfall, ...) have no classic construct here: they export as a clustered column chart, never silently.
2177
- const chartex = resolved.family === 'chartex';
2178
- const spec = chartex ? CHARTEX_FALLBACK : resolved;
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
- if (spec.family === 'circular' && series.length > 1) {
2223
- // A pie or doughnut plots one series; name the ones left out instead of dropping them silently.
2224
- const dropped = series.slice(1).map((entry) => `'${entry.name}'`);
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, 1);
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 {type: spec.pptx, spec, series, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined, adaptations};
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)}\u2013${format(bin.high)}`);
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
+ }
@@ -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
- * For a language written in the latin slot (Latin, Cyrillic, Greek and others) the
68
- * vendored empty ea/cs stay empty unless the design font scheme sets that
69
- * slot explicitly. Filling them with the latin family is gated on FF-05 (core
70
- * script-font-model.md): it did not remove PowerPoint's nameless/Aptos font
71
- * entries, and empty slots keep PowerPoint's per-script theme fallback for
72
- * East Asian or complex-script text typed later. Other languages fill both.
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.scriptRole === "latin" && plan.deck.sources[slot] !== "fontScheme") continue;
83
- const face = plan.deck.sources[slot] === "latin" && latin ? latin : escapeAttribute(slots[slot]);
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|notesSlides\/notesSlide)\d+\.xml$/.test(path)) {
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
- return {consumed, consumedShapes, design: {slideImage: {...treatment, src}, ...(manifest.fill === 'fit' ? {imageFill: 'fit'} : {})}};
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-05) and the documented theme script supplements.
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');