Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/native-editable-pptx.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function SlideShell({ children, n, total, eyebrow, tokens = {} }) {
const t = { ...TWEAK_DEFAULTS, ...tokens };
return (
<div
data-pptx-slide
style={{
width: '100%',
aspectRatio: '16 / 9',
Expand Down
82 changes: 81 additions & 1 deletion apps/desktop/src/main/exporter-ipc.research.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,99 @@ afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});

async function exportDesign(format: ExporterFormat = 'html'): Promise<ExportResponse> {
async function exportDesign(
format: ExporterFormat = 'html',
renderMode?: 'image' | 'native',
): Promise<ExportResponse> {
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',
Expand Down
40 changes: 39 additions & 1 deletion apps/desktop/src/main/exporter-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -11,6 +11,8 @@ import {
resolveExportSource,
} from './exporter-ipc';

vi.mock('./electron-runtime', () => ({ app: {}, dialog: {}, ipcMain: {} }));

let tempDir = '';

beforeAll(() => {
Expand Down Expand Up @@ -41,6 +43,42 @@ describe('parseRequest', () => {
);
});

it('keeps the default PPTX mode unspecified', () => {
expect(parseRequest({ format: 'pptx', artifactSource: '<section />' })).not.toHaveProperty(
'renderMode',
);
});

it.each(['image', 'native'] as const)('accepts PPTX renderMode %s', (renderMode) => {
expect(
parseRequest({ format: 'pptx', artifactSource: '<section />', renderMode }),
).toMatchObject({ renderMode });
});

it.each([
'editable',
'',
'Native',
' native ',
null,
true,
1,
{},
[],
])('rejects invalid PPTX renderMode %j', (renderMode) => {
expect(() =>
parseRequest({ format: 'pptx', artifactSource: '<section />', 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: '<section />', renderMode }),
).toThrowError(expect.objectContaining({ code: 'IPC_BAD_INPUT' }));
}
});

it('accepts a valid pdf request', () => {
const result = parseRequest({
format: 'pdf',
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/main/exporter-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function normalizeExportSourcePath(raw: string): string {

export interface ExportRequest {
format: ExporterFormat;
renderMode?: 'image' | 'native';
artifactSource: string;
defaultFilename?: string;
designId?: string;
Expand All @@ -76,6 +77,7 @@ export interface ExportResponse {
status: 'saved' | 'cancelled';
sourcesPath?: string;
researchWarnings?: string[];
exportWarnings?: string[];
path?: string;
bytes?: number;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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 } : {}),
};
Expand Down
3 changes: 0 additions & 3 deletions apps/desktop/src/main/preview-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,8 @@ export async function runPreview(opts: RunPreviewOptions): Promise<PreviewResult
const metrics = await boundedPreview(
page.evaluate(() => {
// 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),
Expand Down Expand Up @@ -285,7 +283,6 @@ export async function runPreview(opts: RunPreviewOptions): Promise<PreviewResult
const children = kids.map((c) => 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,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('../store')>('../store');
return {
...actual,
useCodesignStore: Object.assign(
(selector: (state: CodesignState) => unknown) => selector(actual.useCodesignStore.getState()),
actual.useCodesignStore,
),
};
});

const menus = vi.hoisted(() => new Map<string, { items: PreviewMenuItem[]; disabled: boolean }>());
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: '<section>Deck</section>',
exportActive,
});
});

function exportMenu() {
renderToStaticMarkup(<PreviewToolbar />);
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);
}
});
});
24 changes: 18 additions & 6 deletions apps/desktop/src/renderer/src/components/PreviewToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,24 @@ export function PreviewToolbar({
<PreviewToolbarMenu
label={t('export.button')}
disabled={disabled}
items={EXPORT_FORMATS.map((format) => ({
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'),
},
]
: []),
])}
>
<Download className="w-[var(--size-icon-md)] h-[var(--size-icon-md)]" aria-hidden />
{t('export.button')}
Expand Down
Loading
Loading