Skip to content

Commit 520236b

Browse files
committed
add DOCX support (requires libreoffice) + upload test
1 parent 4c5866c commit 520236b

File tree

12 files changed

+172
-10
lines changed

12 files changed

+172
-10
lines changed

.github/workflows/playwright.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ jobs:
1313
- uses: actions/setup-node@v4
1414
with:
1515
node-version: lts/*
16+
- name: Install Deps (FFmpeg is install through Playwright)
17+
run: |
18+
sudo apt-get update
19+
sudo apt-get install -y libreoffice
1620
- name: Install dependencies
1721
run: npm ci
1822
- name: Install Playwright Browsers

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Use Node.js slim image
22
FROM node:current-alpine
33

4-
# Add ffmpeg using Alpine package manager
5-
RUN apk add --no-cache ffmpeg
4+
# Add ffmpeg and libreoffice using Alpine package manager
5+
RUN apk add --no-cache ffmpeg libreoffice
66

77
# Create app directory
88
WORKDIR /app

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,11 @@ https://github.com/user-attachments/assets/262b9a01-c608-4fee-893c-9461dd48c99b
9494
9595
### Prerequisites
9696
- Node.js & npm (recommended: use [nvm](https://github.com/nvm-sh/nvm))
97+
Optionally required for different features:
9798
- [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only)
99+
- [libreoffice](https://www.libreoffice.org) (required for DOCX files)
100+
- On Linux: `sudo apt install ffmpeg libreoffice`
101+
- On MacOS: `brew install ffmpeg libreoffice`
98102

99103
### Steps
100104

@@ -129,8 +133,6 @@ https://github.com/user-attachments/assets/262b9a01-c608-4fee-893c-9461dd48c99b
129133

130134
Visit [http://localhost:3003](http://localhost:3003) to run the app.
131135

132-
> Dev server runs on port 3000 by default, while the production server runs on port 3003.
133-
134136

135137
## 💡 Feature requests
136138

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { writeFile, mkdir, unlink, readFile } from 'fs/promises';
3+
import { spawn } from 'child_process';
4+
import path from 'path';
5+
import { existsSync } from 'fs';
6+
import { randomUUID } from 'crypto';
7+
8+
const TEMP_DIR = path.join(process.cwd(), 'temp');
9+
10+
async function ensureTempDir() {
11+
if (!existsSync(TEMP_DIR)) {
12+
await mkdir(TEMP_DIR, { recursive: true });
13+
}
14+
}
15+
16+
async function convertDocxToPdf(inputPath: string, outputDir: string): Promise<void> {
17+
return new Promise((resolve, reject) => {
18+
const process = spawn('soffice', [
19+
'--headless',
20+
'--convert-to', 'pdf',
21+
'--outdir', outputDir,
22+
inputPath
23+
]);
24+
25+
process.on('error', (error) => {
26+
reject(error);
27+
});
28+
29+
process.on('close', (code) => {
30+
if (code === 0) {
31+
resolve();
32+
} else {
33+
reject(new Error(`LibreOffice conversion failed with code ${code}`));
34+
}
35+
});
36+
});
37+
}
38+
39+
export async function POST(req: NextRequest) {
40+
try {
41+
await ensureTempDir();
42+
43+
const formData = await req.formData();
44+
const file = formData.get('file') as File;
45+
46+
if (!file) {
47+
return NextResponse.json(
48+
{ error: 'No file provided' },
49+
{ status: 400 }
50+
);
51+
}
52+
53+
if (!file.name.toLowerCase().endsWith('.docx')) {
54+
return NextResponse.json(
55+
{ error: 'File must be a .docx document' },
56+
{ status: 400 }
57+
);
58+
}
59+
60+
const buffer = Buffer.from(await file.arrayBuffer());
61+
const tempId = randomUUID();
62+
const inputPath = path.join(TEMP_DIR, `${tempId}.docx`);
63+
const outputPath = path.join(TEMP_DIR, `${tempId}.pdf`);
64+
65+
// Write the uploaded file
66+
await writeFile(inputPath, buffer);
67+
68+
try {
69+
// Convert the file
70+
await convertDocxToPdf(inputPath, TEMP_DIR);
71+
72+
// Return the PDF file
73+
const pdfContent = await readFile(outputPath);
74+
75+
// Clean up temp files
76+
await Promise.all([
77+
unlink(inputPath),
78+
unlink(outputPath)
79+
]).catch(console.error);
80+
81+
return new NextResponse(pdfContent, {
82+
headers: {
83+
'Content-Type': 'application/pdf',
84+
'Content-Disposition': `attachment; filename="${path.parse(file.name).name}.pdf"`
85+
}
86+
});
87+
} catch (error) {
88+
// Clean up temp files on error
89+
await Promise.all([
90+
unlink(inputPath),
91+
unlink(outputPath)
92+
]).catch(console.error);
93+
94+
throw error;
95+
}
96+
} catch (error) {
97+
console.error('Error converting DOCX to PDF:', error);
98+
return NextResponse.json(
99+
{ error: 'Failed to convert document' },
100+
{ status: 500 }
101+
);
102+
}
103+
}

src/components/DocumentUploader.tsx

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,37 @@ import { useDropzone } from 'react-dropzone';
55
import { UploadIcon } from '@/components/icons/Icons';
66
import { useDocuments } from '@/contexts/DocumentContext';
77

8+
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
9+
810
interface DocumentUploaderProps {
911
className?: string;
1012
}
1113

1214
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
1315
const { addPDFDocument: addPDF, addEPUBDocument: addEPUB } = useDocuments();
1416
const [isUploading, setIsUploading] = useState(false);
17+
const [isConverting, setIsConverting] = useState(false);
1518
const [error, setError] = useState<string | null>(null);
1619

20+
const convertDocxToPdf = async (file: File): Promise<File> => {
21+
const formData = new FormData();
22+
formData.append('file', file);
23+
24+
const response = await fetch('/api/documents/docx-to-pdf', {
25+
method: 'POST',
26+
body: formData,
27+
});
28+
29+
if (!response.ok) {
30+
throw new Error('Failed to convert DOCX to PDF');
31+
}
32+
33+
const pdfBlob = await response.blob();
34+
return new File([pdfBlob], file.name.replace(/\.docx$/, '.pdf'), {
35+
type: 'application/pdf',
36+
});
37+
};
38+
1739
const onDrop = useCallback(async (acceptedFiles: File[]) => {
1840
const file = acceptedFiles[0];
1941
if (!file) return;
@@ -26,23 +48,32 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
2648
await addPDF(file);
2749
} else if (file.type === 'application/epub+zip') {
2850
await addEPUB(file);
51+
} else if (isDev && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
52+
setIsUploading(false);
53+
setIsConverting(true);
54+
const pdfFile = await convertDocxToPdf(file);
55+
await addPDF(pdfFile);
2956
}
3057
} catch (err) {
3158
setError('Failed to upload file. Please try again.');
3259
console.error('Upload error:', err);
3360
} finally {
3461
setIsUploading(false);
62+
setIsConverting(false);
3563
}
3664
}, [addPDF, addEPUB]);
3765

3866
const { getRootProps, getInputProps, isDragActive } = useDropzone({
3967
onDrop,
4068
accept: {
4169
'application/pdf': ['.pdf'],
42-
'application/epub+zip': ['.epub']
70+
'application/epub+zip': ['.epub'],
71+
...(isDev ? {
72+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx']
73+
} : {})
4374
},
4475
multiple: false,
45-
disabled: isUploading
76+
disabled: isUploading || isConverting
4677
});
4778

4879
return (
@@ -52,7 +83,7 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
5283
w-full py-5 px-3 border-2 border-dashed rounded-lg
5384
${isDragActive ? 'border-accent bg-base' : 'border-muted'}
5485
transform trasition-transform duration-200 ease-in-out hover:scale-[1.008]
55-
${isUploading ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base'}
86+
${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base'}
5687
${className}
5788
`}
5889
>
@@ -64,13 +95,17 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
6495
<p className="text-sm sm:text-lg font-semibold text-foreground">
6596
Uploading file...
6697
</p>
98+
) : isConverting ? (
99+
<p className="text-sm sm:text-lg font-semibold text-foreground">
100+
Converting DOCX to PDF...
101+
</p>
67102
) : (
68103
<>
69104
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
70105
{isDragActive ? 'Drop your file here' : 'Drop your file here, or click to select'}
71106
</p>
72107
<p className="text-xs sm:text-sm text-muted">
73-
PDF and EPUB files are accepted
108+
{isDev ? 'PDF, EPUB, and DOCX files are accepted' : 'PDF and EPUB files are accepted'}
74109
</p>
75110
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
76111
</>

src/types/documents.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type DocumentType = 'pdf' | 'epub';
1+
export type DocumentType = 'pdf' | 'epub' | 'docx';
22

33
export interface BaseDocument {
44
id: string;
@@ -7,6 +7,7 @@ export interface BaseDocument {
77
lastModified: number;
88
type: DocumentType;
99
folderId?: string;
10+
isConverting?: boolean;
1011
}
1112

1213
export interface PDFDocument extends BaseDocument {
@@ -19,6 +20,11 @@ export interface EPUBDocument extends BaseDocument {
1920
data: ArrayBuffer;
2021
}
2122

23+
export interface DOCXDocument extends BaseDocument {
24+
type: 'docx';
25+
data: ArrayBuffer;
26+
}
27+
2228
export interface DocumentListDocument extends BaseDocument {
2329
type: DocumentType;
2430
}

tests/files/sample.docx

1.25 MB
Binary file not shown.
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)