FRE-600: Fix code review blockers

- Consolidated duplicate UndoManagers to single instance
- Fixed connection promise to only resolve on 'connected' status
- Fixed WebSocketProvider import (WebsocketProvider)
- Added proper doc.destroy() cleanup
- Renamed isPresenceInitialized property to avoid conflict

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-25 00:08:01 -04:00
parent 65b552bb08
commit 7c684a42cc
48450 changed files with 5679671 additions and 383 deletions

126
src/lib/export/fdx.test.ts Normal file
View File

@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest';
import { FdxExporter } from './fdx';
import type { ScreenplayElement } from '../screenplay/types';
const sampleElements: ScreenplayElement[] = [
{ id: 'e1', type: 'sceneHeading', content: 'INT. COFFEE SHOP - DAY' },
{ id: 'e2', type: 'action', content: 'A bustling coffee shop.' },
{ id: 'e3', type: 'character', content: 'Jessica' },
{ id: 'e4', type: 'dialogue', content: 'Hello there.' },
{ id: 'e5', type: 'parenthetical', content: 'smiling' },
{ id: 'e6', type: 'transition', content: 'SMASH CUT TO:' },
{ id: 'e7', type: 'note', content: 'Writer note' },
{ id: 'e8', type: 'retained', content: 'Retained from v1' },
{ id: 'e9', type: 'centered', content: 'FADE OUT.' },
];
describe('FdxExporter', () => {
const exporter = new FdxExporter();
it('supports fdx format', () => {
expect(exporter.supportedFormats).toContain('fdx');
});
it('produces valid XML structure', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(result.data).toContain('<FDX version="8.0">');
expect(result.data).toContain('</FDX>');
});
it('includes title page', () => {
const result = exporter.export(sampleElements, {
format: 'fdx',
title: 'Test Script',
author: 'Jane Doe',
});
expect(result.data).toContain('<TitlePage>');
expect(result.data).toContain('<Title>Test Script</Title>');
expect(result.data).toContain('<Author>Jane Doe</Author>');
});
it('includes font face and page setup', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('FontFace');
expect(result.data).toContain('PageSetup');
});
it('exports scene headings as SceneHeading tags', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<SceneHeading fontFaceId="0">INT. COFFEE SHOP - DAY</SceneHeading>');
});
it('exports action as Action tags', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Action fontFaceId="0">A bustling coffee shop.</Action>');
});
it('exports characters in uppercase', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Character fontFaceId="0">JESSICA</Character>');
});
it('exports dialogue', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Dialogue fontFaceId="0">Hello there.</Dialogue>');
});
it('exports parentheticals', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Parenthetical fontFaceId="0">smiling</Parenthetical>');
});
it('exports transitions in uppercase', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Transition fontFaceId="0">SMASH CUT TO:</Transition>');
});
it('exports notes', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Note fontFaceId="0">Writer note</Note>');
});
it('exports retained text', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Retained fontFaceId="0">Retained from v1</Retained>');
});
it('exports centered text', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Centered fontFaceId="0">FADE OUT.</Centered>');
});
it('escapes XML special characters', () => {
const elements: ScreenplayElement[] = [
{ id: 'e1', type: 'action', content: 'A sign reads: "Hello & Goodbye"' },
];
const result = exporter.export(elements, { format: 'fdx' });
expect(result.data).toContain('&amp;');
expect(result.data).toContain('&quot;');
});
it('uses correct content type and extension', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.contentType).toBe('application/xml');
expect(result.extension).toBe('.fdx');
});
it('generates filename from title', () => {
const result = exporter.export(sampleElements, { format: 'fdx', title: 'My Script' });
expect(result.filename).toBe('My_Script.fdx');
});
it('handles empty elements array', () => {
const result = exporter.export([], { format: 'fdx' });
expect(result.data).toContain('<Content>');
expect(result.data).toContain('</Content>');
});
it('includes contact when provided', () => {
const result = exporter.export(sampleElements, {
format: 'fdx',
contact: 'jane@example.com',
});
expect(result.data).toContain('<Contact>jane@example.com</Contact>');
});
});

104
src/lib/export/fdx.ts Normal file
View File

@@ -0,0 +1,104 @@
import type { ScreenplayElement } from '../screenplay/types';
import type { ScreenplayExporter, ExportOptions, ExportResult } from './types';
import { CONTENT_TYPES, EXTENSIONS } from './types';
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function elementToXmlTag(type: string): string {
const map: Record<string, string> = {
sceneHeading: 'SceneHeading',
action: 'Action',
character: 'Character',
dialogue: 'Dialogue',
parenthetical: 'Parenthetical',
transition: 'Transition',
note: 'Note',
retained: 'Retained',
centered: 'Centered',
};
return map[type] || 'Action';
}
export class FdxExporter implements ScreenplayExporter {
public readonly supportedFormats: readonly ['fdx'] = ['fdx'];
public export(elements: ScreenplayElement[], options: ExportOptions): ExportResult {
const title = options.title ?? 'Untitled';
const author = options.author ?? '';
const datetime = options.datetime ?? new Date().toISOString().split('T')[0] ?? '';
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<FDX version="8.0">\n';
xml += ' <TitlePage>\n';
xml += ' <Title>' + escapeXml(title) + '</Title>\n';
xml += ' <Author>' + escapeXml(author) + '</Author>\n';
xml += ' <Date>' + escapeXml(datetime) + '</Date>\n';
if (options.contact) {
xml += ' <Contact>' + escapeXml(options.contact) + '</Contact>\n';
}
xml += ' </TitlePage>\n';
xml += ' <FontFace id="0" name="Courier" size="12" />\n';
xml += ' <PageSetup width="8.5" height="11" topMargin="1.0" bottomMargin="1.0" leftMargin="1.0" rightMargin="1.0" />\n';
xml += ' <Content>\n';
for (const el of elements) {
const tag = elementToXmlTag(el.type);
const content = escapeXml(el.content.trim());
switch (el.type) {
case 'sceneHeading':
xml += ' <SceneHeading fontFaceId="0">' + content.toUpperCase() + '</SceneHeading>\n';
break;
case 'action':
xml += ' <Action fontFaceId="0">' + content + '</Action>\n';
break;
case 'character':
xml += ' <Character fontFaceId="0">' + content.toUpperCase() + '</Character>\n';
break;
case 'dialogue':
xml += ' <Dialogue fontFaceId="0">' + content + '</Dialogue>\n';
break;
case 'parenthetical':
xml += ' <Parenthetical fontFaceId="0">' + content + '</Parenthetical>\n';
break;
case 'transition':
xml += ' <Transition fontFaceId="0">' + content.toUpperCase() + '</Transition>\n';
break;
case 'note':
xml += ' <Note fontFaceId="0">' + content + '</Note>\n';
break;
case 'retained':
xml += ' <Retained fontFaceId="0">' + content + '</Retained>\n';
break;
case 'centered':
xml += ' <Centered fontFaceId="0">' + content + '</Centered>\n';
break;
default:
xml += ' <Action fontFaceId="0">' + content + '</Action>\n';
}
}
xml += ' </Content>\n';
xml += '</FDX>\n';
const filename = (title || 'screenplay').replace(/[^a-zA-Z0-9]/g, '_');
return {
format: 'fdx',
contentType: CONTENT_TYPES.fdx,
extension: EXTENSIONS.fdx,
data: xml,
filename: filename + EXTENSIONS.fdx,
};
}
}

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { FountainExporter } from './fountain';
import type { ScreenplayElement } from '../screenplay/types';
const sampleElements: ScreenplayElement[] = [
{ id: 'e1', type: 'sceneHeading', content: 'INT. COFFEE SHOP - DAY' },
{ id: 'e2', type: 'action', content: 'A bustling coffee shop. JESSICA sits alone at a corner table.' },
{ id: 'e3', type: 'character', content: 'Jessica' },
{ id: 'e4', type: 'dialogue', content: 'I need to make a decision today.' },
{ id: 'e5', type: 'parenthetical', content: 'to herself' },
{ id: 'e6', type: 'transition', content: 'CUT TO:' },
{ id: 'e7', type: 'sceneHeading', content: 'EXT. PARK - NIGHT' },
{ id: 'e8', type: 'action', content: 'The park is empty. Streetlights flicker.' },
{ id: 'e9', type: 'character', content: 'Marcus' },
{ id: 'e10', type: 'dialogue', content: 'She never came back.' },
{ id: 'e11', type: 'note', content: 'TODO: Add flashback sequence' },
{ id: 'e12', type: 'centered', content: 'THE END' },
];
describe('FountainExporter', () => {
const exporter = new FountainExporter();
it('supports fountain format', () => {
expect(exporter.supportedFormats).toContain('fountain');
});
it('exports scene headings with # marker', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.format).toBe('fountain');
expect(result.data).toContain('# INT. COFFEE SHOP - DAY');
expect(result.data).toContain('# EXT. PARK - NIGHT');
});
it('exports characters with = markers', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.data).toContain('= JESSICA =');
expect(result.data).toContain('= MARCUS =');
});
it('exports parentheticals in parentheses', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.data).toContain('(to herself)');
});
it('exports transitions with -> marker', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.data).toContain('-> CUT TO:');
});
it('exports notes with > marker', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.data).toContain('> TODO: Add flashback sequence');
});
it('exports action with 2-space indent', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.data).toContain(' A bustling coffee shop');
});
it('uses correct content type and extension', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.contentType).toBe('text/plain');
expect(result.extension).toBe('.fountain');
});
it('generates filename from title', () => {
const result = exporter.export(sampleElements, { format: 'fountain', title: 'My Script' });
expect(result.filename).toBe('My_Script.fountain');
});
it('includes cover page when requested', () => {
const result = exporter.export(sampleElements, {
format: 'fountain',
title: 'Test Script',
author: 'Jane Doe',
includeCoverPage: true,
});
expect(result.data).toContain('Test Script');
expect(result.data).toContain('By Jane Doe');
expect(result.data).toContain('***');
});
it('handles empty elements array', () => {
const result = exporter.export([], { format: 'fountain' });
expect(result.data).toBe('');
});
it('returns dialogue without markers', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.data).toContain('I need to make a decision today.');
});
});

View File

@@ -0,0 +1,90 @@
import type { ScreenplayElement } from '../screenplay/types';
import type { ScreenplayExporter, ExportOptions, ExportResult } from './types';
import { CONTENT_TYPES, EXTENSIONS } from './types';
export class FountainExporter implements ScreenplayExporter {
public readonly supportedFormats: readonly ['fountain'] = ['fountain'];
public export(elements: ScreenplayElement[], options: ExportOptions): ExportResult {
const lines: string[] = [];
if (options.includeCoverPage) {
lines.push(options.title || 'Untitled');
lines.push('');
if (options.author) lines.push('By ' + options.author);
if (options.contact) lines.push(options.contact);
if (options.datetime) lines.push(options.datetime);
lines.push('');
lines.push('***');
lines.push('');
}
for (let idx = 0; idx < elements.length; idx++) {
const el = elements[idx];
if (!el) continue;
const nextEl = elements[idx + 1];
switch (el.type) {
case 'sceneHeading':
lines.push('# ' + el.content.trim().toUpperCase());
lines.push('');
break;
case 'action':
lines.push(' ' + el.content.trim());
lines.push('');
break;
case 'character': {
const charName = el.content.trim().toUpperCase();
lines.push('= ' + charName + ' =');
lines.push('');
break;
}
case 'dialogue':
lines.push(el.content.trim());
lines.push('');
break;
case 'parenthetical':
lines.push('(' + el.content.trim() + ')');
lines.push('');
break;
case 'transition':
lines.push('-> ' + el.content.trim().toUpperCase());
lines.push('');
break;
case 'note':
lines.push('> ' + el.content.trim());
lines.push('');
break;
case 'centered':
lines.push(' ' + el.content.trim());
lines.push('');
break;
case 'retained':
lines.push(' ' + el.content.trim());
lines.push('');
break;
}
void nextEl;
}
const content = lines.join('\n').trim();
const filename = (options.title || 'screenplay').replace(/[^a-zA-Z0-9]/g, '_');
return {
format: 'fountain',
contentType: CONTENT_TYPES.fountain,
extension: EXTENSIONS.fountain,
data: content,
filename: filename + EXTENSIONS.fountain,
};
}
}

18
src/lib/export/index.ts Normal file
View File

@@ -0,0 +1,18 @@
export type {
ExportFormat,
ExportOptions,
ExportResult,
ScreenplayExporter,
BatchExportOptions,
BatchExportResult,
BatchExportError,
} from './types';
export { CONTENT_TYPES, EXTENSIONS } from './types';
export { FountainExporter } from './fountain';
export { FdxExporter } from './fdx';
export { PdfExporter } from './pdf';
export { ScreenplayProExporter } from './screenplay-pro';
export { ExportManager } from './manager';
export { generatePreview, computeStats } from './preview';
export type { PreviewOptions, PreviewResult, PreviewStats } from './preview';

View File

@@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ExportManager } from './manager';
import type { ScreenplayElement } from '../screenplay/types';
const sampleElements: ScreenplayElement[] = [
{ id: 'e1', type: 'sceneHeading', content: 'INT. COFFEE SHOP - DAY' },
{ id: 'e2', type: 'action', content: 'A bustling coffee shop.' },
{ id: 'e3', type: 'character', content: 'Jessica' },
{ id: 'e4', type: 'dialogue', content: 'Hello there.' },
{ id: 'e5', type: 'transition', content: 'CUT TO:' },
];
describe('ExportManager', () => {
let manager: ExportManager;
beforeEach(() => {
manager = new ExportManager();
});
describe('getAvailableFormats', () => {
it('returns all registered formats', () => {
const formats = manager.getAvailableFormats();
expect(formats).toContain('fountain');
expect(formats).toContain('fdx');
expect(formats).toContain('pdf');
expect(formats).toContain('screenplay-pro');
});
});
describe('export', () => {
it('exports to fountain', () => {
const result = manager.export(sampleElements, { format: 'fountain', title: 'Test' });
expect(result.format).toBe('fountain');
expect(result.data).toContain('# INT. COFFEE SHOP - DAY');
});
it('exports to fdx', () => {
const result = manager.export(sampleElements, { format: 'fdx', title: 'Test' });
expect(result.format).toBe('fdx');
expect(result.data).toContain('<FDX');
});
it('exports to pdf', () => {
const result = manager.export(sampleElements, { format: 'pdf', title: 'Test' });
expect(result.format).toBe('pdf');
expect(result.data).toContain('<!DOCTYPE html>');
});
it('exports to screenplay-pro', () => {
const result = manager.export(sampleElements, { format: 'screenplay-pro', title: 'Test' });
expect(result.format).toBe('screenplay-pro');
expect(result.data).toContain('[HEADER]');
});
});
describe('batchExport', () => {
it('exports to multiple formats', () => {
const result = manager.batchExport(sampleElements, ['fountain', 'fdx', 'pdf'], {
title: 'Batch Test',
author: 'Jane Doe',
});
expect(result.results).toHaveLength(3);
expect(result.errors).toHaveLength(0);
expect(result.formats).toEqual(['fountain', 'fdx', 'pdf']);
});
it('handles partial failures gracefully', () => {
const result = manager.batchExport(sampleElements, ['fountain', 'fdx'], {});
expect(result.results.length).toBeGreaterThanOrEqual(0);
});
it('exports all four formats', () => {
const result = manager.batchExport(sampleElements, [
'fountain',
'fdx',
'pdf',
'screenplay-pro',
]);
expect(result.results).toHaveLength(4);
expect(result.errors).toHaveLength(0);
});
it('applies base options to all exports', () => {
const result = manager.batchExport(sampleElements, ['fountain', 'fdx'], {
title: 'My Script',
author: 'Test Author',
includeCoverPage: true,
});
for (const r of result.results) {
expect(r.filename).toContain('My_Script');
}
});
});
describe('exportToRawText', () => {
it('exports formatted raw text', () => {
const text = manager.exportToRawText(sampleElements);
expect(text).toContain('INT. COFFEE SHOP - DAY');
expect(text).toContain('JESSICA');
});
it('applies uppercase to scene headings', () => {
const text = manager.exportToRawText(sampleElements);
expect(text).toContain('INT. COFFEE SHOP - DAY');
});
it('applies uppercase to character cues', () => {
const text = manager.exportToRawText(sampleElements);
expect(text).toContain('JESSICA');
});
it('preserves dialogue case', () => {
const text = manager.exportToRawText(sampleElements);
expect(text).toContain('Hello there.');
});
it('uses standard template by default', () => {
const text = manager.exportToRawText(sampleElements);
expect(text.length).toBeGreaterThan(0);
});
it('supports sitcom template', () => {
const text = manager.exportToRawText(sampleElements, 'sitcom');
expect(text.length).toBeGreaterThan(0);
});
});
});

100
src/lib/export/manager.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { ScreenplayElement, TemplateType } from '../screenplay/types';
import type {
ExportFormat,
ExportOptions,
ExportResult,
ScreenplayExporter,
BatchExportOptions,
BatchExportResult,
BatchExportError,
} from './types';
import { FountainExporter } from './fountain';
import { FdxExporter } from './fdx';
import { PdfExporter } from './pdf';
import { ScreenplayProExporter } from './screenplay-pro';
import { getTemplate } from '../screenplay/format';
export class ExportManager {
private exporters: Map<ExportFormat, ScreenplayExporter>;
constructor() {
this.exporters = new Map();
this.register(new FountainExporter());
this.register(new FdxExporter());
this.register(new PdfExporter());
this.register(new ScreenplayProExporter());
}
public register(exp: ScreenplayExporter): void {
for (const format of exp.supportedFormats) {
this.exporters.set(format, exp);
}
}
public getAvailableFormats(): ExportFormat[] {
return Array.from(this.exporters.keys());
}
public export(elements: ScreenplayElement[], options: ExportOptions): ExportResult {
const exporter = this.exporters.get(options.format);
if (!exporter) {
throw new Error(`Unsupported export format: ${options.format}`);
}
return exporter.export(elements, options);
}
public batchExport(
elements: ScreenplayElement[],
formats: ExportFormat[],
baseOptions: BatchExportOptions = {}
): BatchExportResult {
const results: ExportResult[] = [];
const errors: BatchExportError[] = [];
for (const format of formats) {
try {
const options: ExportOptions = {
format,
title: baseOptions.title,
author: baseOptions.author,
contact: baseOptions.contact,
template: baseOptions.template,
includeCoverPage: baseOptions.includeCoverPage,
gutterMargin: baseOptions.gutterMargin,
};
const result = this.export(elements, options);
results.push(result);
} catch (err) {
errors.push({
format,
error: err instanceof Error ? err.message : String(err),
});
}
}
return { formats, results, errors };
}
public exportToRawText(elements: ScreenplayElement[], template: TemplateType = 'standard'): string {
const tpl = getTemplate(template);
const lines: string[] = [];
for (const el of elements) {
const style = tpl.elementStyles[el.type];
const contentLines = el.content.trim().split('\n');
for (const line of contentLines) {
let formatted = line.trim();
if (style.uppercase) formatted = formatted.toUpperCase();
const indent = ' '.repeat(Math.round(style.indentStart * 2));
lines.push(indent + formatted);
}
if (style.marginBottom > 0) {
lines.push('');
}
}
return lines.join('\n').trim();
}
}

130
src/lib/export/pdf.test.ts Normal file
View File

@@ -0,0 +1,130 @@
import { describe, it, expect } from 'vitest';
import { PdfExporter } from './pdf';
import type { ScreenplayElement } from '../screenplay/types';
const sampleElements: ScreenplayElement[] = [
{ id: 'e1', type: 'sceneHeading', content: 'INT. COFFEE SHOP - DAY' },
{ id: 'e2', type: 'action', content: 'A bustling coffee shop. JESSICA sits alone at a corner table, staring at her laptop.' },
{ id: 'e3', type: 'character', content: 'Jessica' },
{ id: 'e4', type: 'dialogue', content: 'I need to make a decision today.' },
{ id: 'e5', type: 'parenthetical', content: 'to herself' },
{ id: 'e6', type: 'dialogue', content: 'There is no other option.' },
{ id: 'e7', type: 'transition', content: 'CUT TO:' },
{ id: 'e8', type: 'sceneHeading', content: 'EXT. PARK - NIGHT' },
{ id: 'e9', type: 'action', content: 'The park is empty. Streetlights flicker overhead.' },
{ id: 'e10', type: 'character', content: 'Marcus' },
{ id: 'e11', type: 'dialogue', content: 'She never came back.' },
];
describe('PdfExporter', () => {
const exporter = new PdfExporter();
it('supports pdf format', () => {
expect(exporter.supportedFormats).toContain('pdf');
});
it('produces HTML output', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.format).toBe('pdf');
expect(result.data).toContain('<!DOCTYPE html>');
expect(result.data).toContain('<html>');
expect(result.data).toContain('</html>');
});
it('includes proper page setup CSS', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('@page');
expect(result.data).toContain('size: 8.5in 11in');
});
it('uses Courier font', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('Courier');
});
it('uses 12pt font size', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('font-size: 12pt');
});
it('renders scene headings in uppercase and bold', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('INT. COFFEE SHOP - DAY');
expect(result.data).toContain('font-weight: bold');
});
it('renders dialogue content', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('I need to make a decision today.');
expect(result.data).toContain('She never came back.');
});
it('renders character cues in uppercase', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('JESSICA');
expect(result.data).toContain('MARCUS');
});
it('includes page numbers', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('1</div>');
});
it('includes cover page when requested', () => {
const result = exporter.export(sampleElements, {
format: 'pdf',
title: 'Test Script',
author: 'Jane Doe',
includeCoverPage: true,
});
expect(result.data).toContain('Test Script');
expect(result.data).toContain('by');
expect(result.data).toContain('Jane Doe');
});
it('uses correct content type and extension', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.contentType).toBe('application/pdf');
expect(result.extension).toBe('.pdf');
});
it('generates filename from title', () => {
const result = exporter.export(sampleElements, { format: 'pdf', title: 'My Script' });
expect(result.filename).toBe('My_Script.pdf');
});
it('reports page count', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.pageCount).toBeGreaterThan(0);
});
it('escapes HTML special characters', () => {
const elements: ScreenplayElement[] = [
{ id: 'e1', type: 'action', content: 'A sign reads: "Hello & Goodbye"' },
];
const result = exporter.export(elements, { format: 'pdf' });
expect(result.data).toContain('&amp;');
expect(result.data).toContain('&quot;');
});
it('handles empty elements array', () => {
const result = exporter.export([], { format: 'pdf' });
expect(result.pageCount).toBe(0);
});
it('supports gutter margin option', () => {
const result = exporter.export(sampleElements, {
format: 'pdf',
gutterMargin: 0.5,
});
expect(result.data).toContain('1.5in');
});
it('supports sitcom template', () => {
const result = exporter.export(sampleElements, {
format: 'pdf',
template: 'sitcom',
});
expect(result.data).toContain('font-weight: bold');
});
});

185
src/lib/export/pdf.ts Normal file
View File

@@ -0,0 +1,185 @@
import type { ScreenplayElement } from '../screenplay/types';
import type { ScreenplayExporter, ExportOptions, ExportResult } from './types';
import { CONTENT_TYPES, EXTENSIONS } from './types';
import { getTemplate } from '../screenplay/format';
const LINES_PER_PAGE = 55;
interface PageElement {
text: string;
type: string;
bold: boolean;
align: 'left' | 'center' | 'right';
indentLeft: number;
indentRight: number;
}
export class PdfExporter implements ScreenplayExporter {
public readonly supportedFormats: readonly ['pdf'] = ['pdf'];
public export(elements: ScreenplayElement[], options: ExportOptions): ExportResult {
const template = getTemplate(options.template || 'standard');
const pages = buildPages(elements, template);
const pageCount = pages.length;
const html = renderHtml(pages, template, options, pageCount);
const filename = (options.title || 'screenplay').replace(/[^a-zA-Z0-9]/g, '_');
return {
format: 'pdf',
contentType: CONTENT_TYPES.pdf,
extension: EXTENSIONS.pdf,
data: html,
filename: filename + EXTENSIONS.pdf,
pageCount,
};
}
}
function buildPages(elements: ScreenplayElement[], template: ReturnType<typeof getTemplate>): PageElement[][] {
const allElements: PageElement[] = [];
for (const el of elements) {
const style = template.elementStyles[el.type];
const lines = el.content.trim().split('\n');
for (const line of lines) {
if (line.trim() === '' && el.type !== 'action') continue;
const text = style.uppercase ? line.trim().toUpperCase() : line.trim();
allElements.push({
text,
type: el.type,
bold: style.bold,
align: style.textAlign,
indentLeft: style.indentStart,
indentRight: style.indentEnd,
});
}
if (style.marginBottom > 0) {
allElements.push({
text: '',
type: 'spacing',
bold: false,
align: 'left',
indentLeft: 0,
indentRight: 0,
});
}
}
const pages: PageElement[][] = [];
let currentPage: PageElement[] = [];
for (const el of allElements) {
if (currentPage.length >= LINES_PER_PAGE) {
pages.push(currentPage);
currentPage = [];
}
currentPage.push(el);
}
if (currentPage.length > 0) {
pages.push(currentPage);
}
return pages;
}
function renderHtml(
pages: PageElement[][],
template: ReturnType<typeof getTemplate>,
options: ExportOptions,
totalPages: number
): string {
const gutterLeft = options.gutterMargin || 0;
const INCH_TO_MM = 25.4;
let bodyContent = '';
if (options.includeCoverPage) {
bodyContent += renderCoverPage(options);
}
for (let p = 0; p < pages.length; p++) {
const page = pages[p];
if (!page) continue;
let pageContent = '';
for (const el of page) {
const indentLeft = (el.indentLeft + gutterLeft) * INCH_TO_MM;
const indentRight = el.indentRight * INCH_TO_MM;
const fontWeight = el.bold || el.type === 'sceneHeading' ? 'bold' : 'normal';
pageContent += `<div style="text-align: ${el.align}; padding-left: ${indentLeft}mm; padding-right: ${indentRight}mm; font-weight: ${fontWeight}; line-height: 1.15;">${escapeHtml(el.text)}</div>\n`;
}
bodyContent += `<div class="page" style="page-break-after: ${p < totalPages - 1 ? 'always' : 'avoid'};">\n`;
bodyContent += pageContent;
bodyContent += `<div style="text-align: right; padding-top: 10mm; font-size: 10pt;">${p + 1}</div>\n`;
bodyContent += '</div>\n';
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${escapeHtml(options.title || 'Screenplay')}</title>
<style>
@page {
size: ${template.pageWidth}in ${template.pageHeight}in;
margin: ${template.topMargin}in ${template.rightMargin}in ${template.bottomMargin}in ${template.leftMargin + gutterLeft}in;
}
body {
font-family: ${template.fontFamily};
font-size: ${template.fontSize}pt;
margin: 0;
padding: 0;
color: #000;
background: #fff;
}
@media print {
body { -webkit-print-color-adjust: exact; }
}
</style>
</head>
<body>
${bodyContent}
</body>
</html>`;
}
function renderCoverPage(options: ExportOptions): string {
let cover = '<div class="page" style="page-break-after: always; text-align: center; padding-top: 3in;">\n';
cover += `<div style="font-size: 14pt; font-weight: bold; margin-bottom: 0.5in;">${escapeHtml(options.title || 'Untitled')}</div>\n`;
if (options.author) {
cover += '<div style="margin-bottom: 0.3in;">by</div>\n';
cover += `<div style="font-size: 12pt; margin-bottom: 1in;">${escapeHtml(options.author)}</div>\n`;
}
if (options.contact) {
cover += `<div style="font-size: 10pt;">${escapeHtml(options.contact)}</div>\n`;
}
if (options.datetime) {
cover += `<div style="font-size: 10pt; margin-top: 0.3in;">${escapeHtml(options.datetime)}</div>\n`;
}
if (options.pageNumber) {
cover += `<div style="font-size: 10pt; margin-top: 0.3in;">Draft ${options.pageNumber}</div>\n`;
}
cover += '</div>\n';
return cover;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest';
import { generatePreview, computeStats } from './preview';
import type { ScreenplayElement } from '../screenplay/types';
const sampleElements: ScreenplayElement[] = [
{ id: 'e1', type: 'sceneHeading', content: 'INT. COFFEE SHOP - DAY' },
{ id: 'e2', type: 'action', content: 'A bustling coffee shop.\nJESSICA sits alone.' },
{ id: 'e3', type: 'character', content: 'Jessica' },
{ id: 'e4', type: 'dialogue', content: 'I need to make a decision.' },
{ id: 'e5', type: 'parenthetical', content: 'to herself' },
{ id: 'e6', type: 'dialogue', content: 'There is no other option.' },
{ id: 'e7', type: 'transition', content: 'CUT TO:' },
{ id: 'e8', type: 'sceneHeading', content: 'EXT. PARK - NIGHT' },
{ id: 'e9', type: 'action', content: 'The park is empty.' },
{ id: 'e10', type: 'character', content: 'Marcus' },
{ id: 'e11', type: 'dialogue', content: 'She never came back.' },
];
describe('generatePreview', () => {
it('generates formatted preview text', () => {
const result = generatePreview(sampleElements);
expect(result.text).toContain('INT. COFFEE SHOP - DAY');
expect(result.text).toContain('JESSICA');
});
it('applies uppercase to scene headings', () => {
const result = generatePreview(sampleElements);
expect(result.text).toContain('INT. COFFEE SHOP - DAY');
});
it('applies uppercase to character cues', () => {
const result = generatePreview(sampleElements);
expect(result.text).toContain('JESSICA');
expect(result.text).toContain('MARCUS');
});
it('preserves dialogue case', () => {
const result = generatePreview(sampleElements);
expect(result.text).toContain('I need to make a decision.');
});
it('includes stats', () => {
const result = generatePreview(sampleElements);
expect(result.stats.totalElements).toBe(sampleElements.length);
expect(result.stats.sceneCount).toBe(2);
expect(result.stats.characterCount).toBe(2);
expect(result.stats.transitionCount).toBe(1);
});
it('respects maxLines option', () => {
const result = generatePreview(sampleElements, { maxLines: 5 });
expect(result.text).toContain('... (truncated)');
});
it('includes page count when requested', () => {
const result = generatePreview(sampleElements, { pageCount: true });
expect(result.text).toContain('Estimated length:');
expect(result.text).toContain('pages');
});
it('uses standard template by default', () => {
const result = generatePreview(sampleElements);
expect(result.stats.totalElements).toBeGreaterThan(0);
});
it('supports sitcom template', () => {
const result = generatePreview(sampleElements, { template: 'sitcom' });
expect(result.stats.totalElements).toBeGreaterThan(0);
});
it('handles empty elements', () => {
const result = generatePreview([]);
expect(result.text).toBe('');
expect(result.stats.totalElements).toBe(0);
});
});
describe('computeStats', () => {
it('counts all element types', () => {
const stats = computeStats(sampleElements);
expect(stats.totalElements).toBe(11);
expect(stats.sceneCount).toBe(2);
expect(stats.actionCount).toBe(3);
expect(stats.characterCount).toBe(2);
expect(stats.dialogueCount).toBe(3);
expect(stats.parentheticalCount).toBe(1);
expect(stats.transitionCount).toBe(1);
});
it('estimates page count', () => {
const stats = computeStats(sampleElements);
expect(stats.estimatedPages).toBeGreaterThan(0);
expect(stats.totalPages).toBe(stats.estimatedPages);
});
it('handles empty elements', () => {
const stats = computeStats([]);
expect(stats.totalElements).toBe(0);
expect(stats.estimatedPages).toBe(0);
});
it('counts multi-line action correctly', () => {
const elements: ScreenplayElement[] = [
{ id: 'e1', type: 'action', content: 'Line one.\nLine two.\nLine three.' },
];
const stats = computeStats(elements);
expect(stats.actionCount).toBe(3);
});
});

149
src/lib/export/preview.ts Normal file
View File

@@ -0,0 +1,149 @@
import type { ScreenplayElement, TemplateType } from '../screenplay/types';
import { getTemplate } from '../screenplay/format';
export interface PreviewOptions {
template?: TemplateType;
maxLines?: number;
characterCount?: boolean;
pageCount?: boolean;
}
export interface PreviewResult {
text: string;
stats: PreviewStats;
}
export interface PreviewStats {
totalElements: number;
totalPages: number;
dialogueCount: number;
actionCount: number;
sceneCount: number;
characterCount: number;
parentheticalCount: number;
transitionCount: number;
estimatedPages: number;
}
const LINES_PER_PAGE = 55;
export function generatePreview(elements: ScreenplayElement[], options: PreviewOptions = {}): PreviewResult {
const template = getTemplate(options.template || 'standard');
const maxLines = options.maxLines ?? 200;
const lines: string[] = [];
if (options.pageCount) {
const estimatedPages = estimatePageCount(elements);
lines.push(`Estimated length: ${estimatedPages} pages`);
lines.push('');
}
let lineCount = 0;
for (const el of elements) {
if (lineCount >= maxLines) {
lines.push('... (truncated)');
break;
}
const style = template.elementStyles[el.type];
const contentLines = el.content.trim().split('\n');
for (const line of contentLines) {
if (lineCount >= maxLines) {
lines.push('... (truncated)');
break;
}
let formatted = line.trim();
if (style.uppercase) formatted = formatted.toUpperCase();
const indent = ' '.repeat(Math.round(style.indentStart * 2));
lines.push(indent + formatted);
lineCount++;
}
if (style.marginBottom > 0) {
lines.push('');
lineCount++;
}
}
const stats = computeStats(elements);
return {
text: lines.join('\n'),
stats,
};
}
export function computeStats(elements: ScreenplayElement[]): PreviewStats {
let dialogueCount = 0;
let actionCount = 0;
let sceneCount = 0;
let characterCount = 0;
let parentheticalCount = 0;
let transitionCount = 0;
let totalLines = 0;
for (const el of elements) {
const contentLines = el.content.trim().split('\n').filter(l => l.trim() !== '');
totalLines += contentLines.length;
switch (el.type) {
case 'dialogue':
dialogueCount += contentLines.length;
break;
case 'action':
actionCount += contentLines.length;
break;
case 'sceneHeading':
sceneCount++;
totalLines += 1;
break;
case 'character':
characterCount++;
break;
case 'parenthetical':
parentheticalCount++;
break;
case 'transition':
transitionCount++;
break;
}
}
const estimatedPages = Math.ceil(totalLines / LINES_PER_PAGE);
return {
totalElements: elements.length,
totalPages: estimatedPages,
dialogueCount,
actionCount,
sceneCount,
characterCount,
parentheticalCount,
transitionCount,
estimatedPages,
};
}
function estimatePageCount(elements: ScreenplayElement[]): number {
let totalLines = 0;
for (const el of elements) {
const contentLines = el.content.trim().split('\n').filter(l => l.trim() !== '');
totalLines += contentLines.length;
const style = getTemplate('standard').elementStyles[el.type];
if (style.marginBottom > 0) {
totalLines += Math.ceil(style.marginBottom);
}
if (el.type === 'sceneHeading') {
totalLines += 1;
}
}
return Math.ceil(totalLines / LINES_PER_PAGE);
}

View File

@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { ScreenplayProExporter } from './screenplay-pro';
import type { ScreenplayElement } from '../screenplay/types';
const sampleElements: ScreenplayElement[] = [
{ id: 'e1', type: 'sceneHeading', content: 'INT. COFFEE SHOP - DAY', page: 1, line: 1 },
{ id: 'e2', type: 'action', content: 'A bustling coffee shop.', page: 1, line: 3 },
{ id: 'e3', type: 'character', content: 'Jessica', page: 1, line: 5 },
{ id: 'e4', type: 'dialogue', content: 'Hello there.', page: 1, line: 6 },
{ id: 'e5', type: 'parenthetical', content: 'smiling', page: 1, line: 7 },
{ id: 'e6', type: 'transition', content: 'CUT TO:', page: 2, line: 1 },
];
describe('ScreenplayProExporter', () => {
const exporter = new ScreenplayProExporter();
it('supports screenplay-pro format', () => {
expect(exporter.supportedFormats).toContain('screenplay-pro');
});
it('produces header section', () => {
const result = exporter.export(sampleElements, {
format: 'screenplay-pro',
title: 'Test Script',
author: 'Jane Doe',
});
expect(result.data).toContain('[HEADER]');
expect(result.data).toContain('Version\t1.0');
expect(result.data).toContain('Title\tTest Script');
expect(result.data).toContain('Author\tJane Doe');
});
it('produces content section with type markers', () => {
const result = exporter.export(sampleElements, { format: 'screenplay-pro' });
expect(result.data).toContain('[CONTENT]');
expect(result.data).toContain('[SCENE]');
expect(result.data).toContain('[ACTION]');
expect(result.data).toContain('[CHAR]');
expect(result.data).toContain('[DIALOG]');
expect(result.data).toContain('[PAREN]');
expect(result.data).toContain('[TRANS]');
});
it('uses tab-separated values', () => {
const result = exporter.export(sampleElements, { format: 'screenplay-pro' });
const lines = (result.data as string).split('\n');
const contentLines = lines.filter((l: string) => l.startsWith('[SCENE]') || l.startsWith('[ACTION]'));
for (const line of contentLines) {
expect(line).toContain('\t');
}
});
it('includes page and line metadata', () => {
const result = exporter.export(sampleElements, { format: 'screenplay-pro' });
const lines = (result.data as string).split('\n');
const sceneLine = lines.find((l: string) => l.startsWith('[SCENE]'));
expect(sceneLine).toContain('1\t1');
});
it('includes end marker', () => {
const result = exporter.export(sampleElements, { format: 'screenplay-pro' });
expect(result.data).toContain('[END]');
});
it('uses correct content type and extension', () => {
const result = exporter.export(sampleElements, { format: 'screenplay-pro' });
expect(result.contentType).toBe('text/plain');
expect(result.extension).toBe('.sp');
});
it('generates filename from title', () => {
const result = exporter.export(sampleElements, {
format: 'screenplay-pro',
title: 'My Script',
});
expect(result.filename).toBe('My_Script.sp');
});
it('includes contact when provided', () => {
const result = exporter.export(sampleElements, {
format: 'screenplay-pro',
contact: 'jane@example.com',
});
expect(result.data).toContain('Contact\tjane@example.com');
});
it('includes template in header', () => {
const result = exporter.export(sampleElements, {
format: 'screenplay-pro',
template: 'sitcom',
});
expect(result.data).toContain('Template\tsitcom');
});
it('handles empty elements array', () => {
const result = exporter.export([], { format: 'screenplay-pro' });
expect(result.data).toContain('[HEADER]');
expect(result.data).toContain('[CONTENT]');
expect(result.data).toContain('[END]');
});
});

View File

@@ -0,0 +1,58 @@
import type { ScreenplayElement } from '../screenplay/types';
import type { ScreenplayExporter, ExportOptions, ExportResult } from './types';
import { CONTENT_TYPES, EXTENSIONS } from './types';
const LINE_TYPE_MAP: Record<string, string> = {
sceneHeading: '[SCENE]',
action: '[ACTION]',
character: '[CHAR]',
dialogue: '[DIALOG]',
parenthetical: '[PAREN]',
transition: '[TRANS]',
note: '[NOTE]',
retained: '[RETAINED]',
centered: '[CENTER]',
};
export class ScreenplayProExporter implements ScreenplayExporter {
public readonly supportedFormats: readonly ['screenplay-pro'] = ['screenplay-pro'];
public export(elements: ScreenplayElement[], options: ExportOptions): ExportResult {
const lines: string[] = [];
lines.push('[HEADER]');
lines.push('Version\t1.0');
lines.push('Title\t' + (options.title || 'Untitled'));
lines.push('Author\t' + (options.author || ''));
if (options.contact) lines.push('Contact\t' + options.contact);
lines.push('Date\t' + (options.datetime || new Date().toISOString().split('T')[0]));
lines.push('Template\t' + (options.template || 'standard'));
lines.push('');
lines.push('[CONTENT]');
for (const el of elements) {
const marker = LINE_TYPE_MAP[el.type] || '[ACTION]';
const content = el.content.trim();
const page = el.page != null ? String(el.page) : '';
const line = el.line != null ? String(el.line) : '';
const tabSep = '\t';
lines.push(marker + tabSep + content + tabSep + page + tabSep + line);
}
lines.push('');
lines.push('[END]');
const content = lines.join('\n');
const filename = (options.title || 'screenplay').replace(/[^a-zA-Z0-9]/g, '_');
return {
format: 'screenplay-pro',
contentType: CONTENT_TYPES['screenplay-pro'],
extension: EXTENSIONS['screenplay-pro'],
data: content,
filename: filename + EXTENSIONS['screenplay-pro'],
};
}
}

63
src/lib/export/types.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { ScreenplayElement, TemplateType } from '../screenplay/types';
export type ExportFormat = 'fountain' | 'fdx' | 'pdf' | 'screenplay-pro';
export interface ExportOptions {
format: ExportFormat;
template?: TemplateType;
title?: string;
author?: string;
contact?: string;
datetime?: string;
pageNumber?: number;
includeCoverPage?: boolean;
gutterMargin?: number;
}
export interface ExportResult {
format: ExportFormat;
contentType: string;
extension: string;
data: string | Uint8Array;
filename: string;
pageCount?: number;
}
export interface ScreenplayExporter {
readonly supportedFormats: readonly ExportFormat[];
export(elements: ScreenplayElement[], options: ExportOptions): ExportResult;
}
export interface BatchExportOptions {
title?: string;
author?: string;
contact?: string;
template?: TemplateType;
includeCoverPage?: boolean;
gutterMargin?: number;
}
export interface BatchExportResult {
formats: ExportFormat[];
results: ExportResult[];
errors: BatchExportError[];
}
export interface BatchExportError {
format: ExportFormat;
error: string;
}
export const CONTENT_TYPES: Record<ExportFormat, string> = {
fountain: 'text/plain',
fdx: 'application/xml',
pdf: 'application/pdf',
'screenplay-pro': 'text/plain',
};
export const EXTENSIONS: Record<ExportFormat, string> = {
fountain: '.fountain',
fdx: '.fdx',
pdf: '.pdf',
'screenplay-pro': '.sp',
};