Skip to content

Commit 28ccc07

Browse files
committed
Add PDF preview support
1 parent 3a11b23 commit 28ccc07

4 files changed

Lines changed: 112 additions & 1 deletion

File tree

components/PromptEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, o
355355

356356
const language = documentNode.language_hint || 'plaintext';
357357
const supportsAiTools = ['markdown', 'plaintext'].includes(language);
358-
const supportsPreview = ['markdown', 'html'].includes(language);
358+
const supportsPreview = ['markdown', 'html', 'pdf'].includes(language);
359359
const supportsFormatting = ['javascript', 'typescript', 'json', 'html', 'css', 'xml', 'yaml'].includes(language);
360360
const isPythonDocument = typeof window !== 'undefined' && !!window.electronAPI && (language === 'python');
361361
const pythonDefaults = useMemo(() => ({

services/languageService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const SUPPORTED_LANGUAGES = [
2121
{ id: 'yaml', label: 'YAML' },
2222
{ id: 'pascal', label: 'Pascal' },
2323
{ id: 'ini', label: 'INI' },
24+
{ id: 'pdf', label: 'PDF' },
2425
];
2526

2627
export const mapExtensionToLanguageId = (extension: string | null): string => {
@@ -75,6 +76,8 @@ export const mapExtensionToLanguageId = (extension: string | null): string => {
7576
case 'fmx':
7677
case 'ini':
7778
return 'ini';
79+
case 'pdf':
80+
return 'pdf';
7881
default:
7982
// Try to find a direct match in supported languages by id
8083
const match = SUPPORTED_LANGUAGES.find(l => l.id === extension.toLowerCase());

services/preview/pdfRenderer.tsx

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import React, { useEffect, useMemo } from 'react';
2+
import type { IRenderer } from './IRenderer';
3+
4+
interface PdfPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
5+
content: string;
6+
}
7+
8+
const PdfPreview = React.forwardRef<HTMLDivElement, PdfPreviewProps>(({ content, className, ...rest }, ref) => {
9+
const { url, error, isBlobUrl } = useMemo(() => {
10+
const trimmed = content.trim();
11+
if (!trimmed) {
12+
return { url: null as string | null, error: 'This document does not contain any PDF data.', isBlobUrl: false };
13+
}
14+
15+
if (trimmed.startsWith('data:application/pdf')) {
16+
return { url: trimmed, error: null, isBlobUrl: false };
17+
}
18+
19+
const cleanBase64 = trimmed.replace(/\s+/g, '');
20+
21+
const decodeBase64 = () => {
22+
try {
23+
const markerIndex = cleanBase64.toLowerCase().indexOf('base64,');
24+
const payload = markerIndex !== -1
25+
? cleanBase64.slice(markerIndex + 'base64,'.length)
26+
: cleanBase64;
27+
const byteCharacters = atob(payload);
28+
const byteLength = byteCharacters.length;
29+
const byteNumbers = new Uint8Array(byteLength);
30+
for (let i = 0; i < byteLength; i += 1) {
31+
byteNumbers[i] = byteCharacters.charCodeAt(i);
32+
}
33+
const blob = new Blob([byteNumbers], { type: 'application/pdf' });
34+
return URL.createObjectURL(blob);
35+
} catch {
36+
return null;
37+
}
38+
};
39+
40+
const blobUrlFromBase64 = decodeBase64();
41+
if (blobUrlFromBase64) {
42+
return { url: blobUrlFromBase64, error: null, isBlobUrl: true };
43+
}
44+
45+
if (trimmed.startsWith('%PDF')) {
46+
const blob = new Blob([trimmed], { type: 'application/pdf' });
47+
return { url: URL.createObjectURL(blob), error: null, isBlobUrl: true };
48+
}
49+
50+
return { url: null, error: 'Stored PDF data is not in a recognized format.', isBlobUrl: false };
51+
}, [content]);
52+
53+
useEffect(() => {
54+
return () => {
55+
if (isBlobUrl && url) {
56+
URL.revokeObjectURL(url);
57+
}
58+
};
59+
}, [isBlobUrl, url]);
60+
61+
if (error) {
62+
return (
63+
<div
64+
ref={ref}
65+
className={`w-full h-full flex items-center justify-center text-text-secondary text-sm ${className ?? ''}`}
66+
{...rest}
67+
>
68+
{error}
69+
</div>
70+
);
71+
}
72+
73+
if (!url) {
74+
return (
75+
<div
76+
ref={ref}
77+
className={`w-full h-full flex items-center justify-center text-text-secondary text-sm ${className ?? ''}`}
78+
{...rest}
79+
>
80+
Unable to display the stored PDF document.
81+
</div>
82+
);
83+
}
84+
85+
return (
86+
<div ref={ref} className={`w-full h-full overflow-auto bg-secondary ${className ?? ''}`} {...rest}>
87+
<iframe
88+
title="PDF Preview"
89+
src={url}
90+
className="w-full h-full border-0 bg-white"
91+
/>
92+
</div>
93+
);
94+
});
95+
96+
PdfPreview.displayName = 'PdfPreview';
97+
98+
export class PdfRenderer implements IRenderer {
99+
canRender(languageId: string): boolean {
100+
return languageId === 'pdf' || languageId === 'application/pdf';
101+
}
102+
103+
async render(content: string) {
104+
return { output: <PdfPreview content={content} /> };
105+
}
106+
}

services/previewService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { IRenderer } from './preview/IRenderer';
22
import { HtmlRenderer } from './preview/htmlRenderer';
33
import { MarkdownRenderer } from './preview/markdownRenderer';
44
import { PlaintextRenderer } from './preview/plaintextRenderer';
5+
import { PdfRenderer } from './preview/pdfRenderer';
56

67
class PreviewService {
78
private renderers: IRenderer[];
@@ -11,6 +12,7 @@ class PreviewService {
1112
this.renderers = [
1213
new MarkdownRenderer(),
1314
new HtmlRenderer(),
15+
new PdfRenderer(),
1416
new PlaintextRenderer(), // Fallback renderer should be last
1517
];
1618
}

0 commit comments

Comments
 (0)