@@ -16,9 +16,23 @@ import {
1616 requireArray ,
1717 requireNumber ,
1818 nextShapeId ,
19+ _createShapeFragment ,
20+ type ShapeFragment ,
1921} from "ha:ooxml-core" ;
2022import { escapeXml } from "ha:xml-escape" ;
2123
24+ // ── Chart Complexity Caps ────────────────────────────────────────────
25+ // Hard limits to prevent decks that exhaust PowerPoint's rendering budget.
26+
27+ /** Maximum charts per presentation deck. */
28+ export const MAX_CHARTS_PER_DECK = 50 ;
29+
30+ /** Maximum data series per chart (Excel column reference limit B–Y). */
31+ export const MAX_SERIES_PER_CHART = 24 ;
32+
33+ /** Maximum categories (X-axis labels) per chart. */
34+ export const MAX_CATEGORIES_PER_CHART = 100 ;
35+
2236// ── Namespace Constants ──────────────────────────────────────────────
2337const NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart" ;
2438const NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main" ;
@@ -99,7 +113,11 @@ function seriesXml(
99113 // Series name is REQUIRED — charts with unnamed series produce meaningless legends.
100114 requireString ( series . name , `series[${ index } ].name` ) ;
101115 // Series values are REQUIRED and must be a non-empty array of numbers.
102- if ( ! series . values || ! Array . isArray ( series . values ) || series . values . length === 0 ) {
116+ if (
117+ ! series . values ||
118+ ! Array . isArray ( series . values ) ||
119+ series . values . length === 0
120+ ) {
103121 throw new Error (
104122 `series[${ index } ].values: array must not be empty. ` +
105123 `This often happens when fetched data is empty. ` +
@@ -327,6 +345,18 @@ export function barChart(opts: BarChartOptions): ChartResult {
327345 requireArray ( opts . categories || [ ] , "barChart.categories" ) ;
328346 requireArray ( opts . series || [ ] , "barChart.series" , { nonEmpty : true } ) ;
329347 if ( opts . textColor ) requireHex ( opts . textColor , "barChart.textColor" ) ;
348+ // Enforce complexity caps
349+ if ( ( opts . categories || [ ] ) . length > MAX_CATEGORIES_PER_CHART ) {
350+ throw new Error (
351+ `barChart: ${ ( opts . categories || [ ] ) . length } categories exceeds the maximum of ${ MAX_CATEGORIES_PER_CHART } . ` +
352+ `Reduce category count or aggregate data.` ,
353+ ) ;
354+ }
355+ if ( ( opts . series || [ ] ) . length > MAX_SERIES_PER_CHART ) {
356+ throw new Error (
357+ `barChart: ${ ( opts . series || [ ] ) . length } series exceeds the maximum of ${ MAX_SERIES_PER_CHART } .` ,
358+ ) ;
359+ }
330360
331361 const dir = opts . horizontal ? "bar" : "col" ;
332362 const grouping = opts . stacked ? "stacked" : "clustered" ;
@@ -345,7 +375,10 @@ ${seriesXmls}
345375${ axisXml ( 1 , 2 , opts . horizontal ? "l" : "b" , true , tc ) }
346376${ axisXml ( 2 , 1 , opts . horizontal ? "b" : "l" , false , tc ) } `;
347377
348- return chartResult ( "bar" , chartXml ( plotArea , opts . title , opts . showLegend , tc ) ) ;
378+ return chartResult (
379+ "bar" ,
380+ chartXml ( plotArea , opts . title , opts . showLegend , tc ) ,
381+ ) ;
349382}
350383
351384export interface PieChartOptions {
@@ -420,6 +453,13 @@ export function pieChart(opts: PieChartOptions): ChartResult {
420453 `has ${ values . length } . They must have the same length — one label per slice.` ,
421454 ) ;
422455 }
456+ // Enforce complexity caps
457+ if ( labels . length > MAX_CATEGORIES_PER_CHART ) {
458+ throw new Error (
459+ `pieChart: ${ labels . length } slices exceeds the maximum of ${ MAX_CATEGORIES_PER_CHART } . ` +
460+ `Group smaller values into an "Other" slice.` ,
461+ ) ;
462+ }
423463 // Validate each value is a finite number
424464 values . forEach ( ( v , i ) => {
425465 if ( typeof v !== "number" || ! Number . isFinite ( v ) ) {
@@ -519,7 +559,10 @@ ${seriesDataLabels}
519559${ holeSize }
520560</${ chartTag } >` ;
521561
522- return chartResult ( "pie" , chartXml ( plotArea , opts . title , effectiveShowLegend , tc ) ) ;
562+ return chartResult (
563+ "pie" ,
564+ chartXml ( plotArea , opts . title , effectiveShowLegend , tc ) ,
565+ ) ;
523566}
524567
525568export interface LineChartOptions {
@@ -566,6 +609,17 @@ export function lineChart(opts: LineChartOptions): ChartResult {
566609 requireArray ( opts . categories || [ ] , "lineChart.categories" ) ;
567610 requireArray ( opts . series || [ ] , "lineChart.series" , { nonEmpty : true } ) ;
568611 if ( opts . textColor ) requireHex ( opts . textColor , "lineChart.textColor" ) ;
612+ // Enforce complexity caps
613+ if ( ( opts . categories || [ ] ) . length > MAX_CATEGORIES_PER_CHART ) {
614+ throw new Error (
615+ `lineChart: ${ ( opts . categories || [ ] ) . length } categories exceeds the maximum of ${ MAX_CATEGORIES_PER_CHART } .` ,
616+ ) ;
617+ }
618+ if ( ( opts . series || [ ] ) . length > MAX_SERIES_PER_CHART ) {
619+ throw new Error (
620+ `lineChart: ${ ( opts . series || [ ] ) . length } series exceeds the maximum of ${ MAX_SERIES_PER_CHART } .` ,
621+ ) ;
622+ }
569623
570624 const chartTag = opts . area ? "c:areaChart" : "c:lineChart" ;
571625 const grouping = "standard" ;
@@ -650,7 +704,10 @@ ${seriesXmls}
650704${ axisXml ( 1 , 2 , "b" , true , tc ) }
651705${ axisXml ( 2 , 1 , "l" , false , tc ) } `;
652706
653- return chartResult ( opts . area ? "area" : "line" , chartXml ( plotArea , opts . title , opts . showLegend , tc ) ) ;
707+ return chartResult (
708+ opts . area ? "area" : "line" ,
709+ chartXml ( plotArea , opts . title , opts . showLegend , tc ) ,
710+ ) ;
654711}
655712
656713export interface ComboChartOptions {
@@ -691,6 +748,12 @@ export interface ComboChartOptions {
691748export function comboChart ( opts : ComboChartOptions ) : ChartResult {
692749 // ── Input validation ──────────────────────────────────────────────
693750 requireArray ( opts . categories || [ ] , "comboChart.categories" ) ;
751+ // Enforce complexity caps
752+ if ( ( opts . categories || [ ] ) . length > MAX_CATEGORIES_PER_CHART ) {
753+ throw new Error (
754+ `comboChart: ${ ( opts . categories || [ ] ) . length } categories exceeds the maximum of ${ MAX_CATEGORIES_PER_CHART } .` ,
755+ ) ;
756+ }
694757 const barSeries = opts . barSeries || [ ] ;
695758 const lineSeries = opts . lineSeries || [ ] ;
696759 requireArray ( barSeries , "comboChart.barSeries" ) ;
@@ -770,7 +833,10 @@ ${lineXmls}
770833${ axisXml ( 1 , 2 , "b" , true , tc ) }
771834${ axisXml ( 2 , 1 , "l" , false , tc ) } `;
772835
773- return chartResult ( "combo" , chartXml ( plotArea , opts . title , opts . showLegend , tc ) ) ;
836+ return chartResult (
837+ "combo" ,
838+ chartXml ( plotArea , opts . title , opts . showLegend , tc ) ,
839+ ) ;
774840}
775841
776842// ── Chart Embedding into PPTX Slides ─────────────────────────────────
@@ -783,11 +849,14 @@ export interface ChartPosition {
783849}
784850
785851export interface EmbedChartResult {
852+ /** ShapeFragment for use in customSlide shapes array. */
853+ shape : ShapeFragment ;
854+ /** @internal Raw shape XML string (kept for internal compatibility). */
786855 shapeXml : string ;
787856 zipEntries : Array < { name : string ; data : string } > ;
788857 chartRelId : string ;
789858 chartIndex : number ;
790- /** Returns shapeXml when converted to string (e.g., in string concatenation) . */
859+ /** @deprecated Throws error — use .shape instead . */
791860 toString ( ) : string ;
792861}
793862
@@ -834,6 +903,14 @@ export function embedChart(
834903 "Pass the object returned by createPresentation()." ,
835904 ) ;
836905 }
906+ // Enforce deck-level chart cap
907+ const currentChartCount = ( pres . _charts || [ ] ) . length ;
908+ if ( currentChartCount >= MAX_CHARTS_PER_DECK ) {
909+ throw new Error (
910+ `embedChart: deck already has ${ currentChartCount } charts — max ${ MAX_CHARTS_PER_DECK } . ` +
911+ `Reduce chart count or split into multiple presentations.` ,
912+ ) ;
913+ }
837914 if ( chart == null || chart . type !== "chart" ) {
838915 throw new Error (
839916 "embedChart: 'chart' must be a chart object from barChart/pieChart/lineChart/comboChart. " +
@@ -908,15 +985,21 @@ export function embedChart(
908985 pres . _chartEntries . push ( entry ) ;
909986 }
910987
911- // Return an object that stringifies to shapeXml for easy use in shape concatenation.
912- // This allows: shapes: textBox(...) + embedChart(pres, chart, pos) + rect(...)
913- // Instead of requiring: embedChart(...).shapeXml
988+ // Return structured result — use .shape for customSlide arrays.
989+ // toString() now THROWS to prevent accidental XML concatenation.
914990 const result : EmbedChartResult = {
991+ shape : _createShapeFragment ( shapeXml ) ,
915992 shapeXml,
916993 zipEntries,
917994 chartRelId : relId ,
918995 chartIndex : idx ,
919- toString : ( ) => shapeXml ,
996+ toString ( ) : string {
997+ throw new Error (
998+ "Cannot concatenate embedChart result directly into shapes. " +
999+ "Use the .shape property in your shapes array: " +
1000+ "customSlide(pres, { shapes: [textBox(...), chart.shape, rect(...)] })" ,
1001+ ) ;
1002+ } ,
9201003 } ;
9211004 return result ;
9221005}
0 commit comments