Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/export-fidelity-assets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@open-codesign/desktop": patch
"@open-codesign/exporters": patch
---

Improve exporter fidelity by resolving workspace-local assets, bundling ZIP resources, preserving Markdown tables, supporting PDF header/footer options, and rendering PPTX slides from Chrome screenshots.
17 changes: 16 additions & 1 deletion apps/desktop/src/main/exporter-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CodesignError } from '@open-codesign/shared';
import { describe, expect, it } from 'vitest';
import { parseRequest } from './exporter-ipc';
import { exportAssetOptions, parseRequest } from './exporter-ipc';

describe('parseRequest', () => {
it('rejects a null payload with IPC_BAD_INPUT', () => {
Expand Down Expand Up @@ -32,4 +32,19 @@ describe('parseRequest', () => {
expect(result.htmlContent).toBe('<html/>');
expect(result.defaultFilename).toBe('report.pdf');
});

it('accepts workspace source context for local asset exports', () => {
const result = parseRequest({
format: 'zip',
htmlContent: '<img src="assets/logo.svg">',
workspacePath: '/workspace',
sourcePath: 'screens/home/index.html',
});

expect(result.workspacePath).toBe('/workspace');
expect(result.sourcePath).toBe('screens/home/index.html');
expect(exportAssetOptions(result)).toMatchObject({
assetRootPath: '/workspace',
});
});
});
31 changes: 28 additions & 3 deletions apps/desktop/src/main/exporter-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ExporterFormat, exportArtifact } from '@open-codesign/exporters';
import path from 'node:path';
import { type ExporterFormat, type ExportOptions, exportArtifact } from '@open-codesign/exporters';
import { CodesignError, ERROR_CODES } from '@open-codesign/shared';
import type { BrowserWindow } from 'electron';
import { dialog, ipcMain } from './electron-runtime';
Expand All @@ -15,6 +16,8 @@ export interface ExportRequest {
format: ExporterFormat;
htmlContent: string;
defaultFilename?: string;
workspacePath?: string;
sourcePath?: string;
}

export interface ExportResponse {
Expand All @@ -31,6 +34,8 @@ export function parseRequest(raw: unknown): ExportRequest {
const format = r['format'];
const html = r['htmlContent'];
const defaultFilename = r['defaultFilename'];
const workspacePath = r['workspacePath'];
const sourcePath = r['sourcePath'];
if (
format !== 'html' &&
format !== 'pdf' &&
Expand All @@ -50,9 +55,24 @@ export function parseRequest(raw: unknown): ExportRequest {
if (typeof defaultFilename === 'string' && defaultFilename.length > 0) {
out.defaultFilename = defaultFilename;
}
if (typeof workspacePath === 'string' && workspacePath.length > 0) {
out.workspacePath = workspacePath;
}
if (typeof sourcePath === 'string' && sourcePath.length > 0) {
out.sourcePath = sourcePath;
}
return out;
}

export function exportAssetOptions(req: ExportRequest): ExportOptions {
if (!req.workspacePath) return {};
const sourceDir = path.dirname(req.sourcePath ?? 'index.html');
return {
assetRootPath: req.workspacePath,
assetBasePath: path.resolve(req.workspacePath, sourceDir),
};
}

export function registerExporterIpc(getWindow: () => BrowserWindow | null): void {
ipcMain.handle('codesign:export', async (_evt, raw: unknown): Promise<ExportResponse> => {
const req = parseRequest(raw);
Expand All @@ -68,9 +88,14 @@ export function registerExporterIpc(getWindow: () => BrowserWindow | null): void
return { status: 'cancelled' };
}

// All four formats ship in tier 1; the heavy deps load lazily inside
// Export formats load their heavy deps lazily inside
// exportArtifact. Errors propagate to the renderer as toasts (PRINCIPLES §10).
const result = await exportArtifact(req.format, req.htmlContent, picked.filePath);
const result = await exportArtifact(
req.format,
req.htmlContent,
picked.filePath,
exportAssetOptions(req),
);
return { status: 'saved', path: result.path, bytes: result.bytes };
});
}
9 changes: 8 additions & 1 deletion apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ export interface ExportInvokeResponse {
path?: string;
bytes?: number;
}
export interface ExportInvokePayload {
format: ExportFormat;
htmlContent: string;
defaultFilename?: string;
workspacePath?: string;
sourcePath?: string;
}

export interface ProviderRow {
provider: string;
Expand Down Expand Up @@ -291,7 +298,7 @@ const api = {
ipcRenderer.invoke('codesign:pick-design-system-directory') as Promise<OnboardingState>,
clearDesignSystem: () =>
ipcRenderer.invoke('codesign:clear-design-system') as Promise<OnboardingState>,
export: (payload: { format: ExportFormat; htmlContent: string; defaultFilename?: string }) =>
export: (payload: ExportInvokePayload) =>
ipcRenderer.invoke('codesign:export', payload) as Promise<ExportInvokeResponse>,
locale: {
getSystem: () => ipcRenderer.invoke('locale:get-system') as Promise<string>,
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/renderer/src/store/slices/generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,10 +879,16 @@ export function makeGenerationSlice(set: SetState, get: GetState): GenerationSli
}
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const ext = format === 'markdown' ? 'md' : format;
const activeDesign =
designId === null
? null
: (get().designs.find((design) => design.id === designId) ?? null);
const res = await window.codesign.export({
format,
htmlContent,
defaultFilename: `codesign-${stamp}.${ext}`,
...(activeDesign?.workspacePath ? { workspacePath: activeDesign.workspacePath } : {}),
sourcePath: resolved.path,
});
if (res.status === 'saved' && res.path) {
set({ toastMessage: tr('notifications.exportedTo', { path: res.path }) });
Expand Down
61 changes: 61 additions & 0 deletions packages/exporters/src/assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
collectLocalAssetsFromHtml,
inlineLocalAssetsInHtml,
rewriteHtmlLocalAssetReferences,
} from './assets';

let tempDir = '';

beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), 'codesign-assets-test-'));
mkdirSync(join(tempDir, 'assets', 'fonts'), { recursive: true });
writeFileSync(join(tempDir, 'assets', 'logo.svg'), '<svg><title>Logo</title></svg>');
writeFileSync(join(tempDir, 'assets', 'fonts', 'demo.woff2'), Buffer.from([1, 2, 3]));
writeFileSync(
join(tempDir, 'assets', 'site.css'),
'@font-face{src:url("./fonts/demo.woff2")} body{background:url("/assets/logo.svg")}',
);
});

afterAll(() => {
rmSync(tempDir, { recursive: true, force: true });
});

describe('local exporter assets', () => {
it('inlines local HTML and nested CSS assets as data URIs', async () => {
const out = await inlineLocalAssetsInHtml(
'<link rel="stylesheet" href="assets/site.css"><img src="assets/logo.svg">',
{ assetBasePath: tempDir, assetRootPath: tempDir },
);

expect(out).toContain('href="data:text/css;charset=utf-8,');
expect(decodeURIComponent(out)).toContain('data:font/woff2;base64,AQID');
expect(out).toContain('src="data:image/svg+xml;charset=utf-8,');
});

it('collects local HTML and nested CSS references for ZIP exports', async () => {
const assets = await collectLocalAssetsFromHtml(
'<link rel="stylesheet" href="assets/site.css"><img src="/assets/logo.svg">',
{ assetBasePath: tempDir, assetRootPath: tempDir },
);

expect(assets.map((asset) => asset.path)).toEqual([
'assets/fonts/demo.woff2',
'assets/logo.svg',
'assets/site.css',
]);
});

it('rewrites root-relative local paths to archive-relative paths', () => {
const out = rewriteHtmlLocalAssetReferences('<img src="/assets/logo.svg?v=1">', {
assetBasePath: tempDir,
assetRootPath: tempDir,
});

expect(out).toContain('src="assets/logo.svg?v=1"');
});
});
Loading
Loading