From 54608d0a7f9d4e296801d540fd39ea7cdc8c9e8d Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 26 Mar 2026 19:40:04 +0000 Subject: [PATCH] feat(pptx): add ShapeFragment safety system and validation engine - Introduce ShapeFragment branded type to prevent raw XML injection - addBody() now validates ShapeFragment, rejects raw strings - Hide _createShapeFragment from LLM discovery (underscore prefix) - Add validation engine: relationship caps, NaN/Infinity detection, cross-slide duplicate shape ID checks, notes sanitization - Chart complexity caps: 50 charts/deck, 24 series, 100 categories - Deduplicate MAX constants (single source in pptx-charts.ts) - Update hints/prompts and SKILL.md with ShapeFragment API docs - Add 59 safety tests covering all shape builders and edge cases Signed-off-by: Simon Davies --- builtin-modules/ooxml-core.json | 4 +- builtin-modules/pptx-charts.json | 9 +- builtin-modules/pptx-tables.json | 11 +- builtin-modules/pptx.json | 13 +- builtin-modules/src/ooxml-core.ts | 87 + builtin-modules/src/pptx-charts.ts | 103 +- builtin-modules/src/pptx-tables.ts | 36 +- builtin-modules/src/pptx.ts | 1804 ++++++++++++++++----- builtin-modules/src/shared-state.ts | 5 +- builtin-modules/src/types/ha-modules.d.ts | 225 ++- skills/pptx-expert/SKILL.md | 37 +- tests/docgen-modules.test.ts | 697 ++++---- tests/pptx-readability.test.ts | 387 +++-- tests/pptx-safety.test.ts | 865 ++++++++++ tests/pptx-validation.test.ts | 343 ++-- 15 files changed, 3431 insertions(+), 1195 deletions(-) create mode 100644 tests/pptx-safety.test.ts diff --git a/builtin-modules/ooxml-core.json b/builtin-modules/ooxml-core.json index 44d7c7e..481dedf 100644 --- a/builtin-modules/ooxml-core.json +++ b/builtin-modules/ooxml-core.json @@ -3,8 +3,8 @@ "description": "Shared OOXML infrastructure - units, colors, themes, Content_Types, relationships", "author": "system", "mutable": false, - "sourceHash": "sha256:24c8441a3504052f", - "dtsHash": "sha256:9f88e7c59a56854c", + "sourceHash": "sha256:00cfaa1e652856b2", + "dtsHash": "sha256:6aac85502082bf89", "importStyle": "named", "hints": { "overview": "Low-level OOXML infrastructure. Most users should use ha:pptx instead.", diff --git a/builtin-modules/pptx-charts.json b/builtin-modules/pptx-charts.json index cb7bc1c..0651b9b 100644 --- a/builtin-modules/pptx-charts.json +++ b/builtin-modules/pptx-charts.json @@ -3,8 +3,8 @@ "description": "OOXML DrawingML chart generation - bar, pie, line charts for PPTX presentations", "author": "system", "mutable": false, - "sourceHash": "sha256:029765ed53b96536", - "dtsHash": "sha256:5f653830226c3554", + "sourceHash": "sha256:27d40e5c5095ec38", + "dtsHash": "sha256:4353b8263dc99405", "importStyle": "named", "hints": { "overview": "Chart generation for PPTX. Always used with ha:pptx.", @@ -16,7 +16,10 @@ "Use chartSlide(pres, {title, chart}) for simple full-slide charts", "Chart values must be finite numbers — not null, undefined, or strings", "pieChart: labels[] and values[] must have the same length", - "barChart/lineChart: each series.values[] length must equal categories[] length" + "barChart/lineChart: each series.values[] length must equal categories[] length", + "Max 50 charts per deck, 24 series per chart, 100 categories per chart", + "embedChart returns {shape, ...} — pass result.shape to customSlide, NOT result directly", + "Do NOT call .toString() on chart results — it throws. Use .shape property." ], "antiPatterns": [ "Don't import chart functions from ha:pptx — they're in this module" diff --git a/builtin-modules/pptx-tables.json b/builtin-modules/pptx-tables.json index e15fde1..a85f796 100644 --- a/builtin-modules/pptx-tables.json +++ b/builtin-modules/pptx-tables.json @@ -3,14 +3,17 @@ "description": "Styled tables for PPTX presentations - headers, borders, alternating rows", "author": "system", "mutable": false, - "sourceHash": "sha256:399b5349b1c8c187", - "dtsHash": "sha256:82d903ffbf4dfb1e", + "sourceHash": "sha256:5940fb396a67f801", + "dtsHash": "sha256:3ba75bbc44353467", "importStyle": "named", "hints": { "overview": "Table generation for PPTX. Always used with ha:pptx.", - "relatedModules": ["ha:pptx"], + "relatedModules": [ + "ha:pptx" + ], "criticalRules": [ - "comparisonTable: options array must not be empty, each option needs {name, values}" + "comparisonTable: options array must not be empty, each option needs {name, values}", + "All table functions return ShapeFragment — pass directly to customSlide shapes array" ], "commonPatterns": [ "table({x, y, w, headers, rows, style}) for data tables", diff --git a/builtin-modules/pptx.json b/builtin-modules/pptx.json index 06e564f..bc01b15 100644 --- a/builtin-modules/pptx.json +++ b/builtin-modules/pptx.json @@ -3,8 +3,8 @@ "description": "PowerPoint PPTX presentation builder - slides, text, shapes, themes, layouts", "author": "system", "mutable": false, - "sourceHash": "sha256:a13871a41506a523", - "dtsHash": "sha256:2107e369816b4bd5", + "sourceHash": "sha256:895d7188d5ba8b46", + "dtsHash": "sha256:27520514e4401465", "importStyle": "named", "hints": { "overview": "Core PPTX slide building. Charts in ha:pptx-charts, tables in ha:pptx-tables.", @@ -27,13 +27,18 @@ "ALL slide functions need pres as FIRST parameter: titleSlide(pres, opts)", "Charts are NOT in this module — import from ha:pptx-charts", "Tables are NOT in this module — import from ha:pptx-tables", + "Shape builders return ShapeFragment objects — NEVER construct raw XML strings", + "customSlide shapes must be ShapeFragment or ShapeFragment[] — raw strings are rejected", "Use getThemeNames() to see valid themes", "DARK THEMES auto-handle contrast — don't use forceColor", - "Don't specify text color — theme auto-selects readable colours" + "Don't specify text color — theme auto-selects readable colours", + "Speaker notes are plain text only — max 12,000 chars, auto-sanitized" ], "antiPatterns": [ "Don't store pres object in shared-state — use pres.serialize()", - "Don't write raw OOXML XML — use module functions", + "Don't write raw OOXML XML — use module shape builder functions", + "Don't concatenate ShapeFragment objects with + — pass as arrays", + "Don't call .toString() on chart results — use .shape property", "Don't guess function names — call module_info first", "series.name is REQUIRED for all chart data series" ], diff --git a/builtin-modules/src/ooxml-core.ts b/builtin-modules/src/ooxml-core.ts index 5b015a2..d9e5cff 100644 --- a/builtin-modules/src/ooxml-core.ts +++ b/builtin-modules/src/ooxml-core.ts @@ -652,6 +652,93 @@ export function isDark(hex: string): boolean { return luminance(hex) < 0.5; } +// ── ShapeFragment (Opaque Branded Type) ────────────────────────────── +// All shape builders (textBox, rect, table, etc.) return ShapeFragment. +// Only code that holds the private SHAPE_BRAND symbol can forge one. +// This prevents LLMs from injecting arbitrary XML strings into slides. +// +// SECURITY MODEL: +// The sandbox architecture shares all ha:* module exports at runtime. +// We cannot make _createShapeFragment truly unexportable for cross-module +// use (pptx.ts, pptx-charts.ts, pptx-tables.ts all need it). +// Defence layers: +// 1. Underscore prefix → excluded from module_info / hints by convention +// 2. Filtered from ha-modules.d.ts → invisible to LLM type discovery +// (generate-ha-modules-dts.ts skips _-prefixed exports) +// 3. SKILL.md documents only builder functions, not the factory +// 4. Code-validator + sandbox provide the hard security boundary +// The threat model is LLM hallucinations, not adversarial humans. + +/** Private brand symbol — never exported by module boundary. */ +const SHAPE_BRAND: unique symbol = Symbol("ShapeFragment"); + +/** + * Opaque shape fragment produced by official shape builders. + * Cannot be constructed from raw strings by LLM code. + * + * Internal code can read `._xml`; external (LLM) code treats this as opaque. + */ +export interface ShapeFragment { + /** @internal Raw OOXML XML for this shape element. */ + readonly _xml: string; + /** Returns the internal XML (for string concatenation in internal code). */ + toString(): string; +} + +/** + * Create a branded ShapeFragment wrapping validated XML. + * Called internally by shape builder functions (textBox, rect, table, etc.). + * Underscore-prefixed to signal internal-only — LLMs should use builder + * functions (textBox, rect, etc.) not this directly. + * @internal + */ +export function _createShapeFragment(xml: string): ShapeFragment { + const obj = { + _xml: xml, + toString(): string { + return xml; + }, + } as ShapeFragment; + // Brand the object with the private symbol (runtime check) + (obj as unknown as Record)[SHAPE_BRAND] = true; + return Object.freeze(obj); +} + +/** + * Check whether a value is a genuine ShapeFragment from a builder function. + * Uses the private symbol brand — cannot be forged by LLM code. + */ +export function isShapeFragment(x: unknown): x is ShapeFragment { + return ( + x != null && + typeof x === "object" && + (x as Record)[SHAPE_BRAND] === true + ); +} + +/** + * Convert an array of ShapeFragments to a single XML string. + * Validates that every element is a genuine branded ShapeFragment. + * @throws If any element is not a ShapeFragment + */ +export function fragmentsToXml( + fragments: ShapeFragment | ShapeFragment[], +): string { + const arr = Array.isArray(fragments) ? fragments : [fragments]; + const parts: string[] = []; + for (let i = 0; i < arr.length; i++) { + const f = arr[i]; + if (!isShapeFragment(f)) { + throw new Error( + `shapes[${i}]: expected a ShapeFragment from textBox/rect/table/bulletList/etc, ` + + `but got ${typeof f}. Do NOT pass raw XML strings — use the shape builder functions.`, + ); + } + parts.push(f._xml); + } + return parts.join(""); +} + // ── Shape ID Counter ───────────────────────────────────────────────── // OOXML requires each shape in a presentation to have a unique positive integer ID. // PowerPoint will show a "found a problem with content" error if multiple diff --git a/builtin-modules/src/pptx-charts.ts b/builtin-modules/src/pptx-charts.ts index 48e1965..c08647a 100644 --- a/builtin-modules/src/pptx-charts.ts +++ b/builtin-modules/src/pptx-charts.ts @@ -16,9 +16,23 @@ import { requireArray, requireNumber, nextShapeId, + _createShapeFragment, + type ShapeFragment, } from "ha:ooxml-core"; import { escapeXml } from "ha:xml-escape"; +// ── Chart Complexity Caps ──────────────────────────────────────────── +// Hard limits to prevent decks that exhaust PowerPoint's rendering budget. + +/** Maximum charts per presentation deck. */ +export const MAX_CHARTS_PER_DECK = 50; + +/** Maximum data series per chart (Excel column reference limit B–Y). */ +export const MAX_SERIES_PER_CHART = 24; + +/** Maximum categories (X-axis labels) per chart. */ +export const MAX_CATEGORIES_PER_CHART = 100; + // ── Namespace Constants ────────────────────────────────────────────── const NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"; const NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"; @@ -99,7 +113,11 @@ function seriesXml( // Series name is REQUIRED — charts with unnamed series produce meaningless legends. requireString(series.name, `series[${index}].name`); // Series values are REQUIRED and must be a non-empty array of numbers. - if (!series.values || !Array.isArray(series.values) || series.values.length === 0) { + if ( + !series.values || + !Array.isArray(series.values) || + series.values.length === 0 + ) { throw new Error( `series[${index}].values: array must not be empty. ` + `This often happens when fetched data is empty. ` + @@ -327,6 +345,18 @@ export function barChart(opts: BarChartOptions): ChartResult { requireArray(opts.categories || [], "barChart.categories"); requireArray(opts.series || [], "barChart.series", { nonEmpty: true }); if (opts.textColor) requireHex(opts.textColor, "barChart.textColor"); + // Enforce complexity caps + if ((opts.categories || []).length > MAX_CATEGORIES_PER_CHART) { + throw new Error( + `barChart: ${(opts.categories || []).length} categories exceeds the maximum of ${MAX_CATEGORIES_PER_CHART}. ` + + `Reduce category count or aggregate data.`, + ); + } + if ((opts.series || []).length > MAX_SERIES_PER_CHART) { + throw new Error( + `barChart: ${(opts.series || []).length} series exceeds the maximum of ${MAX_SERIES_PER_CHART}.`, + ); + } const dir = opts.horizontal ? "bar" : "col"; const grouping = opts.stacked ? "stacked" : "clustered"; @@ -345,7 +375,10 @@ ${seriesXmls} ${axisXml(1, 2, opts.horizontal ? "l" : "b", true, tc)} ${axisXml(2, 1, opts.horizontal ? "b" : "l", false, tc)}`; - return chartResult("bar", chartXml(plotArea, opts.title, opts.showLegend, tc)); + return chartResult( + "bar", + chartXml(plotArea, opts.title, opts.showLegend, tc), + ); } export interface PieChartOptions { @@ -420,6 +453,13 @@ export function pieChart(opts: PieChartOptions): ChartResult { `has ${values.length}. They must have the same length — one label per slice.`, ); } + // Enforce complexity caps + if (labels.length > MAX_CATEGORIES_PER_CHART) { + throw new Error( + `pieChart: ${labels.length} slices exceeds the maximum of ${MAX_CATEGORIES_PER_CHART}. ` + + `Group smaller values into an "Other" slice.`, + ); + } // Validate each value is a finite number values.forEach((v, i) => { if (typeof v !== "number" || !Number.isFinite(v)) { @@ -519,7 +559,10 @@ ${seriesDataLabels} ${holeSize} `; - return chartResult("pie", chartXml(plotArea, opts.title, effectiveShowLegend, tc)); + return chartResult( + "pie", + chartXml(plotArea, opts.title, effectiveShowLegend, tc), + ); } export interface LineChartOptions { @@ -566,6 +609,17 @@ export function lineChart(opts: LineChartOptions): ChartResult { requireArray(opts.categories || [], "lineChart.categories"); requireArray(opts.series || [], "lineChart.series", { nonEmpty: true }); if (opts.textColor) requireHex(opts.textColor, "lineChart.textColor"); + // Enforce complexity caps + if ((opts.categories || []).length > MAX_CATEGORIES_PER_CHART) { + throw new Error( + `lineChart: ${(opts.categories || []).length} categories exceeds the maximum of ${MAX_CATEGORIES_PER_CHART}.`, + ); + } + if ((opts.series || []).length > MAX_SERIES_PER_CHART) { + throw new Error( + `lineChart: ${(opts.series || []).length} series exceeds the maximum of ${MAX_SERIES_PER_CHART}.`, + ); + } const chartTag = opts.area ? "c:areaChart" : "c:lineChart"; const grouping = "standard"; @@ -650,7 +704,10 @@ ${seriesXmls} ${axisXml(1, 2, "b", true, tc)} ${axisXml(2, 1, "l", false, tc)}`; - return chartResult(opts.area ? "area" : "line", chartXml(plotArea, opts.title, opts.showLegend, tc)); + return chartResult( + opts.area ? "area" : "line", + chartXml(plotArea, opts.title, opts.showLegend, tc), + ); } export interface ComboChartOptions { @@ -691,6 +748,12 @@ export interface ComboChartOptions { export function comboChart(opts: ComboChartOptions): ChartResult { // ── Input validation ────────────────────────────────────────────── requireArray(opts.categories || [], "comboChart.categories"); + // Enforce complexity caps + if ((opts.categories || []).length > MAX_CATEGORIES_PER_CHART) { + throw new Error( + `comboChart: ${(opts.categories || []).length} categories exceeds the maximum of ${MAX_CATEGORIES_PER_CHART}.`, + ); + } const barSeries = opts.barSeries || []; const lineSeries = opts.lineSeries || []; requireArray(barSeries, "comboChart.barSeries"); @@ -770,7 +833,10 @@ ${lineXmls} ${axisXml(1, 2, "b", true, tc)} ${axisXml(2, 1, "l", false, tc)}`; - return chartResult("combo", chartXml(plotArea, opts.title, opts.showLegend, tc)); + return chartResult( + "combo", + chartXml(plotArea, opts.title, opts.showLegend, tc), + ); } // ── Chart Embedding into PPTX Slides ───────────────────────────────── @@ -783,11 +849,14 @@ export interface ChartPosition { } export interface EmbedChartResult { + /** ShapeFragment for use in customSlide shapes array. */ + shape: ShapeFragment; + /** @internal Raw shape XML string (kept for internal compatibility). */ shapeXml: string; zipEntries: Array<{ name: string; data: string }>; chartRelId: string; chartIndex: number; - /** Returns shapeXml when converted to string (e.g., in string concatenation). */ + /** @deprecated Throws error — use .shape instead. */ toString(): string; } @@ -834,6 +903,14 @@ export function embedChart( "Pass the object returned by createPresentation().", ); } + // Enforce deck-level chart cap + const currentChartCount = (pres._charts || []).length; + if (currentChartCount >= MAX_CHARTS_PER_DECK) { + throw new Error( + `embedChart: deck already has ${currentChartCount} charts — max ${MAX_CHARTS_PER_DECK}. ` + + `Reduce chart count or split into multiple presentations.`, + ); + } if (chart == null || chart.type !== "chart") { throw new Error( "embedChart: 'chart' must be a chart object from barChart/pieChart/lineChart/comboChart. " + @@ -908,15 +985,21 @@ export function embedChart( pres._chartEntries.push(entry); } - // Return an object that stringifies to shapeXml for easy use in shape concatenation. - // This allows: shapes: textBox(...) + embedChart(pres, chart, pos) + rect(...) - // Instead of requiring: embedChart(...).shapeXml + // Return structured result — use .shape for customSlide arrays. + // toString() now THROWS to prevent accidental XML concatenation. const result: EmbedChartResult = { + shape: _createShapeFragment(shapeXml), shapeXml, zipEntries, chartRelId: relId, chartIndex: idx, - toString: () => shapeXml, + toString(): string { + throw new Error( + "Cannot concatenate embedChart result directly into shapes. " + + "Use the .shape property in your shapes array: " + + "customSlide(pres, { shapes: [textBox(...), chart.shape, rect(...)] })", + ); + }, }; return result; } diff --git a/builtin-modules/src/pptx-tables.ts b/builtin-modules/src/pptx-tables.ts index 1186337..53b7b7c 100644 --- a/builtin-modules/src/pptx-tables.ts +++ b/builtin-modules/src/pptx-tables.ts @@ -21,6 +21,8 @@ import { isDark, nextShapeId, isForceAllColors, + _createShapeFragment, + type ShapeFragment, type Theme, } from "ha:ooxml-core"; import { escapeXml } from "ha:xml-escape"; @@ -186,7 +188,7 @@ export interface TableOptions { * @param opts.style.headerFontSize - Header font size in pt * @returns Shape XML fragment for use in slide body */ -export function table(opts: TableOptions): string { +export function table(opts: TableOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── const headers = opts.headers || []; const rows = opts.rows || []; @@ -254,13 +256,16 @@ export function table(opts: TableOptions): string { // Use explicit rowHeight if provided, otherwise auto-calculate const headerRowHeight = opts.rowHeight ? inches(opts.rowHeight) - : (headers.length > 0 ? inches(calcRowHeight(headers)) : 0); + : headers.length > 0 + ? inches(calcRowHeight(headers)) + : 0; const dataRowHeights = opts.rowHeight ? rows.map(() => inches(opts.rowHeight!)) - : rows.map(row => inches(calcRowHeight(row))); + : rows.map((row) => inches(calcRowHeight(row))); - const totalAutoHeight = headerRowHeight + dataRowHeights.reduce((a, b) => a + b, 0); + const totalAutoHeight = + headerRowHeight + dataRowHeights.reduce((a, b) => a + b, 0); const h = opts.h ? inches(opts.h) : totalAutoHeight; const colWidth = Math.round(w / colCount); @@ -327,13 +332,13 @@ export function table(opts: TableOptions): string { }) .join(""); - return ` + return _createShapeFragment(` ${gridCols}${headerRow}${dataRows} -`; +`); } export interface KVItem { @@ -363,7 +368,7 @@ export interface KVTableOptions { * @param opts - KV table options: { x?, y?, w?, items: Array<{key, value}>, theme?, style? } * @returns Shape XML fragment */ -export function kvTable(opts: KVTableOptions): string { +export function kvTable(opts: KVTableOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── const items = opts.items || []; requireArray(items, "kvTable.items"); @@ -451,7 +456,7 @@ export interface ComparisonTableOptions { * @param opts - REQUIRED: { features: string[], options: Array<{name: string, values: boolean[]}> }. Optional: x?, y?, w?, theme?, style? * @returns Shape XML fragment */ -export function comparisonTable(opts: ComparisonTableOptions): string { +export function comparisonTable(opts: ComparisonTableOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── const features = opts.features || []; const options = opts.options || []; @@ -528,15 +533,24 @@ export interface TimelineOptions { * @param opts - Timeline options: { x?, y?, w?, items: Array<{label, description?, color?}>, theme?, style? } * @returns Shape XML fragment (uses table layout) */ -export function timeline(opts: TimelineOptions): string { +export function timeline(opts: TimelineOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── // Accept 'events' and 'entries' as common aliases for 'items' (LLMs often use these) - const aliasOpts = opts as unknown as { events?: TimelineOptions["items"]; entries?: TimelineOptions["items"] }; + const aliasOpts = opts as unknown as { + events?: TimelineOptions["items"]; + entries?: TimelineOptions["items"]; + }; const rawItems = opts.items || aliasOpts.events || aliasOpts.entries || []; requireArray(rawItems, "timeline.items", { nonEmpty: true }); // Normalize items: accept 'title' as alias for 'label', prepend 'date' if present - type RawItem = { label?: string; title?: string; date?: string; description?: string; color?: string }; + type RawItem = { + label?: string; + title?: string; + date?: string; + description?: string; + color?: string; + }; const items = rawItems.map((raw, i) => { const item = raw as RawItem; const label = item.label || item.title; diff --git a/builtin-modules/src/pptx.ts b/builtin-modules/src/pptx.ts index 04181df..b83e573 100644 --- a/builtin-modules/src/pptx.ts +++ b/builtin-modules/src/pptx.ts @@ -58,6 +58,10 @@ import { setShapeIdCounter, setForceAllColors, isForceAllColors, + _createShapeFragment, + isShapeFragment, + fragmentsToXml, + type ShapeFragment, type Theme, } from "ha:ooxml-core"; import { escapeXml } from "ha:xml-escape"; @@ -133,7 +137,7 @@ export interface SerializedPresentation { export interface Presentation { theme: Theme; slideCount: number; - addBody(shapes: string | string[], opts?: SlideOptions): void; + addBody(shapes: ShapeFragment | ShapeFragment[], opts?: SlideOptions): void; build(): Array<{ name: string; data: string | Uint8Array }>; buildZip(): Uint8Array; serialize(): SerializedPresentation; @@ -700,7 +704,8 @@ export interface ChartSlideOptions { } export interface CustomSlideOptions { - shapes: string; + /** Array of ShapeFragment objects from shape builders (textBox, rect, table, etc.). REQUIRED. */ + shapes: ShapeFragment | ShapeFragment[]; background?: string | GradientSpec; transition?: string; transitionDuration?: number; @@ -734,7 +739,9 @@ export interface StatGridSlideOptions { export interface ImageGridSlideOptions { title?: string; - images: Uint8Array[] | Array<{ data: Uint8Array; format?: string; caption?: string }>; + images: + | Uint8Array[] + | Array<{ data: Uint8Array; format?: string; caption?: string }>; imageFormat?: string; format?: string; gap?: number; @@ -895,6 +902,9 @@ export interface FooterOptions { export { type Theme }; +// Re-export ShapeFragment type + validation for LLMs (NOT _createShapeFragment — internal only) +export { type ShapeFragment, isShapeFragment, fragmentsToXml }; + // Re-export table-related functions from pptx-tables for convenience. // LLMs can import just "ha:pptx" and get access to table(), kvTable(), etc. export { @@ -905,6 +915,9 @@ export { TABLE_STYLES, } from "ha:pptx-tables"; +// Import chart complexity caps for validation engine (defined in pptx-charts, single source of truth) +import { MAX_CHARTS_PER_DECK } from "ha:pptx-charts"; + // Re-export contrastRatio for LLM pre-validation of color combinations export { contrastRatio }; @@ -942,6 +955,14 @@ let _defaultTextColor: string | null = null; // isForceAllColors() are now imported from ha:ooxml-core to break the // circular dependency with ha:pptx-tables. +/** + * Extract XML string from a ShapeFragment for internal slide composition. + * Internal-only helper — not exported to LLMs. + */ +function _s(fragment: ShapeFragment): string { + return fragment._xml; +} + /** * Normalize items input to an array of strings. * Accepts: string[], string (newline-delimited), or undefined. @@ -960,7 +981,10 @@ const normalizeItems = (input: string[] | string | undefined): string[] => { * Get the default text color if set, otherwise return the provided fallback. * Used internally by text-containing shape functions. */ -function getDefaultTextColor(explicitColor: string | undefined, fallback: string): string { +function getDefaultTextColor( + explicitColor: string | undefined, + fallback: string, +): string { if (explicitColor) return explicitColor; if (_defaultTextColor) return _defaultTextColor; return fallback; @@ -1032,7 +1056,7 @@ function _validateOptionalColor( hex: string | null | undefined, paramName: string, theme?: Theme | null, - opts?: { against?: string } + opts?: { against?: string }, ): string | null { if (!hex) return null; // Global escape hatch: skip contrast validation entirely @@ -1050,7 +1074,10 @@ function _validateOptionalColor( * @param {string} paramName - Parameter name for error messages * @returns {string|null} Validated hex or null if input was falsy */ -function _validateOptionalHex(hex: string | null | undefined, paramName: string): string | null { +function _validateOptionalHex( + hex: string | null | undefined, + paramName: string, +): string | null { if (!hex) return null; return requireHex(hex, paramName); } @@ -1066,7 +1093,7 @@ function _validateOptionalHex(hex: string | null | undefined, paramName: string) function _validateOptionalNumber( n: number | null | undefined, paramName: string, - opts?: { min?: number; max?: number } + opts?: { min?: number; max?: number }, ): number | null { if (n == null) return null; return requireNumber(n, paramName, opts); @@ -1102,7 +1129,7 @@ function spTransform(x: number, y: number, w: number, h: number): string { /** * Create a solid fill XML element. - * Use for custom slide backgrounds via pres.addSlide(solidFill('000000'), shapes). + * Use for shape fills or customSlide({ background }) backgrounds. * @param {string} color - Hex color (6 digits, no #) * @param {number} [opacity] - Opacity from 0 (transparent) to 1 (opaque). Omit for fully opaque. * @returns {string} Solid fill XML @@ -1129,7 +1156,9 @@ function textEffectsXml(opts: TextEffectOptions): string { const glowColor = hexColor(opts.glow.color); // Radius in EMUs (1 point = 12700 EMUs) const radius = Math.round((opts.glow.radius ?? 5) * 12700); - effects.push(``); + effects.push( + ``, + ); } // Drop shadow effect @@ -1172,11 +1201,19 @@ function runProperties(opts: RunPropertiesOptions): string { function normalizeAlign(align: string | undefined): string { if (!align) return "l"; requireEnum(align, "align", VALID_ALIGNS); - const map: Record = { center: "ctr", left: "l", right: "r", justify: "just" }; + const map: Record = { + center: "ctr", + left: "l", + right: "r", + justify: "just", + }; return map[align] || align; } -function paragraphXml(text: string, opts: ParagraphOptions | null | undefined): string { +function paragraphXml( + text: string, + opts: ParagraphOptions | null | undefined, +): string { const o = opts || {}; const algn = normalizeAlign(o.align); // lineSpacing is in points (e.g. 24 = 24pt line height) @@ -1191,12 +1228,14 @@ function paragraphXml(text: string, opts: ParagraphOptions | null | undefined): function bulletParagraph( item: string | { text: string; bold?: boolean; color?: string }, opts: BulletOptions | null | undefined, - level: number | undefined + level: number | undefined, ): string { // Normalize item: accept both string and object with text/bold/color const text = typeof item === "string" ? item : item.text; - const itemBold = typeof item === "object" && item !== null ? item.bold : undefined; - const itemColor = typeof item === "object" && item !== null ? item.color : undefined; + const itemBold = + typeof item === "object" && item !== null ? item.bold : undefined; + const itemColor = + typeof item === "object" && item !== null ? item.color : undefined; const o = opts || {}; const lvl = level || 0; @@ -1214,7 +1253,10 @@ function bulletParagraph( return `${bulletColor}${rPr}${escapeXml(String(text))}`; } -function textBodyXml(content: string | null | undefined, opts: TextBodyOptions | null | undefined): string { +function textBodyXml( + content: string | null | undefined, + opts: TextBodyOptions | null | undefined, +): string { const o = opts || {}; const wrap = o.wordWrap !== false ? "square" : "none"; const anchor = @@ -1253,9 +1295,9 @@ function textBodyXml(content: string | null | undefined, opts: TextBodyOptions | * @param {string} [opts.background] - Fill color (hex) * @param {number} [opts.lineSpacing] - Line spacing in points * @param {boolean} [opts.autoFit] - Auto-scale fontSize to fit text in shape. Use when text length is variable. - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function textBox(opts: TextBoxOptions): string { +export function textBox(opts: TextBoxOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── _validateOptionalNumber(opts.fontSize, "textBox.fontSize", { min: 1, @@ -1345,7 +1387,9 @@ export function textBox(opts: TextBoxOptions): string { } const { id, name } = nextShapeIdAndName("TextBox"); - return `${spTransform(x, y, w, h)}${fill}${textBodyXml(paras, opts)}`; + return _createShapeFragment( + `${spTransform(x, y, w, h)}${fill}${textBodyXml(paras, opts)}`, + ); } /** @@ -1364,9 +1408,9 @@ export function textBox(opts: TextBoxOptions): string { * @param {number} [opts.cornerRadius] - Corner radius in points * @param {string} [opts.borderColor] - Border color * @param {number} [opts.borderWidth=1] - Border width in points - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function rect(opts: RectOptions): string { +export function rect(opts: RectOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── _validateOptionalNumber(opts.fontSize, "rect.fontSize", { min: 1, max: 400 }); _validateOptionalNumber(opts.borderWidth, "rect.borderWidth", { min: 0 }); @@ -1419,7 +1463,9 @@ export function rect(opts: RectOptions): string { : ""; const { id, name } = nextShapeIdAndName("Rectangle"); - return `${spTransform(x, y, w, h)}${fill}${border}${textContent}`; + return _createShapeFragment( + `${spTransform(x, y, w, h)}${fill}${border}${textContent}`, + ); } /** @@ -1434,9 +1480,9 @@ export function rect(opts: RectOptions): string { * @param {string} [opts.color] - Text color * @param {string} [opts.bulletColor] - Bullet color * @param {number} [opts.lineSpacing=24] - Line spacing - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function bulletList(opts: BulletListOptions): string { +export function bulletList(opts: BulletListOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── if (opts.items != null) requireArray(opts.items, "bulletList.items"); _validateOptionalNumber(opts.fontSize, "bulletList.fontSize", { @@ -1462,7 +1508,8 @@ export function bulletList(opts: BulletListOptions): string { const w = inches(wIn); const h = inches(hIn); // Fall back to defaultTextColor, then theme foreground so bullets are always readable - const defaultColor = opts.color || _defaultTextColor || (opts._theme || _activeTheme)?.fg; + const defaultColor = + opts.color || _defaultTextColor || (opts._theme || _activeTheme)?.fg; const itemOpts = { fontSize: opts.fontSize || 16, color: defaultColor, @@ -1474,7 +1521,9 @@ export function bulletList(opts: BulletListOptions): string { .join(""); const { id, name } = nextShapeIdAndName("TextBox"); - return `${spTransform(x, y, w, h)}${textBodyXml(paras, opts)}`; + return _createShapeFragment( + `${spTransform(x, y, w, h)}${textBodyXml(paras, opts)}`, + ); } /** @@ -1489,9 +1538,9 @@ export function bulletList(opts: BulletListOptions): string { * @param {string} [opts.color] - Text color * @param {number} [opts.lineSpacing=24] - Line spacing * @param {number} [opts.startAt=1] - Starting number - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function numberedList(opts: NumberedListOptions): string { +export function numberedList(opts: NumberedListOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── if (opts.items != null) requireArray(opts.items, "numberedList.items"); _validateOptionalNumber(opts.fontSize, "numberedList.fontSize", { @@ -1519,7 +1568,8 @@ export function numberedList(opts: NumberedListOptions): string { const h = inches(hIn); const startAt = opts.startAt || 1; // Fall back to defaultTextColor, then theme foreground so items are always readable - const defaultColor = opts.color || _defaultTextColor || (opts._theme || _activeTheme)?.fg; + const defaultColor = + opts.color || _defaultTextColor || (opts._theme || _activeTheme)?.fg; const paras = (opts.items || []) .map((item, idx) => { const num = startAt + idx; @@ -1532,7 +1582,9 @@ export function numberedList(opts: NumberedListOptions): string { .join(""); const { id, name } = nextShapeIdAndName("TextBox"); - return `${spTransform(x, y, w, h)}${textBodyXml(paras, opts)}`; + return _createShapeFragment( + `${spTransform(x, y, w, h)}${textBodyXml(paras, opts)}`, + ); } /** @@ -1546,9 +1598,9 @@ export function numberedList(opts: NumberedListOptions): string { * @param {string} [opts.label='Image'] - Placeholder label * @param {string} [opts.fill='3D4450'] - Background color (dark gray) * @param {string} [opts.color='B0B8C0'] - Label color (light gray, passes WCAG AA on 3D4450) - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function imagePlaceholder(opts: ImagePlaceholderOptions): string { +export function imagePlaceholder(opts: ImagePlaceholderOptions): ShapeFragment { return rect({ x: opts.x, y: opts.y, @@ -1577,9 +1629,9 @@ export function imagePlaceholder(opts: ImagePlaceholderOptions): string { * @param {string} [opts.labelColor] - Label text color (hex). OMIT to auto-select against background. * @param {string} [opts.background] - Background fill * @param {boolean} [opts.forceColor] - Set true to bypass WCAG contrast validation for valueColor/labelColor. - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function statBox(opts: StatBoxOptions): string { +export function statBox(opts: StatBoxOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── _validateOptionalNumber(opts.valueSize, "statBox.valueSize", { min: 1, @@ -1650,7 +1702,9 @@ export function statBox(opts: StatBoxOptions): string { }); const { id, name } = nextShapeIdAndName("TextBox"); - return `${spTransform(x, y, w, h)}${fill}${textBodyXml(valuePara + labelPara, { valign: "middle" })}`; + return _createShapeFragment( + `${spTransform(x, y, w, h)}${fill}${textBodyXml(valuePara + labelPara, { valign: "middle" })}`, + ); } // ── Lines, Arrows, and Connectors ──────────────────────────────────── @@ -1665,9 +1719,9 @@ export function statBox(opts: StatBoxOptions): string { * @param {string} [opts.color='666666'] - Line color (hex) * @param {number} [opts.width=1.5] - Line width in points * @param {string} [opts.dash] - Dash style: 'solid', 'dash', 'dot', 'dashDot' - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function line(opts: LineOptions): string { +export function line(opts: LineOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── _validateOptionalNumber(opts.width, "line.width", { min: 0.1, max: 100 }); _validateOptionalHex(opts.color, "line.color"); @@ -1698,7 +1752,9 @@ export function line(opts: LineOptions): string { : ""; const { id, name } = nextShapeIdAndName("Line"); - return `${dashXml}`; + return _createShapeFragment( + `${dashXml}`, + ); } /** @@ -1713,9 +1769,9 @@ export function line(opts: LineOptions): string { * @param {string} [opts.headType='triangle'] - Arrowhead: 'triangle', 'stealth', 'diamond', 'oval', 'arrow' * @param {boolean} [opts.bothEnds=false] - Arrowhead on both ends * @param {string} [opts.dash] - Dash style: 'solid', 'dash', 'dot', 'dashDot' - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function arrow(opts: ArrowOptions): string { +export function arrow(opts: ArrowOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── _validateOptionalNumber(opts.width, "arrow.width", { min: 0.1, max: 100 }); _validateOptionalHex(opts.color, "arrow.color"); @@ -1753,7 +1809,9 @@ export function arrow(opts: ArrowOptions): string { : ""; const { id, name } = nextShapeIdAndName("Arrow"); - return `${dashXml}${headArrow}${tailArrow}`; + return _createShapeFragment( + `${dashXml}${headArrow}${tailArrow}`, + ); } /** @@ -1769,9 +1827,9 @@ export function arrow(opts: ArrowOptions): string { * @param {string} [opts.color='FFFFFF'] - Text color * @param {string} [opts.borderColor] - Border color * @param {number} [opts.borderWidth=1] - Border width in points - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function circle(opts: CircleOptions): string { +export function circle(opts: CircleOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── _validateOptionalNumber(opts.fontSize, "circle.fontSize", { min: 1, @@ -1822,7 +1880,9 @@ export function circle(opts: CircleOptions): string { : ""; const { id, name } = nextShapeIdAndName("Ellipse"); - return `${spTransform(x, y, w, h)}${fill}${border}${textContent}`; + return _createShapeFragment( + `${spTransform(x, y, w, h)}${fill}${border}${textContent}`, + ); } /** @@ -1838,9 +1898,9 @@ export function circle(opts: CircleOptions): string { * @param {string} [opts.background='F5F5F5'] - Fill color * @param {number} [opts.fontSize=14] - Font size * @param {string} [opts.color] - Text color (hex). OMIT to auto-select a readable colour against the background. Do NOT hardcode. - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function callout(opts: CalloutOptions): string { +export function callout(opts: CalloutOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── _validateOptionalNumber(opts.fontSize, "callout.fontSize", { min: 1, @@ -1878,7 +1938,7 @@ export function callout(opts: CalloutOptions): string { color: opts.color || autoTextColor(bg), }); - return accentBar + mainBox; + return _createShapeFragment(accentBar + mainBox.toString()); } // ── Icons (Preset Shapes) ──────────────────────────────────────────── @@ -2019,7 +2079,10 @@ const ICON_SHAPES: Record = { }; /** SVG path icons for tech concepts not available as OOXML presets */ -const SVG_ICONS: Record = { +const SVG_ICONS: Record< + string, + { d: string; viewBox?: { w: number; h: number } } +> = { // Status indicators (Lucide) check: { d: "M20 6L9 17l-5-5", @@ -2254,9 +2317,9 @@ const SVG_ICONS: Record${spTransform(x, y, w, h)}${fill}${textContent}`; + return _createShapeFragment( + `${spTransform(x, y, w, h)}${fill}${textContent}`, + ); } // ── SVG Path Parser ───────────────────────────────────────────────── @@ -2332,7 +2400,10 @@ export function icon(opts: IconOptions): string { * Coordinates are normalized to OOXML EMUs based on viewBox. * @internal */ -function parseSvgPath(d: string, viewBox: { x?: number; y?: number; w: number; h: number } | undefined): string { +function parseSvgPath( + d: string, + viewBox: { x?: number; y?: number; w: number; h: number } | undefined, +): string { const vb = viewBox || { x: 0, y: 0, w: 24, h: 24 }; // default 24x24 viewBox const cmds: string[] = []; // Regex to tokenize SVG path: command letters and numbers @@ -2617,7 +2688,8 @@ function parseSvgPath(d: string, viewBox: { x?: number; y?: number; w: number; h ry2 = ryAdj * ryAdj; } - let sq = (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2) / (rx2 * y1p2 + ry2 * x1p2); + let sq = + (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2) / (rx2 * y1p2 + ry2 * x1p2); if (sq < 0) sq = 0; const coef = (largeArc !== sweep ? 1 : -1) * Math.sqrt(sq); const cxp = (coef * rxAdj * y1p) / ryAdj; @@ -2641,7 +2713,10 @@ function parseSvgPath(d: string, viewBox: { x?: number; y?: number; w: number; h if (!sweep && dAngle > 0) dAngle -= 2 * Math.PI; // Split arc into segments of at most 90 degrees (pi/2) - const numSegments = Math.max(1, Math.ceil(Math.abs(dAngle) / (Math.PI / 2))); + const numSegments = Math.max( + 1, + Math.ceil(Math.abs(dAngle) / (Math.PI / 2)), + ); const segmentAngle = dAngle / numSegments; // Generate cubic bezier for each segment @@ -2651,7 +2726,8 @@ function parseSvgPath(d: string, viewBox: { x?: number; y?: number; w: number; h // Control point factor for cubic bezier approximation of arc const t = Math.tan(segmentAngle / 4); - const alpha = (Math.sin(segmentAngle) * (Math.sqrt(4 + 3 * t * t) - 1)) / 3; + const alpha = + (Math.sin(segmentAngle) * (Math.sqrt(4 + 3 * t * t) - 1)) / 3; const cos1 = Math.cos(currentAngle), sin1 = Math.sin(currentAngle); @@ -2730,9 +2806,9 @@ function parseSvgPath(d: string, viewBox: { x?: number; y?: number; w: number; h * @param {string} [opts.fill] - Fill color (hex, e.g. '2196F3') * @param {string} [opts.stroke] - Stroke color (hex) * @param {number} [opts.strokeWidth=1] - Stroke width in points - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function svgPath(opts: SvgPathOptions): string { +export function svgPath(opts: SvgPathOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── requireString(opts.d, "svgPath.d"); _validateOptionalHex(opts.fill, "svgPath.fill"); @@ -2783,7 +2859,9 @@ export function svgPath(opts: SvgPathOptions): string { ""; const { id, name } = nextShapeIdAndName("Icon"); - return `${spTransform(x, y, w, h)}${custGeom}${fill}${stroke}`; + return _createShapeFragment( + `${spTransform(x, y, w, h)}${custGeom}${fill}${stroke}`, + ); } // ── Gradient Fill Helper ───────────────────────────────────────────── @@ -2808,7 +2886,12 @@ export function svgPath(opts: SvgPathOptions): string { * // Transparent-to-opaque overlay for photos * gradientFill('000000', '000000', 270, { opacity1: 0, opacity2: 0.8 }) */ -export function gradientFill(color1: string, color2: string, angle?: number, opts?: { opacity1?: number; opacity2?: number }): string { +export function gradientFill( + color1: string, + color2: string, + angle?: number, + opts?: { opacity1?: number; opacity2?: number }, +): string { // ── Input validation ────────────────────────────────────────────── requireHex(color1, "gradientFill.color1"); requireHex(color2, "gradientFill.color2"); @@ -2951,9 +3034,9 @@ export function markdownToNotes(md: string): string { * @param {string} [opts.align='l'] - Paragraph alignment ('l', 'ctr', 'r') * @param {string} [opts.valign='t'] - Vertical alignment ('t', 'ctr', 'b') * @param {string} [opts.background] - Fill color (hex) - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function richText(opts: RichTextOptions): string { +export function richText(opts: RichTextOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── if (opts.paragraphs != null) { requireArray(opts.paragraphs, "richText.paragraphs"); @@ -2988,7 +3071,10 @@ export function richText(opts: RichTextOptions): string { // Fall back to defaultTextColor, then theme foreground for runs without explicit color const resolvedRun = run.color ? run - : { ...run, color: _defaultTextColor || (opts._theme || _activeTheme)?.fg }; + : { + ...run, + color: _defaultTextColor || (opts._theme || _activeTheme)?.fg, + }; const rPr = runProperties(resolvedRun); return `${rPr}${escapeXml(String(run.text || ""))}`; }) @@ -2998,7 +3084,9 @@ export function richText(opts: RichTextOptions): string { .join(""); const { id, name } = nextShapeIdAndName("TextBox"); - return `${spTransform(x, y, w, h)}${fill}${textBodyXml(parasXml, opts)}`; + return _createShapeFragment( + `${spTransform(x, y, w, h)}${fill}${textBodyXml(parasXml, opts)}`, + ); } // ── Composite Shapes ───────────────────────────────────────────────── @@ -3072,7 +3160,7 @@ export interface PanelOptions { * @param opts - Panel options * @returns Shape XML fragments for all panel elements */ -export function panel(opts: PanelOptions): string { +export function panel(opts: PanelOptions): ShapeFragment { // Support aliases: background → fill, text → title, fontSize → titleSize, color → titleColor const fill = opts.fill || opts.background || "1A1A1A"; const title = opts.title || opts.text; @@ -3097,7 +3185,7 @@ export function panel(opts: PanelOptions): string { h: opts.h, fill, cornerRadius, - }); + }).toString(); let contentY = opts.y + pad; @@ -3115,7 +3203,7 @@ export function panel(opts: PanelOptions): string { bold: opts.titleBold !== false, forceColor: true, autoFit: true, // Auto-scale if title wraps - }); + }).toString(); contentY += titleH + gap; } @@ -3133,10 +3221,10 @@ export function panel(opts: PanelOptions): string { color: bodyColor, forceColor: true, autoFit: true, // Auto-scale if body is long - }); + }).toString(); } - return shapes; + return _createShapeFragment(shapes); } /** Options for card() composite shape */ @@ -3164,7 +3252,7 @@ export interface CardOptions extends PanelOptions { * @param opts - Card options * @returns Shape XML fragments */ -export function card(opts: CardOptions): string { +export function card(opts: CardOptions): ShapeFragment { let shapes = ""; const accentH = opts.accentHeight ?? 0.08; const accent = opts.accent || opts.accentColor; // Support alias @@ -3178,19 +3266,19 @@ export function card(opts: CardOptions): string { h: accentH, fill: accent, cornerRadius: opts.cornerRadius ?? 8, - }); + }).toString(); // Adjust panel to start below accent shapes += panel({ ...opts, y: opts.y + accentH, h: opts.h - accentH, cornerRadius: 0, // Flat top since accent has the rounded corners - }); + }).toString(); } else { - shapes += panel(opts); + shapes += panel(opts).toString(); } - return shapes; + return _createShapeFragment(shapes); } // ── Hyperlinks ─────────────────────────────────────────────────────── @@ -3216,9 +3304,12 @@ export function card(opts: CardOptions): string { * @param {string} [opts.color='2196F3'] - Text color (default blue) * @param {boolean} [opts.underline=true] - Underline text * @param {Object} pres - Presentation builder (needed to register the link relationship) - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function hyperlink(opts: HyperlinkOptions, pres: PresentationInternal): string { +export function hyperlink( + opts: HyperlinkOptions, + pres: PresentationInternal, +): ShapeFragment { // ── Input validation ────────────────────────────────────────────── requireString(opts.url, "hyperlink.url"); if (pres == null) { @@ -3249,7 +3340,9 @@ export function hyperlink(opts: HyperlinkOptions, pres: PresentationInternal): s const color = hexColor(opts.color || "2196F3"); const { id, name } = nextShapeIdAndName("Hyperlink"); - return `${spTransform(x, y, w, h)}${escapeXml(String(opts.text || ""))}`; + return _createShapeFragment( + `${spTransform(x, y, w, h)}${escapeXml(String(opts.text || ""))}`, + ); } // ── Image Dimension Detection ─────────────────────────────────────────── @@ -3473,9 +3566,12 @@ const IMAGE_CONTENT_TYPES: Record = { * @param {string} [opts.format='png'] - Image format: 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg' * @param {string} [opts.fit='stretch'] - How to fit image: 'stretch' (distort to fill), 'contain' (fit within, may letterbox), 'cover' (fill, may crop) * @param {string} [opts.name] - Optional image name (for the ZIP path) - * @returns {string} Shape XML fragment for use in slide body + * @returns {ShapeFragment} Branded shape fragment for use in slide body */ -export function embedImage(pres: PresentationInternal, opts: EmbedImageOptions): string { +export function embedImage( + pres: PresentationInternal, + opts: EmbedImageOptions, +): ShapeFragment { // ── Input validation ────────────────────────────────────────────── if (pres == null) { throw new Error( @@ -3564,7 +3660,9 @@ export function embedImage(pres: PresentationInternal, opts: EmbedImageOptions): // Picture shape with blipFill referencing the image relationship. // OOXML DrawingML uses r:embed (not r:id) to reference embedded media. - return `${blipFillContent}${spTransform(x, y, w, h)}`; + return _createShapeFragment( + `${blipFillContent}${spTransform(x, y, w, h)}`, + ); } /** @@ -3593,9 +3691,12 @@ export function embedImage(pres: PresentationInternal, opts: EmbedImageOptions): * @param {number} opts.w - Width in inches * @param {number} opts.h - Height in inches * @param {string} [opts.format] - Override format detection (png, jpg, gif, etc.) - * @returns {string} Shape XML fragment for use in slide body + * @returns {ShapeFragment} Branded shape fragment for use in slide body */ -export function embedImageFromUrl(pres: PresentationInternal, opts: EmbedImageOptions & { url: string }): string { +export function embedImageFromUrl( + pres: PresentationInternal, + opts: EmbedImageOptions & { url: string }, +): ShapeFragment { if (pres == null) { throw new Error( "embedImageFromUrl: 'pres' (presentation builder) is required as the first argument.", @@ -3813,12 +3914,10 @@ function textFitsInBox( * @param items - Array of shape XML strings or objects with toString() * @returns Combined XML string */ -export function shapes( - items: Array, -): string { +export function shapes(items: Array): ShapeFragment { if (!Array.isArray(items)) { throw new Error( - `shapes(): expected an array of shape XML fragments, but got ${typeof items}. ` + + `shapes(): expected an array of ShapeFragment items, but got ${typeof items}. ` + `Usage: shapes([textBox(...), rect(...), embedChart(...)])`, ); } @@ -3828,29 +3927,17 @@ export function shapes( const item = items[i]; if (item == null) continue; // Skip null/undefined - if (typeof item === "string") { - result.push(item); - } else if (typeof item === "object" && typeof item.toString === "function") { - const str = item.toString(); - // Check if toString returned "[object Object]" (default Object.prototype.toString) - if (str === "[object Object]") { - throw new Error( - `shapes()[${i}]: received an object that converts to "[object Object]". ` + - `This usually means you passed a chart/result object directly instead of ` + - `using its .shapeXml property or a function that returns XML. ` + - `For charts, either use chartSlide() or access embedChart(...).shapeXml`, - ); - } - result.push(str); + if (isShapeFragment(item)) { + result.push(item.toString()); } else { throw new Error( - `shapes()[${i}]: expected a string or object with toString(), but got ${typeof item}. ` + - `Each item must be shape XML from textBox(), rect(), embedChart(), etc.`, + `shapes()[${i}]: expected a ShapeFragment (from textBox(), rect(), etc.), but got ${typeof item}. ` + + `Each item must be a shape returned by a builder function like textBox(), rect(), embedChart(), etc.`, ); } } - return result.join(""); + return _createShapeFragment(result.join("")); } /** @@ -3872,7 +3959,10 @@ export function shapes( * @param {number} [opts.h=2] - Height of all items in inches * @returns {Array<{x: number, y: number, w: number, h: number}>} */ -export function layoutColumns(count: number, opts: LayoutColumnsOptions = {}): LayoutRect[] { +export function layoutColumns( + count: number, + opts: LayoutColumnsOptions = {}, +): LayoutRect[] { const margin = opts.margin ?? 0.5; const gap = opts.gap ?? 0.25; const y = opts.y ?? 1; @@ -3912,7 +4002,10 @@ export function layoutColumns(count: number, opts: LayoutColumnsOptions = {}): L * @param {number} [opts.maxH] - Maximum height of grid area (auto-calc item height) * @returns {Array<{x: number, y: number, w: number, h: number}>} */ -export function layoutGrid(count: number, opts: LayoutGridOptions = {}): LayoutRect[] { +export function layoutGrid( + count: number, + opts: LayoutGridOptions = {}, +): LayoutRect[] { const cols = opts.cols ?? 3; const margin = opts.margin ?? 0.5; const gapX = opts.gapX ?? opts.gap ?? 0.25; @@ -3981,9 +4074,9 @@ export function getContentArea(opts?: { hasTitle?: boolean }): LayoutRect { * @param {number} [opts.y=0] - Y position in inches * @param {number} [opts.w] - Width in inches (default: full slide width) * @param {number} [opts.h] - Height in inches (default: full slide height) - * @returns {string} OOXML shape string + * @returns {ShapeFragment} Branded shape fragment */ -export function overlay(opts: OverlayOptions = {}): string { +export function overlay(opts: OverlayOptions = {}): ShapeFragment { return rect({ x: opts.x ?? 0, y: opts.y ?? 0, @@ -4021,16 +4114,28 @@ export function overlay(opts: OverlayOptions = {}): string { * @param {number} [opts.y=0] - Y position in inches * @param {number} [opts.w] - Width in inches (default full slide) * @param {number} [opts.h] - Height in inches (default full slide) - * @returns {string} OOXML shape string + * @returns {ShapeFragment} Branded shape fragment */ -export function gradientOverlay(opts: GradientOverlayOptions = {}): string { - const color1 = opts.color1 ? requireHex(opts.color1, "gradientOverlay.color1") : "000000"; - const color2 = opts.color2 ? requireHex(opts.color2, "gradientOverlay.color2") : "000000"; +export function gradientOverlay( + opts: GradientOverlayOptions = {}, +): ShapeFragment { + const color1 = opts.color1 + ? requireHex(opts.color1, "gradientOverlay.color1") + : "000000"; + const color2 = opts.color2 + ? requireHex(opts.color2, "gradientOverlay.color2") + : "000000"; const fromOpacity = opts.fromOpacity ?? 0.8; const toOpacity = opts.toOpacity ?? 0; - _validateOptionalNumber(fromOpacity, "gradientOverlay.fromOpacity", { min: 0, max: 1 }); - _validateOptionalNumber(toOpacity, "gradientOverlay.toOpacity", { min: 0, max: 1 }); + _validateOptionalNumber(fromOpacity, "gradientOverlay.fromOpacity", { + min: 0, + max: 1, + }); + _validateOptionalNumber(toOpacity, "gradientOverlay.toOpacity", { + min: 0, + max: 1, + }); const x = inches(opts.x ?? 0); const y = inches(opts.y ?? 0); @@ -4055,7 +4160,9 @@ export function gradientOverlay(opts: GradientOverlayOptions = {}): string { ``; const { id, name } = nextShapeIdAndName("Rectangle"); - return `${spTransform(x, y, w, h)}${gradFill}`; + return _createShapeFragment( + `${spTransform(x, y, w, h)}${gradFill}`, + ); } /** @@ -4074,9 +4181,13 @@ export function gradientOverlay(opts: GradientOverlayOptions = {}): string { * @param {Object} pres - Presentation object from createPresentation() * @param {Uint8Array} data - Image data (from fetchBinary, readBinary, or shared-state) * @param {string} [format='jpg'] - Image format (jpg, png, gif, webp, etc.) - * @returns {string} OOXML shape string for a full-slide image + * @returns {ShapeFragment} Branded shape fragment for a full-slide image */ -export function backgroundImage(pres: PresentationInternal, data: Uint8Array, format: string = "jpg"): string { +export function backgroundImage( + pres: PresentationInternal, + data: Uint8Array, + format: string = "jpg", +): ShapeFragment { // Validate pres object — check for .theme which is always present if (!pres || typeof pres.theme !== "object") { throw new Error( @@ -4106,17 +4217,17 @@ function solidBg(color: string): string { /** * Create a gradient background for slides. - * Use with pres.addSlide() or as defaultBackground in createPresentation(). + * Use with customSlide({ background }) or as defaultBackground in createPresentation(). * * @param {string} color1 - Start color (hex, e.g. '000000') * @param {string} color2 - End color (hex, e.g. '1a1a2e') * @param {number} [angle=270] - Gradient angle in degrees (0=right, 90=down, 180=left, 270=up) - * @returns {string} Background XML for use with pres.addSlide() + * @returns {string} Background XML for use with customSlide() * * @example * // Vertical gradient (top to bottom) - * const bg = gradientBg('000000', '1a1a2e', 180); - * pres.addSlide(bg, shapes); + * const pres = createPresentation({ theme: 'brutalist' }); + * customSlide(pres, { shapes: [...], background: '000000' }); * * @example * // As default background for all slides @@ -4125,7 +4236,11 @@ function solidBg(color: string): string { * defaultBackground: { color1: '0a0a0a', color2: '1a1a2e', angle: 180 } * }); */ -export function gradientBg(color1: string, color2: string, angle?: number): string { +export function gradientBg( + color1: string, + color2: string, + angle?: number, +): string { requireHex(color1, "gradientBg.color1"); requireHex(color2, "gradientBg.color2"); const a = ((angle || 270) % 360) * 60000; // degrees to 60000ths @@ -4148,8 +4263,10 @@ function _extractBgColor(bgXml: string | null | undefined): string | null { /** Map of transition names to OOXML transition elements. */ const TRANSITIONS: Record string> = { fade: (spd: string) => ``, - push: (spd: string) => ``, - wipe: (spd: string) => ``, + push: (spd: string) => + ``, + wipe: (spd: string) => + ``, split: (spd: string) => ``, cover: (spd: string) => @@ -4161,18 +4278,25 @@ const TRANSITIONS: Record string> = { ``, dissolve: (spd: string) => ``, - zoom: (spd: string) => ``, + zoom: (spd: string) => + ``, fly: (spd: string) => ``, wheel: (spd: string) => ``, - random: (spd: string) => ``, + random: (spd: string) => + ``, none: () => "", }; -function buildTransitionXml(type: string | null | undefined, durationMs: number): string { +function buildTransitionXml( + type: string | null | undefined, + durationMs: number, +): string { const spd = durationMs <= 300 ? "fast" : durationMs >= 800 ? "slow" : "med"; - const builder = type ? (TRANSITIONS[type] || TRANSITIONS.fade) : TRANSITIONS.fade; + const builder = type + ? TRANSITIONS[type] || TRANSITIONS.fade + : TRANSITIONS.fade; return builder(spd); } @@ -4192,6 +4316,381 @@ function notesSlideXml(notesText: string, slideIndex: number): string { `; } +// ── Strict Validation Engine ───────────────────────────────────────── +// Enforces structural correctness before build/export. All issues are +// reported with machine-readable codes, slide indices, and LLM-actionable hints. + +/** Maximum notes length per slide in characters. */ +const MAX_NOTES_LENGTH = 12_000; + +/** Regex matching XML control characters that are invalid in OOXML text. */ +const INVALID_XML_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/; + +/** Allowed top-level shape node types in slide shapes XML. */ +const ALLOWED_SHAPE_NODES = new Set([ + "p:sp", + "p:pic", + "p:graphicFrame", + "p:cxnSp", + "p:grpSp", +]); + +/** Regex matching invalid XML chars as a global variant (for replacement). */ +const INVALID_XML_CHARS_G = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g; + +/** + * Sanitize speaker notes at input-stage: enforce type, strip invalid XML + * characters, and truncate to MAX_NOTES_LENGTH. + * Returns null if input is falsy, the sanitized string otherwise. + */ +function _sanitizeNotes(raw: unknown): string | null { + if (raw == null || raw === "") return null; + if (typeof raw !== "string") { + throw new Error( + `notes must be a string but got ${typeof raw}. ` + + `Pass plain text — no HTML, XML, or objects.`, + ); + } + // Strip invalid XML control chars (keep \t, \n, \r) + let text = raw.replace(INVALID_XML_CHARS_G, ""); + // Truncate to cap + if (text.length > MAX_NOTES_LENGTH) { + text = text.slice(0, MAX_NOTES_LENGTH); + } + return text || null; +} + +export interface ValidationIssue { + code: string; + severity: "error" | "warn"; + message: string; + part?: string; + slideIndex?: number; + hint?: string; +} + +export interface ValidationResult { + ok: boolean; + errors: ValidationIssue[]; + warnings: ValidationIssue[]; +} + +/** + * Validate the presentation structure for OOXML correctness. + * Called automatically by buildZip() and exportToFile() — cannot be bypassed. + * + * @param slides - Internal slide data array + * @param charts - Chart metadata array + * @param chartEntries - Chart ZIP entries + * @param images - Image entries + * @param links - Hyperlink entries + * @returns ValidationResult with any issues found + * @internal + */ +function _validatePresentation( + slides: SlideData[], + charts: ChartEntry[], + chartEntries: Array<{ name: string; data: string }>, + images: ImageEntry[], + _links: Array<{ slideIndex: number; relId: string; url: string }>, +): ValidationResult { + const errors: ValidationIssue[] = []; + const warnings: ValidationIssue[] = []; + + // ── A) Slide integrity ────────────────────────────────────────────── + const globalShapeIds = new Map(); // track shape ID usage + for (let i = 0; i < slides.length; i++) { + const slide = slides[i]; + const shapes = slide.shapes || ""; + + // A1: Check for nested/foreign document roots (e.g. chart XML pasted in) + if (shapes.includes("]/g); + if (topTags) { + for (const tag of topTags) { + const nodeName = tag.match(/<(p:\w+)/)?.[1]; + if (nodeName && !ALLOWED_SHAPE_NODES.has(nodeName) && nodeName !== "p:txBody") { + // Only warn for unexpected nodes (not errors, since some are legit internal use) + warnings.push({ + code: "PPTX_UNEXPECTED_SHAPE_NODE", + severity: "warn", + message: `Unexpected shape node <${nodeName}> found.`, + slideIndex: i, + hint: `Only standard shapes (sp, pic, graphicFrame, cxnSp, grpSp) are expected.`, + }); + } + } + } + + // A3: Detect duplicate shape IDs within a slide + const idMatches = shapes.matchAll(/(); + for (const m of idMatches) { + const id = m[1]; + if (slideIds.has(id)) { + errors.push({ + code: "PPTX_DUPLICATE_SHAPE_ID", + severity: "error", + message: `Duplicate shape ID ${id} found on slide.`, + slideIndex: i, + hint: "Each shape must have a unique ID. This usually means shapes were copy-pasted incorrectly.", + }); + } + slideIds.add(id); + // Track globally too + if (!globalShapeIds.has(id)) globalShapeIds.set(id, []); + globalShapeIds.get(id)!.push(i); + } + + // A4: Check balanced required tags + for (const tag of ["p:sp", "p:pic", "p:graphicFrame", "p:cxnSp"]) { + const opens = (shapes.match(new RegExp(`<${tag}[\\s>]`, "g")) || []).length; + const closes = (shapes.match(new RegExp(``, "g")) || []).length; + if (opens !== closes) { + errors.push({ + code: "PPTX_UNBALANCED_TAGS", + severity: "error", + message: `Unbalanced <${tag}> tags: ${opens} opening vs ${closes} closing.`, + slideIndex: i, + hint: "Shape XML is malformed. Regenerate the slide using the high-level slide functions.", + }); + } + } + } + + // A5: Cross-slide duplicate shape ID check + for (const [id, slideIndices] of globalShapeIds) { + if (slideIndices.length > 1) { + warnings.push({ + code: "PPTX_CROSS_SLIDE_DUPLICATE_ID", + severity: "warn", + message: `Shape ID ${id} appears on slides [${slideIndices.map((s) => s + 1).join(", ")}].`, + hint: "Cross-slide duplicate IDs can trigger PowerPoint repair. Ensure each shape has a unique ID.", + }); + } + } + + // ── B) Chart integrity ──────────────────────────────────────────────── + // B1: Check total chart count + if (charts.length > MAX_CHARTS_PER_DECK) { + errors.push({ + code: "PPTX_TOO_MANY_CHARTS", + severity: "error", + message: `Deck has ${charts.length} charts — max allowed is ${MAX_CHARTS_PER_DECK}.`, + hint: "Reduce the number of charts or split into multiple presentations.", + }); + } + + // B2: Check for duplicate chart relation IDs + const chartRelIds = new Set(); + for (const chart of charts) { + if (chart.relId) { + if (chartRelIds.has(chart.relId)) { + errors.push({ + code: "PPTX_DUPLICATE_CHART_REL", + severity: "error", + message: `Duplicate chart relationship ID "${chart.relId}".`, + part: chart.chartPath, + hint: "Chart indexing is inconsistent. This is likely a bug — regenerate the deck.", + }); + } + chartRelIds.add(chart.relId); + } + } + + // B3: Validate chart XML entries + for (const entry of chartEntries) { + if (!entry.name.endsWith(".xml.rels") && entry.name.includes("chart")) { + const xml = entry.data; + + // B3a: Check required chart nodes + if (!xml.includes(" or elements.", + part: entry.name, + hint: "Regenerate the chart using barChart/pieChart/lineChart/comboChart.", + }); + } + + // B3b: Axis ID/crossAx consistency (for bar/line/combo charts) + const axIds = [...xml.matchAll(//g)].map((m) => + m[1], + ); + const crossAxIds = [ + ...xml.matchAll(//g), + ].map((m) => m[1]); + for (const crossId of crossAxIds) { + if (!axIds.includes(crossId)) { + errors.push({ + code: "PPTX_CHART_AXIS_MISMATCH", + severity: "error", + message: `crossAx ${crossId} not found in chart axis IDs [${axIds.join(", ")}].`, + part: entry.name, + hint: "Regenerate chart via comboChart/barChart with consistent axes.", + }); + } + } + + // B3c: Check for non-finite values in chart data + const valMatches = [...xml.matchAll(/([^<]+)<\/c:v>/g)]; + for (const vm of valMatches) { + const val = vm[1]; + // Numeric values must be finite numbers + if (val === "NaN" || val === "Infinity" || val === "-Infinity") { + errors.push({ + code: "PPTX_CHART_INVALID_VALUE", + severity: "error", + message: `Chart contains non-finite value "${val}".`, + part: entry.name, + hint: "All chart data values must be finite numbers. Check for NaN/Infinity in data.", + }); + } + } + } + } + + // B4: Chart parts have matching ZIP entries + for (const chart of charts) { + const expectedPath = `ppt/${chart.chartPath}`; + const found = chartEntries.some((e) => e.name === expectedPath); + if (!found) { + errors.push({ + code: "PPTX_CHART_MISSING_PART", + severity: "error", + message: `Chart part "${chart.chartPath}" referenced but ZIP entry not found.`, + part: chart.chartPath, + hint: "Chart may have been orphaned. Regenerate the chart.", + }); + } + } + + // ── C) Notes integrity ──────────────────────────────────────────────── + for (let i = 0; i < slides.length; i++) { + const notes = slides[i].notes; + if (notes == null) continue; + + // C1: Notes must be a string + if (typeof notes !== "string") { + errors.push({ + code: "PPTX_NOTES_TYPE", + severity: "error", + message: `Notes must be a string, got ${typeof notes}.`, + slideIndex: i, + hint: "Pass a plain text string as notes.", + }); + continue; + } + + // C2: Notes length cap (defence-in-depth — _sanitizeNotes truncates at input, + // but this catches any notes injected by restore/deserialization bypassing sanitization) + if (notes.length > MAX_NOTES_LENGTH) { + errors.push({ + code: "PPTX_NOTES_TOO_LONG", + severity: "error", + message: `Notes are ${notes.length} chars — max ${MAX_NOTES_LENGTH}.`, + slideIndex: i, + hint: `Trim notes to ${MAX_NOTES_LENGTH} characters or split across slides.`, + }); + } + + // C3: Invalid XML characters + if (INVALID_XML_CHARS.test(notes)) { + const match = notes.match(INVALID_XML_CHARS); + const charCode = match ? match[0].charCodeAt(0) : 0; + errors.push({ + code: "PPTX_NOTES_INVALID_CHARS", + severity: "error", + message: `Notes contain invalid XML control character (U+${charCode.toString(16).padStart(4, "0").toUpperCase()}).`, + slideIndex: i, + hint: "Remove control characters. Only printable UTF-8 text, tabs, and newlines are allowed.", + }); + } + } + + // ── D) Package integrity ────────────────────────────────────────────── + // D1: Check for orphan chart entries (charts in ZIP not referenced by any slide rels) + const usedChartPaths = new Set(charts.map((c) => `ppt/${c.chartPath}`)); + for (const entry of chartEntries) { + if (entry.name.endsWith(".xml") && !entry.name.endsWith(".xml.rels")) { + if (!usedChartPaths.has(entry.name)) { + warnings.push({ + code: "PPTX_ORPHAN_CHART", + severity: "warn", + message: `Chart ZIP entry "${entry.name}" not referenced by any slide.`, + part: entry.name, + hint: "This chart was created but never embedded in a slide.", + }); + } + } + } + + // D2: Check image slide index references are valid + for (const img of images) { + if (img.slideIndex < 1 || img.slideIndex > slides.length) { + warnings.push({ + code: "PPTX_IMAGE_BAD_SLIDE_REF", + severity: "warn", + message: `Image "${img.id}" references slide ${img.slideIndex} but only ${slides.length} slides exist.`, + hint: "Image may have been orphaned by slide deletion.", + }); + } + } + + return { + ok: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Throw a structured validation error with machine-readable payload. + * Format: PPTX_VALIDATION_FAILED: X errors, Y warnings + * @internal + */ +function _throwValidationError(result: ValidationResult): never { + const maxIssues = 5; // Show first N issues in message + const lines: string[] = [ + `PPTX_VALIDATION_FAILED: ${result.errors.length} error${result.errors.length !== 1 ? "s" : ""}, ${result.warnings.length} warning${result.warnings.length !== 1 ? "s" : ""}`, + ]; + + const allIssues = [...result.errors, ...result.warnings].slice(0, maxIssues); + for (const issue of allIssues) { + const severity = issue.severity === "error" ? "ERROR" : "WARN"; + const slide = issue.slideIndex != null ? ` slide=${issue.slideIndex}` : ""; + const part = issue.part ? ` part=${issue.part}` : ""; + lines.push(`[${severity}] ${issue.code}${slide}${part}`); + lines.push(` ${issue.message}`); + if (issue.hint) { + lines.push(` Hint: ${issue.hint}`); + } + } + + if (result.errors.length + result.warnings.length > maxIssues) { + lines.push( + ` ... and ${result.errors.length + result.warnings.length - maxIssues} more issue(s)`, + ); + } + + const error = new Error(lines.join("\n")); + // Attach machine-readable payload + (error as Error & { validation: ValidationResult }).validation = result; + throw error; +} + // ── Slide XML Assembly ─────────────────────────────────────────────── function slideXml( @@ -4214,7 +4713,8 @@ function slideXml( // not when the XML is assembled. The counter is managed globally and must // be preserved across handler boundaries via serialize()/restorePresentation(). const transXml = transition || ""; - const animXml = animations && animations.length > 0 ? animations.join("") : ""; + const animXml = + animations && animations.length > 0 ? animations.join("") : ""; return ` ${bg}${shapes}${transXml}${animXml}`; @@ -4238,11 +4738,12 @@ function slideXml( * titleSlide(pres, { title: 'My Title' }); * contentSlide(pres, { title: 'Content', bullets: ['Point 1', 'Point 2'] }); * - * // For CUSTOM layouts, use pres.addSlide() directly: - * const bg = solidFill(pres.theme.bg); - * const shapes = textBox({x: 1, y: 1, w: 8, h: 1, text: 'Custom text'}) + - * rect({x: 1, y: 3, w: 4, h: 2, fill: pres.theme.accent1}); - * pres.addSlide(bg, shapes, { transition: 'fade' }); + * // For CUSTOM layouts, use customSlide(): + * customSlide(pres, { + * shapes: [textBox({x: 1, y: 1, w: 8, h: 1, text: 'Custom text'}), + * rect({x: 1, y: 3, w: 4, h: 2, fill: pres.theme.accent1})], + * transition: 'fade' + * }); * * // Build final file * const zip = pres.buildZip(); @@ -4374,7 +4875,11 @@ export function createPresentation(opts?: CreatePresentationOptions) { * @param {number} [slideOpts.transitionDuration=500] - Transition duration in ms * @param {string} [slideOpts.notes] - Speaker notes text */ - addSlide(bgXml: string, shapesXml: string | string[], slideOpts?: SlideOptions) { + addSlide( + bgXml: string, + shapesXml: string | string[], + slideOpts?: SlideOptions, + ) { // Defensively handle arrays - join them if accidentally passed const shapes = Array.isArray(shapesXml) ? shapesXml.join("") : shapesXml; slides.push({ @@ -4382,7 +4887,7 @@ export function createPresentation(opts?: CreatePresentationOptions) { shapes: shapes, transition: slideOpts?.transition || null, transitionDuration: slideOpts?.transitionDuration || 500, - notes: slideOpts?.notes || null, + notes: _sanitizeNotes(slideOpts?.notes), }); }, @@ -4402,14 +4907,45 @@ export function createPresentation(opts?: CreatePresentationOptions) { * pres.addBody(textBox({x:1, y:1, w:8, h:1, text:'Hello'})); * * // With solid background: - * pres.addBody(shapes, { background: '0D1117', transition: 'fade' }); + * pres.addBody([shape1, shape2], { background: '0D1117', transition: 'fade' }); * * // With gradient background: - * pres.addBody(shapes, { background: {color1: '000000', color2: '1a1a2e', angle: 180} }); + * pres.addBody([shape1], { background: {color1: '000000', color2: '1a1a2e', angle: 180} }); */ - addBody(shapesXml: string | string[], slideOpts?: SlideOptions) { - // Defensively handle arrays - join them if accidentally passed - const shapesStr = Array.isArray(shapesXml) ? shapesXml.join("") : shapesXml; + addBody( + shapesInput: ShapeFragment | ShapeFragment[] | string | string[], + slideOpts?: SlideOptions, + ) { + // Reject raw strings — LLMs must use shape builder functions + if (typeof shapesInput === "string") { + throw new Error( + "addBody: raw XML strings are no longer accepted. " + + "Pass ShapeFragment objects from builder functions (textBox, rect, bulletList, etc.). " + + "Example: pres.addBody(textBox({ x:1, y:1, w:8, h:1, text:'Hello' }))", + ); + } + if ( + Array.isArray(shapesInput) && + shapesInput.length > 0 && + typeof shapesInput[0] === "string" + ) { + throw new Error( + "addBody: raw XML string arrays are no longer accepted. " + + "Pass ShapeFragment objects from builder functions (textBox, rect, bulletList, etc.).", + ); + } + // Validate and convert ShapeFragment(s) to XML, then delegate to internal method + const shapesStr = fragmentsToXml(shapesInput as ShapeFragment | ShapeFragment[]); + pres._addBodyRaw(shapesStr, slideOpts); + }, + + /** + * Internal: add shapes (as pre-validated XML string) to a new slide. + * Resolves background from per-slide > defaultBackground > theme. + * Not on the Presentation interface — internal use only. + * @internal + */ + _addBodyRaw(shapesStr: string, slideOpts?: SlideOptions) { // Resolve background: per-slide > defaultBackground > theme.bg let bgXml: string; const bgSpec = slideOpts?.background; @@ -4424,7 +4960,10 @@ export function createPresentation(opts?: CreatePresentationOptions) { } } else if (defaultBackground) { // Use presentation default - if (typeof defaultBackground === "object" && "color1" in defaultBackground) { + if ( + typeof defaultBackground === "object" && + "color1" in defaultBackground + ) { bgXml = gradientBg( defaultBackground.color1, defaultBackground.color2, @@ -4442,7 +4981,7 @@ export function createPresentation(opts?: CreatePresentationOptions) { shapes: shapesStr, transition: slideOpts?.transition || null, transitionDuration: slideOpts?.transitionDuration || 500, - notes: slideOpts?.notes || null, + notes: _sanitizeNotes(slideOpts?.notes), }); }, @@ -4453,13 +4992,18 @@ export function createPresentation(opts?: CreatePresentationOptions) { * @param {string} shapesXml - Concatenated shape XML fragments * @param {Object} [slideOpts] - Optional slide-level settings */ - insertSlideAt(index: number, bgXml: string, shapesXml: string, slideOpts?: SlideOptions) { + insertSlideAt( + index: number, + bgXml: string, + shapesXml: string, + slideOpts?: SlideOptions, + ) { const slide: SlideData = { bg: bgXml, shapes: shapesXml, transition: slideOpts?.transition || null, transitionDuration: slideOpts?.transitionDuration || 500, - notes: slideOpts?.notes || null, + notes: _sanitizeNotes(slideOpts?.notes), }; const clampedIndex = Math.max(0, Math.min(index, slides.length)); slides.splice(clampedIndex, 0, slide); @@ -4819,7 +5363,12 @@ ${notesMasterIdLst}${slideList} // Track sequential notes slide index (notes slides must be numbered 1, 2, 3... not matching slide numbers) // This is set after we check if slide.notes exists, so we can use it for the filename let notesSlideIndex: number | undefined; - const slideRels: Array<{ id: string; type: string; target: string; targetMode?: string }> = [ + const slideRels: Array<{ + id: string; + type: string; + target: string; + targetMode?: string; + }> = [ { id: `rId${relIdCounter++}`, type: RT_SLIDE_LAYOUT, @@ -4855,7 +5404,10 @@ ${notesMasterIdLst}${slideList} if (this._links) { const seenLinkRelIds = new Set(); for (const link of this._links) { - if (link.slideIndex === slideNum && !seenLinkRelIds.has(link.relId)) { + if ( + link.slideIndex === slideNum && + !seenLinkRelIds.has(link.relId) + ) { seenLinkRelIds.add(link.relId); slideRels.push({ id: link.relId, @@ -4889,7 +5441,10 @@ ${notesMasterIdLst}${slideList} // Build transition XML const transXml = slide.transition - ? buildTransitionXml(slide.transition, slide.transitionDuration ?? 500) + ? buildTransitionXml( + slide.transition, + slide.transitionDuration ?? 500, + ) : ""; // Get animations for this slide (if any) @@ -4969,7 +5524,8 @@ ${notesMasterIdLst}${slideList} const defaults = [ { extension: "rels", - contentType: "application/vnd.openxmlformats-package.relationships+xml", + contentType: + "application/vnd.openxmlformats-package.relationships+xml", }, { extension: "xml", contentType: "application/xml" }, ]; @@ -5007,9 +5563,20 @@ ${notesMasterIdLst}${slideList} */ buildZip() { // Clean up orphan charts (charts created but never used in a slide) - // This can happen when a handler fails mid-execution after embedChart() - // but before customSlide() — the chart is tracked but never added to slide XML this._cleanupOrphanCharts(); + + // ── Strict validation (mandatory, no bypass) ────────────────────── + const validationResult = _validatePresentation( + slides, + this._charts || [], + this._chartEntries || [], + this._images || [], + this._links || [], + ); + if (!validationResult.ok) { + _throwValidationError(validationResult); + } + // Insert warning slide at the beginning this._insertWarningSlide(); return createZip(this.build()); @@ -5195,7 +5762,10 @@ ${notesMasterIdLst}${slideList} } // Cast to unknown to satisfy StorableValue type - the serialized state // contains nested objects that match StorableValue semantically but not structurally - sharedStateSet(key, this.serialize() as unknown as import("ha:shared-state").StorableValue); + sharedStateSet( + key, + this.serialize() as unknown as import("ha:shared-state").StorableValue, + ); }, }; @@ -5499,7 +6069,7 @@ export function titleSlide(pres: Pres, opts: TitleSlideOptions) { }), ); } - pres.addSlide(bg, shapes.join(""), opts); + pres.addSlide(bg, shapes.map(_s).join(""), opts); } /** @@ -5551,7 +6121,7 @@ export function sectionSlide(pres: Pres, opts: SectionSlideOptions) { }), ); } - pres.addSlide(bg, shapes.join(""), opts); + pres.addSlide(bg, shapes.map(_s).join(""), opts); } /** @@ -5601,22 +6171,34 @@ export function contentSlide(pres: Pres, opts: ContentSlideOptions) { bold: true, _skipBoundsCheck: true, }); - const accentBar = rect({ x: 0.5, y: 1.05, w: 2, h: 0.05, fill: t.accent1, _skipBoundsCheck: true }); + const accentBar = rect({ + x: 0.5, + y: 1.05, + w: 2, + h: 0.05, + fill: t.accent1, + _skipBoundsCheck: true, + }); const itemsArr = normalizeItems(opts.items); - const body = itemsArr.length > 0 - ? bulletList({ - x: 0.5, - y: 1.5, - w: 12, - h: 5.5, - items: itemsArr, - color: t.fg, - _skipBoundsCheck: true, - }) - : ""; + const body = + itemsArr.length > 0 + ? bulletList({ + x: 0.5, + y: 1.5, + w: 12, + h: 5.5, + items: itemsArr, + color: t.fg, + _skipBoundsCheck: true, + }) + : ""; - pres.addSlide(bg, titleShape + accentBar + body, opts); + pres.addSlide( + bg, + _s(titleShape) + _s(accentBar) + (body === "" ? "" : _s(body)), + opts, + ); } /** @@ -5664,54 +6246,79 @@ export function twoColumnSlide(pres: Pres, opts: TwoColumnSlideOptions) { bold: true, _skipBoundsCheck: true, }); - const accentBar = rect({ x: 0.5, y: 1.05, w: 2, h: 0.05, fill: t.accent1, _skipBoundsCheck: true }); - const divider = rect({ x: 6.5, y: 1.3, w: 0.03, h: 5.5, fill: t.subtle, _skipBoundsCheck: true }); + const accentBar = rect({ + x: 0.5, + y: 1.05, + w: 2, + h: 0.05, + fill: t.accent1, + _skipBoundsCheck: true, + }); + const divider = rect({ + x: 6.5, + y: 1.3, + w: 0.03, + h: 5.5, + fill: t.subtle, + _skipBoundsCheck: true, + }); const leftItemsArr = normalizeItems(opts.leftItems); const rightItemsArr = normalizeItems(opts.rightItems); - const left = leftItemsArr.length > 0 - ? bulletList({ - x: 0.5, - y: 1.5, - w: 5.5, - h: 5.5, - items: leftItemsArr, - color: t.fg, - _skipBoundsCheck: true, - }) - : ""; + const left = + leftItemsArr.length > 0 + ? bulletList({ + x: 0.5, + y: 1.5, + w: 5.5, + h: 5.5, + items: leftItemsArr, + color: t.fg, + _skipBoundsCheck: true, + }) + : ""; - const right = rightItemsArr.length > 0 - ? bulletList({ - x: 7, - y: 1.5, - w: 5.5, - h: 5.5, - items: rightItemsArr, - color: t.fg, - _skipBoundsCheck: true, - }) - : ""; + const right = + rightItemsArr.length > 0 + ? bulletList({ + x: 7, + y: 1.5, + w: 5.5, + h: 5.5, + items: rightItemsArr, + color: t.fg, + _skipBoundsCheck: true, + }) + : ""; - pres.addSlide(bg, titleShape + accentBar + divider + left + right, opts); + pres.addSlide( + bg, + _s(titleShape) + + _s(accentBar) + + _s(divider) + + (left === "" ? "" : _s(left)) + + (right === "" ? "" : _s(right)), + opts, + ); } /** * Add a blank slide with just the theme background (NO content). * * ⚠️ WARNING: This creates an EMPTY slide. You CANNOT add shapes to it later. - * For custom layouts with shapes, use pres.addSlide() directly instead: + * For custom layouts with shapes, use customSlide() instead: * * @example * // DON'T do this — blankSlide creates empty slide with no way to add content: * blankSlide(pres); // Creates empty slide, cannot add shapes after * - * // DO this instead — use addSlide for custom layouts: - * const bg = solidFill(pres.theme.bg); - * const shapes = textBox({x: 1, y: 1, w: 8, h: 1, text: 'Custom slide'}) + - * rect({x: 1, y: 3, w: 4, h: 2, fill: pres.theme.accent1}); - * pres.addSlide(bg, shapes, { transition: 'fade' }); + * // DO this instead — use customSlide for custom layouts: + * customSlide(pres, { + * shapes: [textBox({x: 1, y: 1, w: 8, h: 1, text: 'Custom slide'}), + * rect({x: 1, y: 3, w: 4, h: 2, fill: pres.theme.accent1})], + * transition: 'fade' + * }); * * @param {Object} pres - Presentation object from createPresentation(). REQUIRED as first param. * @returns {void} @@ -5759,13 +6366,16 @@ export function blankSlide(pres: Pres): void { */ export function customSlide(pres: Pres, opts: CustomSlideOptions) { _requirePres(pres, "customSlide"); - if (!opts || typeof opts.shapes !== "string") { + if (!opts || opts.shapes == null) { throw new Error( - "customSlide: 'shapes' parameter is required and must be a string of shape XML. " + - "Example: customSlide(pres, { shapes: textBox({...}) + rect({...}) })", + "customSlide: 'shapes' parameter is required. " + + "Pass an array of ShapeFragment objects from shape builders: " + + "customSlide(pres, { shapes: [textBox({...}), rect({...})] })", ); } - pres.addBody(opts.shapes, { + // Validate and convert ShapeFragment(s) to XML string + const shapesXml = fragmentsToXml(opts.shapes); + pres._addBodyRaw(shapesXml, { background: opts.background, transition: opts.transition, transitionDuration: opts.transitionDuration, @@ -5945,20 +6555,25 @@ export function chartSlide(pres: Pres, opts: ChartSlideOptions) { // Handle extraItems const extraItemsArr = normalizeItems(opts.extraItems); - const extra = extraItemsArr.length > 0 - ? bulletList({ - x: 0.5, - y: 6.6, - w: 12, - h: 1, - items: extraItemsArr, - fontSize: 12, - color: t.fg, - _skipBoundsCheck: true, - }) - : ""; + const extra = + extraItemsArr.length > 0 + ? bulletList({ + x: 0.5, + y: 6.6, + w: 12, + h: 1, + items: extraItemsArr, + fontSize: 12, + color: t.fg, + _skipBoundsCheck: true, + }) + : ""; - pres.addSlide(bg, titleShape + accentBar + chartXml + extra, opts); + pres.addSlide( + bg, + _s(titleShape) + _s(accentBar) + chartXml + (extra === "" ? "" : _s(extra)), + opts, + ); } /** @@ -6061,35 +6676,43 @@ export function comparisonSlide(pres: Pres, opts: ComparisonSlideOptions) { // Build left column content const leftItemsArr = normalizeItems(opts.leftItems); - const left = leftItemsArr.length > 0 - ? bulletList({ - x: LEFT_COL.x, - y: BODY_Y, - w: LEFT_COL.w, - h: BODY_H, - items: leftItemsArr.slice(0, MAX_ITEMS), - color: t.bodyText, - bulletColor: t.accent1, - }) - : ""; + const left = + leftItemsArr.length > 0 + ? bulletList({ + x: LEFT_COL.x, + y: BODY_Y, + w: LEFT_COL.w, + h: BODY_H, + items: leftItemsArr.slice(0, MAX_ITEMS), + color: t.bodyText, + bulletColor: t.accent1, + }) + : ""; // Build right column content const rightItemsArr = normalizeItems(opts.rightItems); - const right = rightItemsArr.length > 0 - ? bulletList({ - x: RIGHT_COL.x, - y: BODY_Y, - w: RIGHT_COL.w, - h: BODY_H, - items: rightItemsArr.slice(0, MAX_ITEMS), - color: t.bodyText, - bulletColor: t.accent2, - }) - : ""; + const right = + rightItemsArr.length > 0 + ? bulletList({ + x: RIGHT_COL.x, + y: BODY_Y, + w: RIGHT_COL.w, + h: BODY_H, + items: rightItemsArr.slice(0, MAX_ITEMS), + color: t.bodyText, + bulletColor: t.accent2, + }) + : ""; pres.addSlide( bg, - titleShape + accentBar + leftHeader + rightHeader + divider + left + right, + _s(titleShape) + + _s(accentBar) + + _s(leftHeader) + + _s(rightHeader) + + _s(divider) + + (left === "" ? "" : _s(left)) + + (right === "" ? "" : _s(right)), opts, ); } @@ -6157,7 +6780,11 @@ export function heroSlide(pres: Pres, opts: HeroSlideOptions) { // Calculate positions based on alignment const xMap: Record = { left: 0.8, center: 0.5, right: 0.5 }; - const wMap: Record = { left: 11.5, center: 12.333, right: 11.5 }; + const wMap: Record = { + left: 11.5, + center: 12.333, + right: 11.5, + }; const textX = xMap[align] || 0.5; const textW = wMap[align] || 12.333; @@ -6165,7 +6792,7 @@ export function heroSlide(pres: Pres, opts: HeroSlideOptions) { const bgImg = backgroundImage(pres, opts.image, format); const darkOverlay = overlay({ opacity: overlayOpacity, color: overlayColor }); - let shapes = bgImg + darkOverlay; + let shapes = _s(bgImg) + _s(darkOverlay); // Only add title if provided if (opts.title) { @@ -6182,7 +6809,7 @@ export function heroSlide(pres: Pres, opts: HeroSlideOptions) { align: align, forceColor: true, }); - shapes += titleShape; + shapes += _s(titleShape); if (opts.subtitle) { const subtitleShape = textBox({ @@ -6196,12 +6823,11 @@ export function heroSlide(pres: Pres, opts: HeroSlideOptions) { align: align, forceColor: true, }); - shapes += subtitleShape; + shapes += _s(subtitleShape); } } - customSlide(pres, { - shapes: shapes, + pres._addBodyRaw(shapes, { background: "000000", // Black bg in case image doesn't fully load transition: opts.transition, notes: opts.notes, @@ -6329,7 +6955,7 @@ export function statGridSlide(pres: Pres, opts: StatGridSlideOptions) { } } - pres.addBody(shapes, { + pres._addBodyRaw(shapes, { transition: opts.transition, notes: opts.notes, }); @@ -6417,7 +7043,8 @@ export function imageGridSlide(pres: Pres, opts: ImageGridSlideOptions) { const margin = 0.5; const availW = SLIDE_WIDTH_INCHES - 2 * margin; const firstImg = opts.images[0]; - const firstHasCaption = firstImg && !(firstImg instanceof Uint8Array) && firstImg.caption; + const firstHasCaption = + firstImg && !(firstImg instanceof Uint8Array) && firstImg.caption; const availH = (opts.title ? 6 : 6.5) - (firstHasCaption ? 0.5 : 0); const cellW = (availW - (cols - 1) * gap) / cols; const cellH = (availH - (rows - 1) * gap) / rows; @@ -6432,7 +7059,9 @@ export function imageGridSlide(pres: Pres, opts: ImageGridSlideOptions) { const imgItem = opts.images[i]; const isUint8Array = imgItem instanceof Uint8Array; const imgData = isUint8Array ? imgItem : imgItem.data; - const imgFormat = isUint8Array ? defaultFormat : (imgItem.format || defaultFormat); + const imgFormat = isUint8Array + ? defaultFormat + : imgItem.format || defaultFormat; const caption = isUint8Array ? undefined : imgItem.caption; if (!imgData || !(imgData instanceof Uint8Array)) { @@ -6466,7 +7095,7 @@ export function imageGridSlide(pres: Pres, opts: ImageGridSlideOptions) { } } - pres.addBody(shapes, { + pres._addBodyRaw(shapes, { transition: opts.transition, notes: opts.notes, }); @@ -6572,7 +7201,7 @@ export function quoteSlide(pres: Pres, opts: QuoteSlideOptions) { fill: t.accent1, }); - pres.addBody(shapes, { + pres._addBodyRaw(shapes, { transition: opts.transition, notes: opts.notes, }); @@ -6674,7 +7303,7 @@ export function bigNumberSlide(pres: Pres, opts: BigNumberSlideOptions) { }); } - pres.addBody(shapes, { + pres._addBodyRaw(shapes, { background: opts.background, transition: opts.transition, notes: opts.notes, @@ -6713,7 +7342,9 @@ export function architectureDiagramSlide( ) { _requirePres(pres, "architectureDiagramSlide"); requireString(opts.title, "architectureDiagramSlide.title"); - requireArray(opts.components, "architectureDiagramSlide.components", { nonEmpty: true }); + requireArray(opts.components, "architectureDiagramSlide.components", { + nonEmpty: true, + }); const t = pres.theme; const bg = solidBg(t.bg); @@ -6722,14 +7353,28 @@ export function architectureDiagramSlide( const comps = opts.components.slice(0, 6); // Max 6 components // Title - let shapes = textBox({ - x: 0.5, y: 0.3, w: 12, h: 0.8, - text: opts.title, - fontSize: 28, color: t.fg, bold: true, - }); - shapes += rect({ x: 0.5, y: 1.05, w: 2, h: 0.05, fill: t.accent1 }); - - const accentColors = [t.accent1, t.accent2, "00E676", "FFD700", "FF7043", "AB47BC"]; + let shapes = _s( + textBox({ + x: 0.5, + y: 0.3, + w: 12, + h: 0.8, + text: opts.title, + fontSize: 28, + color: t.fg, + bold: true, + }), + ); + shapes += _s(rect({ x: 0.5, y: 1.05, w: 2, h: 0.05, fill: t.accent1 })); + + const accentColors = [ + t.accent1, + t.accent2, + "00E676", + "FFD700", + "FF7043", + "AB47BC", + ]; if (layout === "horizontal") { // Horizontal layout: components in a row @@ -6745,28 +7390,49 @@ export function architectureDiagramSlide( const color = comp.color || accentColors[i % accentColors.length]; // Component box - shapes += rect({ - x, y, w: boxW, h: boxH, - fill: color, cornerRadius: 8, - text: comp.label, - fontSize: 12, color: autoTextColor(color), bold: true, - }); + shapes += _s( + rect({ + x, + y, + w: boxW, + h: boxH, + fill: color, + cornerRadius: 8, + text: comp.label, + fontSize: 12, + color: autoTextColor(color), + bold: true, + }), + ); // Description below if (comp.description) { - shapes += textBox({ - x, y: y + boxH + 0.1, w: boxW, h: 0.6, - text: comp.description, - fontSize: 9, color: t.subtle, align: "center", - }); + shapes += _s( + textBox({ + x, + y: y + boxH + 0.1, + w: boxW, + h: 0.6, + text: comp.description, + fontSize: 9, + color: t.subtle, + align: "center", + }), + ); } // Arrow to next component if (showArrows && i < comps.length - 1) { - shapes += icon({ - x: x + boxW + 0.2, y: y + boxH / 2 - 0.2, - w: 0.4, h: 0.4, shape: "right-arrow", fill: t.subtle, - }); + shapes += _s( + icon({ + x: x + boxW + 0.2, + y: y + boxH / 2 - 0.2, + w: 0.4, + h: 0.4, + shape: "right-arrow", + fill: t.subtle, + }), + ); } }); } else { @@ -6792,19 +7458,33 @@ export function architectureDiagramSlide( const y = startY + i * (boxH + gap); const color = comp.color || accentColors[i % accentColors.length]; - shapes += rect({ - x: startX, y, w: boxW, h: boxH, - fill: color, cornerRadius: 6, - text: comp.label + (comp.description ? ` — ${comp.description}` : ""), - fontSize: 14, color: autoTextColor(color), bold: true, - }); + shapes += _s( + rect({ + x: startX, + y, + w: boxW, + h: boxH, + fill: color, + cornerRadius: 6, + text: comp.label + (comp.description ? ` — ${comp.description}` : ""), + fontSize: 14, + color: autoTextColor(color), + bold: true, + }), + ); // Arrow down if (showArrows && i < comps.length - 1) { - shapes += icon({ - x: startX + boxW / 2 - 0.2, y: y + boxH + 0.1, - w: 0.4, h: 0.4, shape: "down-arrow", fill: t.subtle, - }); + shapes += _s( + icon({ + x: startX + boxW / 2 - 0.2, + y: y + boxH + 0.1, + w: 0.4, + h: 0.4, + shape: "down-arrow", + fill: t.subtle, + }), + ); } }); } @@ -6832,7 +7512,10 @@ export function architectureDiagramSlide( * @param {Array} [opts.bullets] - Explanation points beside the code * @param {string} [opts.language] - Language label (displayed, no highlighting) */ -export function codeWalkthroughSlide(pres: Pres, opts: CodeWalkthroughSlideOptions) { +export function codeWalkthroughSlide( + pres: Pres, + opts: CodeWalkthroughSlideOptions, +) { _requirePres(pres, "codeWalkthroughSlide"); requireString(opts.title, "codeWalkthroughSlide.title"); requireString(opts.code, "codeWalkthroughSlide.code"); @@ -6841,39 +7524,70 @@ export function codeWalkthroughSlide(pres: Pres, opts: CodeWalkthroughSlideOptio const bg = solidBg(t.bg); // Title - let shapes = textBox({ - x: 0.5, y: 0.3, w: 12, h: 0.8, - text: opts.title, - fontSize: 28, color: t.fg, bold: true, - }); - shapes += rect({ x: 0.5, y: 1.05, w: 2, h: 0.05, fill: t.accent1 }); + let shapes = _s( + textBox({ + x: 0.5, + y: 0.3, + w: 12, + h: 0.8, + text: opts.title, + fontSize: 28, + color: t.fg, + bold: true, + }), + ); + shapes += _s(rect({ x: 0.5, y: 1.05, w: 2, h: 0.05, fill: t.accent1 })); // Code block (left side) const codeW = opts.bullets && opts.bullets.length > 0 ? 7.5 : 12; - shapes += codeBlock({ - x: 0.5, y: 1.5, w: codeW, h: 5, - code: opts.code, - fontSize: opts.codeFontSize || 11, - title: opts.language, - }); + shapes += _s( + codeBlock({ + x: 0.5, + y: 1.5, + w: codeW, + h: 5, + code: opts.code, + fontSize: opts.codeFontSize || 11, + title: opts.language, + }), + ); // Explanation bullets (right side) if (opts.bullets && opts.bullets.length > 0) { - shapes += rect({ - x: 8.3, y: 1.5, w: 4.5, h: 5, - fill: isDark(t.bg) ? "1A1A2E" : "F5F5F5", - cornerRadius: 8, - }); - shapes += textBox({ - x: 8.5, y: 1.6, w: 4, h: 0.5, - text: "Key Points", - fontSize: 16, color: t.accent1, bold: true, - }); - shapes += bulletList({ - x: 8.5, y: 2.2, w: 4, h: 4, - items: opts.bullets, - fontSize: 12, color: t.fg, bulletColor: t.accent1, - }); + shapes += _s( + rect({ + x: 8.3, + y: 1.5, + w: 4.5, + h: 5, + fill: isDark(t.bg) ? "1A1A2E" : "F5F5F5", + cornerRadius: 8, + }), + ); + shapes += _s( + textBox({ + x: 8.5, + y: 1.6, + w: 4, + h: 0.5, + text: "Key Points", + fontSize: 16, + color: t.accent1, + bold: true, + }), + ); + shapes += _s( + bulletList({ + x: 8.5, + y: 2.2, + w: 4, + h: 4, + items: opts.bullets, + fontSize: 12, + color: t.fg, + bulletColor: t.accent1, + }), + ); } pres.addSlide(bg, shapes, opts); @@ -6909,7 +7623,10 @@ export function beforeAfterSlide(pres: Pres, opts: BeforeAfterSlideOptions) { // Normalize content const normalizeBullets = (input: string[] | string): string[] => { if (Array.isArray(input)) return input; - return input.split("\n").map(s => s.trim()).filter(s => s.length > 0); + return input + .split("\n") + .map((s) => s.trim()) + .filter((s) => s.length > 0); }; const beforeItems = normalizeBullets(opts.beforeContent); const afterItems = normalizeBullets(opts.afterContent); @@ -6918,48 +7635,127 @@ export function beforeAfterSlide(pres: Pres, opts: BeforeAfterSlideOptions) { const afterColor = opts.afterColor || "00E676"; // Green // Title - let shapes = textBox({ - x: 0.5, y: 0.3, w: 12, h: 0.8, - text: opts.title, - fontSize: 28, color: t.fg, bold: true, - }); - shapes += rect({ x: 0.5, y: 1.05, w: 2, h: 0.05, fill: t.accent1 }); + let shapes = _s( + textBox({ + x: 0.5, + y: 0.3, + w: 12, + h: 0.8, + text: opts.title, + fontSize: 28, + color: t.fg, + bold: true, + }), + ); + shapes += _s(rect({ x: 0.5, y: 1.05, w: 2, h: 0.05, fill: t.accent1 })); // Before column - shapes += rect({ x: 0.5, y: 1.4, w: 5.8, h: 5.3, fill: isDark(t.bg) ? "1F1F2E" : "FFF5F5", cornerRadius: 10 }); - shapes += rect({ x: 0.5, y: 1.4, w: 5.8, h: 0.6, fill: beforeColor, cornerRadius: 10 }); - shapes += rect({ x: 0.5, y: 1.7, w: 5.8, h: 0.3, fill: beforeColor }); // Cover bottom radius - shapes += textBox({ - x: 0.7, y: 1.45, w: 5.4, h: 0.5, - text: opts.beforeTitle || "Before", - fontSize: 18, color: "FFFFFF", bold: true, align: "center", - }); - shapes += bulletList({ - x: 0.7, y: 2.2, w: 5.4, h: 4.3, - items: beforeItems, - fontSize: 13, color: t.fg, bulletColor: beforeColor, - }); + shapes += _s( + rect({ + x: 0.5, + y: 1.4, + w: 5.8, + h: 5.3, + fill: isDark(t.bg) ? "1F1F2E" : "FFF5F5", + cornerRadius: 10, + }), + ); + shapes += _s( + rect({ + x: 0.5, + y: 1.4, + w: 5.8, + h: 0.6, + fill: beforeColor, + cornerRadius: 10, + }), + ); + shapes += _s(rect({ x: 0.5, y: 1.7, w: 5.8, h: 0.3, fill: beforeColor })); // Cover bottom radius + shapes += _s( + textBox({ + x: 0.7, + y: 1.45, + w: 5.4, + h: 0.5, + text: opts.beforeTitle || "Before", + fontSize: 18, + color: "FFFFFF", + bold: true, + align: "center", + }), + ); + shapes += _s( + bulletList({ + x: 0.7, + y: 2.2, + w: 5.4, + h: 4.3, + items: beforeItems, + fontSize: 13, + color: t.fg, + bulletColor: beforeColor, + }), + ); // Arrow in center - shapes += icon({ - x: 6.4, y: 3.5, w: 0.6, h: 0.6, - shape: "right-arrow", fill: t.subtle, - }); + shapes += _s( + icon({ + x: 6.4, + y: 3.5, + w: 0.6, + h: 0.6, + shape: "right-arrow", + fill: t.subtle, + }), + ); // After column - shapes += rect({ x: 7.1, y: 1.4, w: 5.8, h: 5.3, fill: isDark(t.bg) ? "1F2E1F" : "F5FFF5", cornerRadius: 10 }); - shapes += rect({ x: 7.1, y: 1.4, w: 5.8, h: 0.6, fill: afterColor, cornerRadius: 10 }); - shapes += rect({ x: 7.1, y: 1.7, w: 5.8, h: 0.3, fill: afterColor }); // Cover bottom radius - shapes += textBox({ - x: 7.3, y: 1.45, w: 5.4, h: 0.5, - text: opts.afterTitle || "After", - fontSize: 18, color: "FFFFFF", bold: true, align: "center", - }); - shapes += bulletList({ - x: 7.3, y: 2.2, w: 5.4, h: 4.3, - items: afterItems, - fontSize: 13, color: t.fg, bulletColor: afterColor, - }); + shapes += _s( + rect({ + x: 7.1, + y: 1.4, + w: 5.8, + h: 5.3, + fill: isDark(t.bg) ? "1F2E1F" : "F5FFF5", + cornerRadius: 10, + }), + ); + shapes += _s( + rect({ + x: 7.1, + y: 1.4, + w: 5.8, + h: 0.6, + fill: afterColor, + cornerRadius: 10, + }), + ); + shapes += _s(rect({ x: 7.1, y: 1.7, w: 5.8, h: 0.3, fill: afterColor })); // Cover bottom radius + shapes += _s( + textBox({ + x: 7.3, + y: 1.45, + w: 5.4, + h: 0.5, + text: opts.afterTitle || "After", + fontSize: 18, + color: "FFFFFF", + bold: true, + align: "center", + }), + ); + shapes += _s( + bulletList({ + x: 7.3, + y: 2.2, + w: 5.4, + h: 4.3, + items: afterItems, + fontSize: 13, + color: t.fg, + bulletColor: afterColor, + }), + ); pres.addSlide(bg, shapes, opts); } @@ -6997,15 +7793,29 @@ export function processFlowSlide(pres: Pres, opts: ProcessFlowSlideOptions) { const layout = opts.layout || "horizontal"; const showNumbers = opts.showNumbers !== false; const steps = opts.steps.slice(0, 6); // Max 6 steps - const accentColors = [t.accent1, t.accent2, "00E676", "FFD700", "FF7043", "AB47BC"]; + const accentColors = [ + t.accent1, + t.accent2, + "00E676", + "FFD700", + "FF7043", + "AB47BC", + ]; // Title - let shapes = textBox({ - x: 0.5, y: 0.3, w: 12, h: 0.8, - text: opts.title, - fontSize: 28, color: t.fg, bold: true, - }); - shapes += rect({ x: 0.5, y: 1.05, w: 2, h: 0.05, fill: t.accent1 }); + let shapes = _s( + textBox({ + x: 0.5, + y: 0.3, + w: 12, + h: 0.8, + text: opts.title, + fontSize: 28, + color: t.fg, + bold: true, + }), + ); + shapes += _s(rect({ x: 0.5, y: 1.05, w: 2, h: 0.05, fill: t.accent1 })); if (layout === "horizontal") { // Adaptive sizing: reduce box width as step count increases @@ -7025,50 +7835,90 @@ export function processFlowSlide(pres: Pres, opts: ProcessFlowSlideOptions) { // Step number circle if (showNumbers) { - shapes += icon({ - x: x + boxW / 2 - 0.25, y: y - 0.6, - w: 0.5, h: 0.5, shape: "circle", fill: color, - text: String(i + 1), fontSize: 14, color: autoTextColor(color), - }); + shapes += _s( + icon({ + x: x + boxW / 2 - 0.25, + y: y - 0.6, + w: 0.5, + h: 0.5, + shape: "circle", + fill: color, + text: String(i + 1), + fontSize: 14, + color: autoTextColor(color), + }), + ); } // Step box - shapes += rect({ - x, y, w: boxW, h: boxH, - fill: isDark(t.bg) ? "1A1A2E" : "F5F5F5", - cornerRadius: 8, - }); + shapes += _s( + rect({ + x, + y, + w: boxW, + h: boxH, + fill: isDark(t.bg) ? "1A1A2E" : "F5F5F5", + cornerRadius: 8, + }), + ); // Icon (if provided) if (step.icon) { - shapes += icon({ - x: x + boxW / 2 - 0.3, y: y + 0.2, - w: 0.6, h: 0.6, shape: step.icon, fill: color, - }); + shapes += _s( + icon({ + x: x + boxW / 2 - 0.3, + y: y + 0.2, + w: 0.6, + h: 0.6, + shape: step.icon, + fill: color, + }), + ); } // Label - shapes += textBox({ - x, y: step.icon ? y + 0.9 : y + 0.3, w: boxW, h: 0.5, - text: step.label, - fontSize: 12, color: t.fg, bold: true, align: "center", - }); + shapes += _s( + textBox({ + x, + y: step.icon ? y + 0.9 : y + 0.3, + w: boxW, + h: 0.5, + text: step.label, + fontSize: 12, + color: t.fg, + bold: true, + align: "center", + }), + ); // Description if (step.description) { - shapes += textBox({ - x, y: y + boxH + 0.1, w: boxW, h: 0.8, - text: step.description, - fontSize: 9, color: t.subtle, align: "center", - }); + shapes += _s( + textBox({ + x, + y: y + boxH + 0.1, + w: boxW, + h: 0.8, + text: step.description, + fontSize: 9, + color: t.subtle, + align: "center", + }), + ); } // Arrow to next if (i < steps.length - 1) { - shapes += icon({ - x: x + boxW + 0.15, y: y + boxH / 2 - 0.15, - w: 0.3, h: 0.3, shape: "right-arrow", fill: t.subtle, - }); + shapes += _s( + icon({ + x: x + boxW + 0.15, + y: y + boxH / 2 - 0.15, + w: 0.3, + h: 0.3, + shape: "right-arrow", + fill: t.subtle, + }), + ); } }); } else { @@ -7085,35 +7935,62 @@ export function processFlowSlide(pres: Pres, opts: ProcessFlowSlideOptions) { // Number circle if (showNumbers) { - shapes += icon({ - x: startX - 0.6, y: y + 0.2, - w: 0.5, h: 0.5, shape: "circle", fill: color, - text: String(i + 1), fontSize: 12, color: autoTextColor(color), - }); + shapes += _s( + icon({ + x: startX - 0.6, + y: y + 0.2, + w: 0.5, + h: 0.5, + shape: "circle", + fill: color, + text: String(i + 1), + fontSize: 12, + color: autoTextColor(color), + }), + ); } // Step bar - shapes += rect({ - x: startX, y, w: boxW, h: boxH, - fill: color, cornerRadius: 6, - }); + shapes += _s( + rect({ + x: startX, + y, + w: boxW, + h: boxH, + fill: color, + cornerRadius: 6, + }), + ); const labelText = step.description ? `${step.label} — ${step.description}` : step.label; - shapes += textBox({ - x: startX + 0.3, y: y + 0.15, w: boxW - 0.6, h: 0.6, - text: labelText, - fontSize: 14, color: autoTextColor(color), bold: true, - }); + shapes += _s( + textBox({ + x: startX + 0.3, + y: y + 0.15, + w: boxW - 0.6, + h: 0.6, + text: labelText, + fontSize: 14, + color: autoTextColor(color), + bold: true, + }), + ); // Arrow down if (i < steps.length - 1) { - shapes += icon({ - x: startX + boxW / 2 - 0.15, y: y + boxH + 0.1, - w: 0.3, h: 0.3, shape: "down-arrow", fill: t.subtle, - }); + shapes += _s( + icon({ + x: startX + boxW / 2 - 0.15, + y: y + boxH + 0.1, + w: 0.3, + h: 0.3, + shape: "down-arrow", + fill: t.subtle, + }), + ); } }); } @@ -7350,18 +8227,19 @@ function buildAnimationXml( const dur = opts.duration || 500; // Map animation types to OOXML preset IDs - const entrancePresets: Record = { - appear: { preset: 1 }, - fadeIn: { preset: 10 }, - flyInLeft: { preset: 2, subtype: "l" }, - flyInRight: { preset: 2, subtype: "r" }, - flyInTop: { preset: 2, subtype: "t" }, - flyInBottom: { preset: 2, subtype: "b" }, - zoomIn: { preset: 23, subtype: "in" }, - bounceIn: { preset: 26 }, - wipeRight: { preset: 22, subtype: "r" }, - wipeDown: { preset: 22, subtype: "d" }, - }; + const entrancePresets: Record = + { + appear: { preset: 1 }, + fadeIn: { preset: 10 }, + flyInLeft: { preset: 2, subtype: "l" }, + flyInRight: { preset: 2, subtype: "r" }, + flyInTop: { preset: 2, subtype: "t" }, + flyInBottom: { preset: 2, subtype: "b" }, + zoomIn: { preset: 23, subtype: "in" }, + bounceIn: { preset: 26 }, + wipeRight: { preset: 22, subtype: "r" }, + wipeDown: { preset: 22, subtype: "d" }, + }; const emphasisPresets: Record = { pulse: { preset: 32 }, @@ -7385,11 +8263,14 @@ function buildAnimationXml( const animations: string[] = []; // Determine trigger - let triggerNode = ''; + let triggerNode = + ''; if (opts.trigger === "withPrevious") { - triggerNode = ''; + triggerNode = + ''; } else if (opts.trigger === "afterPrevious") { - triggerNode = ''; + triggerNode = + ''; } // Build entrance animation @@ -7446,10 +8327,12 @@ function buildAnimationXml( if (animations.length === 0) return ""; - return `${triggerNode}` + + return ( + `${triggerNode}` + `` + `${animations.join("")}` + - ``; + `` + ); } /** @@ -7554,16 +8437,19 @@ export function addStaggeredAnimation( for (let i = 0; i < shapeCount; i++) { const shapeId = String(i + 2); // Shape IDs start at 2 after container - const delay = (baseAnimation.delay || 0) + (i * staggerDelay); + const delay = (baseAnimation.delay || 0) + i * staggerDelay; const seqNum = 3 + i; const animOpts: AnimationOptions = { ...baseAnimation, delay, // In sequential mode, first shape is onClick, rest are afterPrevious - trigger: mode === "sequential" - ? (i === 0 ? "onClick" : "afterPrevious") - : "withPrevious", + trigger: + mode === "sequential" + ? i === 0 + ? "onClick" + : "afterPrevious" + : "withPrevious", }; const animXml = buildAnimationXml(shapeId, animOpts, seqNum); @@ -7609,9 +8495,9 @@ export function addStaggeredAnimation( * @param {string} [opts.titleColor='8B949E'] - Title color * @param {boolean} [opts.lineNumbers=false] - Show line numbers * @param {number} [opts.cornerRadius=4] - Corner radius in points - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ -export function codeBlock(opts: CodeBlockOptions): string { +export function codeBlock(opts: CodeBlockOptions): ShapeFragment { // ── Input validation ────────────────────────────────────────────── _validateOptionalNumber(opts.fontSize, "codeBlock.fontSize", { min: 1, @@ -7635,13 +8521,15 @@ export function codeBlock(opts: CodeBlockOptions): string { if (opts.lineNumbers) { const pad = String(lines.length).length; displayText = lines - .map((line: string, i: number) => `${String(i + 1).padStart(pad)} ${line}`) + .map( + (line: string, i: number) => `${String(i + 1).padStart(pad)} ${line}`, + ) .join("\n"); } else { displayText = code; } - const shapes: string[] = []; + const shapes: ShapeFragment[] = []; // Optional title bar if (opts.title) { @@ -7681,7 +8569,7 @@ export function codeBlock(opts: CodeBlockOptions): string { }), ); - return shapes.join(""); + return _createShapeFragment(shapes.join("")); } // ── Batch & Quick APIs ────────────────────────────────────────────────── @@ -7727,7 +8615,9 @@ export interface SlideConfig { export function addSlidesFromConfig(pres: Pres, configs: SlideConfig[]): void { _requirePres(pres, "addSlidesFromConfig"); if (!Array.isArray(configs)) { - throw new Error("addSlidesFromConfig: 'configs' must be an array of slide configurations"); + throw new Error( + "addSlidesFromConfig: 'configs' must be an array of slide configurations", + ); } for (const config of configs) { @@ -7790,11 +8680,28 @@ export interface QuickSection { /** Slides in this section */ slides: Array< | { type: "content"; title: string; items: string[] | string } - | { type: "stats"; title: string; stats: Array<{ value: string; label: string }> } + | { + type: "stats"; + title: string; + stats: Array<{ value: string; label: string }>; + } | { type: "quote"; quote: string; author?: string; role?: string } - | { type: "comparison"; title: string; leftTitle: string; rightTitle: string; leftItems: string[] | string; rightItems: string[] | string } + | { + type: "comparison"; + title: string; + leftTitle: string; + rightTitle: string; + leftItems: string[] | string; + rightItems: string[] | string; + } | { type: "bigNumber"; number: string; unit?: string; label?: string } - | { type: "hero"; title: string; subtitle?: string; image: Uint8Array; imageFormat?: string } + | { + type: "hero"; + title: string; + subtitle?: string; + image: Uint8Array; + imageFormat?: string; + } >; } @@ -7931,20 +8838,21 @@ export function quickDeck(config: QuickDeckConfig): Pres { * * @example * // Single image - * const imgXml = await fetchAndEmbed(pres, { + * const img = fetchAndEmbed(pres, { * url: "https://example.com/photo.jpg", - * x: 1, y: 1, w: 4, h: 3 + * x: 1, y: 1, w: 4, h: 3, + * fetchFn: fetchBinary * }); - * customSlide(pres, { shapes: imgXml + textBox({...}) }); + * customSlide(pres, { shapes: [img, textBox({...})] }); * * @example * // With fetch plugin * import { fetchBinary } from "host:fetch"; - * const imgXml = await fetchAndEmbed(pres, { + * const img = fetchAndEmbed(pres, { * url: "https://cdn.example.com/hero.jpg", * x: 0, y: 0, w: 13.333, h: 7.5, * fit: "cover", - * fetchFn: fetchBinary // Pass the fetch function + * fetchFn: fetchBinary * }); * * @param {Object} pres - Presentation object @@ -7957,7 +8865,7 @@ export function quickDeck(config: QuickDeckConfig): Pres { * @param {string} [opts.format] - Image format (auto-detected from URL if omitted) * @param {string} [opts.fit] - Fit mode: 'stretch', 'contain', 'cover' * @param {Function} opts.fetchFn - Fetch function (e.g., fetchBinary from host:fetch) - * @returns {string} Image XML fragment for use in shapes + * @returns {ShapeFragment} Branded image shape fragment */ export function fetchAndEmbed( pres: Pres, @@ -7971,7 +8879,7 @@ export function fetchAndEmbed( fit?: "stretch" | "contain" | "cover"; fetchFn: (url: string) => Uint8Array; }, -): string { +): ShapeFragment { _requirePres(pres, "fetchAndEmbed"); if (!opts.url) { throw new Error("fetchAndEmbed: 'url' is required"); @@ -8001,7 +8909,7 @@ export function fetchAndEmbed( ); } - // Embed it + // Embed it — return ShapeFragment directly (embedImage returns ShapeFragment) return embedImage(pres, { data, x: opts.x, @@ -8027,13 +8935,13 @@ export function fetchAndEmbed( * ], * fetchBatchFn: fetchBinaryBatch * }); - * // images = [{ url, xml }, { url, xml }, { url, xml }] or [{ url, error }, ...] + * // images = [{ url, shape }, { url, shape }, { url, shape }] or [{ url, error }, ...] * * @param {Object} pres - Presentation object * @param {Object} opts - Options * @param {Array} opts.items - Array of {url, x, y, w, h, format?, fit?} * @param {Function} opts.fetchBatchFn - Batch fetch function (fetchBinaryBatch from host:fetch) - * @returns {Array} Array of {url, xml} or {url, error} for each item + * @returns {Array} Array of {url, shape: ShapeFragment} or {url, error} for each item */ export function fetchAndEmbedBatch( pres: Pres, @@ -8047,9 +8955,11 @@ export function fetchAndEmbedBatch( format?: string; fit?: "stretch" | "contain" | "cover"; }>; - fetchBatchFn: (urls: string[]) => Array<{ url: string; data?: Uint8Array; error?: string }>; + fetchBatchFn: ( + urls: string[], + ) => Array<{ url: string; data?: Uint8Array; error?: string }>; }, -): Array<{ url: string; xml?: string; error?: string }> { +): Array<{ url: string; shape?: ShapeFragment; error?: string }> { _requirePres(pres, "fetchAndEmbedBatch"); if (!Array.isArray(opts.items) || opts.items.length === 0) { throw new Error("fetchAndEmbedBatch: 'items' must be a non-empty array"); @@ -8089,7 +8999,7 @@ export function fetchAndEmbedBatch( } try { - const xml = embedImage(pres, { + const shape = embedImage(pres, { data: result.data, x: item.x, y: item.y, @@ -8098,7 +9008,7 @@ export function fetchAndEmbedBatch( format, fit: item.fit, }); - return { url: result.url, xml }; + return { url: result.url, shape }; } catch (e) { return { url: result.url, error: String(e) }; } diff --git a/builtin-modules/src/shared-state.ts b/builtin-modules/src/shared-state.ts index 99a8bf6..f40e739 100644 --- a/builtin-modules/src/shared-state.ts +++ b/builtin-modules/src/shared-state.ts @@ -147,7 +147,10 @@ export function getSize(key: string): number { * Useful for debugging memory usage and finding large values. * @returns Object with { totalBytes, entries: [{key, bytes}...] } sorted by size descending */ -export function stats(): { totalBytes: number; entries: Array<{ key: string; bytes: number }> } { +export function stats(): { + totalBytes: number; + entries: Array<{ key: string; bytes: number }>; +} { const entries: Array<{ key: string; bytes: number }> = []; let totalBytes = 0; diff --git a/builtin-modules/src/types/ha-modules.d.ts b/builtin-modules/src/types/ha-modules.d.ts index a16a77d..62e93ef 100644 --- a/builtin-modules/src/types/ha-modules.d.ts +++ b/builtin-modules/src/types/ha-modules.d.ts @@ -338,6 +338,37 @@ declare module "ha:ooxml-core" { * @returns True if the colour is dark */ export declare function isDark(hex: string): boolean; + /** + * Opaque shape fragment produced by official shape builders. + * Cannot be constructed from raw strings by LLM code. + * + * Internal code can read `._xml`; external (LLM) code treats this as opaque. + */ + export interface ShapeFragment { + /** @internal Raw OOXML XML for this shape element. */ + readonly _xml: string; + /** Returns the internal XML (for string concatenation in internal code). */ + toString(): string; + } + /** + * Create a branded ShapeFragment wrapping validated XML. + * Called internally by shape builder functions (textBox, rect, table, etc.). + * Underscore-prefixed to signal internal-only — LLMs should use builder + * functions (textBox, rect, etc.) not this directly. + * @internal + */ + export declare function _createShapeFragment(xml: string): ShapeFragment; + /** + * Check whether a value is a genuine ShapeFragment from a builder function. + * Uses the private symbol brand — cannot be forged by LLM code. + */ + export declare function isShapeFragment(x: unknown): x is ShapeFragment; + /** + * Convert an array of ShapeFragments to a single XML string. + * Validates that every element is a genuine branded ShapeFragment. + * @throws If any element is not a ShapeFragment + */ + export declare function fragmentsToXml(fragments: ShapeFragment | ShapeFragment[]): string; /** * Get the next unique shape ID. * Used by shape-generating functions to ensure unique IDs within a slide. @@ -383,6 +414,12 @@ declare module "ha:ooxml-core" { } declare module "ha:pptx-charts" { + /** Maximum charts per presentation deck. */ + export declare const MAX_CHARTS_PER_DECK = 50; + /** Maximum data series per chart (Excel column reference limit B–Y). */ + export declare const MAX_SERIES_PER_CHART = 24; + /** Maximum categories (X-axis labels) per chart. */ + export declare const MAX_CATEGORIES_PER_CHART = 100; export interface ChartSeries { /** Series name (appears in legend). REQUIRED. */ name: string; @@ -574,6 +611,9 @@ declare module "ha:pptx-charts" { h?: number; } export interface EmbedChartResult { + /** ShapeFragment for use in customSlide shapes array. */ + shape: ShapeFragment; + /** @internal Raw shape XML string (kept for internal compatibility). */ shapeXml: string; zipEntries: Array<{ name: string; @@ -581,7 +621,7 @@ declare module "ha:pptx-charts" { }>; chartRelId: string; chartIndex: number; - /** Returns shapeXml when converted to string (e.g., in string concatenation). */ + /** @deprecated Throws error — use .shape instead. */ toString(): string; } interface PresentationWithCharts { @@ -737,7 +777,7 @@ declare module "ha:pptx-tables" { * @param opts.style.headerFontSize - Header font size in pt * @returns Shape XML fragment for use in slide body */ - export declare function table(opts: TableOptions): string; + export declare function table(opts: TableOptions): ShapeFragment; export interface KVItem { key: string; value: string; @@ -766,7 +806,7 @@ declare module "ha:pptx-tables" { * @param opts - KV table options: { x?, y?, w?, items: Array<{key, value}>, theme?, style? } * @returns Shape XML fragment */ - export declare function kvTable(opts: KVTableOptions): string; + export declare function kvTable(opts: KVTableOptions): ShapeFragment; export interface ComparisonOption { /** Column header name */ name: string; @@ -813,7 +853,7 @@ declare module "ha:pptx-tables" { * @param opts - REQUIRED: { features: string[], options: Array<{name: string, values: boolean[]}> }. Optional: x?, y?, w?, theme?, style? * @returns Shape XML fragment */ - export declare function comparisonTable(opts: ComparisonTableOptions): string; + export declare function comparisonTable(opts: ComparisonTableOptions): ShapeFragment; export interface TimelineItem { /** Phase/milestone label */ label: string; @@ -846,7 +886,7 @@ declare module "ha:pptx-tables" { * @param opts - Timeline options: { x?, y?, w?, items: Array<{label, description?, color?}>, theme?, style? } * @returns Shape XML fragment (uses table layout) */ - export declare function timeline(opts: TimelineOptions): string; + export declare function timeline(opts: TimelineOptions): ShapeFragment; } declare module "ha:pptx" { @@ -902,7 +942,7 @@ declare module "ha:pptx" { export interface Presentation { theme: Theme; slideCount: number; - addBody(shapes: string | string[], opts?: SlideOptions): void; + addBody(shapes: ShapeFragment | ShapeFragment[], opts?: SlideOptions): void; build(): Array<{ name: string; data: string | Uint8Array; @@ -1393,7 +1433,8 @@ declare module "ha:pptx" { extraItems?: string[] | string; } export interface CustomSlideOptions { - shapes: string; + /** Array of ShapeFragment objects from shape builders (textBox, rect, table, etc.). REQUIRED. */ + shapes: ShapeFragment | ShapeFragment[]; background?: string | GradientSpec; transition?: string; transitionDuration?: number; @@ -1580,13 +1621,14 @@ declare module "ha:pptx" { fontSize?: number; } export { type Theme }; + export { type ShapeFragment, isShapeFragment, fragmentsToXml }; export { table, kvTable, comparisonTable, timeline, TABLE_STYLES, } from "ha:pptx-tables"; export { contrastRatio }; export { getThemeNames }; export { inches, fontSize } from "ha:ooxml-core"; /** * Create a solid fill XML element. - * Use for custom slide backgrounds via pres.addSlide(solidFill('000000'), shapes). + * Use for shape fills or customSlide({ background }) backgrounds. * @param {string} color - Hex color (6 digits, no #) * @param {number} [opacity] - Opacity from 0 (transparent) to 1 (opaque). Omit for fully opaque. * @returns {string} Solid fill XML @@ -1617,9 +1659,9 @@ declare module "ha:pptx" { * @param {string} [opts.background] - Fill color (hex) * @param {number} [opts.lineSpacing] - Line spacing in points * @param {boolean} [opts.autoFit] - Auto-scale fontSize to fit text in shape. Use when text length is variable. - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function textBox(opts: TextBoxOptions): string; + export declare function textBox(opts: TextBoxOptions): ShapeFragment; /** * Create a colored rectangle with optional text. * @param {Object} opts @@ -1636,9 +1678,9 @@ declare module "ha:pptx" { * @param {number} [opts.cornerRadius] - Corner radius in points * @param {string} [opts.borderColor] - Border color * @param {number} [opts.borderWidth=1] - Border width in points - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function rect(opts: RectOptions): string; + export declare function rect(opts: RectOptions): ShapeFragment; /** * Create a bulleted list. * @param {Object} opts @@ -1651,9 +1693,9 @@ declare module "ha:pptx" { * @param {string} [opts.color] - Text color * @param {string} [opts.bulletColor] - Bullet color * @param {number} [opts.lineSpacing=24] - Line spacing - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function bulletList(opts: BulletListOptions): string; + export declare function bulletList(opts: BulletListOptions): ShapeFragment; /** * Create a numbered list. * @param {Object} opts @@ -1666,9 +1708,9 @@ declare module "ha:pptx" { * @param {string} [opts.color] - Text color * @param {number} [opts.lineSpacing=24] - Line spacing * @param {number} [opts.startAt=1] - Starting number - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function numberedList(opts: NumberedListOptions): string; + export declare function numberedList(opts: NumberedListOptions): ShapeFragment; /** * Create an image placeholder (colored rect with label). * Use this until binary image embedding is supported. @@ -1680,9 +1722,9 @@ declare module "ha:pptx" { * @param {string} [opts.label='Image'] - Placeholder label * @param {string} [opts.fill='3D4450'] - Background color (dark gray) * @param {string} [opts.color='B0B8C0'] - Label color (light gray, passes WCAG AA on 3D4450) - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function imagePlaceholder(opts: ImagePlaceholderOptions): string; + export declare function imagePlaceholder(opts: ImagePlaceholderOptions): ShapeFragment; /** * Create a big metric display (number + label stacked). * @param {Object} opts @@ -1698,9 +1740,9 @@ declare module "ha:pptx" { * @param {string} [opts.labelColor] - Label text color (hex). OMIT to auto-select against background. * @param {string} [opts.background] - Background fill * @param {boolean} [opts.forceColor] - Set true to bypass WCAG contrast validation for valueColor/labelColor. - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function statBox(opts: StatBoxOptions): string; + export declare function statBox(opts: StatBoxOptions): ShapeFragment; /** * Create a line between two points. * @param {Object} opts @@ -1711,9 +1753,9 @@ declare module "ha:pptx" { * @param {string} [opts.color='666666'] - Line color (hex) * @param {number} [opts.width=1.5] - Line width in points * @param {string} [opts.dash] - Dash style: 'solid', 'dash', 'dot', 'dashDot' - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function line(opts: LineOptions): string; + export declare function line(opts: LineOptions): ShapeFragment; /** * Create an arrow (line with arrowhead) between two points. * @param {Object} opts @@ -1726,9 +1768,9 @@ declare module "ha:pptx" { * @param {string} [opts.headType='triangle'] - Arrowhead: 'triangle', 'stealth', 'diamond', 'oval', 'arrow' * @param {boolean} [opts.bothEnds=false] - Arrowhead on both ends * @param {string} [opts.dash] - Dash style: 'solid', 'dash', 'dot', 'dashDot' - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function arrow(opts: ArrowOptions): string; + export declare function arrow(opts: ArrowOptions): ShapeFragment; /** * Create a circle or ellipse shape. * @param {Object} opts @@ -1742,9 +1784,9 @@ declare module "ha:pptx" { * @param {string} [opts.color='FFFFFF'] - Text color * @param {string} [opts.borderColor] - Border color * @param {number} [opts.borderWidth=1] - Border width in points - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function circle(opts: CircleOptions): string; + export declare function circle(opts: CircleOptions): ShapeFragment; /** * Create a callout box — rounded rectangle with accent left border. * Good for highlighting insights, quotes, or key takeaways. @@ -1758,9 +1800,9 @@ declare module "ha:pptx" { * @param {string} [opts.background='F5F5F5'] - Fill color * @param {number} [opts.fontSize=14] - Font size * @param {string} [opts.color] - Text color (hex). OMIT to auto-select a readable colour against the background. Do NOT hardcode. - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function callout(opts: CalloutOptions): string; + export declare function callout(opts: CalloutOptions): ShapeFragment; /** * Create a preset shape icon. * @@ -1798,9 +1840,9 @@ declare module "ha:pptx" { * @param {string} [opts.text] - Optional text inside the shape * @param {number} [opts.fontSize=12] - Text font size * @param {string} [opts.color='FFFFFF'] - Text color - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function icon(opts: IconOptions): string; + export declare function icon(opts: IconOptions): ShapeFragment; /** * Create a shape from an SVG path string. * Enables custom icons, logos, and diagrams using standard SVG path data. @@ -1835,9 +1877,9 @@ declare module "ha:pptx" { * @param {string} [opts.fill] - Fill color (hex, e.g. '2196F3') * @param {string} [opts.stroke] - Stroke color (hex) * @param {number} [opts.strokeWidth=1] - Stroke width in points - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function svgPath(opts: SvgPathOptions): string; + export declare function svgPath(opts: SvgPathOptions): ShapeFragment; /** * Create a gradient fill XML fragment for use in shapes. * Supports transparency for cinematic photo overlays (e.g., transparent-to-black). @@ -1923,9 +1965,9 @@ declare module "ha:pptx" { * @param {string} [opts.align='l'] - Paragraph alignment ('l', 'ctr', 'r') * @param {string} [opts.valign='t'] - Vertical alignment ('t', 'ctr', 'b') * @param {string} [opts.background] - Fill color (hex) - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function richText(opts: RichTextOptions): string; + export declare function richText(opts: RichTextOptions): ShapeFragment; /** Options for panel() composite shape */ export interface PanelOptions { /** X position in inches */ @@ -1992,7 +2034,7 @@ declare module "ha:pptx" { * @param opts - Panel options * @returns Shape XML fragments for all panel elements */ - export declare function panel(opts: PanelOptions): string; + export declare function panel(opts: PanelOptions): ShapeFragment; /** Options for card() composite shape */ export interface CardOptions extends PanelOptions { /** Accent color for top border (hex). If set, adds a colored stripe at top */ @@ -2017,7 +2059,7 @@ declare module "ha:pptx" { * @param opts - Card options * @returns Shape XML fragments */ - export declare function card(opts: CardOptions): string; + export declare function card(opts: CardOptions): ShapeFragment; /** * Create a text box with a clickable hyperlink. * The entire text box is clickable. For inline hyperlinks within @@ -2034,9 +2076,9 @@ declare module "ha:pptx" { * @param {string} [opts.color='2196F3'] - Text color (default blue) * @param {boolean} [opts.underline=true] - Underline text * @param {Object} pres - Presentation builder (needed to register the link relationship) - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function hyperlink(opts: HyperlinkOptions, pres: PresentationInternal): string; + export declare function hyperlink(opts: HyperlinkOptions, pres: PresentationInternal): ShapeFragment; /** Image dimensions in pixels */ export interface ImageDimensions { width: number; @@ -2100,9 +2142,9 @@ declare module "ha:pptx" { * @param {string} [opts.format='png'] - Image format: 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg' * @param {string} [opts.fit='stretch'] - How to fit image: 'stretch' (distort to fill), 'contain' (fit within, may letterbox), 'cover' (fill, may crop) * @param {string} [opts.name] - Optional image name (for the ZIP path) - * @returns {string} Shape XML fragment for use in slide body + * @returns {ShapeFragment} Branded shape fragment for use in slide body */ - export declare function embedImage(pres: PresentationInternal, opts: EmbedImageOptions): string; + export declare function embedImage(pres: PresentationInternal, opts: EmbedImageOptions): ShapeFragment; /** * Helper to embed an image from a URL with auto-detected format. * This combines readBinary() and embedImage() into a simpler workflow. @@ -2129,11 +2171,11 @@ declare module "ha:pptx" { * @param {number} opts.w - Width in inches * @param {number} opts.h - Height in inches * @param {string} [opts.format] - Override format detection (png, jpg, gif, etc.) - * @returns {string} Shape XML fragment for use in slide body + * @returns {ShapeFragment} Branded shape fragment for use in slide body */ export declare function embedImageFromUrl(pres: PresentationInternal, opts: EmbedImageOptions & { url: string; - }): string; + }): ShapeFragment; /** Slide width in inches (16:9 aspect ratio). */ export declare const SLIDE_WIDTH_INCHES = 13.333; /** Slide height in inches (16:9 aspect ratio). */ @@ -2168,9 +2210,7 @@ declare module "ha:pptx" { * @param items - Array of shape XML strings or objects with toString() * @returns Combined XML string */ - export declare function shapes(items: Array): string; + export declare function shapes(items: Array): ShapeFragment; /** * Calculate positions for items in equal-width columns. * Useful for stat boxes, image cards, or any side-by-side layout. @@ -2241,9 +2281,9 @@ declare module "ha:pptx" { * @param {number} [opts.y=0] - Y position in inches * @param {number} [opts.w] - Width in inches (default: full slide width) * @param {number} [opts.h] - Height in inches (default: full slide height) - * @returns {string} OOXML shape string + * @returns {ShapeFragment} Branded shape fragment */ - export declare function overlay(opts?: OverlayOptions): string; + export declare function overlay(opts?: OverlayOptions): ShapeFragment; /** * Create a gradient overlay for cinematic effects. * Use for half-fades, vignettes, or directional darkening on image slides. @@ -2271,9 +2311,9 @@ declare module "ha:pptx" { * @param {number} [opts.y=0] - Y position in inches * @param {number} [opts.w] - Width in inches (default full slide) * @param {number} [opts.h] - Height in inches (default full slide) - * @returns {string} OOXML shape string + * @returns {ShapeFragment} Branded shape fragment */ - export declare function gradientOverlay(opts?: GradientOverlayOptions): string; + export declare function gradientOverlay(opts?: GradientOverlayOptions): ShapeFragment; /** * Create a full-bleed background image that covers the entire slide. * Use with customSlide to create hero slides with image backgrounds. @@ -2290,22 +2330,22 @@ declare module "ha:pptx" { * @param {Object} pres - Presentation object from createPresentation() * @param {Uint8Array} data - Image data (from fetchBinary, readBinary, or shared-state) * @param {string} [format='jpg'] - Image format (jpg, png, gif, webp, etc.) - * @returns {string} OOXML shape string for a full-slide image + * @returns {ShapeFragment} Branded shape fragment for a full-slide image */ - export declare function backgroundImage(pres: PresentationInternal, data: Uint8Array, format?: string): string; + export declare function backgroundImage(pres: PresentationInternal, data: Uint8Array, format?: string): ShapeFragment; /** * Create a gradient background for slides. - * Use with pres.addSlide() or as defaultBackground in createPresentation(). + * Use with customSlide({ background }) or as defaultBackground in createPresentation(). * * @param {string} color1 - Start color (hex, e.g. '000000') * @param {string} color2 - End color (hex, e.g. '1a1a2e') * @param {number} [angle=270] - Gradient angle in degrees (0=right, 90=down, 180=left, 270=up) - * @returns {string} Background XML for use with pres.addSlide() + * @returns {string} Background XML for use with customSlide() * * @example * // Vertical gradient (top to bottom) - * const bg = gradientBg('000000', '1a1a2e', 180); - * pres.addSlide(bg, shapes); + * const pres = createPresentation({ theme: 'brutalist' }); + * customSlide(pres, { shapes: [...], background: '000000' }); * * @example * // As default background for all slides @@ -2315,6 +2355,19 @@ declare module "ha:pptx" { * }); */ export declare function gradientBg(color1: string, color2: string, angle?: number): string; + export interface ValidationIssue { + code: string; + severity: "error" | "warn"; + message: string; + part?: string; + slideIndex?: number; + hint?: string; + } + export interface ValidationResult { + ok: boolean; + errors: ValidationIssue[]; + warnings: ValidationIssue[]; + } /** * Create a new presentation builder. * @@ -2331,11 +2384,12 @@ declare module "ha:pptx" { * titleSlide(pres, { title: 'My Title' }); * contentSlide(pres, { title: 'Content', bullets: ['Point 1', 'Point 2'] }); * - * // For CUSTOM layouts, use pres.addSlide() directly: - * const bg = solidFill(pres.theme.bg); - * const shapes = textBox({x: 1, y: 1, w: 8, h: 1, text: 'Custom text'}) + - * rect({x: 1, y: 3, w: 4, h: 2, fill: pres.theme.accent1}); - * pres.addSlide(bg, shapes, { transition: 'fade' }); + * // For CUSTOM layouts, use customSlide(): + * customSlide(pres, { + * shapes: [textBox({x: 1, y: 1, w: 8, h: 1, text: 'Custom text'}), + * rect({x: 1, y: 3, w: 4, h: 2, fill: pres.theme.accent1})], + * transition: 'fade' + * }); * * // Build final file * const zip = pres.buildZip(); @@ -2423,12 +2477,19 @@ declare module "ha:pptx" { * pres.addBody(textBox({x:1, y:1, w:8, h:1, text:'Hello'})); * * // With solid background: - * pres.addBody(shapes, { background: '0D1117', transition: 'fade' }); + * pres.addBody([shape1, shape2], { background: '0D1117', transition: 'fade' }); * * // With gradient background: - * pres.addBody(shapes, { background: {color1: '000000', color2: '1a1a2e', angle: 180} }); + * pres.addBody([shape1], { background: {color1: '000000', color2: '1a1a2e', angle: 180} }); */ - addBody(shapesXml: string | string[], slideOpts?: SlideOptions): void; + addBody(shapesInput: ShapeFragment | ShapeFragment[] | string | string[], slideOpts?: SlideOptions): void; + /** + * Internal: add shapes (as pre-validated XML string) to a new slide. + * Resolves background from per-slide > defaultBackground > theme. + * Not on the Presentation interface — internal use only. + * @internal + */ + _addBodyRaw(shapesStr: string, slideOpts?: SlideOptions): void; /** * Insert a slide at a specific index. Existing slides shift right. * @param {number} index - Position to insert (0-based). Clamped to valid range. @@ -2714,17 +2775,18 @@ declare module "ha:pptx" { * Add a blank slide with just the theme background (NO content). * * ⚠️ WARNING: This creates an EMPTY slide. You CANNOT add shapes to it later. - * For custom layouts with shapes, use pres.addSlide() directly instead: + * For custom layouts with shapes, use customSlide() instead: * * @example * // DON'T do this — blankSlide creates empty slide with no way to add content: * blankSlide(pres); // Creates empty slide, cannot add shapes after * - * // DO this instead — use addSlide for custom layouts: - * const bg = solidFill(pres.theme.bg); - * const shapes = textBox({x: 1, y: 1, w: 8, h: 1, text: 'Custom slide'}) + - * rect({x: 1, y: 3, w: 4, h: 2, fill: pres.theme.accent1}); - * pres.addSlide(bg, shapes, { transition: 'fade' }); + * // DO this instead — use customSlide for custom layouts: + * customSlide(pres, { + * shapes: [textBox({x: 1, y: 1, w: 8, h: 1, text: 'Custom slide'}), + * rect({x: 1, y: 3, w: 4, h: 2, fill: pres.theme.accent1})], + * transition: 'fade' + * }); * * @param {Object} pres - Presentation object from createPresentation(). REQUIRED as first param. * @returns {void} @@ -3230,9 +3292,9 @@ declare module "ha:pptx" { * @param {string} [opts.titleColor='8B949E'] - Title color * @param {boolean} [opts.lineNumbers=false] - Show line numbers * @param {number} [opts.cornerRadius=4] - Corner radius in points - * @returns {string} Shape XML fragment + * @returns {ShapeFragment} Branded shape fragment */ - export declare function codeBlock(opts: CodeBlockOptions): string; + export declare function codeBlock(opts: CodeBlockOptions): ShapeFragment; /** * Slide configuration for batch creation. * Each object describes one slide using a declarative config. @@ -3364,20 +3426,21 @@ declare module "ha:pptx" { * * @example * // Single image - * const imgXml = await fetchAndEmbed(pres, { + * const img = fetchAndEmbed(pres, { * url: "https://example.com/photo.jpg", - * x: 1, y: 1, w: 4, h: 3 + * x: 1, y: 1, w: 4, h: 3, + * fetchFn: fetchBinary * }); - * customSlide(pres, { shapes: imgXml + textBox({...}) }); + * customSlide(pres, { shapes: [img, textBox({...})] }); * * @example * // With fetch plugin * import { fetchBinary } from "host:fetch"; - * const imgXml = await fetchAndEmbed(pres, { + * const img = fetchAndEmbed(pres, { * url: "https://cdn.example.com/hero.jpg", * x: 0, y: 0, w: 13.333, h: 7.5, * fit: "cover", - * fetchFn: fetchBinary // Pass the fetch function + * fetchFn: fetchBinary * }); * * @param {Object} pres - Presentation object @@ -3390,7 +3453,7 @@ declare module "ha:pptx" { * @param {string} [opts.format] - Image format (auto-detected from URL if omitted) * @param {string} [opts.fit] - Fit mode: 'stretch', 'contain', 'cover' * @param {Function} opts.fetchFn - Fetch function (e.g., fetchBinary from host:fetch) - * @returns {string} Image XML fragment for use in shapes + * @returns {ShapeFragment} Branded image shape fragment */ export declare function fetchAndEmbed(pres: Pres, opts: { url: string; @@ -3401,7 +3464,7 @@ declare module "ha:pptx" { format?: string; fit?: "stretch" | "contain" | "cover"; fetchFn: (url: string) => Uint8Array; - }): string; + }): ShapeFragment; /** * Fetch multiple images and embed them all, returning XML fragments. * Uses fetchBinaryBatch for efficient parallel downloads when maxParallelFetches > 1. @@ -3416,13 +3479,13 @@ declare module "ha:pptx" { * ], * fetchBatchFn: fetchBinaryBatch * }); - * // images = [{ url, xml }, { url, xml }, { url, xml }] or [{ url, error }, ...] + * // images = [{ url, shape }, { url, shape }, { url, shape }] or [{ url, error }, ...] * * @param {Object} pres - Presentation object * @param {Object} opts - Options * @param {Array} opts.items - Array of {url, x, y, w, h, format?, fit?} * @param {Function} opts.fetchBatchFn - Batch fetch function (fetchBinaryBatch from host:fetch) - * @returns {Array} Array of {url, xml} or {url, error} for each item + * @returns {Array} Array of {url, shape: ShapeFragment} or {url, error} for each item */ export declare function fetchAndEmbedBatch(pres: Pres, opts: { items: Array<{ @@ -3441,7 +3504,7 @@ declare module "ha:pptx" { }>; }): Array<{ url: string; - xml?: string; + shape?: ShapeFragment; error?: string; }>; } diff --git a/skills/pptx-expert/SKILL.md b/skills/pptx-expert/SKILL.md index e365fac..653617d 100644 --- a/skills/pptx-expert/SKILL.md +++ b/skills/pptx-expert/SKILL.md @@ -15,7 +15,9 @@ patterns: - image-embed - file-generation antiPatterns: - - Don't write inline OOXML XML — use ha:pptx module functions + - Don't write inline OOXML XML — use ha:pptx module shape builder functions + - Don't concatenate ShapeFragment objects with + — pass as arrays to customSlide + - Don't call .toString() on chart results — use the .shape property - Don't place two shapes at the same x,y coordinates - Don't use one monolithic handler for research + build - series.name is REQUIRED for all chart data series @@ -45,6 +47,31 @@ You are an expert at building professional, polished PowerPoint presentations inside the Hyperlight sandbox. You have deep knowledge of the PPTX system modules and always produce OOXML-compliant, visually clean output. +## CRITICAL: ShapeFragment API + +All shape builder functions (`textBox`, `rect`, `bulletList`, `statBox`, `callout`, etc.) return `ShapeFragment` objects — **NOT raw XML strings**. + +- Pass ShapeFragment objects directly to slide functions: `contentSlide(pres, { body: [shape1, shape2] })` +- `customSlide` accepts `ShapeFragment | ShapeFragment[]` — never raw strings +- Do NOT concatenate fragments with `+` — pass them as arrays +- Chart `embedChart()` returns `{ shape: ShapeFragment, ... }` — use `.shape` to get the fragment +- Table functions (`table`, `kvTable`, etc.) also return ShapeFragment +- `isShapeFragment(obj)` checks if a value is a valid fragment +- `fragmentsToXml(fragments)` converts to XML (internal use only) + +## Chart Complexity Caps + +- Max **50 charts** per deck — split into multiple presentations if needed +- Max **24 series** per chart (Excel column reference limit) +- Max **100 categories** per chart — group data or paginate +- Pie charts: max **100 slices** — group small values into "Other" + +## Notes Policy + +- Speaker notes are **plain text only** — no HTML, XML, or markup +- Auto-sanitized: invalid XML characters stripped, truncated to 12,000 chars +- Use `markdownToNotes(md)` to convert markdown to plain text for notes + ## CRITICAL: State Management Rules ### Small Decks (≤10 slides, no images): Single Handler @@ -157,7 +184,7 @@ const grid = layoutGrid(6, { cols: 3, margin: 0.5, gap: 0.25, y: 1.5 }); // Dark overlay for image backgrounds const bg = backgroundImage(pres, imgData, 'jpg'); -customSlide(pres, { shapes: bg + overlay({opacity: 0.6}) + textBox({...}) }); +customSlide(pres, { shapes: [bg, overlay({opacity: 0.6}), textBox({...})] }); ``` ## Slide Manipulation (Reorder, Insert, Delete) @@ -280,7 +307,7 @@ To modify an existing handler without losing shared-state: - `overlay({opacity, color})` — dark overlay XML for image backgrounds - `SLIDE_WIDTH_INCHES` (13.333), `SLIDE_HEIGHT_INCHES` (7.5) -### Shapes +### Shapes (all return ShapeFragment) - `textBox({x, y, w, h, text, fontSize, bold, align})` — text (color auto-selected by theme) - `rect({x, y, w, h, fill, text, cornerRadius})` — rectangles @@ -306,7 +333,7 @@ To modify an existing handler without losing shared-state: - `comboChart({categories, barSeries, lineSeries, title})` — combo charts - Use `chartSlide(pres, {title, chart})` to embed — NO manual chart wiring needed -### Tables +### Tables (all return ShapeFragment) **CRITICAL: Pass `theme: pres.theme` to all table functions for proper text contrast:** @@ -438,4 +465,6 @@ Do NOT use `pres.build()` directly — it returns raw ZIP entries, not bytes. - Nested backticks (`) in template literals — the #1 cause of invisible syntax errors - Storing `pres` object in shared-state without serialize() — methods get stripped - Writing manual fetch read loops — use `fetchBinary()` or `fetchBinaryBatch()` instead +- Concatenating ShapeFragment objects with `+` — pass as arrays instead +- Calling `.toString()` on chart/embed results — use `.shape` property - **Missing `theme: pres.theme` on tables** — causes dark text on dark backgrounds (invisible) diff --git a/tests/docgen-modules.test.ts b/tests/docgen-modules.test.ts index bd8acac..30be812 100644 --- a/tests/docgen-modules.test.ts +++ b/tests/docgen-modules.test.ts @@ -11,6 +11,9 @@ import { describe, it, expect } from "vitest"; const core = await import("../builtin-modules/ooxml-core.js"); +/** Convert ShapeFragment or string to XML string for test assertions */ +const toXml = (v: unknown): string => (typeof v === "string" ? v : String(v)); + describe("ooxml-core", () => { describe("unit conversions", () => { it("should convert inches to EMU", () => { @@ -155,14 +158,16 @@ describe("pptx", () => { describe("textBox", () => { it("should generate shape XML with text", () => { - const xml = pptx.textBox({ - x: 1, - y: 2, - w: 8, - h: 1, - text: "Hello", - fontSize: 24, - }); + const xml = toXml( + pptx.textBox({ + x: 1, + y: 2, + w: 8, + h: 1, + text: "Hello", + fontSize: 24, + }), + ); expect(xml).toContain("p:sp"); expect(xml).toContain("txBox"); expect(xml).toContain("Hello"); @@ -170,97 +175,113 @@ describe("pptx", () => { }); it("should escape XML in text", () => { - const xml = pptx.textBox({ x: 0, y: 0, w: 1, h: 1, text: "A & B" }); + const xml = toXml( + pptx.textBox({ x: 0, y: 0, w: 1, h: 1, text: "A & B" }), + ); expect(xml).toContain("A & B"); expect(xml).not.toContain("A & B"); }); it("should handle array of paragraphs", () => { - const xml = pptx.textBox({ - x: 0, - y: 0, - w: 1, - h: 1, - text: ["Line 1", "Line 2"], - }); + const xml = toXml( + pptx.textBox({ + x: 0, + y: 0, + w: 1, + h: 1, + text: ["Line 1", "Line 2"], + }), + ); expect(xml).toContain("Line 1"); expect(xml).toContain("Line 2"); }); it("should normalize 'center' alignment to 'ctr' (OOXML enum)", () => { - const xml = pptx.textBox({ - x: 0, - y: 0, - w: 4, - h: 1, - text: "Centered", - align: "center", - }); + const xml = toXml( + pptx.textBox({ + x: 0, + y: 0, + w: 4, + h: 1, + text: "Centered", + align: "center", + }), + ); expect(xml).toContain('algn="ctr"'); expect(xml).not.toContain('algn="center"'); }); it("should pass through valid alignment values unchanged", () => { - const xml = pptx.textBox({ - x: 0, - y: 0, - w: 4, - h: 1, - text: "Right", - align: "r", - }); + const xml = toXml( + pptx.textBox({ + x: 0, + y: 0, + w: 4, + h: 1, + text: "Right", + align: "r", + }), + ); expect(xml).toContain('algn="r"'); }); }); describe("rect", () => { it("should generate rectangle with fill", () => { - const xml = pptx.rect({ - x: 1, - y: 2, - w: 3, - h: 1, - fill: "#FF0000", - }); + const xml = toXml( + pptx.rect({ + x: 1, + y: 2, + w: 3, + h: 1, + fill: "#FF0000", + }), + ); expect(xml).toContain("p:sp"); expect(xml).toContain('val="FF0000"'); expect(xml).toContain('prst="rect"'); }); it("should use roundRect when cornerRadius is set", () => { - const xml = pptx.rect({ - x: 0, - y: 0, - w: 1, - h: 1, - fill: "000000", - cornerRadius: 5, - }); + const xml = toXml( + pptx.rect({ + x: 0, + y: 0, + w: 1, + h: 1, + fill: "000000", + cornerRadius: 5, + }), + ); expect(xml).toContain('prst="roundRect"'); }); it("should include text overlay when specified", () => { - const xml = pptx.rect({ - x: 0, - y: 0, - w: 2, - h: 1, - fill: "2196F3", - text: "Label", - }); + const xml = toXml( + pptx.rect({ + x: 0, + y: 0, + w: 2, + h: 1, + fill: "2196F3", + text: "Label", + }), + ); expect(xml).toContain("Label"); }); }); describe("bulletList", () => { it("should generate bulleted items", () => { - const xml = pptx.bulletList({ - x: 1, - y: 2, - w: 8, - h: 4, - items: ["First", "Second", "Third"], - }); + const xml = toXml( + pptx.bulletList({ + x: 1, + y: 2, + w: 8, + h: 4, + items: ["First", "Second", "Third"], + }), + ); expect(xml).toContain("First"); expect(xml).toContain("Second"); expect(xml).toContain("Third"); @@ -268,14 +289,16 @@ describe("pptx", () => { }); it("should produce well-formed XML with bulletColor", () => { - const xml = pptx.bulletList({ - x: 0, - y: 0, - w: 8, - h: 4, - items: ["Item A", "Item B"], - bulletColor: "FF0000", - }); + const xml = toXml( + pptx.bulletList({ + x: 0, + y: 0, + w: 8, + h: 4, + items: ["Item A", "Item B"], + bulletColor: "FF0000", + }), + ); // Every opened tag must close — no mismatched tags // The bullet char must use XML entity, not raw Unicode expect(xml).toContain('char="•"'); @@ -291,15 +314,17 @@ describe("pptx", () => { describe("statBox", () => { it("should generate value + label layout", () => { - const xml = pptx.statBox({ - x: 1, - y: 2, - w: 3, - h: 2, - value: "$2.4M", - label: "Revenue", - valueSize: 36, - }); + const xml = toXml( + pptx.statBox({ + x: 1, + y: 2, + w: 3, + h: 2, + value: "$2.4M", + label: "Revenue", + valueSize: 36, + }), + ); expect(xml).toContain("$2.4M"); expect(xml).toContain("Revenue"); expect(xml).toContain('sz="3600"'); // 36pt @@ -350,110 +375,126 @@ describe("slide builders", () => { describe("line", () => { it("should generate connection shape XML", () => { - const xml = pptx.line({ - x1: 1, - y1: 2, - x2: 5, - y2: 2, - color: "#FF0000", - width: 2, - }); + const xml = toXml( + pptx.line({ + x1: 1, + y1: 2, + x2: 5, + y2: 2, + color: "#FF0000", + width: 2, + }), + ); expect(xml).toContain("p:cxnSp"); expect(xml).toContain('val="FF0000"'); expect(xml).toContain('prst="line"'); }); it("should handle reverse direction (flip)", () => { - const xml = pptx.line({ x1: 5, y1: 3, x2: 1, y2: 1 }); + const xml = toXml(pptx.line({ x1: 5, y1: 3, x2: 1, y2: 1 })); expect(xml).toContain('flipH="1"'); expect(xml).toContain('flipV="1"'); }); it("should support dash styles", () => { - const xml = pptx.line({ - x1: 0, - y1: 0, - x2: 5, - y2: 0, - dash: "dash", - }); + const xml = toXml( + pptx.line({ + x1: 0, + y1: 0, + x2: 5, + y2: 0, + dash: "dash", + }), + ); expect(xml).toContain('prstDash val="dash"'); }); }); describe("arrow", () => { it("should generate line with arrowhead", () => { - const xml = pptx.arrow({ - x1: 1, - y1: 2, - x2: 5, - y2: 4, - color: "2196F3", - }); + const xml = toXml( + pptx.arrow({ + x1: 1, + y1: 2, + x2: 5, + y2: 4, + color: "2196F3", + }), + ); expect(xml).toContain("p:cxnSp"); expect(xml).toContain('type="triangle"'); expect(xml).toContain("a:tailEnd"); }); it("should support both-ends arrowhead", () => { - const xml = pptx.arrow({ - x1: 0, - y1: 0, - x2: 5, - y2: 0, - bothEnds: true, - }); + const xml = toXml( + pptx.arrow({ + x1: 0, + y1: 0, + x2: 5, + y2: 0, + bothEnds: true, + }), + ); expect(xml).toContain("a:headEnd"); expect(xml).toContain("a:tailEnd"); }); it("should support custom head types", () => { - const xml = pptx.arrow({ - x1: 0, - y1: 0, - x2: 5, - y2: 0, - headType: "stealth", - }); + const xml = toXml( + pptx.arrow({ + x1: 0, + y1: 0, + x2: 5, + y2: 0, + headType: "stealth", + }), + ); expect(xml).toContain('type="stealth"'); }); }); describe("circle", () => { it("should generate ellipse shape", () => { - const xml = pptx.circle({ - x: 5, - y: 3, - w: 2, - fill: "4CAF50", - }); + const xml = toXml( + pptx.circle({ + x: 5, + y: 3, + w: 2, + fill: "4CAF50", + }), + ); expect(xml).toContain("p:sp"); expect(xml).toContain('prst="ellipse"'); expect(xml).toContain('val="4CAF50"'); }); it("should include text when specified", () => { - const xml = pptx.circle({ - x: 5, - y: 3, - w: 2, - fill: "FF0000", - text: "OK", - }); + const xml = toXml( + pptx.circle({ + x: 5, + y: 3, + w: 2, + fill: "FF0000", + text: "OK", + }), + ); expect(xml).toContain("OK"); }); }); describe("callout", () => { it("should generate accent bar + text box", () => { - const xml = pptx.callout({ - x: 1, - y: 2, - w: 8, - h: 1.5, - text: "Key insight here", - accentColor: "E91E63", - }); + const xml = toXml( + pptx.callout({ + x: 1, + y: 2, + w: 8, + h: 1.5, + text: "Key insight here", + accentColor: "E91E63", + }), + ); expect(xml).toContain("Key insight here"); expect(xml).toContain('val="E91E63"'); // Should have two shapes (accent bar + main box) @@ -463,19 +504,21 @@ describe("callout", () => { describe("icon", () => { it("should generate preset shape", () => { - const xml = pptx.icon({ - x: 1, - y: 2, - w: 0.5, - shape: "star", - fill: "FFD700", - }); + const xml = toXml( + pptx.icon({ + x: 1, + y: 2, + w: 0.5, + shape: "star", + fill: "FFD700", + }), + ); expect(xml).toContain('prst="star5"'); expect(xml).toContain('val="FFD700"'); }); it("should support heart shape", () => { - const xml = pptx.icon({ x: 0, y: 0, w: 1, shape: "heart" }); + const xml = toXml(pptx.icon({ x: 0, y: 0, w: 1, shape: "heart" })); expect(xml).toContain('prst="heart"'); }); }); @@ -491,18 +534,20 @@ describe("gradientFill", () => { describe("richText", () => { it("should support mixed formatting runs", () => { - const xml = pptx.richText({ - x: 1, - y: 2, - w: 8, - h: 1, - paragraphs: [ - [ - { text: "Hello ", bold: true, color: "FF6666" }, - { text: "World", italic: true, color: "66AAFF" }, + const xml = toXml( + pptx.richText({ + x: 1, + y: 2, + w: 8, + h: 1, + paragraphs: [ + [ + { text: "Hello ", bold: true, color: "FF6666" }, + { text: "World", italic: true, color: "66AAFF" }, + ], ], - ], - }); + }), + ); expect(xml).toContain("Hello "); expect(xml).toContain("World"); expect(xml).toContain('b="1"'); @@ -510,13 +555,15 @@ describe("richText", () => { }); it("should support multiple paragraphs", () => { - const xml = pptx.richText({ - x: 0, - y: 0, - w: 5, - h: 2, - paragraphs: [[{ text: "Line 1" }], [{ text: "Line 2" }]], - }); + const xml = toXml( + pptx.richText({ + x: 0, + y: 0, + w: 5, + h: 2, + paragraphs: [[{ text: "Line 1" }], [{ text: "Line 2" }]], + }), + ); expect((xml.match(//g) || []).length).toBe(2); }); }); @@ -524,16 +571,18 @@ describe("richText", () => { describe("hyperlink", () => { it("should generate clickable text with link relationship", () => { const pres = pptx.createPresentation(); - const xml = pptx.hyperlink( - { - x: 1, - y: 2, - w: 4, - h: 0.5, - text: "Visit GitHub", - url: "https://github.com", - }, - pres, + const xml = toXml( + pptx.hyperlink( + { + x: 1, + y: 2, + w: 4, + h: 0.5, + text: "Visit GitHub", + url: "https://github.com", + }, + pres, + ), ); expect(xml).toContain("Visit GitHub"); expect(xml).toContain("a:hlinkClick"); @@ -584,13 +633,15 @@ describe("hyperlink", () => { describe("numberedList", () => { it("should generate numbered items", () => { - const xml = pptx.numberedList({ - x: 1, - y: 2, - w: 8, - h: 4, - items: ["First", "Second", "Third"], - }); + const xml = toXml( + pptx.numberedList({ + x: 1, + y: 2, + w: 8, + h: 4, + items: ["First", "Second", "Third"], + }), + ); expect(xml).toContain("First"); expect(xml).toContain("Third"); expect(xml).toContain("buAutoNum"); @@ -600,19 +651,21 @@ describe("numberedList", () => { describe("imagePlaceholder", () => { it("should generate placeholder rect", () => { - const xml = pptx.imagePlaceholder({ x: 2, y: 3, w: 5, h: 4 }); + const xml = toXml(pptx.imagePlaceholder({ x: 2, y: 3, w: 5, h: 4 })); expect(xml).toContain("Image"); expect(xml).toContain('prst="roundRect"'); }); it("should accept custom label", () => { - const xml = pptx.imagePlaceholder({ - x: 0, - y: 0, - w: 3, - h: 2, - label: "Logo here", - }); + const xml = toXml( + pptx.imagePlaceholder({ + x: 0, + y: 0, + w: 3, + h: 2, + label: "Logo here", + }), + ); expect(xml).toContain("Logo here"); }); }); @@ -621,14 +674,16 @@ describe("embedImage", () => { it("should generate picture shape XML with blip reference", () => { const pres = pptx.createPresentation(); const fakeImage = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG header - const xml = pptx.embedImage(pres, { - x: 1, - y: 2, - w: 5, - h: 3, - data: fakeImage, - format: "png", - }); + const xml = toXml( + pptx.embedImage(pres, { + x: 1, + y: 2, + w: 5, + h: 3, + data: fakeImage, + format: "png", + }), + ); expect(xml).toContain("p:pic"); expect(xml).toContain("a:blip"); expect(xml).toContain("rIdImage1"); @@ -857,13 +912,15 @@ describe("build", () => { describe("codeBlock", () => { it("should create a code block with monospace font and dark background", () => { - const xml = pptx.codeBlock({ - x: 1, - y: 2, - w: 10, - h: 4, - code: 'fn main() {\n println!("Hello");\n}', - }); + const xml = toXml( + pptx.codeBlock({ + x: 1, + y: 2, + w: 10, + h: 4, + code: 'fn main() {\n println!("Hello");\n}', + }), + ); expect(xml).toContain("Consolas"); // monospace font expect(xml).toContain('val="161B22"'); // dark background expect(xml).toContain('val="E6EDF3"'); // light text @@ -871,42 +928,48 @@ describe("codeBlock", () => { }); it("should support line numbers", () => { - const xml = pptx.codeBlock({ - x: 0, - y: 0, - w: 10, - h: 3, - code: "line one\nline two\nline three", - lineNumbers: true, - }); + const xml = toXml( + pptx.codeBlock({ + x: 0, + y: 0, + w: 10, + h: 3, + code: "line one\nline two\nline three", + lineNumbers: true, + }), + ); expect(xml).toContain("1 line one"); expect(xml).toContain("3 line three"); }); it("should support optional title bar", () => { - const xml = pptx.codeBlock({ - x: 0, - y: 0, - w: 10, - h: 4, - code: "hello()", - title: "example.rs", - }); + const xml = toXml( + pptx.codeBlock({ + x: 0, + y: 0, + w: 10, + h: 4, + code: "hello()", + title: "example.rs", + }), + ); expect(xml).toContain("example.rs"); expect(xml).toContain('val="0D1117"'); // title bar bg }); it("should accept custom colors and font", () => { - const xml = pptx.codeBlock({ - x: 0, - y: 0, - w: 8, - h: 3, - code: "test", - background: "1E1E1E", - color: "D4D4D4", - fontFamily: "Courier New", - }); + const xml = toXml( + pptx.codeBlock({ + x: 0, + y: 0, + w: 8, + h: 3, + code: "test", + background: "1E1E1E", + color: "D4D4D4", + fontFamily: "Courier New", + }), + ); expect(xml).toContain('val="1E1E1E"'); expect(xml).toContain('val="D4D4D4"'); expect(xml).toContain("Courier New"); @@ -1265,16 +1328,18 @@ const tables: any = await import("../builtin-modules/pptx-tables.js"); describe("pptx-tables", () => { describe("table", () => { it("should generate table XML with headers and rows", () => { - const xml = tables.table({ - x: 1, - y: 2, - w: 10, - headers: ["Name", "Value"], - rows: [ - ["CPU", "1000ms"], - ["Heap", "64MB"], - ], - }); + const xml = toXml( + tables.table({ + x: 1, + y: 2, + w: 10, + headers: ["Name", "Value"], + rows: [ + ["CPU", "1000ms"], + ["Heap", "64MB"], + ], + }), + ); expect(xml).toContain("a:tbl"); expect(xml).toContain("Name"); expect(xml).toContain("Value"); @@ -1283,37 +1348,43 @@ describe("pptx-tables", () => { }); it("should escape XML in cell content", () => { - const xml = tables.table({ - x: 0, - y: 0, - w: 5, - headers: ["Test"], - rows: [["A & B"]], - }); + const xml = toXml( + tables.table({ + x: 0, + y: 0, + w: 5, + headers: ["Test"], + rows: [["A & B"]], + }), + ); expect(xml).toContain("A & B"); }); it("should apply custom header styling", () => { - const xml = tables.table({ - x: 0, - y: 0, - w: 5, - headers: ["H1"], - rows: [["D1"]], - style: { headerBg: "FF0000", headerColor: "000000" }, - }); + const xml = toXml( + tables.table({ + x: 0, + y: 0, + w: 5, + headers: ["H1"], + rows: [["D1"]], + style: { headerBg: "FF0000", headerColor: "000000" }, + }), + ); expect(xml).toContain('val="FF0000"'); }); it("should support alternating row colors", () => { - const xml = tables.table({ - x: 0, - y: 0, - w: 5, - headers: ["H"], - rows: [["R1"], ["R2"], ["R3"]], - style: { altRows: true, altRowColor: "EEEEFF" }, - }); + const xml = toXml( + tables.table({ + x: 0, + y: 0, + w: 5, + headers: ["H"], + rows: [["R1"], ["R2"], ["R3"]], + style: { altRows: true, altRowColor: "EEEEFF" }, + }), + ); expect(xml).toContain('val="EEEEFF"'); expect(xml).toContain('bandRow="1"'); }); @@ -1321,15 +1392,17 @@ describe("pptx-tables", () => { describe("kvTable", () => { it("should create a two-column key-value table", () => { - const xml = tables.kvTable({ - x: 1, - y: 2, - w: 6, - items: [ - { key: "CPU", value: "1000ms" }, - { key: "Heap", value: "64MB" }, - ], - }); + const xml = toXml( + tables.kvTable({ + x: 1, + y: 2, + w: 6, + items: [ + { key: "CPU", value: "1000ms" }, + { key: "Heap", value: "64MB" }, + ], + }), + ); expect(xml).toContain("CPU"); expect(xml).toContain("1000ms"); expect(xml).toContain("Heap"); @@ -1339,17 +1412,19 @@ describe("pptx-tables", () => { describe("comparisonTable", () => { it("should generate comparison with check/cross marks", () => { - const xml = tables.comparisonTable({ - x: 1, - y: 2, - w: 10, - features: ["Fast startup", "Low memory", "Sandboxed"], - options: [ - { name: "VMs", values: [false, false, true] }, - { name: "Containers", values: [true, true, false] }, - { name: "Hyperlight", values: [true, true, true] }, - ], - }); + const xml = toXml( + tables.comparisonTable({ + x: 1, + y: 2, + w: 10, + features: ["Fast startup", "Low memory", "Sandboxed"], + options: [ + { name: "VMs", values: [false, false, true] }, + { name: "Containers", values: [true, true, false] }, + { name: "Hyperlight", values: [true, true, true] }, + ], + }), + ); expect(xml).toContain("a:tbl"); expect(xml).toContain("VMs"); expect(xml).toContain("Hyperlight"); @@ -1360,16 +1435,18 @@ describe("pptx-tables", () => { describe("timeline", () => { it("should generate timeline with phases", () => { - const xml = tables.timeline({ - x: 0.5, - y: 3, - w: 12, - items: [ - { label: "Q1", description: "Research" }, - { label: "Q2", description: "Build" }, - { label: "Q3", description: "Launch" }, - ], - }); + const xml = toXml( + tables.timeline({ + x: 0.5, + y: 3, + w: 12, + items: [ + { label: "Q1", description: "Research" }, + { label: "Q2", description: "Build" }, + { label: "Q3", description: "Launch" }, + ], + }), + ); expect(xml).toContain("a:tbl"); expect(xml).toContain("Q1"); expect(xml).toContain("Build"); @@ -1378,14 +1455,16 @@ describe("pptx-tables", () => { describe("border XML", () => { it("should generate well-formed border elements", () => { - const xml = tables.table({ - x: 0, - y: 0, - w: 5, - headers: ["H"], - rows: [["R1"]], - style: { borderColor: "334455" }, - }); + const xml = toXml( + tables.table({ + x: 0, + y: 0, + w: 5, + headers: ["H"], + rows: [["R1"]], + style: { borderColor: "334455" }, + }), + ); // Each lnX element must be self-consistent (no inner ) expect(xml).not.toContain(""); // lnL must open and close correctly @@ -1405,14 +1484,16 @@ describe("pptx-tables", () => { }); it("should place borders BEFORE fill in tcPr (ECMA-376 §21.1.3.17)", () => { - const xml = tables.table({ - x: 0, - y: 0, - w: 5, - headers: ["H"], - rows: [["R1"]], - style: { headerBg: "2196F3", borderColor: "CCCCCC" }, - }); + const xml = toXml( + tables.table({ + x: 0, + y: 0, + w: 5, + headers: ["H"], + rows: [["R1"]], + style: { headerBg: "2196F3", borderColor: "CCCCCC" }, + }), + ); // In every tcPr, lnL must appear before solidFill const tcPrs = xml.match(/]*>.*?<\/a:tcPr>/gs) || []; expect(tcPrs.length).toBeGreaterThan(0); diff --git a/tests/pptx-readability.test.ts b/tests/pptx-readability.test.ts index 775c43d..89ec33f 100644 --- a/tests/pptx-readability.test.ts +++ b/tests/pptx-readability.test.ts @@ -16,6 +16,9 @@ import { describe, it, expect } from "vitest"; const core: any = await import("../builtin-modules/ooxml-core.js"); +/** Convert ShapeFragment or string to XML string for test assertions */ +const toXml = (v: unknown): string => (typeof v === "string" ? v : String(v)); + describe("ooxml-core contrast utilities", () => { describe("luminance", () => { it("should return ~0 for black", () => { @@ -218,80 +221,92 @@ const pptx: any = await import("../builtin-modules/pptx.js"); describe("pptx theme-aware shape defaults", () => { describe("rect", () => { it("should auto-select white text on dark fill", () => { - const xml = pptx.rect({ - x: 0, - y: 0, - w: 2, - h: 1, - fill: "0D1117", - text: "Hello", - }); + const xml = toXml( + pptx.rect({ + x: 0, + y: 0, + w: 2, + h: 1, + fill: "0D1117", + text: "Hello", + }), + ); // Should contain FFFFFF (white) not 333333 (dark) expect(xml).toContain("FFFFFF"); expect(xml).not.toContain("333333"); }); it("should auto-select dark text on light fill", () => { - const xml = pptx.rect({ - x: 0, - y: 0, - w: 2, - h: 1, - fill: "FFFFFF", - text: "Hello", - }); + const xml = toXml( + pptx.rect({ + x: 0, + y: 0, + w: 2, + h: 1, + fill: "FFFFFF", + text: "Hello", + }), + ); expect(xml).toContain("333333"); }); it("should respect explicitly set color", () => { - const xml = pptx.rect({ - x: 0, - y: 0, - w: 2, - h: 1, - fill: "000000", - text: "Hello", - color: "FF0000", - }); + const xml = toXml( + pptx.rect({ + x: 0, + y: 0, + w: 2, + h: 1, + fill: "000000", + text: "Hello", + color: "FF0000", + }), + ); expect(xml).toContain("FF0000"); }); }); describe("callout", () => { it("should auto-select dark text on default light background", () => { - const xml = pptx.callout({ - x: 0, - y: 0, - w: 8, - h: 1, - text: "Insight", - }); + const xml = toXml( + pptx.callout({ + x: 0, + y: 0, + w: 8, + h: 1, + text: "Insight", + }), + ); // Default bg is F5F5F5 (light) — text should be dark expect(xml).toContain("333333"); }); it("should auto-select white text on dark background", () => { - const xml = pptx.callout({ - x: 0, - y: 0, - w: 8, - h: 1, - text: "Insight", - background: "0D1117", - }); + const xml = toXml( + pptx.callout({ + x: 0, + y: 0, + w: 8, + h: 1, + text: "Insight", + background: "0D1117", + }), + ); expect(xml).toContain("FFFFFF"); }); }); describe("circle", () => { it("should auto-select readable text on default blue fill", () => { - const xml = pptx.circle({ - x: 2, - y: 2, - w: 1, - fill: "2196F3", - text: "1", - }); + const xml = toXml( + pptx.circle({ + x: 2, + y: 2, + w: 1, + fill: "2196F3", + text: "1", + }), + ); // 2196F3 (Material Blue) has luminance ~0.29 — dark text (333333) has // higher contrast (4.1:1) than white (3.1:1), so autoTextColor is correct // to pick dark. Verify the auto-selection works (not hardcoded FFFFFF). @@ -299,39 +314,45 @@ describe("pptx theme-aware shape defaults", () => { }); it("should auto-select white text on very dark fill", () => { - const xml = pptx.circle({ - x: 2, - y: 2, - w: 1, - fill: "0D1117", - text: "1", - }); + const xml = toXml( + pptx.circle({ + x: 2, + y: 2, + w: 1, + fill: "0D1117", + text: "1", + }), + ); expect(xml).toContain("FFFFFF"); }); it("should auto-select dark text on light fill", () => { - const xml = pptx.circle({ - x: 2, - y: 2, - w: 1, - fill: "FFFFFF", - text: "1", - }); + const xml = toXml( + pptx.circle({ + x: 2, + y: 2, + w: 1, + fill: "FFFFFF", + text: "1", + }), + ); expect(xml).toContain("333333"); }); }); describe("statBox", () => { it("should auto-select readable text when background is set", () => { - const xml = pptx.statBox({ - x: 0, - y: 0, - w: 3, - h: 2, - value: "42", - label: "Items", - background: "0D1117", - }); + const xml = toXml( + pptx.statBox({ + x: 0, + y: 0, + w: 3, + h: 2, + value: "42", + label: "Items", + background: "0D1117", + }), + ); // Dark bg — text should be white expect(xml).toContain("FFFFFF"); }); @@ -340,14 +361,16 @@ describe("pptx theme-aware shape defaults", () => { // When _activeTheme is set (post-createPresentation), statBox without // a background fill picks up the theme foreground automatically. pptx.createPresentation({ theme: "dark-gradient" }); - const xml = pptx.statBox({ - x: 0, - y: 0, - w: 3, - h: 2, - value: "42", - label: "Items", - }); + const xml = toXml( + pptx.statBox({ + x: 0, + y: 0, + w: 3, + h: 2, + value: "42", + label: "Items", + }), + ); // dark-gradient fg = E6EDF3 — should be used for value + label text expect(xml).toContain("E6EDF3"); }); @@ -355,59 +378,67 @@ describe("pptx theme-aware shape defaults", () => { describe("icon", () => { it("should use theme accent for fill when _theme is passed", () => { - const xml = pptx.icon({ - x: 0, - y: 0, - w: 0.5, - shape: "star", - _theme: { accent1: "58A6FF", subtle: "8B949E" }, - }); + const xml = toXml( + pptx.icon({ + x: 0, + y: 0, + w: 0.5, + shape: "star", + _theme: { accent1: "58A6FF", subtle: "8B949E" }, + }), + ); expect(xml).toContain("58A6FF"); }); it("should fall back to 2196F3 when no theme provided", () => { - const xml = pptx.icon({ - x: 0, - y: 0, - w: 0.5, - shape: "star", - }); + const xml = toXml( + pptx.icon({ + x: 0, + y: 0, + w: 0.5, + shape: "star", + }), + ); expect(xml).toContain("2196F3"); }); }); describe("line", () => { it("should use theme subtle colour when _theme is passed", () => { - const xml = pptx.line({ - x1: 0, - y1: 0, - x2: 5, - y2: 0, - _theme: { subtle: "8B949E" }, - }); + const xml = toXml( + pptx.line({ + x1: 0, + y1: 0, + x2: 5, + y2: 0, + _theme: { subtle: "8B949E" }, + }), + ); expect(xml).toContain("8B949E"); }); it("should fall back to 666666 when no theme provided", () => { - const xml = pptx.line({ x1: 0, y1: 0, x2: 5, y2: 0 }); + const xml = toXml(pptx.line({ x1: 0, y1: 0, x2: 5, y2: 0 })); expect(xml).toContain("666666"); }); }); describe("arrow", () => { it("should use theme subtle colour when _theme is passed", () => { - const xml = pptx.arrow({ - x1: 0, - y1: 0, - x2: 5, - y2: 0, - _theme: { subtle: "8B949E" }, - }); + const xml = toXml( + pptx.arrow({ + x1: 0, + y1: 0, + x2: 5, + y2: 0, + _theme: { subtle: "8B949E" }, + }), + ); expect(xml).toContain("8B949E"); }); it("should fall back to 666666 when no theme provided", () => { - const xml = pptx.arrow({ x1: 0, y1: 0, x2: 5, y2: 0 }); + const xml = toXml(pptx.arrow({ x1: 0, y1: 0, x2: 5, y2: 0 })); expect(xml).toContain("666666"); }); }); @@ -419,29 +450,35 @@ const tables: any = await import("../builtin-modules/pptx-tables.js"); describe("pptx-tables theme text colour", () => { it("should use themeTextColor fallback when textColor not set", () => { - const xml = tables.table({ - headers: ["Name", "Value"], - rows: [["A", "1"]], - style: { themeTextColor: "E6EDF3" }, - }); + const xml = toXml( + tables.table({ + headers: ["Name", "Value"], + rows: [["A", "1"]], + style: { themeTextColor: "E6EDF3" }, + }), + ); // The text colour E6EDF3 should appear in the output expect(xml).toContain("E6EDF3"); }); it("should prefer explicit textColor over themeTextColor", () => { - const xml = tables.table({ - headers: ["Name", "Value"], - rows: [["A", "1"]], - style: { textColor: "FF0000", themeTextColor: "E6EDF3" }, - }); + const xml = toXml( + tables.table({ + headers: ["Name", "Value"], + rows: [["A", "1"]], + style: { textColor: "FF0000", themeTextColor: "E6EDF3" }, + }), + ); expect(xml).toContain("FF0000"); }); it("should fall back to 333333 when neither is set", () => { - const xml = tables.table({ - headers: ["Name", "Value"], - rows: [["A", "1"]], - }); + const xml = toXml( + tables.table({ + headers: ["Name", "Value"], + rows: [["A", "1"]], + }), + ); expect(xml).toContain("333333"); }); }); @@ -485,21 +522,23 @@ describe("active theme auto text colour", () => { it("textBox should use theme fg when no colour specified", () => { pptx.createPresentation({ theme: "dark-gradient" }); - const xml = pptx.textBox({ x: 0, y: 0, w: 4, h: 1, text: "Hello" }); + const xml = toXml(pptx.textBox({ x: 0, y: 0, w: 4, h: 1, text: "Hello" })); // dark-gradient fg = E6EDF3 expect(xml).toContain("E6EDF3"); }); it("textBox with explicit colour should use that colour", () => { pptx.createPresentation({ theme: "dark-gradient" }); - const xml = pptx.textBox({ - x: 0, - y: 0, - w: 4, - h: 1, - text: "Hello", - color: "FF0000", - }); + const xml = toXml( + pptx.textBox({ + x: 0, + y: 0, + w: 4, + h: 1, + text: "Hello", + color: "FF0000", + }), + ); expect(xml).toContain("FF0000"); // Should NOT contain theme fg since explicit colour overrides expect(xml).not.toContain("E6EDF3"); @@ -507,52 +546,60 @@ describe("active theme auto text colour", () => { it("bulletList should use theme fg when no colour specified", () => { pptx.createPresentation({ theme: "dark-gradient" }); - const xml = pptx.bulletList({ - x: 0, - y: 0, - w: 8, - h: 4, - items: ["item1", "item2"], - }); + const xml = toXml( + pptx.bulletList({ + x: 0, + y: 0, + w: 8, + h: 4, + items: ["item1", "item2"], + }), + ); expect(xml).toContain("E6EDF3"); }); it("numberedList should use theme fg when no colour specified", () => { pptx.createPresentation({ theme: "dark-gradient" }); - const xml = pptx.numberedList({ - x: 0, - y: 0, - w: 8, - h: 4, - items: ["first", "second"], - }); + const xml = toXml( + pptx.numberedList({ + x: 0, + y: 0, + w: 8, + h: 4, + items: ["first", "second"], + }), + ); expect(xml).toContain("E6EDF3"); }); it("statBox without background should use theme fg", () => { pptx.createPresentation({ theme: "dark-gradient" }); - const xml = pptx.statBox({ - x: 0, - y: 0, - w: 3, - h: 2, - value: "42", - label: "Score", - }); + const xml = toXml( + pptx.statBox({ + x: 0, + y: 0, + w: 3, + h: 2, + value: "42", + label: "Score", + }), + ); expect(xml).toContain("E6EDF3"); }); it("statBox WITH background should use autoTextColor not theme fg", () => { pptx.createPresentation({ theme: "dark-gradient" }); - const xml = pptx.statBox({ - x: 0, - y: 0, - w: 3, - h: 2, - value: "42", - label: "Score", - background: "FFFFFF", - }); + const xml = toXml( + pptx.statBox({ + x: 0, + y: 0, + w: 3, + h: 2, + value: "42", + label: "Score", + background: "FFFFFF", + }), + ); // White background → dark text (333333), not theme fg expect(xml).toContain("333333"); expect(xml).not.toContain("E6EDF3"); @@ -560,19 +607,21 @@ describe("active theme auto text colour", () => { it("richText should use theme fg for runs without explicit colour", () => { pptx.createPresentation({ theme: "dark-gradient" }); - const xml = pptx.richText({ - x: 0, - y: 0, - w: 8, - h: 2, - paragraphs: [[{ text: "Hello" }]], - }); + const xml = toXml( + pptx.richText({ + x: 0, + y: 0, + w: 8, + h: 2, + paragraphs: [[{ text: "Hello" }]], + }), + ); expect(xml).toContain("E6EDF3"); }); it("textBox on light-clean theme should use light theme fg", () => { pptx.createPresentation({ theme: "light-clean" }); - const xml = pptx.textBox({ x: 0, y: 0, w: 4, h: 1, text: "Hello" }); + const xml = toXml(pptx.textBox({ x: 0, y: 0, w: 4, h: 1, text: "Hello" })); // light-clean fg = 333333 expect(xml).toContain("333333"); }); diff --git a/tests/pptx-safety.test.ts b/tests/pptx-safety.test.ts new file mode 100644 index 0000000..76de7fc --- /dev/null +++ b/tests/pptx-safety.test.ts @@ -0,0 +1,865 @@ +// ── PPTX Safety Spec Tests ───────────────────────────────────────────── +// +// Tests for the PPTX Safety Spec (LLM-ONLY, BREAKING CHANGES). +// Covers: ShapeFragment typed model, chart complexity caps, +// notes sanitization, validation engine, and structural integrity. +// ────────────────────────────────────────────────────────────────────── + +import { describe, it, expect } from "vitest"; + +// ── Module imports ─────────────────────────────────────────────────── + +const core: any = await import("../builtin-modules/ooxml-core.js"); +const pptx: any = await import("../builtin-modules/pptx.js"); +const charts: any = await import("../builtin-modules/pptx-charts.js"); +const tables: any = await import("../builtin-modules/pptx-tables.js"); + +/** Convert ShapeFragment or string to XML string for test assertions */ +const toXml = (v: unknown): string => (typeof v === "string" ? v : String(v)); + +// ══════════════════════════════════════════════════════════════════════ +// 1. ShapeFragment Typed Composition Model +// ══════════════════════════════════════════════════════════════════════ + +describe("ShapeFragment", () => { + describe("creation and identity", () => { + it("createShapeFragment produces a branded object", () => { + const frag = core._createShapeFragment("test"); + expect(core.isShapeFragment(frag)).toBe(true); + }); + + it("isShapeFragment rejects plain strings", () => { + expect(core.isShapeFragment("test")).toBe(false); + }); + + it("isShapeFragment rejects null/undefined", () => { + expect(core.isShapeFragment(null)).toBe(false); + expect(core.isShapeFragment(undefined)).toBe(false); + }); + + it("isShapeFragment rejects plain objects without brand", () => { + expect(core.isShapeFragment({ _xml: "" })).toBe(false); + }); + + it("toString() returns the XML", () => { + const frag = core._createShapeFragment("hello"); + expect(String(frag)).toBe("hello"); + }); + }); + + describe("fragmentsToXml", () => { + it("converts single fragment to XML", () => { + const frag = core._createShapeFragment("a"); + expect(core.fragmentsToXml(frag)).toBe("a"); + }); + + it("converts array of fragments to joined XML", () => { + const a = core._createShapeFragment("a"); + const b = core._createShapeFragment("b"); + expect(core.fragmentsToXml([a, b])).toBe("ab"); + }); + + it("rejects raw strings", () => { + expect(() => core.fragmentsToXml("raw")).toThrow(); + }); + + it("rejects arrays containing raw strings", () => { + const frag = core._createShapeFragment("ok"); + expect(() => core.fragmentsToXml([frag, "raw"])).toThrow(); + }); + }); + + describe("shape builders return ShapeFragment", () => { + it("textBox returns ShapeFragment", () => { + const result = pptx.textBox({ x: 1, y: 1, w: 4, h: 1, text: "Hi" }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("rect returns ShapeFragment", () => { + const result = pptx.rect({ + x: 1, + y: 1, + w: 4, + h: 2, + fill: "336699", + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("bulletList returns ShapeFragment", () => { + const result = pptx.bulletList({ + x: 1, + y: 1, + w: 4, + h: 3, + items: ["a", "b"], + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("statBox returns ShapeFragment", () => { + const result = pptx.statBox({ + x: 1, + y: 1, + w: 3, + h: 2, + value: "42", + label: "Answer", + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("callout returns ShapeFragment", () => { + const result = pptx.callout({ + x: 1, + y: 1, + w: 6, + h: 2, + text: "Note", + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("codeBlock returns ShapeFragment", () => { + const result = pptx.codeBlock({ + x: 1, + y: 1, + w: 8, + h: 4, + code: 'console.log("hello")', + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("table returns ShapeFragment", () => { + const result = tables.table({ + x: 1, + y: 1, + w: 8, + headers: ["A", "B"], + rows: [ + ["1", "2"], + ["3", "4"], + ], + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("kvTable returns ShapeFragment", () => { + const result = tables.kvTable({ + x: 1, + y: 1, + w: 6, + items: [{ key: "Name", value: "Test" }], + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("numberedList returns ShapeFragment", () => { + const result = pptx.numberedList({ + x: 1, + y: 1, + w: 4, + h: 3, + items: ["first", "second"], + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("imagePlaceholder returns ShapeFragment", () => { + const result = pptx.imagePlaceholder({ + x: 1, + y: 1, + w: 4, + h: 3, + label: "Photo here", + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("line returns ShapeFragment", () => { + const result = pptx.line({ + x1: 1, + y1: 1, + x2: 5, + y2: 1, + color: "336699", + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("arrow returns ShapeFragment", () => { + const result = pptx.arrow({ + x1: 1, + y1: 1, + x2: 5, + y2: 3, + color: "336699", + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("circle returns ShapeFragment", () => { + const result = pptx.circle({ + x: 1, + y: 1, + w: 2, + fill: "336699", + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("icon returns ShapeFragment", () => { + const result = pptx.icon({ + x: 1, + y: 1, + w: 1, + shape: "star", + fill: "FFD700", + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("svgPath returns ShapeFragment", () => { + const result = pptx.svgPath({ + x: 1, + y: 1, + w: 4, + h: 4, + d: "M 0 0 L 100 0 L 100 100 Z", + fill: "336699", + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("richText returns ShapeFragment", () => { + const result = pptx.richText({ + x: 1, + y: 1, + w: 8, + h: 2, + paragraphs: [[{ text: "Hello", bold: true }]], + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("panel returns ShapeFragment", () => { + const result = pptx.panel({ + x: 1, + y: 1, + w: 6, + h: 3, + title: "Info", + body: "Details here", + accentColor: "2196F3", + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("card returns ShapeFragment", () => { + const result = pptx.card({ + x: 1, + y: 1, + w: 4, + h: 3, + title: "Card Title", + body: "Card body text", + background: "1A1A2E", + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("overlay returns ShapeFragment", () => { + const result = pptx.overlay({ opacity: 0.5 }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("gradientOverlay returns ShapeFragment", () => { + const result = pptx.gradientOverlay({}); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("shapes() returns ShapeFragment", () => { + const a = pptx.textBox({ x: 1, y: 1, w: 4, h: 1, text: "A" }); + const b = pptx.rect({ x: 1, y: 2, w: 4, h: 2, fill: "336699" }); + const result = pptx.shapes([a, b]); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("hyperlink returns ShapeFragment", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + const result = pptx.hyperlink( + { + x: 1, + y: 1, + w: 4, + h: 1, + text: "Click me", + url: "https://example.com", + }, + pres, + ); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("comparisonTable returns ShapeFragment", () => { + const result = tables.comparisonTable({ + x: 1, + y: 1, + w: 10, + features: ["SSO", "API"], + options: [{ name: "Free", values: [false, true] }], + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("timeline returns ShapeFragment", () => { + const result = tables.timeline({ + x: 1, + y: 1, + w: 10, + items: [ + { label: "Phase 1", description: "Setup" }, + { label: "Phase 2", description: "Build" }, + ], + }); + expect(core.isShapeFragment(result)).toBe(true); + }); + + it("fetchAndEmbed returns ShapeFragment (not string)", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + // Create a mock fetchFn that returns a minimal 1x1 PNG + const PNG_1x1 = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, + ]); + const result = pptx.fetchAndEmbed(pres, { + url: "https://example.com/photo.png", + x: 1, + y: 1, + w: 4, + h: 3, + fetchFn: () => PNG_1x1, + }); + expect(core.isShapeFragment(result)).toBe(true); + expect(typeof result).not.toBe("string"); + }); + }); + + describe("customSlide accepts ShapeFragment", () => { + it("accepts single ShapeFragment", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + const shape = pptx.textBox({ + x: 1, + y: 1, + w: 4, + h: 1, + text: "Hello", + }); + // Should not throw + pptx.customSlide(pres, { shapes: shape }); + expect(pres.slideCount).toBe(1); + }); + + it("accepts array of ShapeFragments", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + const shapes = [ + pptx.textBox({ x: 1, y: 1, w: 4, h: 1, text: "A" }), + pptx.rect({ x: 1, y: 2, w: 4, h: 2, fill: "336699" }), + ]; + pptx.customSlide(pres, { shapes }); + expect(pres.slideCount).toBe(1); + }); + }); +}); + +// ══════════════════════════════════════════════════════════════════════ +// 2. Chart toString() Coercion Blocked +// ══════════════════════════════════════════════════════════════════════ + +describe("embedChart toString blocked", () => { + it("embedChart result has .shape ShapeFragment", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + const chart = charts.barChart({ + categories: ["A", "B"], + series: [{ name: "S1", values: [1, 2] }], + }); + const result = charts.embedChart(pres, chart); + expect(core.isShapeFragment(result.shape)).toBe(true); + }); + + it("embedChart .toString() throws", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + const chart = charts.barChart({ + categories: ["A", "B"], + series: [{ name: "S1", values: [1, 2] }], + }); + const result = charts.embedChart(pres, chart); + expect(() => result.toString()).toThrow(/\.shape/); + }); +}); + +// ══════════════════════════════════════════════════════════════════════ +// 2b. addBody Rejects Raw Strings +// ══════════════════════════════════════════════════════════════════════ + +describe("addBody rejects raw strings", () => { + it("rejects a raw XML string", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + expect(() => pres.addBody("raw")).toThrow( + /raw XML strings are no longer accepted/, + ); + }); + + it("rejects an array of raw strings", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + expect(() => pres.addBody(["a", "b"])).toThrow( + /raw XML string arrays are no longer accepted/, + ); + }); + + it("rejects a fake ShapeFragment (plain object without brand)", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + const fake = { + _xml: "fake", + toString: () => "fake", + }; + expect(() => pres.addBody(fake as any)).toThrow(/ShapeFragment/); + }); + + it("accepts a real ShapeFragment", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + const shape = pptx.textBox({ x: 1, y: 1, w: 4, h: 1, text: "OK" }); + pres.addBody(shape); + expect(pres.slideCount).toBeGreaterThanOrEqual(1); + }); +}); + +// ══════════════════════════════════════════════════════════════════════ +// 3. Chart Complexity Caps +// ══════════════════════════════════════════════════════════════════════ + +describe("chart complexity caps", () => { + describe("barChart caps", () => { + it("rejects more than 100 categories", () => { + const cats = Array.from({ length: 101 }, (_, i) => `Cat${i}`); + expect(() => + charts.barChart({ + categories: cats, + series: [{ name: "S", values: cats.map(() => 1) }], + }), + ).toThrow(/101 categories exceeds the maximum of 100/); + }); + + it("rejects more than 24 series", () => { + const series = Array.from({ length: 25 }, (_, i) => ({ + name: `S${i}`, + values: [1], + })); + expect(() => + charts.barChart({ + categories: ["A"], + series, + }), + ).toThrow(/25 series exceeds the maximum of 24/); + }); + + it("accepts exactly 100 categories", () => { + const cats = Array.from({ length: 100 }, (_, i) => `Cat${i}`); + // Should not throw + charts.barChart({ + categories: cats, + series: [{ name: "S", values: cats.map(() => 1) }], + }); + }); + }); + + describe("lineChart caps", () => { + it("rejects more than 100 categories", () => { + const cats = Array.from({ length: 101 }, (_, i) => `Cat${i}`); + expect(() => + charts.lineChart({ + categories: cats, + series: [{ name: "S", values: cats.map(() => 1) }], + }), + ).toThrow(/101 categories exceeds the maximum of 100/); + }); + + it("rejects more than 24 series", () => { + const series = Array.from({ length: 25 }, (_, i) => ({ + name: `S${i}`, + values: [1], + })); + expect(() => + charts.lineChart({ + categories: ["A"], + series, + }), + ).toThrow(/25 series exceeds the maximum of 24/); + }); + }); + + describe("pieChart caps", () => { + it("rejects more than 100 slices", () => { + const labels = Array.from({ length: 101 }, (_, i) => `L${i}`); + const values = labels.map(() => 1); + expect(() => charts.pieChart({ labels, values })).toThrow( + /101 slices exceeds the maximum of 100/, + ); + }); + }); + + describe("comboChart caps", () => { + it("rejects more than 100 categories", () => { + const cats = Array.from({ length: 101 }, (_, i) => `Cat${i}`); + expect(() => + charts.comboChart({ + categories: cats, + barSeries: [{ name: "S", values: cats.map(() => 1) }], + }), + ).toThrow(/101 categories exceeds the maximum of 100/); + }); + + it("rejects more than 24 combined series", () => { + const barSeries = Array.from({ length: 13 }, (_, i) => ({ + name: `B${i}`, + values: [1], + })); + const lineSeries = Array.from({ length: 12 }, (_, i) => ({ + name: `L${i}`, + values: [1], + })); + expect(() => + charts.comboChart({ + categories: ["A"], + barSeries, + lineSeries, + }), + ).toThrow(/25.*exceeds the maximum of 24/); + }); + }); + + describe("deck-level chart cap", () => { + it("rejects more than 50 charts in a single deck", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + // Add 50 charts + for (let i = 0; i < 50; i++) { + const chart = charts.barChart({ + categories: ["A"], + series: [{ name: `S${i}`, values: [i] }], + }); + charts.embedChart(pres, chart); + } + // 51st should fail + const chart51 = charts.barChart({ + categories: ["A"], + series: [{ name: "S51", values: [51] }], + }); + expect(() => charts.embedChart(pres, chart51)).toThrow(/max 50/); + }); + }); +}); + +// ══════════════════════════════════════════════════════════════════════ +// 4. Notes Sanitization +// ══════════════════════════════════════════════════════════════════════ + +describe("notes sanitization", () => { + it("strips invalid XML control characters from notes", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + // \x01 is an invalid XML char that should be stripped + pptx.titleSlide(pres, { title: "Test" }, { notes: "Hello\x01World" }); + // Should build successfully (validation would catch invalid chars) + const zip = pres.buildZip(); + expect(zip).toBeInstanceOf(Uint8Array); + }); + + it("truncates notes exceeding 12,000 characters", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + const longNotes = "x".repeat(15_000); + pptx.titleSlide(pres, { title: "Test" }, { notes: longNotes }); + // Should build without validation error about note length + const zip = pres.buildZip(); + expect(zip).toBeInstanceOf(Uint8Array); + }); + + it("preserves valid notes text", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + pptx.titleSlide( + pres, + { title: "Test" }, + { notes: "These are speaker notes with tabs\tand\nnewlines" }, + ); + const zip = pres.buildZip(); + expect(zip).toBeInstanceOf(Uint8Array); + }); + + it("null/empty notes are handled gracefully", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + pptx.titleSlide(pres, { title: "Test" }, { notes: "" }); + pptx.titleSlide(pres, { title: "Test2" }); + const zip = pres.buildZip(); + expect(zip).toBeInstanceOf(Uint8Array); + }); +}); + +// ══════════════════════════════════════════════════════════════════════ +// 5. Validation Engine — buildZip enforcement +// ══════════════════════════════════════════════════════════════════════ + +describe("validation engine", () => { + it("valid deck builds without error", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + pptx.titleSlide(pres, { title: "Welcome", subtitle: "Test deck" }); + pptx.contentSlide(pres, { + title: "Content", + body: [pptx.textBox({ x: 1, y: 2, w: 8, h: 3, text: "Body text" })], + }); + const chart = charts.barChart({ + categories: ["Q1", "Q2"], + series: [{ name: "Revenue", values: [100, 200] }], + }); + pptx.chartSlide(pres, { title: "Chart", chart }); + pptx.addSlideNumbers(pres); + + const zip = pres.buildZip(); + expect(zip).toBeInstanceOf(Uint8Array); + expect(zip.length).toBeGreaterThan(0); + }); + + it("_validatePresentation is called internally by buildZip", () => { + // _validatePresentation is an internal function — not exported. + // It's exercised via buildZip(). Verify buildZip succeeds on valid deck. + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + pptx.titleSlide(pres, { title: "Test" }); + const zip = pres.buildZip(); + expect(zip).toBeInstanceOf(Uint8Array); + }); +}); + +// ══════════════════════════════════════════════════════════════════════ +// 6. Regression Fixture — Full Deck with Charts, Tables, Notes +// ══════════════════════════════════════════════════════════════════════ + +describe("full deck regression fixture", () => { + it("builds a complete deck with all slide types, charts, tables, and notes", () => { + const pres = pptx.createPresentation({ theme: "dark-gradient" }); + + // Title slide with notes + pptx.titleSlide( + pres, + { title: "Q4 Report", subtitle: "Annual Review" }, + { notes: "Welcome everyone to the quarterly review." }, + ); + + // Section divider + pptx.sectionSlide(pres, { + title: "Financial Overview", + subtitle: "Key metrics and trends", + }); + + // Content slide + pptx.contentSlide(pres, { + title: "Summary", + body: [ + pptx.bulletList({ + x: 0.5, + y: 2, + w: 10, + h: 4, + items: ["Revenue grew 15%", "Costs reduced 8%", "Margin improved"], + }), + ], + }); + + // Stat grid + pptx.statGridSlide(pres, { + title: "Key Metrics", + stats: [ + { value: "$10M", label: "Revenue" }, + { value: "15%", label: "Growth" }, + { value: "92%", label: "Retention" }, + ], + }); + + // Bar chart + const barData = charts.barChart({ + categories: ["Q1", "Q2", "Q3", "Q4"], + series: [ + { name: "Revenue", values: [2.1, 2.5, 2.8, 3.2] }, + { name: "Target", values: [2.0, 2.3, 2.6, 3.0] }, + ], + title: "Revenue vs Target", + }); + pptx.chartSlide( + pres, + { title: "Revenue Trend", chart: barData }, + { notes: "Revenue exceeded target in all quarters." }, + ); + + // Pie chart + const pieData = charts.pieChart({ + labels: ["Product A", "Product B", "Product C", "Other"], + values: [45, 30, 15, 10], + title: "Revenue Mix", + }); + pptx.chartSlide(pres, { title: "Revenue Breakdown", chart: pieData }); + + // Line chart + const lineData = charts.lineChart({ + categories: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], + series: [{ name: "Users", values: [1000, 1200, 1500, 1800, 2200, 2800] }], + title: "User Growth", + }); + pptx.chartSlide(pres, { title: "User Growth", chart: lineData }); + + // Table slide + const tbl = tables.table({ + x: 0.5, + y: 1.8, + w: 12, + theme: pres.theme, + headers: ["Region", "Revenue", "Growth"], + rows: [ + ["North America", "$5.2M", "+12%"], + ["Europe", "$3.1M", "+18%"], + ["Asia Pacific", "$1.7M", "+25%"], + ], + }); + pptx.customSlide(pres, { + shapes: [ + pptx.textBox({ + x: 0.5, + y: 0.5, + w: 10, + h: 1, + text: "Regional Performance", + fontSize: 28, + bold: true, + }), + tbl, + ], + }); + + // Comparison table + const comp = tables.comparisonTable({ + x: 0.5, + y: 1.8, + w: 12, + theme: pres.theme, + features: ["SSO", "API Access", "Support", "Custom Domain"], + options: [ + { name: "Free", values: [false, true, false, false] }, + { name: "Pro", values: [true, true, true, false] }, + { name: "Enterprise", values: [true, true, true, true] }, + ], + }); + pptx.customSlide(pres, { + shapes: [ + pptx.textBox({ + x: 0.5, + y: 0.5, + w: 10, + h: 1, + text: "Plan Comparison", + fontSize: 28, + bold: true, + }), + comp, + ], + }); + + // Quote slide + pptx.quoteSlide(pres, { + quote: "The best way to predict the future is to invent it.", + author: "Alan Kay", + role: "Computer Scientist", + }); + + // Add slide numbers + pptx.addSlideNumbers(pres); + + // Build — this exercises the full validation pipeline + const zip = pres.buildZip(); + expect(zip).toBeInstanceOf(Uint8Array); + expect(zip.length).toBeGreaterThan(1000); + // buildZip may add a warning slide, so use >= + expect(pres.slideCount).toBeGreaterThanOrEqual(10); + }); + + it("builds deck with many charts at the cap limit", () => { + const pres = pptx.createPresentation({ theme: "corporate-blue" }); + + // Add 10 chart slides — exercises chart embedding at scale + for (let i = 0; i < 10; i++) { + const chart = charts.barChart({ + categories: ["A", "B", "C"], + series: [{ name: `Series ${i}`, values: [i * 10, i * 20, i * 30] }], + }); + pptx.chartSlide( + pres, + { title: `Chart ${i + 1}`, chart }, + { notes: `Notes for chart slide ${i + 1}` }, + ); + } + + const zip = pres.buildZip(); + expect(zip).toBeInstanceOf(Uint8Array); + // buildZip may add a warning slide, so use >= + expect(pres.slideCount).toBeGreaterThanOrEqual(10); + }); + + it("builds deck with all table types", () => { + const pres = pptx.createPresentation({ theme: "light-clean" }); + + // Regular table + const t1 = tables.table({ + x: 1, + y: 1.5, + w: 11, + headers: ["Name", "Value"], + rows: [["Alpha", "100"]], + }); + pptx.customSlide(pres, { shapes: t1 }); + + // KV table + const t2 = tables.kvTable({ + x: 1, + y: 1.5, + w: 6, + items: [ + { key: "Status", value: "Active" }, + { key: "Version", value: "2.0" }, + ], + }); + pptx.customSlide(pres, { shapes: t2 }); + + // Comparison table + const t3 = tables.comparisonTable({ + x: 1, + y: 1.5, + w: 11, + features: ["Feature A"], + options: [{ name: "Plan 1", values: [true] }], + }); + pptx.customSlide(pres, { shapes: t3 }); + + // Timeline + const t4 = tables.timeline({ + x: 1, + y: 1.5, + w: 11, + items: [ + { label: "Phase 1", description: "Setup" }, + { label: "Phase 2", description: "Build" }, + ], + }); + pptx.customSlide(pres, { shapes: t4 }); + + const zip = pres.buildZip(); + expect(zip).toBeInstanceOf(Uint8Array); + // buildZip may add a warning slide, so use >= + expect(pres.slideCount).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/tests/pptx-validation.test.ts b/tests/pptx-validation.test.ts index d5833b4..4911039 100644 --- a/tests/pptx-validation.test.ts +++ b/tests/pptx-validation.test.ts @@ -17,6 +17,9 @@ const pptx: any = await import("../builtin-modules/pptx.js"); const charts: any = await import("../builtin-modules/pptx-charts.js"); const tables: any = await import("../builtin-modules/pptx-tables.js"); +/** Convert ShapeFragment or string to XML string for test assertions */ +const toXml = (v: unknown): string => (typeof v === "string" ? v : String(v)); + // ══════════════════════════════════════════════════════════════════════ // ooxml-core: Central Validation Functions // ══════════════════════════════════════════════════════════════════════ @@ -453,25 +456,29 @@ describe("pptx shape validation", () => { it("should emit spcPts for lineSpacing in points", () => { // lineSpacing: 24 should produce spcPts val="2400" (points × 100 = centipoints) - const xml = pptx.textBox({ - x: 0, - y: 0, - w: 4, - h: 1, - text: "test", - lineSpacing: 24, - }); + const xml = toXml( + pptx.textBox({ + x: 0, + y: 0, + w: 4, + h: 1, + text: "test", + lineSpacing: 24, + }), + ); expect(xml).toContain(''); }); it("should not add lnSpc element when lineSpacing omitted", () => { - const xml = pptx.textBox({ - x: 0, - y: 0, - w: 4, - h: 1, - text: "no spacing", - }); + const xml = toXml( + pptx.textBox({ + x: 0, + y: 0, + w: 4, + h: 1, + text: "no spacing", + }), + ); expect(xml).not.toContain(""); }); @@ -522,15 +529,17 @@ describe("pptx shape validation", () => { .fill("This line of text needs space") .join("\n"); // With autoFit and explicit large fontSize, it should scale down - const xml = pptx.textBox({ - x: 1, - y: 1, - w: 8, - h: 1.5, - text: longText, - fontSize: 72, - autoFit: true, - }); + const xml = toXml( + pptx.textBox({ + x: 1, + y: 1, + w: 8, + h: 1.5, + text: longText, + fontSize: 72, + autoFit: true, + }), + ); // The fontSize in the XML should be smaller than 72pt (7200 centipoints) // Look for sz="XXXX" where XXXX < 7200 const match = xml.match(/sz="(\d+)"/); @@ -814,7 +823,7 @@ describe("pptx shape validation", () => { it("should render SVG icons from SVG_ICONS map", () => { // "layers" is an SVG icon, not an OOXML preset - const result = pptx.icon({ x: 1, y: 1, w: 0.5, shape: "layers" }); + const result = toXml(pptx.icon({ x: 1, y: 1, w: 0.5, shape: "layers" })); // SVG icons use custGeom, not prstGeom expect(result).toContain(""); expect(result).toContain(""); @@ -841,13 +850,15 @@ describe("pptx shape validation", () => { }); it("should accept valid SVG path with fill", () => { - const xml = pptx.svgPath({ - x: 1, - y: 1, - w: 1, - d: "M0 0L24 12L0 24Z", - fill: "2196F3", - }); + const xml = toXml( + pptx.svgPath({ + x: 1, + y: 1, + w: 1, + d: "M0 0L24 12L0 24Z", + fill: "2196F3", + }), + ); expect(xml).toContain(""); expect(xml).toContain(""); expect(xml).toContain(""); @@ -856,49 +867,57 @@ describe("pptx shape validation", () => { }); it("should parse cubic bezier curves", () => { - const xml = pptx.svgPath({ - x: 0, - y: 0, - w: 1, - d: "M0 0C10 0 20 10 20 20", - fill: "FFFFFF", - }); + const xml = toXml( + pptx.svgPath({ + x: 0, + y: 0, + w: 1, + d: "M0 0C10 0 20 10 20 20", + fill: "FFFFFF", + }), + ); expect(xml).toContain(""); }); it("should parse quadratic bezier curves", () => { - const xml = pptx.svgPath({ - x: 0, - y: 0, - w: 1, - d: "M0 0Q12 0 12 12", - fill: "FFFFFF", - }); + const xml = toXml( + pptx.svgPath({ + x: 0, + y: 0, + w: 1, + d: "M0 0Q12 0 12 12", + fill: "FFFFFF", + }), + ); expect(xml).toContain(""); }); it("should handle relative commands (lowercase)", () => { - const xml = pptx.svgPath({ - x: 0, - y: 0, - w: 1, - d: "m5 5l10 0l0 10l-10 0z", - fill: "FFFFFF", - }); + const xml = toXml( + pptx.svgPath({ + x: 0, + y: 0, + w: 1, + d: "m5 5l10 0l0 10l-10 0z", + fill: "FFFFFF", + }), + ); expect(xml).toContain(""); expect(xml).toContain(""); expect(xml).toContain(""); }); it("should support stroke without fill", () => { - const xml = pptx.svgPath({ - x: 0, - y: 0, - w: 1, - d: "M0 0L10 10", - stroke: "333333", - strokeWidth: 2, - }); + const xml = toXml( + pptx.svgPath({ + x: 0, + y: 0, + w: 1, + d: "M0 0L10 10", + stroke: "333333", + strokeWidth: 2, + }), + ); expect(xml).toContain(""); expect(xml).toContain("333333"); expect(xml).toContain("a:ln"); @@ -906,52 +925,60 @@ describe("pptx shape validation", () => { it("should use custom viewBox dimensions", () => { // With viewBox 100x100, coordinates should be normalized differently - const xml = pptx.svgPath({ - x: 0, - y: 0, - w: 1, - d: "M50 50L100 100", - fill: "FFFFFF", - viewBox: { w: 100, h: 100 }, - }); + const xml = toXml( + pptx.svgPath({ + x: 0, + y: 0, + w: 1, + d: "M50 50L100 100", + fill: "FFFFFF", + viewBox: { w: 100, h: 100 }, + }), + ); // 50/100 = 0.5 = 50000 EMUs expect(xml).toContain('x="50000"'); }); it("should parse SVG arc commands (A/a)", () => { // Absolute arc: M10,10 A5,5 0 0,1 20,10 (half circle) - const xml = pptx.svgPath({ - x: 0, - y: 0, - w: 1, - d: "M10 10 A5 5 0 0 1 20 10", - fill: "FFFFFF", - }); + const xml = toXml( + pptx.svgPath({ + x: 0, + y: 0, + w: 1, + d: "M10 10 A5 5 0 0 1 20 10", + fill: "FFFFFF", + }), + ); expect(xml).toContain(""); // arcs are approximated with beziers expect(xml).toContain(""); }); it("should parse relative SVG arc commands (a)", () => { // Relative arc: m10,10 a5,5 0 0,1 10,0 - const xml = pptx.svgPath({ - x: 0, - y: 0, - w: 1, - d: "m10 10 a5 5 0 0 1 10 0", - fill: "FFFFFF", - }); + const xml = toXml( + pptx.svgPath({ + x: 0, + y: 0, + w: 1, + d: "m10 10 a5 5 0 0 1 10 0", + fill: "FFFFFF", + }), + ); expect(xml).toContain(""); }); it("should handle degenerate arcs (zero radius)", () => { // Zero radius should produce a line - const xml = pptx.svgPath({ - x: 0, - y: 0, - w: 1, - d: "M10 10 A0 0 0 0 1 20 10", - fill: "FFFFFF", - }); + const xml = toXml( + pptx.svgPath({ + x: 0, + y: 0, + w: 1, + d: "M10 10 A0 0 0 0 1 20 10", + fill: "FFFFFF", + }), + ); expect(xml).toContain(""); }); }); @@ -1180,14 +1207,16 @@ describe("pptx shape validation", () => { it("should auto-detect png format from URL", () => { const pres = pptx.createPresentation(); - const shape = pptx.embedImageFromUrl(pres, { - url: "https://example.com/image.png", - data: new Uint8Array(10), - x: 1, - y: 2, - w: 3, - h: 2, - }); + const shape = toXml( + pptx.embedImageFromUrl(pres, { + url: "https://example.com/image.png", + data: new Uint8Array(10), + x: 1, + y: 2, + w: 3, + h: 2, + }), + ); expect(shape).toContain("p:pic"); expect(pres._images[0].contentType).toBe("image/png"); }); @@ -1466,7 +1495,7 @@ describe("pptx slide validation", () => { pptx.textBox({ x: 0, y: 0, w: 4, h: 1, text: "Inserted" }), ); expect(pres.slides.length).toBe(3); - expect(pres.slides[1].shapes).toContain("Inserted"); + expect(toXml(pres.slides[1].shapes)).toContain("Inserted"); }); it("should reorder slides with valid newOrder array", () => { @@ -1861,43 +1890,49 @@ describe("pptx-tables validation", () => { }); it("should use dark theme colors when theme.bg is dark", () => { - const xml = tables.table({ - x: 0, - y: 0, - w: 10, - headers: ["Name"], - rows: [["Alpha"], ["Beta"]], - theme: { bg: "1B2A4A", fg: "E6EDF3" }, - }); + const xml = toXml( + tables.table({ + x: 0, + y: 0, + w: 10, + headers: ["Name"], + rows: [["Alpha"], ["Beta"]], + theme: { bg: "1B2A4A", fg: "E6EDF3" }, + }), + ); // On dark themes: alt-row should be dark (2D333B), text should be light (E6EDF3) expect(xml).toContain("2D333B"); // dark alt-row expect(xml).toContain("E6EDF3"); // light text }); it("should use light theme colors when theme.bg is light", () => { - const xml = tables.table({ - x: 0, - y: 0, - w: 10, - headers: ["Name"], - rows: [["Alpha"], ["Beta"]], - theme: { bg: "FFFFFF" }, - }); + const xml = toXml( + tables.table({ + x: 0, + y: 0, + w: 10, + headers: ["Name"], + rows: [["Alpha"], ["Beta"]], + theme: { bg: "FFFFFF" }, + }), + ); // On light themes: alt-row should be light (F5F5F5), text should be dark (333333) expect(xml).toContain("F5F5F5"); // light alt-row expect(xml).toContain("333333"); // dark text }); it("should allow style overrides to take precedence over theme", () => { - const xml = tables.table({ - x: 0, - y: 0, - w: 10, - headers: ["Name"], - rows: [["Alpha"]], - theme: { bg: "1B2A4A", fg: "E6EDF3" }, - style: { textColor: "FF0000", altRowColor: "00FF00" }, - }); + const xml = toXml( + tables.table({ + x: 0, + y: 0, + w: 10, + headers: ["Name"], + rows: [["Alpha"]], + theme: { bg: "1B2A4A", fg: "E6EDF3" }, + style: { textColor: "FF0000", altRowColor: "00FF00" }, + }), + ); expect(xml).toContain("FF0000"); // explicit text color expect(xml).not.toContain("E6EDF3"); // theme should be overridden }); @@ -1933,13 +1968,15 @@ describe("pptx-tables validation", () => { }); it("should pass theme through to underlying table()", () => { - const xml = tables.kvTable({ - x: 0, - y: 0, - w: 6, - items: [{ key: "Name", value: "HyperAgent" }], - theme: { bg: "1B2A4A", fg: "E6EDF3" }, - }); + const xml = toXml( + tables.kvTable({ + x: 0, + y: 0, + w: 6, + items: [{ key: "Name", value: "HyperAgent" }], + theme: { bg: "1B2A4A", fg: "E6EDF3" }, + }), + ); // On dark theme, should use light text expect(xml).toContain("E6EDF3"); }); @@ -1995,14 +2032,16 @@ describe("pptx-tables validation", () => { }); it("should pass theme through to underlying table()", () => { - const xml = tables.comparisonTable({ - x: 0, - y: 0, - w: 10, - features: ["Speed", "Cost"], - options: [{ name: "Alpha", values: [true, false] }], - theme: { bg: "1B2A4A", fg: "E6EDF3" }, - }); + const xml = toXml( + tables.comparisonTable({ + x: 0, + y: 0, + w: 10, + features: ["Speed", "Cost"], + options: [{ name: "Alpha", values: [true, false] }], + theme: { bg: "1B2A4A", fg: "E6EDF3" }, + }), + ); // On dark theme, should use light text expect(xml).toContain("E6EDF3"); }); @@ -2041,16 +2080,18 @@ describe("pptx-tables validation", () => { }); it("should pass theme through to underlying table()", () => { - const xml = tables.timeline({ - x: 0, - y: 0, - w: 12, - items: [ - { label: "Phase 1", description: "Planning" }, - { label: "Phase 2", description: "Execution" }, - ], - theme: { bg: "1B2A4A", fg: "E6EDF3" }, - }); + const xml = toXml( + tables.timeline({ + x: 0, + y: 0, + w: 12, + items: [ + { label: "Phase 1", description: "Planning" }, + { label: "Phase 2", description: "Execution" }, + ], + theme: { bg: "1B2A4A", fg: "E6EDF3" }, + }), + ); // On dark theme, should use light text expect(xml).toContain("E6EDF3"); }); @@ -2342,13 +2383,13 @@ describe("layout helpers", () => { describe("overlay", () => { it("should create full-slide overlay by default", () => { - const xml = pptx.overlay(); + const xml = toXml(pptx.overlay()); expect(xml).toContain("p:sp"); // is a shape expect(xml).toContain("000000"); // default black color }); it("should respect custom options", () => { - const xml = pptx.overlay({ color: "FF0000", opacity: 0.7 }); + const xml = toXml(pptx.overlay({ color: "FF0000", opacity: 0.7 })); expect(xml).toContain("FF0000"); }); });