From f6d5f7bc0b2c5341de130270455e51e70f1a5780 Mon Sep 17 00:00:00 2001
From: HUANG <15866338256@163.com>
Date: Mon, 28 Sep 2026 00:51:46 +0800
Subject: [PATCH] feat(exporters): add layout-aware editable PPTX beta
Signed-off-by: HUANG <15866338256@163.com>
---
.changeset/native-editable-pptx.md | 11 +
.../templates/design-skills/slide-deck.jsx | 1 +
.../src/main/exporter-ipc.research.test.ts | 82 ++-
apps/desktop/src/main/exporter-ipc.test.ts | 40 +-
apps/desktop/src/main/exporter-ipc.ts | 15 +
apps/desktop/src/main/preview-runtime.ts | 3 -
apps/desktop/src/preload/index.ts | 2 +
.../components/PreviewToolbar.export.test.tsx | 97 +++
.../src/components/PreviewToolbar.tsx | 24 +-
apps/desktop/src/renderer/src/store.test.ts | 49 ++
apps/desktop/src/renderer/src/store.ts | 2 +-
.../renderer/src/store/slices/generation.ts | 4 +-
package.json | 3 +
packages/exporters/README.md | 91 +++
.../exporters/fixtures/pptx-beta-menu.png | Bin 0 -> 22922 bytes
packages/exporters/package.json | 1 +
packages/exporters/src/index.ts | 6 +-
.../exporters/src/pptx-beta.browser.test.ts | 89 +++
packages/exporters/src/pptx-dom.ts | 508 +++++++++++++
packages/exporters/src/pptx-model.test.ts | 683 ++++++++++++++++++
packages/exporters/src/pptx-model.ts | 313 ++++++++
.../exporters/src/pptx-native.browser.test.ts | 297 ++++++++
packages/exporters/src/pptx-native.ts | 380 ++++++++++
.../exporters/src/pptx-poster.browser.test.ts | 100 +++
.../src/pptx-repair-edge.browser.test.ts | 240 ++++++
packages/exporters/src/pptx.ts | 12 +-
packages/i18n/src/locales/en.json | 4 +
packages/i18n/src/locales/es.json | 4 +
packages/i18n/src/locales/pt-BR.json | 4 +
packages/i18n/src/locales/zh-CN.json | 4 +
patches/README.md | 21 +
patches/pptxgenjs@4.0.1.patch | 46 ++
pnpm-lock.yaml | 14 +-
33 files changed, 3130 insertions(+), 20 deletions(-)
create mode 100644 .changeset/native-editable-pptx.md
create mode 100644 apps/desktop/src/renderer/src/components/PreviewToolbar.export.test.tsx
create mode 100644 packages/exporters/README.md
create mode 100644 packages/exporters/fixtures/pptx-beta-menu.png
create mode 100644 packages/exporters/src/pptx-beta.browser.test.ts
create mode 100644 packages/exporters/src/pptx-dom.ts
create mode 100644 packages/exporters/src/pptx-model.test.ts
create mode 100644 packages/exporters/src/pptx-model.ts
create mode 100644 packages/exporters/src/pptx-native.browser.test.ts
create mode 100644 packages/exporters/src/pptx-native.ts
create mode 100644 packages/exporters/src/pptx-poster.browser.test.ts
create mode 100644 packages/exporters/src/pptx-repair-edge.browser.test.ts
create mode 100644 patches/README.md
create mode 100644 patches/pptxgenjs@4.0.1.patch
diff --git a/.changeset/native-editable-pptx.md b/.changeset/native-editable-pptx.md
new file mode 100644
index 000000000..622bd9b6e
--- /dev/null
+++ b/.changeset/native-editable-pptx.md
@@ -0,0 +1,11 @@
+---
+"@open-codesign/exporters": minor
+"@open-codesign/desktop": minor
+"@open-codesign/i18n": patch
+---
+
+Add a beta editable PPTX export that renders HTML/JSX slides into native measured-line text boxes, basic shapes, and independent images while preserving the existing image-first default and legacy text-only library mode. Normalize preview ancestors to design coordinates, support inactive slide containers, resolve actual platform fonts without embedding them, and keep ordinary text editable alongside isolated backgrounds, nonuniform borders, and generated decorations. Multiline text is edited as separate line objects; unsupported compositing, clipping, and complex content can still require warned subtree rasterization.
+
+Preserve CSS paint order between translucent decoration and positioned `z-index:auto` content so background grids do not cover editable text. Recognize a unique explicitly marked or proportioned poster and export its actual page aspect ratio without the preview stage, while retaining ordinary deck sizing.
+
+Fit unstructured root/body content into one page with a warning when no slide containers or unique poster artboard are found, and reject explicit custom selectors with no matches. Add descriptive model validation, XML-character sanitation, a persistent PptxGenJS 4.0.1 XML/package repair patch, and browser plus dev-only XML-parser regression coverage. A private deck and portrait poster were opened, rendered, saved, and reopened in installed Windows PowerPoint with their objects and text retained. Document the Beta compatibility matrix, manual acceptance checklist, and synthetic fallback regressions; macOS PowerPoint and WPS remain unverified. Arbitrary CSS fidelity is not guaranteed, and small clipping/font differences can remain.
diff --git a/apps/desktop/resources/templates/design-skills/slide-deck.jsx b/apps/desktop/resources/templates/design-skills/slide-deck.jsx
index 7b69b41d9..37e8235af 100644
--- a/apps/desktop/resources/templates/design-skills/slide-deck.jsx
+++ b/apps/desktop/resources/templates/design-skills/slide-deck.jsx
@@ -19,6 +19,7 @@ function SlideShell({ children, n, total, eyebrow, tokens = {} }) {
const t = { ...TWEAK_DEFAULTS, ...tokens };
return (
{
await rm(directory, { recursive: true, force: true });
});
-async function exportDesign(format: ExporterFormat = 'html'): Promise
{
+async function exportDesign(
+ format: ExporterFormat = 'html',
+ renderMode?: 'image' | 'native',
+): Promise {
const destination = join(directory, `export.${format === 'markdown' ? 'md' : format}`);
mocks.pick.mockResolvedValueOnce({ canceled: false, filePath: destination });
const handler = mocks.handlers.get('codesign:export');
if (!handler) throw new Error('Missing export IPC handler');
return (await handler(null, {
format,
+ ...(renderMode !== undefined ? { renderMode } : {}),
workspacePath: directory,
sourcePath: 'deck.html',
artifactSource: source,
})) as ExportResponse;
}
+describe('PPTX IPC wiring', () => {
+ it.each([
+ undefined,
+ 'image',
+ 'native',
+ ] as const)('forwards mode %s with resolved source and asset options', async (renderMode) => {
+ const result = await exportDesign('pptx', renderMode);
+ expect(result.status).toBe('saved');
+ expect(mocks.exportArtifact).toHaveBeenCalledWith(
+ 'pptx',
+ source,
+ join(directory, 'export.pptx'),
+ expect.objectContaining({
+ sourcePath: 'deck.html',
+ assetRootPath: directory,
+ assetBasePath: directory,
+ }),
+ );
+ const options = mocks.exportArtifact.mock.calls[0]?.[3];
+ if (renderMode === undefined) {
+ expect(options).not.toHaveProperty('renderMode');
+ } else {
+ expect(options).toHaveProperty('renderMode', renderMode);
+ }
+ expect(result).not.toHaveProperty('exportWarnings');
+ });
+
+ it('returns exporter warnings separately from failed research companion warnings', async () => {
+ const warnings = ['Slide 2 uses an image fallback.'];
+ mocks.exportArtifact.mockResolvedValueOnce({
+ path: join(directory, 'export.pptx'),
+ bytes: 42,
+ warnings,
+ });
+ mocks.slides.mockRejectedValueOnce(new Error('Research content is stale.'));
+ const result = await exportDesign('pptx', 'native');
+ expect(result.exportWarnings).toEqual(warnings);
+ expect(result.researchWarnings).toEqual([
+ 'Sources companion was not exported: Research content is stale.',
+ ]);
+ expect(warnings).toEqual(['Slide 2 uses an image fallback.']);
+ });
+
+ it('surfaces exporter warnings even without research records', async () => {
+ await rm(join(directory, '.codesign', 'research.json'));
+ mocks.exportArtifact.mockResolvedValueOnce({
+ path: join(directory, 'export.pptx'),
+ bytes: 42,
+ warnings: ['Complex visuals became images.'],
+ });
+ const result = await exportDesign('pptx', 'native');
+ expect(result.exportWarnings).toEqual(['Complex visuals became images.']);
+ expect(result).not.toHaveProperty('researchWarnings');
+ });
+
+ it('validates renderMode before opening a save dialog or invoking exporters', async () => {
+ const handler = mocks.handlers.get('codesign:export');
+ if (!handler) throw new Error('Missing export IPC handler');
+ await expect(
+ handler(null, { format: 'pdf', artifactSource: source, renderMode: 'native' }),
+ ).rejects.toMatchObject({ code: 'IPC_BAD_INPUT' });
+ expect(mocks.pick).not.toHaveBeenCalled();
+ expect(mocks.exportArtifact).not.toHaveBeenCalled();
+ });
+
+ it('does not export when the native PPTX save dialog is cancelled', async () => {
+ const handler = mocks.handlers.get('codesign:export');
+ if (!handler) throw new Error('Missing export IPC handler');
+ mocks.pick.mockResolvedValueOnce({ canceled: true });
+ await expect(
+ handler(null, { format: 'pptx', artifactSource: source, renderMode: 'native' }),
+ ).resolves.toEqual({ status: 'cancelled' });
+ expect(mocks.exportArtifact).not.toHaveBeenCalled();
+ });
+});
+
describe('optional research companion export', () => {
it.each([
'html',
diff --git a/apps/desktop/src/main/exporter-ipc.test.ts b/apps/desktop/src/main/exporter-ipc.test.ts
index e158182e9..2739b359b 100644
--- a/apps/desktop/src/main/exporter-ipc.test.ts
+++ b/apps/desktop/src/main/exporter-ipc.test.ts
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { CodesignError } from '@open-codesign/shared';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
buildDefaultExportPath,
ensureExportExtension,
@@ -11,6 +11,8 @@ import {
resolveExportSource,
} from './exporter-ipc';
+vi.mock('./electron-runtime', () => ({ app: {}, dialog: {}, ipcMain: {} }));
+
let tempDir = '';
beforeAll(() => {
@@ -41,6 +43,42 @@ describe('parseRequest', () => {
);
});
+ it('keeps the default PPTX mode unspecified', () => {
+ expect(parseRequest({ format: 'pptx', artifactSource: '' })).not.toHaveProperty(
+ 'renderMode',
+ );
+ });
+
+ it.each(['image', 'native'] as const)('accepts PPTX renderMode %s', (renderMode) => {
+ expect(
+ parseRequest({ format: 'pptx', artifactSource: '', renderMode }),
+ ).toMatchObject({ renderMode });
+ });
+
+ it.each([
+ 'editable',
+ '',
+ 'Native',
+ ' native ',
+ null,
+ true,
+ 1,
+ {},
+ [],
+ ])('rejects invalid PPTX renderMode %j', (renderMode) => {
+ expect(() =>
+ parseRequest({ format: 'pptx', artifactSource: '', renderMode }),
+ ).toThrowError(expect.objectContaining({ code: 'IPC_BAD_INPUT' }));
+ });
+
+ it.each(['html', 'pdf', 'zip', 'markdown'])('rejects renderMode on %s exports', (format) => {
+ for (const renderMode of ['image', 'native']) {
+ expect(() =>
+ parseRequest({ format, artifactSource: '', renderMode }),
+ ).toThrowError(expect.objectContaining({ code: 'IPC_BAD_INPUT' }));
+ }
+ });
+
it('accepts a valid pdf request', () => {
const result = parseRequest({
format: 'pdf',
diff --git a/apps/desktop/src/main/exporter-ipc.ts b/apps/desktop/src/main/exporter-ipc.ts
index a1e523a2f..917cead39 100644
--- a/apps/desktop/src/main/exporter-ipc.ts
+++ b/apps/desktop/src/main/exporter-ipc.ts
@@ -64,6 +64,7 @@ function normalizeExportSourcePath(raw: string): string {
export interface ExportRequest {
format: ExporterFormat;
+ renderMode?: 'image' | 'native';
artifactSource: string;
defaultFilename?: string;
designId?: string;
@@ -76,6 +77,7 @@ export interface ExportResponse {
status: 'saved' | 'cancelled';
sourcesPath?: string;
researchWarnings?: string[];
+ exportWarnings?: string[];
path?: string;
bytes?: number;
}
@@ -107,7 +109,18 @@ export function parseRequest(raw: unknown): ExportRequest {
if (typeof source !== 'string' || source.length === 0) {
throw new CodesignError('export requires non-empty artifactSource', ERROR_CODES.IPC_BAD_INPUT);
}
+ const renderMode = r['renderMode'];
+ if (
+ renderMode !== undefined &&
+ (format !== 'pptx' || (renderMode !== 'image' && renderMode !== 'native'))
+ ) {
+ throw new CodesignError(
+ 'export renderMode must be image or native and is only supported for PPTX',
+ ERROR_CODES.IPC_BAD_INPUT,
+ );
+ }
const out: ExportRequest = { format, artifactSource: source };
+ if (renderMode !== undefined) out.renderMode = renderMode;
if (typeof defaultFilename === 'string' && defaultFilename.length > 0) {
out.defaultFilename = defaultFilename;
}
@@ -271,6 +284,7 @@ export function registerExporterIpc(
: undefined;
const result = await exportArtifact(req.format, resolved.artifactSource, destinationPath, {
...exportAssetOptions(resolved),
+ ...(req.renderMode !== undefined ? { renderMode: req.renderMode } : {}),
...(assets ? { assets } : {}),
});
let sourcesPath: string | undefined;
@@ -289,6 +303,7 @@ export function registerExporterIpc(
status: 'saved',
path: result.path,
bytes: result.bytes,
+ ...(result.warnings ? { exportWarnings: result.warnings } : {}),
...(sourcesPath ? { sourcesPath } : {}),
...(companion || researchWarnings.length ? { researchWarnings } : {}),
};
diff --git a/apps/desktop/src/main/preview-runtime.ts b/apps/desktop/src/main/preview-runtime.ts
index 4c0cc5844..ecfa5af0e 100644
--- a/apps/desktop/src/main/preview-runtime.ts
+++ b/apps/desktop/src/main/preview-runtime.ts
@@ -223,10 +223,8 @@ export async function runPreview(opts: RunPreviewOptions): Promise {
// Runs in the browser; DOM globals are defined at call time.
- // @ts-expect-error browser context
const rect = document.documentElement.getBoundingClientRect();
return {
- // @ts-expect-error browser context
nodes: document.querySelectorAll('*').length,
width: Math.round(rect.width),
height: Math.round(rect.height),
@@ -285,7 +283,6 @@ export async function runPreview(opts: RunPreviewOptions): Promise outline(c, depth + 1, maxDepth)).join('\n');
return children.length > 0 ? `${self}\n${children}` : self;
}
- // @ts-expect-error browser context
return outline(document.documentElement as unknown as El, 0, 4);
}),
2000,
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index 2a6e40577..c34cc053f 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -182,12 +182,14 @@ export interface RenameDesignOptions {
export interface ExportInvokeResponse {
sourcesPath?: string;
researchWarnings?: string[];
+ exportWarnings?: string[];
status: 'saved' | 'cancelled';
path?: string;
bytes?: number;
}
export interface ExportInvokePayload {
format: ExportFormat;
+ renderMode?: 'image' | 'native';
artifactSource: string;
defaultFilename?: string;
designId?: string;
diff --git a/apps/desktop/src/renderer/src/components/PreviewToolbar.export.test.tsx b/apps/desktop/src/renderer/src/components/PreviewToolbar.export.test.tsx
new file mode 100644
index 000000000..28bee735f
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/PreviewToolbar.export.test.tsx
@@ -0,0 +1,97 @@
+import { availableLocales, i18n, initI18n, setLocale } from '@open-codesign/i18n';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { type CodesignState, useCodesignStore } from '../store';
+import { PreviewToolbar } from './PreviewToolbar';
+import type { PreviewMenuItem } from './PreviewToolbarMenu';
+
+vi.mock('../store', async () => {
+ const actual = await vi.importActual('../store');
+ return {
+ ...actual,
+ useCodesignStore: Object.assign(
+ (selector: (state: CodesignState) => unknown) => selector(actual.useCodesignStore.getState()),
+ actual.useCodesignStore,
+ ),
+ };
+});
+
+const menus = vi.hoisted(() => new Map());
+vi.mock('./PreviewToolbarMenu', () => ({
+ PreviewToolbarMenu: (props: { label: string; items: PreviewMenuItem[]; disabled: boolean }) => {
+ menus.set(props.label, props);
+ return null;
+ },
+}));
+
+const initialState = useCodesignStore.getState();
+const exportActive = vi.fn();
+
+beforeAll(async () => {
+ await initI18n('en');
+});
+beforeEach(async () => {
+ await setLocale('en');
+ menus.clear();
+ exportActive.mockClear();
+ useCodesignStore.setState({
+ ...initialState,
+ previewSource: '',
+ exportActive,
+ });
+});
+
+function exportMenu() {
+ renderToStaticMarkup();
+ const menu = menus.get(i18n.t('export.button'));
+ if (!menu) throw new Error('Missing export menu');
+ return menu;
+}
+
+describe('preview export menu', () => {
+ it('keeps all default exports and adds a separate native PPTX action', () => {
+ const { items, disabled } = exportMenu();
+ expect(disabled).toBe(false);
+ expect(items.map((item) => item.id)).toEqual([
+ 'html',
+ 'pdf',
+ 'pptx',
+ 'pptx-native',
+ 'zip',
+ 'markdown',
+ ]);
+ for (const item of items) item.onSelect();
+ expect(exportActive.mock.calls).toEqual([
+ ['html'],
+ ['pdf'],
+ ['pptx'],
+ ['pptx', 'native'],
+ ['zip'],
+ ['markdown'],
+ ]);
+ expect(items.find((item) => item.id === 'pptx')?.label).toBe('PPTX');
+ expect(items.find((item) => item.id === 'pptx-native')?.label).toBe('PPTX (editable, beta)');
+ });
+
+ it('disables the export menu without a preview', () => {
+ useCodesignStore.setState({ previewSource: null });
+ expect(exportMenu().disabled).toBe(true);
+ });
+
+ it.each(
+ availableLocales,
+ )('uses a translated native label and fallback hint in %s', async (locale) => {
+ await setLocale(locale);
+ const item = exportMenu().items.find((candidate) => candidate.id === 'pptx-native');
+ for (const field of ['label', 'hint'] as const) {
+ const translated = i18n.getResource(
+ locale,
+ 'translation',
+ `export.items.pptxNative.${field}`,
+ );
+ expect(typeof translated).toBe('string');
+ expect(translated.length).toBeGreaterThan(0);
+ expect(item?.[field]).toBe(translated);
+ }
+ });
+});
diff --git a/apps/desktop/src/renderer/src/components/PreviewToolbar.tsx b/apps/desktop/src/renderer/src/components/PreviewToolbar.tsx
index a1527a9df..1f7513c2f 100644
--- a/apps/desktop/src/renderer/src/components/PreviewToolbar.tsx
+++ b/apps/desktop/src/renderer/src/components/PreviewToolbar.tsx
@@ -126,12 +126,24 @@ export function PreviewToolbar({
({
- id: format,
- label: t(`export.items.${format}.label`),
- hint: t(`export.items.${format}.hint`),
- onSelect: () => void exportActive(format),
- }))}
+ items={EXPORT_FORMATS.flatMap((format) => [
+ {
+ id: format,
+ label: t(`export.items.${format}.label`),
+ hint: t(`export.items.${format}.hint`),
+ onSelect: () => void exportActive(format),
+ },
+ ...(format === 'pptx'
+ ? [
+ {
+ id: 'pptx-native',
+ label: t('export.items.pptxNative.label'),
+ hint: t('export.items.pptxNative.hint'),
+ onSelect: () => void exportActive('pptx', 'native'),
+ },
+ ]
+ : []),
+ ])}
>
{t('export.button')}
diff --git a/apps/desktop/src/renderer/src/store.test.ts b/apps/desktop/src/renderer/src/store.test.ts
index 47dcbed7c..fbead0e42 100644
--- a/apps/desktop/src/renderer/src/store.test.ts
+++ b/apps/desktop/src/renderer/src/store.test.ts
@@ -2289,6 +2289,55 @@ describe('useCodesignStore artifact persistence', () => {
});
});
+describe('PPTX export requests', () => {
+ it.each([
+ undefined,
+ 'image',
+ 'native',
+ ] as const)('forwards the selected mode %s without changing the default', async (renderMode) => {
+ const exportFile = vi.fn().mockResolvedValue({ status: 'saved', path: '/tmp/deck.pptx' });
+ vi.stubGlobal('window', { codesign: { export: exportFile }, setTimeout });
+ useCodesignStore.setState({ currentDesignId: null, previewSource: '' });
+
+ await useCodesignStore.getState().exportActive('pptx', renderMode);
+
+ expect(exportFile).toHaveBeenCalledOnce();
+ expect(exportFile.mock.calls[0]?.[0]).toMatchObject({
+ format: 'pptx',
+ artifactSource: '',
+ });
+ if (renderMode === undefined) {
+ expect(exportFile.mock.calls[0]?.[0]).not.toHaveProperty('renderMode');
+ } else {
+ expect(exportFile.mock.calls[0]?.[0]).toHaveProperty('renderMode', renderMode);
+ }
+ });
+
+ it('shows exporter warnings and research warnings alongside both saved paths', async () => {
+ const exportWarnings = ['Slide 2 uses an image fallback.'];
+ const researchWarnings = ['Some sources are stale.'];
+ const exportFile = vi.fn().mockResolvedValue({
+ status: 'saved',
+ path: '/tmp/deck.pptx',
+ sourcesPath: '/tmp/deck.sources.md',
+ exportWarnings,
+ researchWarnings,
+ });
+ vi.stubGlobal('window', { codesign: { export: exportFile }, setTimeout });
+ useCodesignStore.setState({ currentDesignId: null, previewSource: '' });
+
+ await useCodesignStore.getState().exportActive('pptx', 'native');
+
+ const toast = useCodesignStore.getState().toastMessage;
+ expect(toast).toContain('/tmp/deck.pptx');
+ expect(toast).toContain('/tmp/deck.sources.md');
+ expect(toast).toContain(exportWarnings[0]);
+ expect(toast).toContain(researchWarnings[0]);
+ expect(exportWarnings).toEqual(['Slide 2 uses an image fallback.']);
+ expect(researchWarnings).toEqual(['Some sources are stale.']);
+ });
+});
+
describe('loadDesigns startup', () => {
it('shares in-flight startup initialization instead of creating duplicate blank designs', async () => {
const creation = deferred();
diff --git a/apps/desktop/src/renderer/src/store.ts b/apps/desktop/src/renderer/src/store.ts
index 776ad58f6..57a48305c 100644
--- a/apps/desktop/src/renderer/src/store.ts
+++ b/apps/desktop/src/renderer/src/store.ts
@@ -322,7 +322,7 @@ export interface CodesignState {
clearError: () => void;
clearIframeErrors: () => void;
pushIframeError: (message: string) => void;
- exportActive: (format: ExportFormat) => Promise;
+ exportActive: (format: ExportFormat, renderMode?: 'image' | 'native') => Promise;
pickInputFiles: () => Promise;
importFilesToWorkspace: (input: {
diff --git a/apps/desktop/src/renderer/src/store/slices/generation.ts b/apps/desktop/src/renderer/src/store/slices/generation.ts
index e3354ad3a..4db3b35c4 100644
--- a/apps/desktop/src/renderer/src/store/slices/generation.ts
+++ b/apps/desktop/src/renderer/src/store/slices/generation.ts
@@ -1140,7 +1140,7 @@ export function makeGenerationSlice(set: SetState, get: GetState): GenerationSli
});
},
- async exportActive(format: ExportFormat) {
+ async exportActive(format: ExportFormat, renderMode?: 'image' | 'native') {
recordAction({ type: 'design.export', data: { format } });
const source = get().previewSource;
if (!source) {
@@ -1187,6 +1187,7 @@ export function makeGenerationSlice(set: SetState, get: GetState): GenerationSli
: (get().designs.find((design) => design.id === designId) ?? null);
const res = await window.codesign.export({
format,
+ ...(renderMode !== undefined ? { renderMode } : {}),
artifactSource,
...(designId !== null ? { designId } : {}),
...(activeDesign?.name ? { designName: activeDesign.name } : {}),
@@ -1200,6 +1201,7 @@ export function makeGenerationSlice(set: SetState, get: GetState): GenerationSli
...(res.sourcesPath
? [tr('notifications.exportedTo', { path: res.sourcesPath })]
: []),
+ ...(res.exportWarnings ?? []),
...(res.researchWarnings ?? []),
].join('\n'),
});
diff --git a/package.json b/package.json
index 65fc921f4..808ea4a26 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,9 @@
"fast-xml-parser@<5.7.0": ">=5.7.0",
"vitepress>vite": "6.4.2",
"esbuild@<0.25.0": ">=0.25.0"
+ },
+ "patchedDependencies": {
+ "pptxgenjs@4.0.1": "patches/pptxgenjs@4.0.1.patch"
}
},
"devDependencies": {
diff --git a/packages/exporters/README.md b/packages/exporters/README.md
new file mode 100644
index 000000000..ee65b2355
--- /dev/null
+++ b/packages/exporters/README.md
@@ -0,0 +1,91 @@
+# Exporters
+
+## Editable PPTX (beta, phase one)
+
+The desktop export menu keeps **PPTX** as the existing screenshot-based, appearance-first option. **PPTX (editable, beta)** uses `renderMode: 'native'`: Chromium executes the HTML/JSX, the exporter reads the rendered layout into an in-memory slide model, and PptxGenJS writes native text boxes, basic shapes, and independent image objects. Heavy export dependencies remain lazy-loaded. No new runtime dependencies or browser downloads are introduced.
+
+The library's legacy `renderMode: 'editable'` still extracts headings/bullets from source into a fixed template. It is not the new layout-aware mode and is not exposed in the desktop menu.
+
+
+
+Actual toolbar and menu rendered with synthetic preview state; no user design content is shown.
+
+### Authoring contract
+
+Use fixed-size HTML slide containers, preferably 1280 × 720 pixels:
+
+```jsx
+function App() {
+ const metrics = ['Revenue grew 32%', '120 new customers'];
+ return (
+
+ Quarterly business review
+
+ {metrics.map(value => (
+
+ ))}
+
+
+ );
+}
+```
+
+- Supported page markers: `data-pptx-slide`, `data-slide`, `data-slide-container`, `data-slide-id`, and `.slide`. Nested containers do not create additional pages. A library caller may supply `slideSelector`; an explicit selector with no matches is an error.
+- Without slide markers, a unique visible poster artboard is considered before generic `section` elements: use `data-pptx-poster`, or a `poster` / `*-poster` / `*_poster` class with an explicit CSS `aspect-ratio` and portrait dimensions. That single poster gets a source-proportioned PPT page without the surrounding preview stage. Multiple candidates are not guessed; explicit slide markers/custom selectors retain priority.
+- Without slide markers, a unique poster, or sections, the exporter fits the complete rendered root/body into one widescreen page and returns a warning. It does not automatically paginate a long webpage. Add slide markers when separate pages are intended.
+- Non-active pages are shown one at a time; ordinary `.slide.active` / `display:none` decks are supported. Pages absent from the DOM (conditional rendering or virtualized navigation) cannot be discovered: render every page for export.
+- Preview-only ancestor scaling and positioning effects are normalized before measuring a slide. Geometry and typography use its design coordinates, not the size of a scaled preview thumbnail. Design transforms inside the slide are not treated as preview scaling.
+- Use `data-pptx-ignore` on navigation/controls to exclude them from editable export.
+- Use `data-pptx-raster` to explicitly flatten an unsupported component. Its descendants are not also emitted as text.
+- Use explicit slide dimensions. Ordinary slides retain the existing widescreen fit with whitespace for other aspect ratios, never stretching. The single-poster path above instead preserves the source page aspect ratio.
+- Browser-computed grid/flex positions are supported for layout; PowerPoint receives fixed geometry, not responsive CSS rules.
+- Text is measured into visual lines, including inline emphasis, explicit line breaks, and letter spacing. Each line becomes a separate editable text box without automatic wrapping or shrinking. Editing a multiline paragraph therefore means editing separate line objects; PowerPoint does not retain the original paragraph's browser reflow behavior.
+- Ordinary PNG/JPEG data assets are preserved as independent source images. Local images are resolved by the existing workspace asset pipeline. Other image formats/crops may be captured as separate PNG objects.
+- Font names are resolved from the actual platform fonts used by Chromium, rather than only the first requested CSS family. Fonts are not embedded. Install the intended fonts on the destination machine; fallback glyphs, CJK, and Office/Chromium text metrics can still differ.
+
+### Supported scope and fallback
+
+Native objects include measured text lines with basic inline runs (font face/size, weight, italic, underline, color, and letter spacing), solid rectangles, rounded rectangles, circles, solid line-like rectangles, uniform solid borders, common simple generated bullet shapes, and independent images. Source pixel positions and font sizes are scaled consistently to PPT inches/points. Rounded-rectangle radii are limited to half the shorter side; other shape types do not receive rectangle-radius adjustments.
+
+Decorative backgrounds, gradients, shadows, nonuniform borders, and generated pseudo-elements are handled separately from ordinary text where possible. Local decoration captures isolate their own pixels so sibling objects and editable foreground text are not baked into the same image. Simple generated decorations can remain native shapes. The outer slide shadow is omitted with a warning because it lies outside the slide canvas.
+
+Subtree rasterization is reserved for explicit image fallback or content that cannot safely be represented by the supported native objects, such as SVG/canvas, tables/charts, unsupported compositing/transforms, and clipping that requires preserving the rendered pixels. Rasterized subtrees are emitted once, not again as descendant text. Images with CSS backgrounds, padding, or cropping may also be captured independently. Conversion warnings identify affected regions and lost editability rather than treating every decorated card or mixed-text block as a whole-slide screenshot.
+
+Warnings are returned in `ExportResult.warnings` and shown by the desktop independently of research-source warnings. Invalid dimensions or per-object numeric/color data, unresolved image fallbacks, broken images, and runtime errors fail descriptively instead of silently dropping objects. XML-forbidden characters in text/font names are replaced with a warning; ordinary text and XML-special punctuation retain their literal contents.
+
+This remains a beta, not a guarantee that arbitrary HTML/CSS will reproduce flawlessly in Office. Source clipping and font metrics can still cause small differences. Native tables, editable chart datasets, paragraph-level reflow across measured line objects, and PowerPoint-to-source round trips are not part of phase one. Prefer the existing image-first export when appearance matters more than editing or the design relies heavily on unsupported effects.
+
+### Verification and dependency repair
+
+```sh
+corepack pnpm@10.33.4 --filter @open-codesign/exporters test
+corepack pnpm@10.33.4 --filter @open-codesign/exporters typecheck
+```
+
+Model regressions parse generated PPTX XML and relationship parts with a dev-only XML parser, check internal relationship targets and ContentTypes declarations, and verify literal text/font-name round trips. A persistent pnpm patch for PptxGenJS 4.0.1 repairs font-attribute escaping and nonexistent per-slide master declarations in both its CJS and ESM entry points. The patch is applied through the package configuration and lockfile, not an untracked edit to installed dependencies; maintenance instructions are in [the patch README](../../patches/README.md).
+
+Native browser tests use an existing system Chrome/Chromium/Edge and skip when none is installed; they never download a browser. Coverage includes JSX props/maps, CJK, grids, hidden slides, preview scaling, local assets, measured mixed-font text, explicit line breaks, isolated decoration pixels, clipping/stacking edge cases, document fallback, and error handling.
+
+### Beta acceptance matrix
+
+This is an opt-in, phase-one Beta, not a cross-platform compatibility certification. Keep image-first PPTX as the appearance-first fallback.
+
+| Environment / layer | Evidence | Boundary |
+| --- | --- | --- |
+| System Chromium on Windows | Real rendered-DOM regressions: JSX, CJK, cards, hidden pages, preview scales, rich-text lines, clipping, stacking, isolated pixels, and posters | Runs against the installed browser; no bundled browser. Browser suites skip if none is available, so a skipped run is not a browser acceptance pass. |
+| Generated Open XML | Parsed XML/rels, valid internal targets and ContentTypes, literal text/font escaping, bounded geometry, single-poster page sizing | Structural regression checks, not complete Open XML XSD certification. |
+| Installed Windows PowerPoint | A private six-slide deck and one portrait poster opened, rendered, saved as copies, and reopened; 171 / 55 objects and all text retained | Sample-specific checks, not all designs, fonts, Office releases, or editing operations. Private source and output files are excluded from the repository. |
+| macOS PowerPoint, other Windows Office releases, WPS | Not verified for this native Beta | Must be checked before claiming support or identical layout. |
+| Linux Chromium / upstream CI | Test suites can use a discovered system browser | Local Windows results do not imply Linux/browser CI acceptance; inspect actual CI results and skips. |
+
+Public synthetic coverage lives in the [main browser suite](src/pptx-native.browser.test.ts), [layering/text edge suite](src/pptx-repair-edge.browser.test.ts), [poster suite](src/pptx-poster.browser.test.ts), [Beta fallback suite](src/pptx-beta.browser.test.ts), and [model/XML suite](src/pptx-model.test.ts). The fallback cases exercise SVG/table regions, compositing/clipping, unavailable fonts, and ignored controls without including user business material.
+
+Before promoting the Beta on another environment:
+
+1. Record the OS, browser and Office/WPS version, installed fonts, and whether browser tests actually ran.
+2. Compare browser references with Office-rendered pages, including text baselines, clipping, foreground/background order, portrait boundaries, and fallback regions.
+3. In a copy, select and edit ordinary text, move a basic shape, and verify background images do not obstruct the expected foreground objects. Text remains separate measured-line objects, not a reflowing browser paragraph.
+4. Save, close, and reopen the copy. Check for repair prompts, object loss and changed text; test at least a text/card deck, an SVG/table page, a scaled/hidden-page deck, and a portrait poster.
+5. Confirm image-first and legacy text-only exports still work. Keep the Beta label and document any unverified environment or fidelity limitation rather than silently widening the support claim.
diff --git a/packages/exporters/fixtures/pptx-beta-menu.png b/packages/exporters/fixtures/pptx-beta-menu.png
new file mode 100644
index 0000000000000000000000000000000000000000..18572bca9bfd87253aaa30d296c757af9ce97372
GIT binary patch
literal 22922
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
literal 0
HcmV?d00001
diff --git a/packages/exporters/package.json b/packages/exporters/package.json
index d7412ebfc..576aea2c9 100644
--- a/packages/exporters/package.json
+++ b/packages/exporters/package.json
@@ -26,6 +26,7 @@
},
"devDependencies": {
"@types/node": "^22.19.19",
+ "@xmldom/xmldom": "0.8.13",
"typescript": "^6.0.3",
"vitest": "^4.1.11"
}
diff --git a/packages/exporters/src/index.ts b/packages/exporters/src/index.ts
index 957c90c55..698b9014b 100644
--- a/packages/exporters/src/index.ts
+++ b/packages/exporters/src/index.ts
@@ -13,7 +13,10 @@ import type { LocalAssetOptions } from './assets';
export const EXPORTER_FORMATS = ['html', 'pdf', 'pptx', 'zip', 'markdown'] as const;
export type ExporterFormat = (typeof EXPORTER_FORMATS)[number];
-export type ExportOptions = LocalAssetOptions & { assets?: import('./zip').ZipAsset[] };
+export type ExportOptions = LocalAssetOptions & {
+ assets?: import('./zip').ZipAsset[];
+ renderMode?: import('./pptx').ExportPptxOptions['renderMode'];
+};
export async function readResearchSlides(
source: string,
@@ -26,6 +29,7 @@ export async function readResearchSlides(
export interface ExportResult {
bytes: number;
path: string;
+ warnings?: string[];
}
export function isExporterReady(_format: ExporterFormat): boolean {
diff --git a/packages/exporters/src/pptx-beta.browser.test.ts b/packages/exporters/src/pptx-beta.browser.test.ts
new file mode 100644
index 000000000..0dc460fa6
--- /dev/null
+++ b/packages/exporters/src/pptx-beta.browser.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest';
+import { findSystemChrome } from './chrome-discovery';
+import { renderNativeSlides } from './pptx-native';
+
+const chromePath = await findSystemChrome().catch(() => undefined);
+const html = (body: string) => `Editable overview
${body}Editable caption
`;
+
+async function render(body: string) {
+ const [model] = await renderNativeSlides(html(body), {
+ ...(chromePath ? { chromePath } : {}),
+ injectTailwind: false,
+ renderTimeoutMs: 20_000,
+ });
+ if (!model) throw new Error('Expected one slide');
+ return model;
+}
+
+describe.skipIf(!chromePath)('representative native PPTX Beta boundaries', () => {
+ it.each([
+ [
+ 'SVG chart',
+ '',
+ 'complex content',
+ ],
+ [
+ 'table',
+ '',
+ 'complex content',
+ ],
+ [
+ 'transform',
+ '',
+ 'transform',
+ ],
+ [
+ 'clip path',
+ '',
+ 'CSS compositing',
+ ],
+ [
+ 'group opacity',
+ '',
+ 'opacity',
+ ],
+ ])(
+ 'keeps surrounding text native when %s needs one local raster',
+ async (_name, body, reason) => {
+ const model = await render(body);
+ const texts = model.elements
+ .filter((e) => e.type === 'text')
+ .flatMap((e) => e.runs.map((r) => r.text));
+ expect(texts).toEqual(['Editable overview', 'Editable caption']);
+ const images = model.elements.filter((e) => e.type === 'image');
+ expect(images).toHaveLength(1);
+ expect(images[0]?.width).toBeLessThan(model.width / 2);
+ expect(images[0]?.height).toBeLessThan(model.height / 2);
+ expect(model.warnings.join(' ')).toContain(reason);
+ expect(model.warnings.join(' ')).toContain('not editable');
+ },
+ 30_000,
+ );
+
+ it('resolves an unavailable requested font rather than writing its absent name', async () => {
+ const missing = 'MissingBetaFixtureFont_7F57';
+ const model = await render(
+ `Font fallback remains editable
`,
+ );
+ const runs = model.elements.filter((e) => e.type === 'text').flatMap((e) => e.runs);
+ expect(runs.some((r) => r.text === 'Font fallback remains editable')).toBe(true);
+ expect(runs.every((r) => r.fontFace !== missing && r.fontFace.length > 0)).toBe(true);
+ expect(model.warnings.join(' ')).toContain(`instead of ${missing}`);
+ expect(model.elements.some((e) => e.type === 'image')).toBe(false);
+ }, 30_000);
+
+ it('excludes ignored interactive controls and their generated decorations', async () => {
+ const model = await render(
+ 'Visible content
',
+ );
+ const texts = model.elements
+ .filter((e) => e.type === 'text')
+ .flatMap((e) => e.runs.map((r) => r.text));
+ expect(texts).toEqual(['Editable overview', 'Visible content', 'Editable caption']);
+ expect(model.elements.some((e) => e.type === 'image')).toBe(false);
+ }, 30_000);
+});
diff --git a/packages/exporters/src/pptx-dom.ts b/packages/exporters/src/pptx-dom.ts
new file mode 100644
index 000000000..69569a1e8
--- /dev/null
+++ b/packages/exporters/src/pptx-dom.ts
@@ -0,0 +1,508 @@
+///
+///
+
+import type {
+ NativeSlideElement,
+ NativeSlideModel,
+ SlideBox,
+ SlideColor,
+ SlideTextRun,
+} from './pptx-model';
+
+/** Serialized into Chromium: keep runtime helpers inside this function. */
+export function extractNativeSlide(
+ root: Element,
+ platformFonts: Record = {},
+): NativeSlideModel {
+ const rootRect = root.getBoundingClientRect();
+ const elements: NativeSlideElement[] = [];
+ const warnings = new Set();
+ const canvas = document.createElement('canvas');
+ canvas.width = canvas.height = 1;
+ const context = canvas.getContext('2d', { willReadFrequently: true });
+ const colors = new Map();
+ const number = (value: string) => Number.parseFloat(value) || 0;
+ const color = (value: string): SlideColor => {
+ const cached = colors.get(value);
+ if (cached) return cached;
+ if (!context) throw new Error('Unable to resolve CSS colors for PPTX.');
+ context.clearRect(0, 0, 1, 1);
+ context.fillStyle = value;
+ context.fillRect(0, 0, 1, 1);
+ const rgba = context.getImageData(0, 0, 1, 1).data;
+ const result = {
+ hex: Array.from(rgba.slice(0, 3), (n) => n.toString(16).padStart(2, '0'))
+ .join('')
+ .toUpperCase(),
+ opacity: (rgba[3] ?? 255) / 255,
+ };
+ colors.set(value, result);
+ return result;
+ };
+ const box = (rect: DOMRect): SlideBox => ({
+ x: rect.x - rootRect.x,
+ y: rect.y - rootRect.y,
+ width: rect.width,
+ height: rect.height,
+ });
+ const visible = (el: Element) => {
+ if (getComputedStyle(el).visibility !== 'visible') return false;
+ for (let current: Element | null = el; current; current = current.parentElement) {
+ const s = getComputedStyle(current);
+ if (s.display === 'none' || number(s.opacity) === 0) return false;
+ }
+ return true;
+ };
+ let nextId = 0;
+ const selector = (el: Element) => {
+ let id = el.getAttribute('data-codesign-pptx-node');
+ if (!id) {
+ id = `capture-${nextId++}`;
+ el.setAttribute('data-codesign-pptx-node', id);
+ }
+ return `[data-codesign-pptx-node="${id}"]`;
+ };
+ const raster = (
+ el: Element,
+ reason: string,
+ capture: 'subtree' | 'decoration' | 'before' | 'after' = 'subtree',
+ bounds?: DOMRect,
+ ) => {
+ const rect = bounds ?? el.getBoundingClientRect();
+ const s = getComputedStyle(
+ el,
+ capture === 'before' || capture === 'after' ? `::${capture}` : null,
+ );
+ // A decoration image owns its shadow as well as its background, but never its text.
+ const shadowNumbers =
+ s.boxShadow
+ .replace(/rgba?\([^)]*\)/g, '')
+ .match(/-?[\d.]+px/g)
+ ?.map(number) ?? [];
+ const spread = shadowNumbers.reduce((sum, n) => sum + Math.abs(n), 0) * 2;
+ const left = Math.max(rootRect.left, rect.left - spread, 0);
+ const top = Math.max(rootRect.top, rect.top - spread, 0);
+ const right = Math.min(rootRect.right, rect.right + spread);
+ const bottom = Math.min(rootRect.bottom, rect.bottom + spread);
+ if (right <= left || bottom <= top) return;
+ elements.push({
+ type: 'raster',
+ x: left - rootRect.x,
+ y: top - rootRect.y,
+ width: right - left,
+ height: bottom - top,
+ clip: { x: left + scrollX, y: top + scrollY, width: right - left, height: bottom - top },
+ selector: selector(el),
+ capture,
+ reason,
+ });
+ warnings.add(
+ `${el.tagName.toLowerCase()} ${capture === 'subtree' ? 'contents' : 'decoration'} rasterized (${reason}); ${capture === 'subtree' ? 'this region is not editable' : 'text remains editable'}.`,
+ );
+ };
+ const hasPseudo = (el: Element, pseudo: string) => {
+ const s = getComputedStyle(el, pseudo);
+ return s.display !== 'none' && s.content !== 'none' && s.content !== 'normal';
+ };
+ const compositeReason = (el: Element, s: CSSStyleDeclaration): string | null => {
+ if (/^(svg|canvas|table|video|iframe|input|textarea|select)$/i.test(el.tagName))
+ return 'complex content';
+ if (el.hasAttribute('data-pptx-raster')) return 'explicit image fallback';
+ if (
+ s.transform !== 'none' ||
+ s.rotate !== 'none' ||
+ s.scale !== 'none' ||
+ s.translate !== 'none'
+ )
+ return 'CSS transform';
+ if (
+ s.filter !== 'none' ||
+ s.backdropFilter !== 'none' ||
+ s.mixBlendMode !== 'normal' ||
+ s.clipPath !== 'none' ||
+ s.maskImage !== 'none'
+ )
+ return 'CSS compositing';
+ if (number(s.opacity) !== 1) return 'group opacity';
+ if (s.writingMode !== 'horizontal-tb' || s.direction === 'rtl') return 'vertical or RTL text';
+ if (s.textShadow !== 'none') return 'text shadow';
+ if (s.textOverflow === 'ellipsis' || s.webkitLineClamp !== 'none') return 'clamped text';
+ if (s.display === 'list-item' && s.listStyleType !== 'none') return 'list marker';
+ return null;
+ };
+ const decorationReason = (s: CSSStyleDeclaration): string | null => {
+ if (s.backgroundImage !== 'none') return 'background image or gradient';
+ if (s.boxShadow !== 'none') return 'shadow';
+ if (s.outlineStyle !== 'none' && number(s.outlineWidth) > 0) return 'outline';
+ const borders = ['top', 'right', 'bottom', 'left'].map(
+ (side) =>
+ `${s.getPropertyValue(`border-${side}-width`)} ${s.getPropertyValue(`border-${side}-style`)} ${s.getPropertyValue(`border-${side}-color`)}`,
+ );
+ if (new Set(borders).size > 1) return 'nonuniform border';
+ if (number(s.borderTopWidth) > 0 && s.borderTopStyle !== 'solid') return 'patterned border';
+ if (
+ new Set([
+ s.borderTopLeftRadius,
+ s.borderTopRightRadius,
+ s.borderBottomLeftRadius,
+ s.borderBottomRightRadius,
+ ]).size > 1
+ )
+ return 'nonuniform corner radius';
+ if (s.borderTopLeftRadius.includes(' ')) return 'elliptical corner radius';
+ return null;
+ };
+ const shape = (rect: SlideBox, s: CSSStyleDeclaration) => {
+ const fill = color(s.backgroundColor);
+ const strokeWidth = number(s.borderTopWidth);
+ if (fill.opacity === 0 && strokeWidth === 0) return;
+ const radiusValue = s.borderTopLeftRadius;
+ const percent = radiusValue.endsWith('%');
+ const ellipse = percent && number(radiusValue) >= 50;
+ const radius = percent
+ ? (Math.min(rect.width, rect.height) * number(radiusValue)) / 100
+ : number(radiusValue);
+ elements.push({
+ type: 'shape',
+ ...rect,
+ shape: ellipse ? 'ellipse' : radius > 0 ? 'roundRect' : 'rect',
+ radius: ellipse ? 0 : Math.min(radius, rect.width / 2, rect.height / 2),
+ fill,
+ stroke: strokeWidth > 0 ? color(s.borderTopColor) : { hex: '000000', opacity: 0 },
+ strokeWidth,
+ });
+ };
+ const pseudo = (el: Element, name: 'before' | 'after') => {
+ if (!hasPseudo(el, `::${name}`)) return;
+ const s = getComputedStyle(el, `::${name}`);
+ const r = el.getBoundingClientRect();
+ const simple =
+ (s.content === '""' || s.content === "''") &&
+ s.position === 'absolute' &&
+ s.left.endsWith('px') &&
+ s.top.endsWith('px') &&
+ s.width.endsWith('px') &&
+ s.height.endsWith('px') &&
+ !decorationReason(s) &&
+ s.transform === 'none' &&
+ s.filter === 'none' &&
+ number(s.opacity) === 1;
+ if (simple) {
+ const b = {
+ x: r.x - rootRect.x + number(getComputedStyle(el).borderLeftWidth) + number(s.left),
+ y: r.y - rootRect.y + number(getComputedStyle(el).borderTopWidth) + number(s.top),
+ width: number(s.width),
+ height: number(s.height),
+ };
+ if (b.width > 0 && b.height > 0) shape(b, s);
+ } else {
+ let bounds = r;
+ if (s.position === 'absolute' || s.position === 'fixed') {
+ const probe = document.createElement('span');
+ for (const property of Array.from(s))
+ probe.style.setProperty(property, s.getPropertyValue(property), 'important');
+ probe.style.setProperty('visibility', 'hidden', 'important');
+ probe.style.setProperty('pointer-events', 'none', 'important');
+ if (s.content.startsWith('"') && s.content.endsWith('"'))
+ probe.textContent = s.content.slice(1, -1);
+ el.appendChild(probe);
+ bounds = probe.getBoundingClientRect();
+ probe.remove();
+ }
+ raster(el, `generated ::${name} decoration`, name, bounds);
+ }
+ };
+ const inlineTree = (el: Element): boolean =>
+ Array.from(el.children).every((child) => {
+ if (!visible(child)) return true;
+ if (child.tagName === 'BR') return true;
+ if (child instanceof HTMLImageElement) return false;
+ const s = getComputedStyle(child);
+ return (
+ s.display === 'inline' &&
+ s.position === 'static' &&
+ !compositeReason(child, s) &&
+ !decorationReason(s) &&
+ color(s.backgroundColor).opacity === 0 &&
+ number(s.borderTopWidth) === 0 &&
+ s.verticalAlign === 'baseline' &&
+ !hasPseudo(child, '::before') &&
+ !hasPseudo(child, '::after') &&
+ inlineTree(child)
+ );
+ });
+ const text = (el: Element, directOnly = false) => {
+ type Fragment = { rect: DOMRect; run: SlideTextRun; flow: number };
+ const fragments: Fragment[] = [];
+ let flow = 0;
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
+ const walk = (node: Node, owner: Element) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ if (directOnly) flow++;
+ const s = getComputedStyle(owner);
+ const raw = node.textContent ?? '';
+ const requested =
+ s.fontFamily
+ .split(',')[0]
+ ?.trim()
+ .replace(/^['"]|['"]$/g, '') || 'Arial';
+ const fontFace =
+ platformFonts[owner.getAttribute('data-codesign-pptx-node') ?? ''] || requested;
+ if (
+ fontFace !== requested &&
+ !/^(serif|sans-serif|monospace|system-ui|ui-sans-serif)$/i.test(requested)
+ ) {
+ warnings.add(
+ `Font resolved to ${fontFace} instead of ${requested}; fonts are not embedded.`,
+ );
+ }
+ for (const part of segmenter.segment(raw)) {
+ if (
+ /^[\r\n]+$/.test(part.segment) &&
+ (s.whiteSpace.startsWith('pre') || s.whiteSpace === 'break-spaces')
+ ) {
+ flow++;
+ continue;
+ }
+ const range = document.createRange();
+ range.setStart(node, part.index);
+ range.setEnd(node, part.index + part.segment.length);
+ const r = range.getBoundingClientRect();
+ if (r.width <= 0 || r.height <= 0) continue;
+ let value = part.segment;
+ if (!s.whiteSpace.startsWith('pre') && s.whiteSpace !== 'break-spaces')
+ value = value.replace(/\s+/g, ' ');
+ if (s.textTransform === 'uppercase') value = value.toUpperCase();
+ if (s.textTransform === 'lowercase') value = value.toLowerCase();
+ const previous = fragments.at(-1);
+ if (value === ' ' && previous?.run.text === ' ' && Math.abs(previous.rect.x - r.x) < 0.1)
+ continue;
+ fragments.push({
+ rect: r,
+ flow,
+ run: {
+ text: value,
+ fontFace,
+ fontSize: number(s.fontSize),
+ color: color(s.color),
+ bold: number(s.fontWeight) >= 600 || s.fontWeight === 'bold',
+ italic: s.fontStyle !== 'normal',
+ underline: s.textDecorationLine.includes('underline'),
+ charSpacing: number(s.letterSpacing),
+ },
+ });
+ }
+ } else if (node instanceof Element && visible(node)) {
+ if (node.tagName === 'BR') {
+ flow++;
+ return;
+ }
+ for (const child of node.childNodes)
+ if (!directOnly || child.nodeType === Node.TEXT_NODE) walk(child, node);
+ }
+ };
+ walk(el, el);
+ const lines: Fragment[][] = [];
+ for (const fragment of fragments) {
+ const last = lines.at(-1);
+ const first = last?.[0];
+ const overlap = first
+ ? Math.min(first.rect.bottom, fragment.rect.bottom) -
+ Math.max(first.rect.top, fragment.rect.top)
+ : 0;
+ const previous = last?.at(-1);
+ const gap = previous ? fragment.rect.left - previous.rect.right : 0;
+ if (
+ first &&
+ previous &&
+ first.flow === fragment.flow &&
+ gap <= Math.max(1.5, previous.run.charSpacing ?? 0) &&
+ overlap > Math.min(first.rect.height, fragment.rect.height) * 0.5 &&
+ fragment.rect.left >= first.rect.left - 1
+ )
+ last?.push(fragment);
+ else lines.push([fragment]);
+ }
+ for (const line of lines) {
+ while (line[0]?.run.text === ' ') line.shift();
+ while (line.at(-1)?.run.text === ' ') line.pop();
+ if (!line.length) continue;
+ const runs: SlideTextRun[] = [];
+ for (const { run } of line) {
+ const previous = runs.at(-1);
+ if (
+ previous &&
+ previous.fontFace === run.fontFace &&
+ previous.fontSize === run.fontSize &&
+ previous.bold === run.bold &&
+ previous.italic === run.italic &&
+ previous.underline === run.underline &&
+ previous.color.hex === run.color.hex &&
+ previous.color.opacity === run.color.opacity &&
+ previous.charSpacing === run.charSpacing
+ )
+ previous.text += run.text;
+ else runs.push({ ...run });
+ }
+ const left = Math.min(...line.map((f) => f.rect.left));
+ const top = Math.min(...line.map((f) => f.rect.top));
+ const right = Math.max(...line.map((f) => f.rect.right));
+ const bottom = Math.max(...line.map((f) => f.rect.bottom));
+ const size = Math.max(...runs.map((run) => run.fontSize));
+ // Range top is the measured font box, not the CSS block top; keep that origin for Office.
+ const y = top;
+ elements.push({
+ type: 'text',
+ x: left - rootRect.x,
+ y: y - rootRect.y,
+ width: Math.max(1, right - left + 1),
+ height: Math.max(size * 1.2, bottom - y),
+ runs,
+ align: 'left',
+ lineHeight: 1,
+ layout: 'lines',
+ });
+ }
+ };
+ const clipped = (el: Element, s: CSSStyleDeclaration) => {
+ if (!(el instanceof HTMLElement)) return false;
+ if (
+ (s.overflowX !== 'visible' && el.scrollWidth > el.clientWidth + 1) ||
+ (s.overflowY !== 'visible' && el.scrollHeight > el.clientHeight + 1)
+ )
+ return true;
+ const rect = el.getBoundingClientRect();
+ return Array.from(el.children).some((child) => {
+ if (!visible(child)) return false;
+ const r = child.getBoundingClientRect();
+ return (
+ (s.overflowX !== 'visible' && (r.left < rect.left - 1 || r.right > rect.right + 1)) ||
+ (s.overflowY !== 'visible' && (r.top < rect.top - 1 || r.bottom > rect.bottom + 1))
+ );
+ });
+ };
+ const visit = (el: Element, deferred?: Set, paintPositioned = false) => {
+ if (
+ (deferred?.has(el) && !paintPositioned) ||
+ /^(script|style|link|meta|noscript)$/i.test(el.tagName) ||
+ !visible(el)
+ )
+ return;
+ const rect = el.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) {
+ for (const child of el.children) visit(child, deferred);
+ return;
+ }
+ if (
+ rect.right <= rootRect.left ||
+ rect.bottom <= rootRect.top ||
+ rect.left >= rootRect.right ||
+ rect.top >= rootRect.bottom
+ )
+ return;
+ const s = getComputedStyle(el);
+ const reason = compositeReason(el, s);
+ if (reason) {
+ raster(el, reason);
+ return;
+ }
+ if (clipped(el, s)) {
+ raster(el, 'clipped text or content');
+ return;
+ }
+ if (
+ rect.left < rootRect.left - 1 ||
+ rect.top < rootRect.top - 1 ||
+ rect.right > rootRect.right + 1 ||
+ rect.bottom > rootRect.bottom + 1
+ ) {
+ raster(el, 'slide boundary clipping');
+ return;
+ }
+ if (el instanceof HTMLImageElement) {
+ if (!el.complete || el.naturalWidth === 0)
+ throw new Error(`PPTX image failed to load: ${el.getAttribute('src') ?? '(missing src)'}`);
+ if (
+ /^data:image\/(png|jpe?g);base64,/i.test(el.currentSrc || el.src) &&
+ s.objectFit === 'fill' &&
+ !decorationReason(s) &&
+ number(s.borderTopLeftRadius) === 0 &&
+ number(s.borderTopWidth) === 0 &&
+ color(s.backgroundColor).opacity === 0 &&
+ [s.paddingTop, s.paddingRight, s.paddingBottom, s.paddingLeft].every((p) => number(p) === 0)
+ ) {
+ elements.push({ type: 'image', ...box(rect), data: el.currentSrc || el.src });
+ } else raster(el, 'image crop or format');
+ return;
+ }
+ const decoration = decorationReason(s);
+ if (decoration === 'shadow' && el === root && s.backgroundImage === 'none') {
+ warnings.add('Slide outer shadow omitted.');
+ shape(box(rect), s);
+ } else if (decoration) raster(el, decoration, 'decoration');
+ else shape(box(rect), s);
+ const children = Array.from(el.children);
+ const contexts: Element[] = [];
+ const positioned = new Set();
+ if (!deferred) {
+ const collect = (parent: Element) => {
+ const parentStyle = getComputedStyle(parent);
+ for (const child of parent.children) {
+ if (!visible(child)) continue;
+ const cs = getComputedStyle(child);
+ const positionedZ =
+ cs.zIndex !== 'auto' &&
+ (cs.position !== 'static' ||
+ parentStyle.display.includes('flex') ||
+ parentStyle.display.includes('grid'));
+ const createsContext =
+ positionedZ ||
+ cs.position === 'fixed' ||
+ cs.position === 'sticky' ||
+ cs.isolation === 'isolate' ||
+ cs.transform !== 'none' ||
+ cs.filter !== 'none' ||
+ number(cs.opacity) !== 1;
+ if (createsContext) contexts.push(child);
+ else {
+ if (cs.position !== 'static') {
+ contexts.push(child);
+ positioned.add(child);
+ }
+ // z-index:auto participates in the positioned paint phase, but its descendants
+ // still belong to this stacking context (not a new, artificially isolated one).
+ if (!compositeReason(child, cs) && !clipped(child, cs)) collect(child);
+ }
+ }
+ };
+ collect(el);
+ }
+ const pending = deferred ?? new Set(contexts);
+ const z = (child: Element) => number(getComputedStyle(child).zIndex);
+ for (const child of contexts.filter((child) => z(child) < 0).sort((a, b) => z(a) - z(b)))
+ visit(child);
+ pseudo(el, 'before');
+ if (inlineTree(el)) text(el);
+ else {
+ // Anonymous inline text is measurable too; do not sacrifice its containing flex/grid card.
+ if (
+ Array.from(el.childNodes).some(
+ (node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim(),
+ )
+ )
+ text(el, true);
+ for (const child of children) visit(child, pending);
+ }
+ pseudo(el, 'after');
+ for (const child of contexts.filter((child) => z(child) >= 0).sort((a, b) => z(a) - z(b))) {
+ if (positioned.has(child)) visit(child, pending, true);
+ else visit(child);
+ }
+ };
+ for (const image of root.querySelectorAll('img')) {
+ if (visible(image) && (!image.complete || image.naturalWidth === 0))
+ throw new Error(`PPTX image failed to load: ${image.getAttribute('src') ?? '(missing src)'}`);
+ }
+ visit(root);
+ return { width: rootRect.width, height: rootRect.height, elements, warnings: [...warnings] };
+}
diff --git a/packages/exporters/src/pptx-model.test.ts b/packages/exporters/src/pptx-model.test.ts
new file mode 100644
index 000000000..e49129a33
--- /dev/null
+++ b/packages/exporters/src/pptx-model.test.ts
@@ -0,0 +1,683 @@
+import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
+import { createRequire } from 'node:module';
+import { tmpdir } from 'node:os';
+import { join, posix } from 'node:path';
+import { DOMParser } from '@xmldom/xmldom';
+import PptxGenJS from 'pptxgenjs';
+import { describe, expect, it, vi } from 'vitest';
+import { extract } from 'zip-lib';
+import {
+ addNativeSlides,
+ type NativeSlideElement,
+ type NativeSlideModel,
+ slideTransform,
+} from './pptx-model';
+
+const png =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
+const drawing = 'http://schemas.openxmlformats.org/drawingml/2006/main';
+const presentation = 'http://schemas.openxmlformats.org/presentationml/2006/main';
+const relationships = 'http://schemas.openxmlformats.org/package/2006/relationships';
+const contentTypes = 'http://schemas.openxmlformats.org/package/2006/content-types';
+
+function parseXml(xml: string) {
+ const errors: string[] = [];
+ const document = new DOMParser({
+ errorHandler: {
+ warning: (message) => errors.push(message),
+ error: (message) => errors.push(message),
+ fatalError: (message) => errors.push(message),
+ },
+ }).parseFromString(xml, 'application/xml');
+ expect(errors, 'XML must parse without recovery').toEqual([]);
+ return document;
+}
+
+function fixture(): NativeSlideModel {
+ return {
+ width: 1280,
+ height: 720,
+ warnings: [],
+ elements: [
+ {
+ type: 'shape',
+ shape: 'roundRect',
+ radius: 12,
+ x: 96,
+ y: 96,
+ width: 192,
+ height: 96,
+ fill: { hex: 'FF0000', opacity: 1 },
+ stroke: { hex: '000000', opacity: 0 },
+ strokeWidth: 0,
+ },
+ {
+ type: 'text',
+ x: 96,
+ y: 192,
+ width: 384,
+ height: 96,
+ align: 'left',
+ lineHeight: 1.2,
+ runs: [
+ {
+ text: '中文 Native text',
+ fontFace: 'Arial',
+ fontSize: 32,
+ color: { hex: '123456', opacity: 1 },
+ bold: true,
+ italic: false,
+ underline: false,
+ },
+ ],
+ },
+ { type: 'image', x: 576, y: 96, width: 96, height: 96, data: png },
+ ],
+ };
+}
+
+async function inspectPresentation(pres: PptxGenJS, inspect: (parts: Map) => void) {
+ const directory = await mkdtemp(join(tmpdir(), 'pptx-native-model-'));
+ try {
+ const destination = join(directory, 'native.pptx');
+ await pres.writeFile({ fileName: destination });
+ const unpacked = join(directory, 'unpacked');
+ await extract(destination, unpacked);
+ const files = (await readdir(unpacked, { recursive: true, withFileTypes: true })).filter(
+ (entry) => entry.isFile(),
+ );
+ const names = new Set(
+ files.map((entry) =>
+ posix
+ .join(entry.parentPath.slice(unpacked.length).replaceAll('\\', '/'), entry.name)
+ .replace(/^\//, ''),
+ ),
+ );
+ const parts = new Map();
+ for (const name of names) {
+ if (!name.endsWith('.xml') && !name.endsWith('.rels')) continue;
+ const xml = await readFile(join(unpacked, name), 'utf8');
+ const document = parseXml(xml);
+ parts.set(name, xml);
+ if (name.endsWith('.rels')) {
+ for (const rel of Array.from(
+ document.getElementsByTagNameNS(relationships, 'Relationship'),
+ )) {
+ if (rel.getAttribute('TargetMode') === 'External') continue;
+ const target = rel.getAttribute('Target') ?? '';
+ const base = name === '_rels/.rels' ? '' : posix.dirname(posix.dirname(name));
+ const resolved = target.startsWith('/')
+ ? target.slice(1)
+ : posix.normalize(posix.join(base, target));
+ expect(names.has(resolved), `${name}: missing relationship target ${target}`).toBe(true);
+ }
+ }
+ }
+ const types = parseXml(parts.get('[Content_Types].xml') ?? '');
+ const overrides = Array.from(types.getElementsByTagNameNS(contentTypes, 'Override')).map(
+ (entry) => entry.getAttribute('PartName') ?? '',
+ );
+ expect(new Set(overrides).size, 'ContentTypes overrides must be unique').toBe(overrides.length);
+ for (const override of overrides) {
+ expect(names.has(override.slice(1)), `missing ContentTypes part ${override}`).toBe(true);
+ }
+ inspect(parts);
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+}
+
+function required(value: T | undefined): T {
+ if (value === undefined) throw new Error('Missing test fixture value');
+ return value;
+}
+
+function textNode(model: NativeSlideModel) {
+ const node = model.elements[1];
+ if (!node || node.type !== 'text') throw new Error('Invalid fixture');
+ return node;
+}
+
+function shapeNode(model: NativeSlideModel) {
+ const node = model.elements[0];
+ if (!node || node.type !== 'shape') throw new Error('Invalid fixture');
+ return node;
+}
+
+describe('native slide writer', () => {
+ it('fits slides proportionally and rejects invalid dimensions', () => {
+ expect(slideTransform(1280, 720).offsetX).toBeCloseTo(0);
+ expect(slideTransform(1280, 720).offsetY).toBeCloseTo(0);
+ expect(slideTransform(1280, 720).scale * 96).toBeCloseTo(1);
+ expect(slideTransform(720, 1280).offsetX).toBeGreaterThan(0);
+ expect(slideTransform(720, 1280).offsetY).toBe(0);
+ for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
+ expect(() => slideTransform(value, 720)).toThrow('positive finite');
+ }
+ });
+
+ it('writes native text, shapes and an independent picture with scaled geometry', async () => {
+ const pres = new PptxGenJS();
+ pres.layout = 'LAYOUT_WIDE';
+ const model = fixture();
+ model.warnings = ['Effect simplified'];
+ expect(addNativeSlides(pres, [model])).toEqual(['Slide 1: Effect simplified']);
+ await inspectPresentation(pres, (parts) => {
+ const document = parseXml(parts.get('ppt/slides/slide1.xml') ?? '');
+ expect(document.getElementsByTagNameNS(drawing, 't')[0]?.textContent).toBe(
+ '中文 Native text',
+ );
+ expect(document.getElementsByTagNameNS(drawing, 'prstGeom')[0]?.getAttribute('prst')).toBe(
+ 'roundRect',
+ );
+ expect(document.getElementsByTagNameNS(presentation, 'sp').length).toBe(2);
+ expect(document.getElementsByTagNameNS(presentation, 'pic').length).toBe(1);
+ const off = document
+ .getElementsByTagNameNS(drawing, 'xfrm')[1]
+ ?.getElementsByTagNameNS(drawing, 'off')[0];
+ expect(off?.getAttribute('x')).toBe('914400');
+ expect(off?.getAttribute('y')).toBe('914400');
+ expect(document.getElementsByTagNameNS(drawing, 'rPr')[0]?.getAttribute('sz')).toBe('2400');
+ });
+ });
+
+ it.each([
+ undefined,
+ 'source',
+ ] as const)('writes portrait geometry with pageLayout=%s using the existing sizing budget', async (pageLayout) => {
+ const pres = new PptxGenJS();
+ pres.layout = 'LAYOUT_WIDE';
+ const model = fixture();
+ model.width = 720;
+ model.height = 1280;
+ if (pageLayout !== undefined) model.pageLayout = pageLayout;
+ Object.assign(shapeNode(model), { x: 0, y: 0, width: 720, height: 1280 });
+ const transform = slideTransform(model.width, model.height);
+ const offsetX = pageLayout === 'source' ? 0 : transform.offsetX;
+ addNativeSlides(pres, [model]);
+ await inspectPresentation(pres, (parts) => {
+ const document = parseXml(parts.get('ppt/presentation.xml') ?? '');
+ const size = document.getElementsByTagNameNS(presentation, 'sldSz')[0];
+ expect(size?.getAttribute('cx')).toBe(pageLayout === 'source' ? '3857625' : '12192000');
+ expect(size?.getAttribute('cy')).toBe('6858000');
+ const slide = parseXml(parts.get('ppt/slides/slide1.xml') ?? '');
+ const objects = [
+ ...Array.from(slide.getElementsByTagNameNS(presentation, 'sp')),
+ ...Array.from(slide.getElementsByTagNameNS(presentation, 'pic')),
+ ];
+ expect(objects).toHaveLength(3);
+ for (const [index, object] of objects.entries()) {
+ const node = required(model.elements[index]);
+ const off = object.getElementsByTagNameNS(drawing, 'off')[0];
+ const ext = object.getElementsByTagNameNS(drawing, 'ext')[0];
+ expect(off?.getAttribute('x')).toBe(
+ String(Math.round((offsetX + node.x * transform.scale) * 914400)),
+ );
+ expect(off?.getAttribute('y')).toBe(String(Math.round(node.y * transform.scale * 914400)));
+ expect(ext?.getAttribute('cx')).toBe(
+ String(Math.round(node.width * transform.scale * 914400)),
+ );
+ expect(ext?.getAttribute('cy')).toBe(
+ String(Math.round(node.height * transform.scale * 914400)),
+ );
+ }
+ expect(slide.getElementsByTagNameNS(drawing, 'rPr')[0]?.getAttribute('sz')).toBe('1350');
+ });
+ });
+
+ it.each([
+ null,
+ 'wide',
+ '',
+ 42,
+ ])('rejects invalid pageLayout %s without mutation', (pageLayout) => {
+ const pres = new PptxGenJS();
+ pres.layout = 'LAYOUT_WIDE';
+ const defineLayout = vi.spyOn(pres, 'defineLayout');
+ const setLayout = vi.spyOn(pres, 'layout', 'set');
+ const addSlide = vi.spyOn(pres, 'addSlide');
+ const model = fixture();
+ Object.assign(model, { pageLayout });
+ expect(() => addNativeSlides(pres, [fixture(), model])).toThrow(
+ 'Slide 2: Unsupported pageLayout',
+ );
+ expect(defineLayout).not.toHaveBeenCalled();
+ expect(setLayout).not.toHaveBeenCalled();
+ expect(addSlide).not.toHaveBeenCalled();
+ expect(pres.layout).toBe('LAYOUT_WIDE');
+ });
+
+ it.each([
+ ['source', undefined],
+ [undefined, 'source'],
+ ['source', 'source'],
+ ] as const)('rejects mixed or multiple source pages (%s, %s) without mutation', (first, second) => {
+ const pres = new PptxGenJS();
+ pres.layout = 'LAYOUT_WIDE';
+ const defineLayout = vi.spyOn(pres, 'defineLayout');
+ const setLayout = vi.spyOn(pres, 'layout', 'set');
+ const addSlide = vi.spyOn(pres, 'addSlide');
+ expect(() =>
+ addNativeSlides(pres, [
+ { ...fixture(), ...(first !== undefined ? { pageLayout: first } : {}) },
+ { ...fixture(), ...(second !== undefined ? { pageLayout: second } : {}) },
+ ]),
+ ).toThrow('Source-sized pageLayout requires exactly one slide model.');
+ expect(defineLayout).not.toHaveBeenCalled();
+ expect(setLayout).not.toHaveBeenCalled();
+ expect(addSlide).not.toHaveBeenCalled();
+ expect(pres.layout).toBe('LAYOUT_WIDE');
+ });
+
+ const malformedSourceCases: [string, (model: NativeSlideModel) => void, string][] = [
+ [
+ 'dimensions',
+ (model) => {
+ model.width = Number.NaN;
+ },
+ 'positive finite dimensions',
+ ],
+ [
+ 'page underflow',
+ (model) => {
+ model.width = Number.MIN_VALUE;
+ model.elements = [];
+ },
+ 'page width (EMU)',
+ ],
+ [
+ 'elements',
+ (model) => {
+ Object.assign(model, { elements: null });
+ },
+ 'elements must be an array',
+ ],
+ [
+ 'warnings',
+ (model) => {
+ Object.assign(model, { warnings: [42] });
+ },
+ 'warnings must be an array of strings',
+ ],
+ [
+ 'object',
+ (model) => {
+ textNode(model).lineHeight = 0;
+ },
+ 'Slide 1, object 2 (text): lineHeight',
+ ],
+ ];
+ it.each(
+ malformedSourceCases,
+ )('rejects malformed source %s before changing existing PPTX', async (_name, mutate, message) => {
+ const pres = new PptxGenJS();
+ pres.layout = 'LAYOUT_WIDE';
+ pres.addSlide().addText('Existing slide');
+ const defineLayout = vi.spyOn(pres, 'defineLayout');
+ const setLayout = vi.spyOn(pres, 'layout', 'set');
+ const addSlide = vi.spyOn(pres, 'addSlide');
+ const model = fixture();
+ model.pageLayout = 'source';
+ model.width = 720;
+ model.height = 1280;
+ mutate(model);
+ expect(() => addNativeSlides(pres, [model])).toThrow(message);
+ expect(defineLayout).not.toHaveBeenCalled();
+ expect(setLayout).not.toHaveBeenCalled();
+ expect(addSlide).not.toHaveBeenCalled();
+ expect(pres.layout).toBe('LAYOUT_WIDE');
+ await inspectPresentation(pres, (parts) => {
+ const document = parseXml(parts.get('ppt/presentation.xml') ?? '');
+ const size = document.getElementsByTagNameNS(presentation, 'sldSz')[0];
+ expect(size?.getAttribute('cx')).toBe('12192000');
+ expect(size?.getAttribute('cy')).toBe('6858000');
+ expect(document.getElementsByTagNameNS(presentation, 'sldId').length).toBe(1);
+ const slide = parseXml(parts.get('ppt/slides/slide1.xml') ?? '');
+ expect(slide.getElementsByTagNameNS(drawing, 't')[0]?.textContent).toBe('Existing slide');
+ });
+ });
+
+ it('emits radius adjustments only for roundRect and clamps them to half the shortest side', async () => {
+ const pres = new PptxGenJS();
+ const model = fixture();
+ const original = shapeNode(model);
+ model.elements = ['rect', 'ellipse', 'roundRect'].map((shape) => ({
+ ...original,
+ shape,
+ radius: 999,
+ })) as NativeSlideElement[];
+ addNativeSlides(pres, [model]);
+ await inspectPresentation(pres, (parts) => {
+ const document = parseXml(parts.get('ppt/slides/slide1.xml') ?? '');
+ const geometries = Array.from(document.getElementsByTagNameNS(drawing, 'prstGeom'));
+ expect(geometries.map((element) => element.getAttribute('prst'))).toEqual([
+ 'rect',
+ 'ellipse',
+ 'roundRect',
+ ]);
+ expect(geometries[0]?.getElementsByTagNameNS(drawing, 'gd').length).toBe(0);
+ expect(geometries[1]?.getElementsByTagNameNS(drawing, 'gd').length).toBe(0);
+ expect(geometries[2]?.getElementsByTagNameNS(drawing, 'gd')[0]?.getAttribute('fmla')).toBe(
+ 'val 50000',
+ );
+ });
+ });
+
+ it('preserves XML entities and Unicode while replacing forbidden characters with warnings', async () => {
+ const pres = new PptxGenJS();
+ const model = fixture();
+ const node = textNode(model);
+ const text = `中文 😀 & < > " ' &\t\n\u0000\u000b\ufffe\uffff\ud800!\udc00`;
+ required(node.runs[0]).text = text;
+ required(node.runs[0]).fontFace = `A&B "Quoted" 'Name'\u0001`;
+ expect(addNativeSlides(pres, [model])).toEqual([
+ 'Slide 1, object 2 (text): Replaced XML-forbidden characters in run 1.text.',
+ 'Slide 1, object 2 (text): Replaced XML-forbidden characters in run 1.fontFace.',
+ ]);
+ await inspectPresentation(pres, (parts) => {
+ const document = parseXml(parts.get('ppt/slides/slide1.xml') ?? '');
+ const textValues = Array.from(document.getElementsByTagNameNS(drawing, 't'))
+ .map((entry) => entry.textContent)
+ .join('\n');
+ expect(textValues).toBe(`中文 😀 & < > " ' &\t\n�����!�`);
+ for (const tag of ['latin', 'ea', 'cs']) {
+ expect(document.getElementsByTagNameNS(drawing, tag)[0]?.getAttribute('typeface')).toBe(
+ `A&B "Quoted" 'Name'�`,
+ );
+ }
+ });
+ expect(required(node.runs[0]).text).toBe(text);
+ });
+
+ it('writes measured lines without shrink/wrap and preserves negative positions and letter spacing', async () => {
+ const pres = new PptxGenJS();
+ const model = fixture();
+ const node = textNode(model);
+ node.layout = 'lines';
+ node.x = -96;
+ node.y = -48;
+ required(node.runs[0]).charSpacing = -2;
+ model.elements = [node];
+ addNativeSlides(pres, [model]);
+ await inspectPresentation(pres, (parts) => {
+ const document = parseXml(parts.get('ppt/slides/slide1.xml') ?? '');
+ const body = document.getElementsByTagNameNS(drawing, 'bodyPr')[0];
+ expect(body?.getAttribute('wrap')).toBe('none');
+ expect(body?.getElementsByTagNameNS(drawing, 'normAutofit').length).toBe(0);
+ expect(body?.getElementsByTagNameNS(drawing, 'spAutoFit').length).toBe(0);
+ expect(document.getElementsByTagNameNS(drawing, 'spcPct')[0]?.getAttribute('val')).toBe(
+ '100000',
+ );
+ expect(document.getElementsByTagNameNS(drawing, 'rPr')[0]?.getAttribute('spc')).toBe('-150');
+ const off = document
+ .getElementsByTagNameNS(drawing, 'xfrm')[1]
+ ?.getElementsByTagNameNS(drawing, 'off')[0];
+ expect(off?.getAttribute('x')).toBe('-914400');
+ expect(off?.getAttribute('y')).toBe('-457200');
+ });
+ });
+
+ const invalidCases: [string, (model: NativeSlideModel) => void, string][] = [
+ [
+ 'NaN position',
+ (m) => {
+ required(m.elements[0]).x = Number.NaN;
+ },
+ 'x',
+ ],
+ [
+ 'infinite position',
+ (m) => {
+ required(m.elements[0]).y = Number.POSITIVE_INFINITY;
+ },
+ 'y',
+ ],
+ [
+ 'oversized position',
+ (m) => {
+ required(m.elements[0]).x = 1e20;
+ },
+ 'scaled x',
+ ],
+ [
+ 'zero width',
+ (m) => {
+ required(m.elements[0]).width = 0;
+ },
+ 'width',
+ ],
+ [
+ 'negative height',
+ (m) => {
+ required(m.elements[0]).height = -1;
+ },
+ 'height',
+ ],
+ [
+ 'underflow width',
+ (m) => {
+ required(m.elements[0]).width = 1e-20;
+ },
+ 'scaled width',
+ ],
+ [
+ 'NaN radius',
+ (m) => {
+ shapeNode(m).radius = Number.NaN;
+ },
+ 'radius',
+ ],
+ [
+ 'negative radius',
+ (m) => {
+ shapeNode(m).radius = -1;
+ },
+ 'radius',
+ ],
+ [
+ 'invalid shape',
+ (m) => {
+ Object.assign(required(m.elements[0]), { shape: 'invalid' });
+ },
+ 'Unsupported shape',
+ ],
+ [
+ 'negative stroke',
+ (m) => {
+ shapeNode(m).strokeWidth = -1;
+ },
+ 'strokeWidth',
+ ],
+ [
+ 'oversized stroke',
+ (m) => {
+ shapeNode(m).strokeWidth = 1e10;
+ },
+ 'strokeWidth',
+ ],
+ [
+ 'numeric color',
+ (m) => {
+ Object.assign(shapeNode(m).fill, { hex: 123456 });
+ },
+ 'fill.hex',
+ ],
+ [
+ 'ambiguous inch dimensions',
+ (m) => {
+ shapeNode(m).width = 9600;
+ },
+ 'scaled w',
+ ],
+ [
+ 'invalid fill',
+ (m) => {
+ shapeNode(m).fill.hex = '#FF0000';
+ },
+ 'fill.hex',
+ ],
+ [
+ 'invalid opacity',
+ (m) => {
+ shapeNode(m).stroke.opacity = 1.1;
+ },
+ 'stroke.opacity',
+ ],
+ [
+ 'NaN opacity',
+ (m) => {
+ required(textNode(m).runs[0]).color.opacity = Number.NaN;
+ },
+ 'color.opacity',
+ ],
+ [
+ 'zero font',
+ (m) => {
+ required(textNode(m).runs[0]).fontSize = 0;
+ },
+ 'fontSize',
+ ],
+ [
+ 'oversized font',
+ (m) => {
+ required(textNode(m).runs[0]).fontSize = 1e10;
+ },
+ 'fontSize',
+ ],
+ [
+ 'empty font',
+ (m) => {
+ required(textNode(m).runs[0]).fontFace = '';
+ },
+ 'fontFace',
+ ],
+ [
+ 'invalid line height',
+ (m) => {
+ textNode(m).lineHeight = 0;
+ },
+ 'lineHeight',
+ ],
+ [
+ 'invalid spacing',
+ (m) => {
+ required(textNode(m).runs[0]).charSpacing = Number.NaN;
+ },
+ 'charSpacing',
+ ],
+ [
+ 'empty runs',
+ (m) => {
+ textNode(m).runs = [];
+ },
+ 'runs',
+ ],
+ [
+ 'invalid text',
+ (m) => {
+ Object.assign(required(textNode(m).runs[0]), { text: 42 });
+ },
+ 'text',
+ ],
+ [
+ 'invalid boolean',
+ (m) => {
+ Object.assign(required(textNode(m).runs[0]), { bold: 'yes' });
+ },
+ 'bold',
+ ],
+ [
+ 'invalid alignment',
+ (m) => {
+ Object.assign(textNode(m), { align: 'invalid' });
+ },
+ 'alignment',
+ ],
+ [
+ 'invalid image',
+ (m) => {
+ Object.assign(required(m.elements[2]), { data: 'https://example.com/image.png' });
+ },
+ 'image.data',
+ ],
+ [
+ 'unknown node',
+ (m) => {
+ Object.assign(required(m.elements[0]), { type: 'unknown' });
+ },
+ 'object type',
+ ],
+ ];
+ it.each(
+ invalidCases,
+ )('rejects %s with slide/object context before modifying presentation', (_name, mutate, field) => {
+ const pres = new PptxGenJS();
+ const model = fixture();
+ mutate(model);
+ const addSlide = vi.spyOn(pres, 'addSlide');
+ expect(() => addNativeSlides(pres, [fixture(), model])).toThrow(
+ new RegExp(`Slide 2, object \\d+ .*${field}`),
+ );
+ expect(addSlide).not.toHaveBeenCalled();
+ });
+
+ it('includes the page number for invalid model dimensions', () => {
+ const model = fixture();
+ model.width = Number.NaN;
+ const pres = new PptxGenJS();
+ const addSlide = vi.spyOn(pres, 'addSlide');
+ expect(() => addNativeSlides(pres, [fixture(), model])).toThrow(
+ 'Slide 2: Native PPTX slides must have positive finite dimensions.',
+ );
+ expect(addSlide).not.toHaveBeenCalled();
+ });
+
+ it('refuses to silently write unresolved image fallbacks', () => {
+ const model = fixture();
+ model.elements = [
+ {
+ type: 'raster',
+ x: 0,
+ y: 0,
+ width: 10,
+ height: 10,
+ clip: { x: 0, y: 0, width: 10, height: 10 },
+ reason: 'test',
+ },
+ ];
+ expect(() => addNativeSlides(new PptxGenJS(), [model])).toThrow(
+ 'Slide 1, object 1 (raster): Unresolved',
+ );
+ });
+});
+
+const require = createRequire(import.meta.url);
+const CommonJsPptxGenJS = require('pptxgenjs') as typeof PptxGenJS;
+
+describe.each([
+ ['ESM', PptxGenJS],
+ ['CJS', CommonJsPptxGenJS],
+] as const)('PptxGenJS persistent patch (%s)', (_format, Constructor) => {
+ it('escapes font attributes exactly once and declares only real package parts in multi-slide decks', async () => {
+ const pres = new Constructor();
+ const fontFace = `A&B "Quoted" 'Name' &`;
+ for (let index = 0; index < 3; index++)
+ pres.addSlide().addText('A & B < C & 😀', { fontFace });
+ await inspectPresentation(pres, (parts) => {
+ const document = parseXml(parts.get('ppt/slides/slide1.xml') ?? '');
+ expect(document.getElementsByTagNameNS(drawing, 't')[0]?.textContent).toBe(
+ 'A & B < C & 😀',
+ );
+ for (const tag of ['latin', 'ea', 'cs']) {
+ expect(document.getElementsByTagNameNS(drawing, tag)[0]?.getAttribute('typeface')).toBe(
+ fontFace,
+ );
+ }
+ expect(
+ Array.from(parts.keys()).filter((name) =>
+ /^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(name),
+ ),
+ ).toEqual(['ppt/slideMasters/slideMaster1.xml']);
+ });
+ });
+});
diff --git a/packages/exporters/src/pptx-model.ts b/packages/exporters/src/pptx-model.ts
new file mode 100644
index 000000000..90cb06398
--- /dev/null
+++ b/packages/exporters/src/pptx-model.ts
@@ -0,0 +1,313 @@
+import type PptxGenJS from 'pptxgenjs';
+
+export interface SlideBox {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export interface SlideColor {
+ hex: string;
+ opacity: number;
+}
+
+export interface SlideTextRun {
+ text: string;
+ fontFace: string;
+ fontSize: number;
+ color: SlideColor;
+ bold: boolean;
+ italic: boolean;
+ underline: boolean;
+ charSpacing?: number;
+}
+
+export type NativeSlideElement =
+ | (SlideBox & {
+ type: 'text';
+ runs: SlideTextRun[];
+ align: 'left' | 'center' | 'right';
+ lineHeight: number;
+ layout?: 'lines';
+ })
+ | (SlideBox & {
+ type: 'shape';
+ shape: 'rect' | 'roundRect' | 'ellipse';
+ radius: number;
+ fill: SlideColor;
+ stroke: SlideColor;
+ strokeWidth: number;
+ })
+ | (SlideBox & { type: 'image'; data: string })
+ | (SlideBox & {
+ type: 'raster';
+ clip: SlideBox;
+ reason: string;
+ selector?: string;
+ capture?: 'subtree' | 'decoration' | 'before' | 'after';
+ });
+
+export interface NativeSlideModel {
+ width: number;
+ height: number;
+ pageLayout?: 'source';
+ elements: NativeSlideElement[];
+ warnings: string[];
+}
+
+/** Fit the entire source slide; unlike stretching, this preserves text and image proportions. */
+export function slideTransform(width: number, height: number) {
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
+ throw new Error('Native PPTX slides must have positive finite dimensions.');
+ }
+ const slideWidth = 40 / 3;
+ const slideHeight = 7.5;
+ const scale = Math.min(slideWidth / width, slideHeight / height);
+ if (!Number.isFinite(scale) || scale <= 0) {
+ throw new Error('Native PPTX slide dimensions are outside the supported range.');
+ }
+ return {
+ scale,
+ offsetX: (slideWidth - width * scale) / 2,
+ offsetY: (slideHeight - height * scale) / 2,
+ };
+}
+
+function checkNumber(value: number, field: string, min: number, max: number): void {
+ if (!Number.isFinite(value) || value < min || value > max) {
+ throw new Error(`${field} must be finite and between ${min} and ${max}; received ${value}.`);
+ }
+}
+
+function checkColor(color: SlideColor, field: string): void {
+ if (!color || typeof color.hex !== 'string' || !/^[\da-f]{6}$/i.test(color.hex)) {
+ throw new Error(`${field}.hex must be a six-digit RGB color.`);
+ }
+ checkNumber(color.opacity, `${field}.opacity`, 0, 1);
+}
+
+function objectBox(node: NativeSlideElement, transform: ReturnType) {
+ const { scale, offsetX, offsetY } = transform;
+ checkNumber(node.x, 'x', -Number.MAX_VALUE, Number.MAX_VALUE);
+ checkNumber(node.y, 'y', -Number.MAX_VALUE, Number.MAX_VALUE);
+ checkNumber(node.width, 'width', Number.MIN_VALUE, Number.MAX_VALUE);
+ checkNumber(node.height, 'height', Number.MIN_VALUE, Number.MAX_VALUE);
+ const box = {
+ x: offsetX + node.x * scale,
+ y: offsetY + node.y * scale,
+ w: node.width * scale,
+ h: node.height * scale,
+ };
+ // PptxGenJS treats numeric values >= 100 as EMUs, not inches.
+ for (const [field, value] of Object.entries(box)) {
+ if (value >= 100) throw new Error(`scaled ${field} must be less than 100 inches.`);
+ }
+ // DrawingML ST_Coordinate and ST_PositiveCoordinate bounds, in EMUs.
+ checkNumber(Math.round(box.x * 914400), 'scaled x (EMU)', -27273042329600, 27273042316900);
+ checkNumber(Math.round(box.y * 914400), 'scaled y (EMU)', -27273042329600, 27273042316900);
+ checkNumber(Math.round(box.w * 914400), 'scaled width (EMU)', 1, 27273042316900);
+ checkNumber(Math.round(box.h * 914400), 'scaled height (EMU)', 1, 27273042316900);
+ return box;
+}
+
+function checkElement(node: NativeSlideElement, scale: number): void {
+ if (node.type === 'raster') throw new Error('Unresolved native PPTX raster fallback.');
+ if (node.type === 'image') {
+ if (
+ typeof node.data !== 'string' ||
+ !/^data:image\/(png|jpeg|gif);base64,[A-Za-z0-9+/]+={0,2}$/.test(node.data)
+ ) {
+ throw new Error('image.data must be an embedded PNG, JPEG or GIF base64 data URL.');
+ }
+ } else if (node.type === 'shape') {
+ if (!['rect', 'roundRect', 'ellipse'].includes(node.shape))
+ throw new Error('Unsupported shape.');
+ checkNumber(node.radius, 'radius', 0, Number.MAX_VALUE);
+ checkNumber(node.strokeWidth, 'strokeWidth', 0, Number.MAX_VALUE);
+ checkNumber(
+ Math.round(node.strokeWidth * scale * 914400),
+ 'scaled strokeWidth (EMU)',
+ 0,
+ 20116800,
+ );
+ checkColor(node.fill, 'fill');
+ checkColor(node.stroke, 'stroke');
+ } else if (node.type === 'text') {
+ if (!['left', 'center', 'right'].includes(node.align))
+ throw new Error('Unsupported text alignment.');
+ if (node.layout !== undefined && node.layout !== 'lines')
+ throw new Error('Unsupported text layout.');
+ checkNumber(node.lineHeight, 'lineHeight', Number.MIN_VALUE, 21474.83647);
+ if (!Array.isArray(node.runs) || node.runs.length === 0)
+ throw new Error('text.runs must not be empty.');
+ for (const [index, run] of node.runs.entries()) {
+ const field = `run ${index + 1}`;
+ if (typeof run.text !== 'string') throw new Error(`${field}.text must be a string.`);
+ if (typeof run.fontFace !== 'string' || !run.fontFace.trim())
+ throw new Error(`${field}.fontFace must not be empty.`);
+ checkNumber(run.fontSize, `${field}.fontSize`, Number.MIN_VALUE, Number.MAX_VALUE);
+ checkNumber(
+ Math.round(run.fontSize * scale * 7200),
+ `${field}.scaled fontSize (hundredths of a point)`,
+ 100,
+ 400000,
+ );
+ checkColor(run.color, `${field}.color`);
+ for (const property of ['bold', 'italic', 'underline'] as const) {
+ if (typeof run[property] !== 'boolean')
+ throw new Error(`${field}.${property} must be a boolean.`);
+ }
+ if (run.charSpacing !== undefined) {
+ checkNumber(run.charSpacing, `${field}.charSpacing`, -Number.MAX_VALUE, Number.MAX_VALUE);
+ checkNumber(
+ Math.round(run.charSpacing * scale * 7200),
+ `${field}.scaled charSpacing (hundredths of a point)`,
+ -400000,
+ 400000,
+ );
+ }
+ }
+ } else {
+ throw new Error('Unsupported native PPTX object type.');
+ }
+}
+
+function cleanXml(value: string, field: string, context: string, warnings: Set): string {
+ // XML 1.0 allows tab/newline/CR, but not other C0 controls or unpaired surrogates.
+ const cleaned = Array.from(value, (character) => {
+ const code = character.codePointAt(0) ?? 0;
+ return code === 9 ||
+ code === 10 ||
+ code === 13 ||
+ (code >= 0x20 && code <= 0xd7ff) ||
+ (code >= 0xe000 && code <= 0xfffd) ||
+ code >= 0x10000
+ ? character
+ : '\uFFFD';
+ }).join('');
+ if (cleaned !== value) warnings.add(`${context}: Replaced XML-forbidden characters in ${field}.`);
+ // Entity escaping belongs to PptxGenJS; pre-escaping ordinary text changes its contents.
+ return cleaned;
+}
+
+export function addNativeSlides(pres: PptxGenJS, models: NativeSlideModel[]): string[] {
+ const warnings = new Set();
+ // Validate the whole deck before mutating the presentation; never silently drop bad objects.
+ const transforms = models.map((model, index) => {
+ let transform: ReturnType;
+ try {
+ transform = slideTransform(model.width, model.height);
+ if (model.pageLayout !== undefined && model.pageLayout !== 'source') {
+ throw new Error('Unsupported pageLayout; expected "source" or undefined.');
+ }
+ if (model.pageLayout === 'source') {
+ if (models.length !== 1) {
+ throw new Error('Source-sized pageLayout requires exactly one slide model.');
+ }
+ transform = { ...transform, offsetX: 0, offsetY: 0 };
+ checkNumber(
+ Math.round(model.width * transform.scale * 914400),
+ 'page width (EMU)',
+ 1,
+ 12192000,
+ );
+ checkNumber(
+ Math.round(model.height * transform.scale * 914400),
+ 'page height (EMU)',
+ 1,
+ 6858000,
+ );
+ }
+ if (!Array.isArray(model.elements)) throw new Error('elements must be an array.');
+ if (
+ !Array.isArray(model.warnings) ||
+ model.warnings.some((warning) => typeof warning !== 'string')
+ ) {
+ throw new Error('warnings must be an array of strings.');
+ }
+ } catch (error) {
+ throw new Error(
+ `Slide ${index + 1}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ for (const [objectIndex, node] of model.elements.entries()) {
+ try {
+ objectBox(node, transform);
+ checkElement(node, transform.scale);
+ } catch (error) {
+ throw new Error(
+ `Slide ${index + 1}, object ${objectIndex + 1} (${node?.type ?? 'unknown'}): ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+ return { model, transform, index };
+ });
+ const sourcePage = transforms[0];
+ if (sourcePage?.model.pageLayout === 'source') {
+ const { model, transform } = sourcePage;
+ pres.defineLayout({
+ name: 'NATIVE_SOURCE',
+ width: model.width * transform.scale,
+ height: model.height * transform.scale,
+ });
+ pres.layout = 'NATIVE_SOURCE';
+ }
+ for (const { model, transform, index } of transforms) {
+ const slide = pres.addSlide();
+ slide.background = { color: 'FFFFFF' };
+ const { scale } = transform;
+ for (const warning of model.warnings) warnings.add(`Slide ${index + 1}: ${warning}`);
+ for (const [objectIndex, node] of model.elements.entries()) {
+ const box = objectBox(node, transform);
+ if (node.type === 'raster') throw new Error('Unresolved native PPTX raster fallback.');
+ if (node.type === 'image') {
+ slide.addImage({ ...box, data: node.data });
+ } else if (node.type === 'shape') {
+ slide.addShape(pres.ShapeType[node.shape], {
+ ...box,
+ ...(node.shape === 'roundRect'
+ ? { rectRadius: Math.min(node.radius, node.width / 2, node.height / 2) * scale }
+ : {}),
+ fill: { color: node.fill.hex, transparency: (1 - node.fill.opacity) * 100 },
+ line: {
+ color: node.stroke.hex,
+ transparency: (1 - node.stroke.opacity) * 100,
+ width: node.strokeWidth * scale * 72,
+ },
+ });
+ } else {
+ const context = `Slide ${index + 1}, object ${objectIndex + 1} (text)`;
+ slide.addText(
+ node.runs.map((run, runIndex) => ({
+ text: cleanXml(run.text, `run ${runIndex + 1}.text`, context, warnings),
+ options: {
+ fontFace: cleanXml(run.fontFace, `run ${runIndex + 1}.fontFace`, context, warnings),
+ fontSize: run.fontSize * scale * 72,
+ ...(run.charSpacing !== undefined
+ ? { charSpacing: run.charSpacing * scale * 72 }
+ : {}),
+ color: run.color.hex,
+ transparency: (1 - run.color.opacity) * 100,
+ bold: run.bold,
+ italic: run.italic,
+ underline: run.underline ? { style: 'sng' as const } : { style: 'none' as const },
+ },
+ })),
+ {
+ ...box,
+ margin: 0,
+ breakLine: false,
+ align: node.align,
+ valign: 'top',
+ lineSpacingMultiple: node.layout === 'lines' ? 1 : node.lineHeight,
+ paraSpaceAfter: 0,
+ wrap: node.layout !== 'lines',
+ fit: node.layout === 'lines' ? 'none' : 'shrink',
+ },
+ );
+ }
+ }
+ }
+ return [...warnings];
+}
diff --git a/packages/exporters/src/pptx-native.browser.test.ts b/packages/exporters/src/pptx-native.browser.test.ts
new file mode 100644
index 000000000..c974c7ed4
--- /dev/null
+++ b/packages/exporters/src/pptx-native.browser.test.ts
@@ -0,0 +1,297 @@
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { extract } from 'zip-lib';
+import { findSystemChrome } from './chrome-discovery';
+import { exportPptx } from './pptx';
+import { renderNativeSlides } from './pptx-native';
+
+const chromePath = await findSystemChrome().catch(() => undefined);
+const png =
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
+const options = {
+ ...(chromePath ? { chromePath } : {}),
+ injectTailwind: false,
+ renderTimeoutMs: 15_000,
+};
+const html = (body: string, css = '') => `${body}`;
+
+describe.skipIf(!chromePath)('native PPTX in system Chromium', () => {
+ let directory: string;
+ beforeAll(async () => {
+ directory = await mkdtemp(join(tmpdir(), 'pptx-native-browser-'));
+ });
+ afterAll(async () => {
+ await rm(directory, { recursive: true, force: true });
+ });
+
+ it('extracts grid cards, CJK rich text, shapes and independent local images', async () => {
+ await writeFile(join(directory, 'sample.png'), Buffer.from(png, 'base64'));
+ const source = html(
+ `季度报告
+
+
+ `,
+ `.cards{display:grid;grid-template-columns:1fr 1fr;gap:24px}.card{padding:20px;background:#e2e8f0;border-radius:12px}.circle{width:48px;height:48px;border-radius:50%;background:#123456}.line{width:240px;height:2px;background:#334455}`,
+ );
+ const models = await renderNativeSlides(source, {
+ ...options,
+ sourcePath: 'deck.html',
+ assetBasePath: directory,
+ assetRootPath: directory,
+ });
+ expect(models).toHaveLength(1);
+ const model = models[0];
+ expect(model?.warnings.some((warning) => warning.includes('rasterized'))).toBe(false);
+ const texts = model?.elements.filter((el) => el.type === 'text') ?? [];
+ expect(texts.flatMap((el) => el.runs.map((run) => run.text)).join('')).toBe(
+ '季度报告收入 增长 32%客户 120 家',
+ );
+ expect(texts[1]?.runs.some((run) => run.bold && run.text === '增长 32%')).toBe(true);
+ expect(model?.elements.filter((el) => el.type === 'image')).toHaveLength(1);
+ expect(model?.elements.some((el) => el.type === 'shape' && el.shape === 'ellipse')).toBe(true);
+ expect(texts[2]?.x).toBeGreaterThan(texts[1]?.x ?? 0);
+ expect(texts[2]?.y).toBe(texts[1]?.y);
+ }, 30_000);
+
+ it('executes JSX props and maps before exporting actual text to PPTX XML', async () => {
+ const source = `const title = '真实标题'; const values = ['第一项', '第二项'];
+ function App() { return {title}
{values.map(value => {value}
)}; }`;
+ const destination = join(directory, 'jsx.pptx');
+ const result = await exportPptx(source, destination, {
+ ...options,
+ sourcePath: 'App.jsx',
+ renderMode: 'native',
+ });
+ expect(result.warnings?.some((warning) => warning.includes('rasterized')) ?? false).toBe(false);
+ const unpacked = join(directory, 'jsx-unpacked');
+ await extract(destination, unpacked);
+ const xml = await readFile(join(unpacked, 'ppt/slides/slide1.xml'), 'utf8');
+ expect(xml).toContain('真实标题');
+ expect(xml).toContain('第一项');
+ expect(xml).toContain('第二项');
+ expect(xml).not.toContain('{title}');
+ expect(xml).not.toContain('');
+ }, 30_000);
+
+ it('exports inactive slides and does not count nested sections as extra pages', async () => {
+ const source = html(
+ ``,
+ '.slide{display:none}.slide.active{display:block}',
+ );
+ const models = await renderNativeSlides(source, options);
+ expect(models).toHaveLength(2);
+ expect(
+ models.map((model) =>
+ model.elements
+ .filter((el) => el.type === 'text')
+ .flatMap((el) => el.runs.map((run) => run.text))
+ .join(''),
+ ),
+ ).toEqual(['One', 'Two']);
+ }, 30_000);
+
+ it('rasterizes gradient decoration while retaining its native text', async () => {
+ const source = html(
+ ``,
+ );
+ const models = await renderNativeSlides(source, options);
+ const model = models[0];
+ expect(model?.warnings.join(' ')).toContain('gradient');
+ expect(model?.elements.filter((el) => el.type === 'image')).toHaveLength(1);
+ expect(
+ model?.elements
+ .filter((el) => el.type === 'text')
+ .flatMap((el) => el.runs.map((run) => run.text)),
+ ).toEqual(['Editable heading', 'Raster text']);
+ const result = await exportPptx(source, join(directory, 'fallback.pptx'), {
+ ...options,
+ renderMode: 'native',
+ });
+ expect(result.warnings?.[0]).toContain('Slide 1:');
+ }, 30_000);
+
+ it('fits unstructured pages and still reports broken images and explicit selector errors', async () => {
+ const fallback = await renderNativeSlides(html('No slides
'), options);
+ expect(fallback).toHaveLength(1);
+ expect(fallback[0]?.warnings.join(' ')).toContain('No explicit slide containers');
+ expect(
+ fallback[0]?.elements.some(
+ (el) => el.type === 'text' && el.runs.some((run) => run.text === 'No slides'),
+ ),
+ ).toBe(true);
+ await expect(
+ renderNativeSlides(html('Content'), { ...options, slideSelector: '.missing' }),
+ ).rejects.toThrow('slideSelector');
+ await expect(
+ renderNativeSlides(
+ html(''),
+ options,
+ ),
+ ).rejects.toThrow('image failed to load');
+ }, 30_000);
+
+ it('measures hidden content-box slides after activation without shrinking their padding', async () => {
+ const models = await renderNativeSlides(
+ html(
+ 'One
Two
',
+ '.slide{display:none;box-sizing:content-box;width:1000px;height:500px;padding:40px;border:10px solid black}.slide.active{display:block}',
+ ),
+ options,
+ );
+ expect(models.map((model) => [model.width, model.height])).toEqual([
+ [1100, 600],
+ [1100, 600],
+ ]);
+ }, 30_000);
+
+ it('preserves aligned anonymous text, clipped text and image backgrounds using local fallback', async () => {
+ const models = await renderNativeSlides(
+ html(
+ `Native heading
Centered text
Long clipped text must not become a complete native paragraph
`,
+ ),
+ options,
+ );
+ const model = models[0];
+ expect(
+ model?.elements
+ .filter((el) => el.type === 'text')
+ .flatMap((el) => el.runs.map((run) => run.text)),
+ ).toEqual(['Native heading', 'Centered text']);
+ expect(model?.elements.filter((el) => el.type === 'image')).toHaveLength(2);
+ expect(model?.warnings.join(' ')).toContain('clipped text');
+ expect(model?.warnings.join(' ')).toContain('image crop or format');
+ }, 30_000);
+
+ it('keeps text editable beside shadows and overlapping decoration captures', async () => {
+ const models = await renderNativeSlides(
+ html(
+ '',
+ ),
+ options,
+ );
+ expect(models).toHaveLength(2);
+ for (const model of models) {
+ expect(model.elements.some((el) => el.type === 'text')).toBe(true);
+ expect(model.elements.filter((el) => el.type === 'image')).toHaveLength(1);
+ expect(
+ model.elements.some((el) => el.type === 'image' && el.width === 1280 && el.height === 720),
+ ).toBe(false);
+ expect(model.warnings.join(' ')).not.toContain('whole-slide fallback');
+ }
+ }, 30_000);
+
+ it('finds all four pages in the bundled JSX slide pattern', async () => {
+ const source = await readFile(
+ new URL(
+ '../../../apps/desktop/resources/templates/design-skills/slide-deck.jsx',
+ import.meta.url,
+ ),
+ 'utf8',
+ );
+ // Keep this resource smoke test offline without changing its layout or dynamic slide content.
+ const offline = source.replace(/Fraunces|DM Serif Display|DM Sans|JetBrains Mono/g, 'Arial');
+ const models = await renderNativeSlides(offline, { ...options, sourcePath: 'App.jsx' });
+ expect(models).toHaveLength(4);
+ expect(
+ models[0]?.elements
+ .filter((el) => el.type === 'text')
+ .flatMap((el) => el.runs.map((run) => run.text))
+ .join(' '),
+ ).toContain('The slow practice of seeing.');
+ expect(models.every((model) => model.elements.some((el) => el.type === 'text'))).toBe(true);
+ }, 30_000);
+
+ it('normalizes nested preview scales for geometry, typography, padding and pill radii', async () => {
+ const snapshots = [];
+ for (const scale of [0.5, 530 / 720, 1, 1.15]) {
+ const models = await renderNativeSlides(
+ html(
+ ``,
+ ),
+ options,
+ );
+ const m = models[0];
+ expect([m?.width, m?.height]).toEqual([1280, 720]);
+ expect(
+ m?.elements.filter((e) => e.type === 'shape').every((e) => e.shape !== 'ellipse'),
+ ).toBe(true);
+ expect(m?.elements.find((e) => e.type === 'text')?.runs[0]?.fontSize).toBe(38);
+ snapshots.push(
+ m?.elements.map((e) => ({
+ type: e.type,
+ x: e.x,
+ y: e.y,
+ width: e.width,
+ height: e.height,
+ ...(e.type === 'text' ? { runs: e.runs } : {}),
+ })),
+ );
+ }
+ for (const snapshot of snapshots.slice(1)) expect(snapshot).toEqual(snapshots[0]);
+ }, 60_000);
+
+ it('retains cover and card text with generated decorations and nonuniform borders', async () => {
+ const models = await renderNativeSlides(
+ html(
+ `Cover remains editable
Ordinary cover text
`,
+ `.cover::after{content:"";position:absolute;inset:0;background:linear-gradient(transparent,rgba(0,0,255,.2));pointer-events:none}.cover h1,.cover p{position:relative;z-index:2}.card{width:500px;padding:20px;border:1px solid gray;border-left:5px solid green;border-radius:12px}.bullet{position:relative;padding-left:16px}.bullet::before{content:"";position:absolute;left:0;top:10px;width:5px;height:5px;background:green;border-radius:50%}`,
+ ),
+ options,
+ );
+ expect(
+ models.map((m) =>
+ m.elements
+ .filter((e) => e.type === 'text')
+ .flatMap((e) => e.runs.map((r) => r.text))
+ .join(' '),
+ ),
+ ).toEqual(['Cover remains editable Ordinary cover text', 'Editable bullet text']);
+ expect(models[1]?.elements.some((e) => e.type === 'shape' && e.shape === 'ellipse')).toBe(true);
+ expect(models.every((m) => !m.warnings.some((w) => w.includes('contents rasterized')))).toBe(
+ true,
+ );
+ }, 30_000);
+
+ it('writes measured lines for wrapping and mixed font sizes without automatic shrink', async () => {
+ const models = await renderNativeSlides(
+ html(
+ 'Several words wrap into multiple separate lines.
Small BIG text
',
+ ),
+ options,
+ );
+ const texts = models[0]?.elements.filter((e) => e.type === 'text') ?? [];
+ expect(texts.length).toBeGreaterThan(2);
+ expect(texts.every((e) => e.layout === 'lines' && e.lineHeight === 1)).toBe(true);
+ expect(texts.map((e) => e.runs.map((r) => r.text).join('')).join(' ')).toBe(
+ 'Several words wrap into multiple separate lines. Small BIG text',
+ );
+ expect(texts.some((e) => e.runs.some((r) => r.fontSize === 32))).toBe(true);
+ }, 30_000);
+
+ it('supports portrait dimensions without stretching and ignores explicitly excluded controls', async () => {
+ const models = await renderNativeSlides(
+ html(
+ '',
+ ),
+ options,
+ );
+ expect(models[0]?.width).toBe(720);
+ expect(models[0]?.height).toBe(1280);
+ expect(models[0]?.warnings).toContain(
+ 'Non-16:9 slide fitted with whitespace; content was not stretched.',
+ );
+ expect(
+ models[0]?.elements
+ .filter((el) => el.type === 'text')
+ .flatMap((el) => el.runs.map((run) => run.text)),
+ ).toEqual(['Portrait']);
+ }, 30_000);
+});
diff --git a/packages/exporters/src/pptx-native.ts b/packages/exporters/src/pptx-native.ts
new file mode 100644
index 000000000..1ad2b12fb
--- /dev/null
+++ b/packages/exporters/src/pptx-native.ts
@@ -0,0 +1,380 @@
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import type { Browser, Page } from 'puppeteer-core';
+import { findSystemChrome } from './chrome-discovery';
+import type { ExportPptxOptions } from './pptx';
+import { extractNativeSlide } from './pptx-dom';
+import type { NativeSlideModel } from './pptx-model';
+import { buildExportHtmlDocument, shouldRenderForStaticDom } from './rendered-html';
+
+const SLIDE_SELECTOR =
+ '[data-pptx-slide], [data-slide], [data-slide-container], [data-slide-id], .slide';
+
+async function waitForSlideAssets(page: Page, timeout: number): Promise {
+ await page.waitForFunction(() => Array.from(document.images).every((image) => image.complete), {
+ timeout,
+ });
+ await page.waitForFunction(() => document.fonts.status === 'loaded', { timeout });
+ await page.evaluate(async () => {
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
+ );
+ });
+}
+
+async function resolvePlatformFonts(page: Page): Promise> {
+ const session = await page.createCDPSession();
+ try {
+ await session.send('DOM.enable');
+ await session.send('CSS.enable');
+ const doc = await session.send('DOM.getDocument', { depth: -1 });
+ const nodes: { nodeId: number; id: string }[] = [];
+ const visit = (node: typeof doc.root) => {
+ const attributes = node.attributes ?? [];
+ const index = attributes.indexOf('data-codesign-pptx-node');
+ const id = attributes[index + 1];
+ if (
+ index >= 0 &&
+ id &&
+ node.children?.some((child) => child.nodeType === 3 && child.nodeValue.trim())
+ )
+ nodes.push({ nodeId: node.nodeId, id });
+ for (const child of node.children ?? []) visit(child);
+ };
+ visit(doc.root);
+ const result: Record = {};
+ for (let index = 0; index < nodes.length; index += 16) {
+ await Promise.all(
+ nodes.slice(index, index + 16).map(async (node) => {
+ const { fonts } = await session.send('CSS.getPlatformFontsForNode', {
+ nodeId: node.nodeId,
+ });
+ const font = fonts.sort((a, b) => b.glyphCount - a.glyphCount)[0];
+ if (font) result[node.id] = font.familyName;
+ }),
+ );
+ }
+ return result;
+ } finally {
+ await session.detach();
+ }
+}
+
+/** Visibility changes preserve layout while ensuring local captures contain no sibling/native text. */
+async function isolateCapture(
+ page: Page,
+ selector: string,
+ mode: 'subtree' | 'decoration' | 'before' | 'after',
+) {
+ await page.evaluate(
+ (input) => {
+ const target = document.querySelector(input.selector);
+ if (!(target instanceof Element)) throw new Error('PPTX capture source disappeared.');
+ const all = Array.from(document.querySelectorAll('*'));
+ const original = all.map((el) => ({
+ el,
+ style: el.getAttribute('style'),
+ visible: getComputedStyle(el).visibility === 'visible',
+ pseudos: ['before', 'after'].filter(
+ (name) => getComputedStyle(el, `::${name}`).visibility === 'visible',
+ ),
+ }));
+ const stylesheet = document.createElement('style');
+ const state = window as unknown as { __pptxRestoreCapture?: () => void };
+ state.__pptxRestoreCapture = () => {
+ stylesheet.remove();
+ for (const { el, style } of original) {
+ if (style === null) el.removeAttribute('style');
+ else el.setAttribute('style', style);
+ }
+ delete state.__pptxRestoreCapture;
+ };
+ for (const { el } of original) el.style.setProperty('visibility', 'hidden', 'important');
+ // The HTML/body canvas background can paint even when visibility is hidden.
+ for (const el of [document.documentElement, document.body]) {
+ if (el !== target && !target.contains(el))
+ el.style.setProperty('background', 'transparent', 'important');
+ }
+ const current = target as HTMLElement | SVGElement;
+ stylesheet.textContent = '*::before,*::after{visibility:hidden!important}';
+ if (input.mode === 'subtree') {
+ for (const { el, visible, pseudos } of original)
+ if (visible && (el === target || target.contains(el))) {
+ el.style.setProperty('visibility', 'visible', 'important');
+ const id = el.getAttribute('data-codesign-pptx-node');
+ if (id)
+ for (const name of pseudos)
+ stylesheet.textContent += `[data-codesign-pptx-node="${id}"]::${name}{visibility:visible!important}`;
+ }
+ } else if (input.mode === 'decoration') {
+ current.style.setProperty('visibility', 'visible', 'important');
+ current.style.setProperty('-webkit-text-fill-color', 'transparent', 'important');
+ current.style.setProperty('text-shadow', 'none', 'important');
+ current.style.setProperty('text-decoration-color', 'transparent', 'important');
+ stylesheet.textContent += `${input.selector}::before,${input.selector}::after{visibility:hidden!important}`;
+ } else {
+ stylesheet.textContent += `${input.selector}::${input.mode}{visibility:visible!important}`;
+ }
+ document.head.appendChild(stylesheet);
+ },
+ { selector, mode },
+ );
+}
+
+export async function renderNativeSlides(
+ artifactSource: string,
+ opts: ExportPptxOptions,
+): Promise {
+ const puppeteer = (await import('puppeteer-core')).default;
+ const executablePath = opts.chromePath ?? (await findSystemChrome());
+ const html = await buildExportHtmlDocument(artifactSource, opts);
+ const timeout = opts.renderTimeoutMs ?? 45_000;
+ const viewport = opts.viewport ?? { width: 1280, height: 720 };
+ if (![viewport.width, viewport.height].every((value) => Number.isInteger(value) && value > 0))
+ throw new Error('PPTX viewport dimensions must be positive integers.');
+ const userDataDir = await mkdtemp(join(tmpdir(), 'codesign-pptx-native-'));
+ let browser: Browser | undefined;
+ try {
+ browser = await puppeteer.launch({
+ executablePath,
+ headless: true,
+ userDataDir,
+ args: [
+ '--disable-dev-shm-usage',
+ '--disable-gpu',
+ '--no-first-run',
+ '--no-default-browser-check',
+ ],
+ });
+ const page = await browser.newPage();
+ page.setDefaultTimeout(timeout);
+ const pageErrors: string[] = [];
+ page.on('pageerror', (error) => pageErrors.push(String(error)));
+ await page.setViewport({ ...viewport, deviceScaleFactor: 2 });
+ await page.setContent(html, { waitUntil: 'load', timeout });
+ if (shouldRenderForStaticDom(artifactSource, opts))
+ await page.waitForFunction(
+ () => (document.getElementById('root')?.childElementCount ?? 0) > 0,
+ );
+ await page.addStyleTag({
+ content: `
+ *, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }
+ [data-pptx-ignore] { display: none !important; }
+ html, body { margin: 0 !important; scroll-behavior: auto !important; }
+ `,
+ });
+ await page.evaluate(() => {
+ for (const image of document.images) image.loading = 'eager';
+ });
+ await waitForSlideAssets(page, timeout);
+ if (pageErrors.length) throw new Error(`Slide runtime error: ${pageErrors[0]}`);
+ const metadata = await page.evaluate(
+ (input) => {
+ for (const el of document.querySelectorAll(
+ '[data-codesign-pptx-page], [data-codesign-pptx-node]',
+ )) {
+ el.removeAttribute('data-codesign-pptx-page');
+ el.removeAttribute('data-codesign-pptx-node');
+ }
+ let candidates = Array.from(document.querySelectorAll(input.selector));
+ let poster = false;
+ if (!candidates.length && !input.custom) {
+ const posters = Array.from(
+ document.querySelectorAll('[data-pptx-poster], [class]'),
+ ).filter((el) => {
+ const style = getComputedStyle(el);
+ const rect = el.getBoundingClientRect();
+ if (
+ style.display === 'none' ||
+ style.visibility !== 'visible' ||
+ rect.width <= 0 ||
+ rect.height <= 0 ||
+ el.closest('[data-pptx-ignore]')
+ )
+ return false;
+ return (
+ el.hasAttribute('data-pptx-poster') ||
+ (Array.from(el.classList).some((name) => /(^|[-_])poster$/i.test(name)) &&
+ style.aspectRatio !== 'auto' &&
+ rect.height > rect.width)
+ );
+ });
+ const outerPosters = posters.filter(
+ (el) => !posters.some((other) => other !== el && other.contains(el)),
+ );
+ // A unique, explicitly proportioned poster is safer than guessing among arbitrary cards/articles.
+ if (outerPosters.length === 1) {
+ candidates = outerPosters;
+ poster = true;
+ }
+ }
+ if (!candidates.length && !input.custom)
+ candidates = Array.from(document.querySelectorAll('section'));
+ let fallback = false;
+ if (!candidates.length && !input.custom) {
+ candidates = [document.getElementById('root') ?? document.body];
+ fallback = true;
+ }
+ const slides = candidates.filter(
+ (el) => !candidates.some((other) => other !== el && other.contains(el)),
+ );
+ const ancestors = new Set();
+ for (const [index, el] of slides.entries()) {
+ el.setAttribute('data-codesign-pptx-page', String(index));
+ let parent = el.parentElement;
+ while (parent) {
+ ancestors.add(parent);
+ parent = parent.parentElement;
+ }
+ }
+ const state = window as unknown as { __pptxRestorePage?: () => void };
+ const original = [...ancestors, ...slides].map((el) => ({
+ el,
+ style: el.getAttribute('style'),
+ className: el.getAttribute('class'),
+ }));
+ state.__pptxRestorePage = () => {
+ for (const { el, style, className } of original) {
+ if (style === null) el.removeAttribute('style');
+ else el.setAttribute('style', style);
+ if (className === null) el.removeAttribute('class');
+ else el.setAttribute('class', className);
+ }
+ };
+ return { count: slides.length, fallback, poster };
+ },
+ { selector: opts.slideSelector ?? SLIDE_SELECTOR, custom: !!opts.slideSelector },
+ );
+ if (!metadata.count) throw new Error('No slides matched the supplied PPTX slideSelector.');
+ const models: NativeSlideModel[] = [];
+ for (let index = 0; index < metadata.count; index++) {
+ await page.evaluate(
+ (input) => {
+ const state = window as unknown as { __pptxRestorePage?: () => void };
+ state.__pptxRestorePage?.();
+ const el = document.querySelector(
+ `[data-codesign-pptx-page="${input.index}"]`,
+ );
+ if (!(el instanceof HTMLElement))
+ throw new Error('PPTX slide container changed or is not an HTML element.');
+ for (const other of document.querySelectorAll('[data-codesign-pptx-page]'))
+ if (other !== el) other.style.setProperty('display', 'none', 'important');
+ // The export document is private. Remove preview-only ancestor transforms, not design transforms inside a slide.
+ for (let parent = el.parentElement; parent; parent = parent.parentElement) {
+ for (const property of [
+ 'transform',
+ 'rotate',
+ 'scale',
+ 'translate',
+ 'perspective',
+ 'filter',
+ 'backdrop-filter',
+ 'contain',
+ 'clip-path',
+ 'mask-image',
+ ])
+ parent.style.setProperty(property, 'none', 'important');
+ parent.style.setProperty('zoom', '1', 'important');
+ parent.style.setProperty('overflow', 'visible', 'important');
+ parent.style.setProperty('clip', 'auto', 'important');
+ parent.style.setProperty('opacity', '1', 'important');
+ if (getComputedStyle(parent).display === 'none')
+ parent.style.setProperty('display', 'block', 'important');
+ }
+ if (getComputedStyle(el).display === 'none') el.classList.add('active');
+ if (getComputedStyle(el).display === 'none')
+ el.style.setProperty('display', 'block', 'important');
+ const s = getComputedStyle(el);
+ const px = (value: string) => Number.parseFloat(value) || 0;
+ const contentBox = s.boxSizing !== 'border-box';
+ let width =
+ px(s.width) +
+ (contentBox
+ ? px(s.paddingLeft) +
+ px(s.paddingRight) +
+ px(s.borderLeftWidth) +
+ px(s.borderRightWidth)
+ : 0);
+ let height =
+ px(s.height) +
+ (contentBox
+ ? px(s.paddingTop) +
+ px(s.paddingBottom) +
+ px(s.borderTopWidth) +
+ px(s.borderBottomWidth)
+ : 0);
+ if (input.fallback) {
+ width = Math.max(width, el.scrollWidth);
+ height = Math.max(height, el.scrollHeight);
+ }
+ el.style.setProperty('position', 'fixed', 'important');
+ el.style.setProperty('left', '0', 'important');
+ el.style.setProperty('top', '0', 'important');
+ el.style.setProperty('right', 'auto', 'important');
+ el.style.setProperty('bottom', 'auto', 'important');
+ el.style.setProperty('margin', '0', 'important');
+ el.style.setProperty('visibility', 'visible', 'important');
+ el.style.setProperty('width', `${width || input.viewport.width}px`, 'important');
+ el.style.setProperty('height', `${height || input.viewport.height}px`, 'important');
+ el.style.setProperty('box-sizing', 'border-box', 'important');
+ window.scrollTo(0, 0);
+ for (const [nodeIndex, node] of [el, ...el.querySelectorAll('*')].entries())
+ node.setAttribute('data-codesign-pptx-node', `${input.index}-${nodeIndex}`);
+ },
+ { index, viewport, fallback: metadata.fallback },
+ );
+ await waitForSlideAssets(page, timeout);
+ if (pageErrors.length) throw new Error(`Slide runtime error: ${pageErrors[0]}`);
+ const fonts = await resolvePlatformFonts(page);
+ const handle = await page.$(`[data-codesign-pptx-page="${index}"]`);
+ if (!handle) throw new Error('Slide container disappeared during export.');
+ const model = await handle.evaluate(extractNativeSlide, fonts);
+ if (model.width <= 0 || model.height <= 0)
+ throw new Error(`Slide ${index + 1} has no visible dimensions.`);
+ if (metadata.fallback)
+ model.warnings.push(
+ 'No explicit slide containers; exported the complete document as one fitted slide. Add slide markers to paginate long pages.',
+ );
+ if (metadata.poster) {
+ model.pageLayout = 'source';
+ model.warnings.push(
+ 'Single poster artboard detected; exported at its source aspect ratio without the preview stage.',
+ );
+ } else if (Math.abs(model.width / model.height - 16 / 9) > 0.01)
+ model.warnings.push('Non-16:9 slide fitted with whitespace; content was not stretched.');
+ for (const [nodeIndex, node] of model.elements.entries()) {
+ if (node.type !== 'raster') continue;
+ try {
+ if (node.selector) await isolateCapture(page, node.selector, node.capture ?? 'subtree');
+ const image = await page.screenshot({
+ type: 'png',
+ clip: node.clip,
+ omitBackground: true,
+ });
+ model.elements[nodeIndex] = {
+ type: 'image',
+ x: node.x,
+ y: node.y,
+ width: node.width,
+ height: node.height,
+ data: `data:image/png;base64,${Buffer.from(image).toString('base64')}`,
+ };
+ } finally {
+ await page.evaluate(() => {
+ (window as unknown as { __pptxRestoreCapture?: () => void }).__pptxRestoreCapture?.();
+ });
+ }
+ }
+ models.push(model);
+ await handle.dispose();
+ }
+ return models;
+ } finally {
+ try {
+ await browser?.close();
+ } finally {
+ await rm(userDataDir, { recursive: true, force: true });
+ }
+ }
+}
diff --git a/packages/exporters/src/pptx-poster.browser.test.ts b/packages/exporters/src/pptx-poster.browser.test.ts
new file mode 100644
index 000000000..739df42db
--- /dev/null
+++ b/packages/exporters/src/pptx-poster.browser.test.ts
@@ -0,0 +1,100 @@
+import { mkdtemp, readFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+import { extract } from 'zip-lib';
+import { findSystemChrome } from './chrome-discovery';
+import { exportPptx } from './pptx';
+import { renderNativeSlides } from './pptx-native';
+
+const chromePath = await findSystemChrome().catch(() => undefined);
+const options = {
+ ...(chromePath ? { chromePath } : {}),
+ injectTailwind: false,
+ renderTimeoutMs: 15_000,
+};
+const html = (body: string) => `${body}`;
+const poster =
+ 'Poster headline
Editable body
';
+
+describe.skipIf(!chromePath)('poster artboard export in system Chromium', () => {
+ it('selects the unique proportioned poster instead of its stage or nested sections', async () => {
+ const [model] = await renderNativeSlides(html(`${poster}`), options);
+ expect(model?.pageLayout).toBe('source');
+ expect(model?.width).toBeCloseTo(720 * 0.62, 1);
+ expect((model?.height ?? 0) / (model?.width ?? 1)).toBeCloseTo(Math.SQRT2, 3);
+ expect(model?.warnings.join(' ')).toContain('poster artboard');
+ expect(model?.warnings.join(' ')).not.toContain('fitted with whitespace');
+ const elements = model?.elements ?? [];
+ const gridIndex = elements.findIndex((e) => e.type === 'image');
+ const textIndex = elements.findIndex((e) => e.type === 'text');
+ expect(gridIndex).toBeGreaterThan(-1);
+ expect(textIndex).toBeGreaterThan(gridIndex);
+ expect(
+ elements.filter((e) => e.type === 'text').map((e) => e.runs.map((r) => r.text).join('')),
+ ).toEqual(['Poster headline', 'Editable body']);
+ }, 30_000);
+
+ it('writes a portrait PPTX page through the public exporter', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'pptx-poster-'));
+ try {
+ const outputPath = join(dir, 'poster.pptx');
+ await exportPptx(html(`${poster}`), outputPath, {
+ ...options,
+ renderMode: 'native',
+ });
+ const unpacked = join(dir, 'unpacked');
+ await extract(outputPath, unpacked);
+ const xml = await readFile(join(unpacked, 'ppt', 'presentation.xml'), 'utf8');
+ const size = xml.match(/Poster headline');
+ expect(slide.indexOf('')).toBeLessThan(slide.indexOf(''));
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ }, 30_000);
+
+ it('accepts an explicit poster marker without requiring a naming convention', async () => {
+ const [model] = await renderNativeSlides(
+ html(
+ '',
+ ),
+ options,
+ );
+ expect(model).toMatchObject({ pageLayout: 'source', width: 400, height: 600 });
+ }, 30_000);
+
+ it('does not guess among multiple posters or turn ordinary portrait cards into pages', async () => {
+ for (const body of [
+ '',
+ 'Card
',
+ ]) {
+ const models = await renderNativeSlides(html(body), options);
+ expect(models).toHaveLength(1);
+ expect(models[0]?.pageLayout).toBeUndefined();
+ expect(models[0]?.warnings.join(' ')).toContain('complete document');
+ }
+ }, 45_000);
+
+ it('keeps explicit slide markers and custom selectors ahead of poster inference', async () => {
+ const [model] = await renderNativeSlides(
+ html(
+ `${poster}`,
+ ),
+ options,
+ );
+ expect(model?.pageLayout).toBeUndefined();
+ expect([model?.width, model?.height]).toEqual([1280, 720]);
+ await expect(
+ renderNativeSlides(html(poster), { ...options, slideSelector: '.missing' }),
+ ).rejects.toThrow('slideSelector');
+ }, 45_000);
+});
diff --git a/packages/exporters/src/pptx-repair-edge.browser.test.ts b/packages/exporters/src/pptx-repair-edge.browser.test.ts
new file mode 100644
index 000000000..f78f1c71f
--- /dev/null
+++ b/packages/exporters/src/pptx-repair-edge.browser.test.ts
@@ -0,0 +1,240 @@
+import type { Browser, Page } from 'puppeteer-core';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { findSystemChrome } from './chrome-discovery';
+import type { NativeSlideElement, NativeSlideModel } from './pptx-model';
+import { renderNativeSlides } from './pptx-native';
+
+const chromePath = await findSystemChrome().catch(() => undefined);
+const html = (body: string, css = '') => ``;
+
+async function render(body: string, css = '') {
+ const models = await renderNativeSlides(html(body, css), {
+ ...(chromePath ? { chromePath } : {}),
+ injectTailwind: false,
+ renderTimeoutMs: 15_000,
+ });
+ const model = models[0];
+ if (!model) throw new Error('Expected one rendered slide');
+ return model;
+}
+
+function texts(model: NativeSlideModel) {
+ return model.elements
+ .filter((element) => element.type === 'text')
+ .map((element) => ({
+ ...element,
+ text: element.runs.map((run) => run.text).join(''),
+ }));
+}
+
+describe.skipIf(!chromePath)('native PPTX repair edge regressions in system Chromium', () => {
+ let browser: Browser | undefined;
+ let page: Page;
+ beforeAll(async () => {
+ const puppeteer = (await import('puppeteer-core')).default;
+ if (!chromePath) throw new Error('System Chromium unavailable');
+ browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
+ page = await browser.newPage();
+ });
+ afterAll(async () => {
+ await browser?.close();
+ });
+
+ // These fixtures use only solid rectangles and PNGs: reconstruct their paint order independently.
+ async function pixel(elements: NativeSlideElement[], x: number, y: number) {
+ return page.evaluate(
+ async (input) => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 1280;
+ canvas.height = 720;
+ const context = canvas.getContext('2d');
+ if (!context) throw new Error('Canvas unavailable');
+ for (const element of input.elements) {
+ if (element.type === 'shape') {
+ context.globalAlpha = element.fill.opacity;
+ context.fillStyle = `#${element.fill.hex}`;
+ context.fillRect(element.x, element.y, element.width, element.height);
+ context.globalAlpha = 1;
+ } else if (element.type === 'image') {
+ const image = new Image();
+ image.src = element.data;
+ await image.decode();
+ context.drawImage(image, element.x, element.y, element.width, element.height);
+ }
+ }
+ return Array.from(context.getImageData(input.x, input.y, 1, 1).data);
+ },
+ { elements, x, y },
+ );
+ }
+
+ it.each([-1, 1])('does not duplicate static inline text with ignored z-index %s', async (z) => {
+ const model = await render(`ADUPZ
`);
+ expect(
+ texts(model)
+ .map((element) => element.text)
+ .join(''),
+ ).toBe('ADUPZ');
+ }, 30_000);
+
+ it('keeps direct text after a decorated inline child at its measured position', async () => {
+ const model = await render('ABBBBZ
');
+ const nodes = texts(model);
+ const before = nodes.find((node) => node.text === 'A');
+ const highlighted = nodes.find((node) => node.text === 'BBBB');
+ const after = nodes.find((node) => node.text === 'Z');
+ expect(before).toBeDefined();
+ expect(highlighted).toBeDefined();
+ expect(after).toBeDefined();
+ expect(after?.x).toBeGreaterThan((highlighted?.x ?? 0) + (highlighted?.width ?? 0) - 2);
+ expect(
+ [...nodes]
+ .sort((a, b) => a.x - b.x)
+ .map((node) => node.text)
+ .join(''),
+ ).toBe('ABBBBZ');
+ }, 30_000);
+
+ it.each([
+ ['BR', 'FIRST
SECOND
'],
+ ['pre newline', 'FIRST\nSECOND
'],
+ ])(
+ 'preserves explicit %s breaks even when glyph rectangles overlap',
+ async (_name, body) => {
+ const nodes = texts(await render(body));
+ expect(nodes.map((node) => node.text)).toEqual(['FIRST', 'SECOND']);
+ expect(nodes[1]?.y).toBeGreaterThan(nodes[0]?.y ?? 0);
+ },
+ 30_000,
+ );
+
+ it('groups baseline-aligned mixed font sizes but not the following explicit line', async () => {
+ const nodes = texts(
+ await render(
+ 'Bigsmall
next
',
+ ),
+ );
+ expect(nodes.map((node) => node.text)).toEqual(['Bigsmall', 'next']);
+ expect(nodes[0]?.runs.map((run) => run.fontSize)).toEqual([60, 12]);
+ expect(nodes.every((node) => node.layout === 'lines')).toBe(true);
+ }, 30_000);
+
+ it('keeps sibling pseudo decorations out of a separately captured background', async () => {
+ const model = await render(
+ '',
+ `
+ #target{position:absolute;left:50px;top:50px;width:100px;height:100px;background:linear-gradient(red,red)}
+ #other{position:absolute;left:50px;top:50px;width:100px;height:100px}
+ #other::before{content:"";visibility:visible;position:absolute;left:0;top:0;width:100px;height:100px;background:lime;z-index:20}
+ `,
+ );
+ const image = model.elements.find((element) => element.type === 'image');
+ expect(image).toBeDefined();
+ expect(await pixel(image ? [image] : [], 80, 80)).toEqual([255, 0, 0, 255]);
+ }, 30_000);
+
+ it('does not bake editable text decorations into a background-only capture', async () => {
+ const model = await render(
+ 'UNDERLINE
',
+ `
+ #target{width:300px;height:80px;background:linear-gradient(red,red);color:blue;text-decoration:underline}
+ `,
+ );
+ expect(texts(model).map((element) => element.text)).toEqual(['UNDERLINE']);
+ const image = model.elements.find((element) => element.type === 'image');
+ if (!image || image.type !== 'image') throw new Error('Expected isolated background image');
+ const nonRedPixels = await page.evaluate(async (data) => {
+ const image = new Image();
+ image.src = data;
+ await image.decode();
+ const canvas = document.createElement('canvas');
+ canvas.width = image.width;
+ canvas.height = image.height;
+ const context = canvas.getContext('2d');
+ if (!context) throw new Error('Canvas unavailable');
+ context.drawImage(image, 0, 0);
+ const rgba = context.getImageData(0, 0, image.width, image.height).data;
+ let count = 0;
+ for (let index = 0; index < rgba.length; index += 4) {
+ if (
+ (rgba[index + 3] ?? 0) > 0 &&
+ ((rgba[index] ?? 0) < 250 || (rgba[index + 1] ?? 0) > 5 || (rgba[index + 2] ?? 0) > 5)
+ )
+ count++;
+ }
+ return count;
+ }, image.data);
+ expect(nonRedPixels).toBe(0);
+ }, 30_000);
+
+ it.each([
+ 'height:0',
+ 'height:200px',
+ ])('preserves painted absolute descendants and global stacking through a non-stacking wrapper (%s)', async (height) => {
+ const model = await render(
+ '',
+ `
+ #wrapper{${height};width:200px}
+ #red,#blue{position:absolute;left:50px;top:50px;width:100px;height:100px}
+ #red{z-index:10;background:red}#blue{z-index:1;background:blue}
+ `,
+ );
+ expect(await pixel(model.elements, 80, 80)).toEqual([255, 0, 0, 255]);
+ }, 30_000);
+
+ it.each([
+ false,
+ true,
+ ])('preserves DOM order between translucent decoration and z-index:auto content (overlay last: %s)', async (overlayLast) => {
+ const grid = '';
+ const inner = '';
+ const model = await render(
+ overlayLast ? inner + grid : grid + inner,
+ `
+ #grid{position:absolute;left:60px;top:60px;width:240px;height:180px;opacity:.5;pointer-events:none;background:linear-gradient(red,red)}
+ #inner{position:relative;width:240px;height:180px;background:blue}
+ `,
+ );
+ const imageIndex = model.elements.findIndex((e) => e.type === 'image');
+ const textIndex = model.elements.findIndex((e) => e.type === 'text');
+ expect(
+ texts(model)
+ .map((e) => e.text)
+ .join(' '),
+ ).toBe('Editable poster text');
+ expect(imageIndex).toBeGreaterThan(-1);
+ expect(imageIndex > textIndex).toBe(overlayLast);
+ const color = await pixel(model.elements, 80, 80);
+ if (overlayLast) {
+ expect(color[0]).toBeGreaterThan(120);
+ expect(color[2]).toBeGreaterThan(120);
+ } else expect(color).toEqual([0, 0, 255, 255]);
+ }, 30_000);
+
+ it('does not isolate high-z descendants inside a positioned z-index:auto wrapper', async () => {
+ const model = await render(
+ '',
+ `
+ #wrapper{position:relative;width:200px;height:200px}
+ #red,#blue{position:absolute;left:0;top:0;width:100px;height:100px}
+ #red{background:red;z-index:10}#blue{left:60px;top:60px;background:blue;z-index:1}
+ `,
+ );
+ expect(await pixel(model.elements, 80, 80)).toEqual([255, 0, 0, 255]);
+ }, 30_000);
+
+ it('retains the visible extent of a generated gradient outside its host border box', async () => {
+ const model = await render(
+ '',
+ `
+ #host{position:absolute;left:100px;top:100px;width:100px;height:100px}
+ #host::before{content:"";position:absolute;left:-20px;top:-20px;width:140px;height:140px;background:linear-gradient(red,red)}
+ `,
+ );
+ expect(await pixel(model.elements, 85, 85)).toEqual([255, 0, 0, 255]);
+ expect(await pixel(model.elements, 215, 215)).toEqual([255, 0, 0, 255]);
+ }, 30_000);
+});
diff --git a/packages/exporters/src/pptx.ts b/packages/exporters/src/pptx.ts
index e1c6b271a..9c5a1576f 100644
--- a/packages/exporters/src/pptx.ts
+++ b/packages/exporters/src/pptx.ts
@@ -20,8 +20,9 @@ export interface ExportPptxOptions {
/**
* `image` preserves visual fidelity by rendering HTML with system Chrome.
* `editable` keeps the legacy title/bullet extraction path.
+ * `native` renders supported slide DOM as editable text, shapes and images (beta).
*/
- renderMode?: 'image' | 'editable';
+ renderMode?: 'image' | 'editable' | 'native' | undefined;
/** Override the discovered Chrome binary path for image rendering. */
chromePath?: string;
/** setContent timeout in milliseconds. Defaults to 45 seconds. */
@@ -70,7 +71,12 @@ export async function exportPptx(
pres.layout = 'LAYOUT_WIDE';
if (opts.deckTitle) pres.title = opts.deckTitle;
- if ((opts.renderMode ?? 'image') === 'image') {
+ let warnings: string[] = [];
+ if (opts.renderMode === 'native') {
+ const { renderNativeSlides } = await import('./pptx-native');
+ const { addNativeSlides } = await import('./pptx-model');
+ warnings = addNativeSlides(pres, await renderNativeSlides(artifactSource, opts));
+ } else if ((opts.renderMode ?? 'image') === 'image') {
const screenshots = await renderSlideScreenshots(artifactSource, opts);
for (const screenshot of screenshots) {
const slide = pres.addSlide();
@@ -125,7 +131,7 @@ export async function exportPptx(
await pres.writeFile({ fileName: destinationPath });
const stat = await fs.stat(destinationPath);
- return { bytes: stat.size, path: destinationPath };
+ return { bytes: stat.size, path: destinationPath, ...(warnings.length ? { warnings } : {}) };
} catch (err) {
throw new CodesignError(
`PPTX export failed: ${err instanceof Error ? err.message : String(err)}`,
diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json
index c59c5b23c..54aa20ec5 100644
--- a/packages/i18n/src/locales/en.json
+++ b/packages/i18n/src/locales/en.json
@@ -853,6 +853,10 @@
"label": "PPTX",
"hint": "High-fidelity slides; one per "
},
+ "pptxNative": {
+ "label": "PPTX (editable, beta)",
+ "hint": "Editable text and shapes; complex visuals may become images"
+ },
"zip": {
"label": "ZIP bundle",
"hint": "index.html + assets + README.md"
diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json
index b9140674b..8f48f618c 100644
--- a/packages/i18n/src/locales/es.json
+++ b/packages/i18n/src/locales/es.json
@@ -772,6 +772,10 @@
"label": "PPTX",
"hint": "Diapositivas de alta fidelidad; una por "
},
+ "pptxNative": {
+ "label": "PPTX (editable, beta)",
+ "hint": "Texto y formas editables; los elementos visuales complejos pueden convertirse en imágenes"
+ },
"zip": {
"label": "Paquete ZIP",
"hint": "index.html + activos + README.md"
diff --git a/packages/i18n/src/locales/pt-BR.json b/packages/i18n/src/locales/pt-BR.json
index 9beab78f4..103a24111 100644
--- a/packages/i18n/src/locales/pt-BR.json
+++ b/packages/i18n/src/locales/pt-BR.json
@@ -738,6 +738,10 @@
"label": "PPTX",
"hint": "Slides de alta fidelidade; um por "
},
+ "pptxNative": {
+ "label": "PPTX (editável, beta)",
+ "hint": "Texto e formas editáveis; elementos visuais complexos podem virar imagens"
+ },
"zip": {
"label": "Pacote ZIP",
"hint": "index.html + assets + README.md"
diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json
index e0d4fdb65..0637e5d78 100644
--- a/packages/i18n/src/locales/zh-CN.json
+++ b/packages/i18n/src/locales/zh-CN.json
@@ -853,6 +853,10 @@
"label": "PPTX",
"hint": "高保真幻灯片;每个 一页"
},
+ "pptxNative": {
+ "label": "PPTX(可编辑,测试版)",
+ "hint": "可编辑的文本和形状;复杂视觉元素可能转为图片"
+ },
"zip": {
"label": "ZIP 包",
"hint": "index.html + 资源文件 + README.md"
diff --git a/patches/README.md b/patches/README.md
new file mode 100644
index 000000000..42eda4b4a
--- /dev/null
+++ b/patches/README.md
@@ -0,0 +1,21 @@
+# PptxGenJS 4.0.1 XML validity patch
+
+`pptxgenjs@4.0.1.patch` repairs the two published entry points used by this workspace (`dist/pptxgen.cjs.js` and `dist/pptxgen.es.js`):
+
+- Escape text-run `fontFace` attributes using the library's existing `encodeXmlEntities` helper. Ordinary text must not be pre-escaped by callers.
+- Declare `/ppt/slideMasters/slideMaster1.xml` once in `[Content_Types].xml`, matching the single master actually written by PptxGenJS. Slides and layouts are not separate slide masters.
+
+The root `pnpm.patchedDependencies` entry and lockfile patch hash make this repair reproducible on install; do not edit installed `node_modules` files. The standalone browser bundles are not used by this workspace and are not modified.
+
+Regression coverage lives in `packages/exporters/src/pptx-model.test.ts`. It exercises both module entry points, parses every generated XML/relationship part without parser recovery, validates internal relationship targets and ContentTypes override existence/uniqueness, and checks literal text/typeface round-trips.
+
+After an upstream upgrade, check whether these fixes are included before removing or refreshing the patch. To refresh against the pinned version:
+
+```sh
+corepack pnpm@10.33.4 patch pptxgenjs@4.0.1
+# Edit only the extracted package directory reported by pnpm.
+corepack pnpm@10.33.4 patch-commit --patches-dir patches
+corepack pnpm@10.33.4 --filter @open-codesign/exporters exec vitest run src/pptx-model.test.ts
+```
+
+XML parsing uses the existing MIT-licensed `@xmldom/xmldom` 0.8.13 version as an explicit, test-only devDependency. It adds no shipped/runtime dependency.
diff --git a/patches/pptxgenjs@4.0.1.patch b/patches/pptxgenjs@4.0.1.patch
new file mode 100644
index 000000000..bff2feff1
--- /dev/null
+++ b/patches/pptxgenjs@4.0.1.patch
@@ -0,0 +1,46 @@
+diff --git a/dist/pptxgen.cjs.js b/dist/pptxgen.cjs.js
+index 55ae0b9eb49b1c87dc2ac2ec9d7c1fa215ddab8d..b3287011e893a0baeed6320fa75a47b67d3e0656 100644
+--- a/dist/pptxgen.cjs.js
++++ b/dist/pptxgen.cjs.js
+@@ -5960,7 +5960,7 @@ function genXmlTextRunProperties(opts, isDefault) {
+ runProps += `${createGlowElement(opts.glow, DEF_TEXT_GLOW)}`;
+ if (opts.fontFace) {
+ // NOTE: 'cs' = Complex Script, 'ea' = East Asian (use "-120" instead of "0" - per Issue #174); ea must come first (Issue #174)
+- runProps += ``;
++ runProps += ``;
+ }
+ }
+ // Hyperlink support
+@@ -6349,8 +6349,8 @@ function makeXmlContTypes(slides, slideLayouts, masterSlide) {
+ // STEP 2: Add presentation and slide master(s)/slide(s)
+ strXml += '';
+ strXml += '';
++ strXml += '';
+ slides.forEach((slide, idx) => {
+- strXml += ``;
+ strXml += ``;
+ // Add charts if any
+ slide._relsChart.forEach(rel => {
+diff --git a/dist/pptxgen.es.js b/dist/pptxgen.es.js
+index cac9ba92a862c3e9e0e52c2319138d78596e5309..5e8818df6ec1410a0595a7756c30064cb49fc8ee 100644
+--- a/dist/pptxgen.es.js
++++ b/dist/pptxgen.es.js
+@@ -5958,7 +5958,7 @@ function genXmlTextRunProperties(opts, isDefault) {
+ runProps += `${createGlowElement(opts.glow, DEF_TEXT_GLOW)}`;
+ if (opts.fontFace) {
+ // NOTE: 'cs' = Complex Script, 'ea' = East Asian (use "-120" instead of "0" - per Issue #174); ea must come first (Issue #174)
+- runProps += ``;
++ runProps += ``;
+ }
+ }
+ // Hyperlink support
+@@ -6347,8 +6347,8 @@ function makeXmlContTypes(slides, slideLayouts, masterSlide) {
+ // STEP 2: Add presentation and slide master(s)/slide(s)
+ strXml += '';
+ strXml += '';
++ strXml += '';
+ slides.forEach((slide, idx) => {
+- strXml += ``;
+ strXml += ``;
+ // Add charts if any
+ slide._relsChart.forEach(rel => {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 815333e57..87a743f9f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,6 +10,11 @@ overrides:
vitepress>vite: 6.4.2
esbuild@<0.25.0: '>=0.25.0'
+patchedDependencies:
+ pptxgenjs@4.0.1:
+ hash: dd46c0a62033b672e35bc3f1d7cb16c23dc8ccaa76d11ba92543083d4eca2def
+ path: patches/pptxgenjs@4.0.1.patch
+
importers:
.:
@@ -146,7 +151,7 @@ importers:
version: 1.14.0(react@19.2.6)
pptxgenjs:
specifier: ^4.0.1
- version: 4.0.1
+ version: 4.0.1(patch_hash=dd46c0a62033b672e35bc3f1d7cb16c23dc8ccaa76d11ba92543083d4eca2def)
react:
specifier: ^19.2.6
version: 19.2.6
@@ -235,7 +240,7 @@ importers:
version: link:../shared
pptxgenjs:
specifier: ^4.0.1
- version: 4.0.1
+ version: 4.0.1(patch_hash=dd46c0a62033b672e35bc3f1d7cb16c23dc8ccaa76d11ba92543083d4eca2def)
puppeteer-core:
specifier: ^24.42.0
version: 24.42.0
@@ -246,6 +251,9 @@ importers:
'@types/node':
specifier: ^22.19.19
version: 22.19.19
+ '@xmldom/xmldom':
+ specifier: 0.8.13
+ version: 0.8.13
typescript:
specifier: ^6.0.3
version: 6.0.3
@@ -10046,7 +10054,7 @@ snapshots:
commander: 9.5.0
optional: true
- pptxgenjs@4.0.1:
+ pptxgenjs@4.0.1(patch_hash=dd46c0a62033b672e35bc3f1d7cb16c23dc8ccaa76d11ba92543083d4eca2def):
dependencies:
'@types/node': 22.19.19
https: 1.0.0