Skip to content

Commit ceeca0a

Browse files
authored
Merge pull request #148 from slaveofcode/feat/documents-docx-to-pdf
feat(documents): Word (DOCX) to PDF converter
2 parents ccf20b1 + c18cef3 commit ceeca0a

8 files changed

Lines changed: 284 additions & 1 deletion

File tree

astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export default defineConfig({
132132
'**/xlsx*.js',
133133
'**/epubjs*.js',
134134
'**/jszip*.js',
135+
'**/html2canvas*.js',
135136
'og/*.png',
136137
],
137138
runtimeCaching: [

package-lock.json

Lines changed: 50 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"hash-wasm": "^4.12.0",
8888
"highlight.js": "^11.11.1",
8989
"html-to-image": "^1.11.13",
90+
"html2canvas": "^1.4.1",
9091
"idb": "^8.0.0",
9192
"jsqr": "^1.4.0",
9293
"libarchive.js": "^2.0.2",
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { useRef, useState } from 'react';
2+
import { FileDown, Printer } from 'lucide-react';
3+
import { Dropzone } from '@/components/ui/Dropzone';
4+
import { Button } from '@/components/ui/Button';
5+
import { Alert } from '@/components/ui/Alert';
6+
import { ProgressBar } from '@/components/ui/ProgressBar';
7+
import { downloadService } from '@/services/download.service';
8+
import { pageSizePt } from '@/tools/documents/docx-pdf.lib';
9+
import type { Lang } from '@/i18n/config';
10+
11+
const TR: Record<Lang, {
12+
intro: string; drop: string; dropSub: string; how: string;
13+
opening: string; another: string; download: string; converting: string; print: string;
14+
note: string; errRead: string; errConvert: string;
15+
}> = {
16+
en: {
17+
intro: 'Convert a Word document (.docx) to PDF entirely in your browser — page-accurate, with your document’s layout, tables and images. Nothing is uploaded.',
18+
drop: 'Drop a Word document (.docx)', dropSub: 'Converted on your device — no upload.',
19+
how: 'Older .doc (binary) files aren’t supported — save as .docx first.',
20+
opening: 'Rendering…', another: 'Convert another', download: 'Download PDF', converting: 'Converting…', print: 'Print / Save as PDF',
21+
note: 'The downloaded PDF is a visual, page-perfect copy (text is rendered as images). For a PDF with selectable, searchable text, use Print / Save as PDF instead.',
22+
errRead: 'Could not open this document — is it a valid .docx file?', errConvert: 'Sorry, converting this document to PDF failed. Try Print / Save as PDF instead.',
23+
},
24+
id: {
25+
intro: 'Konversi dokumen Word (.docx) ke PDF sepenuhnya di browser Anda — akurat per halaman, dengan tata letak, tabel, dan gambar dokumen Anda. Tidak ada yang diunggah.',
26+
drop: 'Letakkan dokumen Word (.docx)', dropSub: 'Dikonversi di perangkat Anda — tanpa unggahan.',
27+
how: 'Berkas .doc lama (biner) tidak didukung — simpan sebagai .docx terlebih dahulu.',
28+
opening: 'Menampilkan…', another: 'Konversi yang lain', download: 'Unduh PDF', converting: 'Mengonversi…', print: 'Cetak / Simpan PDF',
29+
note: 'PDF yang diunduh adalah salinan visual yang akurat per halaman (teks ditampilkan sebagai gambar). Untuk PDF dengan teks yang dapat dipilih dan dicari, gunakan Cetak / Simpan PDF.',
30+
errRead: 'Tidak dapat membuka dokumen ini — apakah berkas .docx yang valid?', errConvert: 'Maaf, konversi dokumen ini ke PDF gagal. Coba Cetak / Simpan PDF.',
31+
},
32+
};
33+
34+
export default function DocxToPdf({ lang = 'en' }: { lang?: Lang }) {
35+
const t = TR[lang] ?? TR.en;
36+
const containerRef = useRef<HTMLDivElement>(null);
37+
const [hasDoc, setHasDoc] = useState(false);
38+
const [busy, setBusy] = useState(false);
39+
const [converting, setConverting] = useState(false);
40+
const [progress, setProgress] = useState(0);
41+
const [baseName, setBaseName] = useState('document');
42+
const [error, setError] = useState('');
43+
44+
const onDrop = async (files: File[]) => {
45+
const f = files[0];
46+
if (!f) return;
47+
setError('');
48+
setBusy(true);
49+
try {
50+
const buf = await f.arrayBuffer();
51+
const { renderAsync } = await import('docx-preview');
52+
const container = containerRef.current!;
53+
container.innerHTML = '';
54+
await renderAsync(buf, container, undefined, {
55+
className: 'docx', inWrapper: true, breakPages: true, ignoreLastRenderedPageBreak: false,
56+
});
57+
setBaseName(f.name.replace(/\.docx?$/i, '') || 'document');
58+
setHasDoc(true);
59+
} catch {
60+
setError(t.errRead);
61+
setHasDoc(false);
62+
} finally {
63+
setBusy(false);
64+
}
65+
};
66+
67+
const downloadPdf = async () => {
68+
const container = containerRef.current;
69+
if (!container) return;
70+
setError('');
71+
setConverting(true);
72+
setProgress(0);
73+
try {
74+
const wrapper = (container.querySelector('.docx-wrapper') as HTMLElement | null) ?? (container.firstElementChild as HTMLElement | null);
75+
const pages = wrapper ? (Array.from(wrapper.children).filter((el): el is HTMLElement => el instanceof HTMLElement)) : [];
76+
if (!pages.length) throw new Error('no pages rendered');
77+
const html2canvas = (await import('html2canvas')).default;
78+
const { PDFDocument } = await import('pdf-lib');
79+
const pdf = await PDFDocument.create();
80+
for (let i = 0; i < pages.length; i++) {
81+
const el = pages[i];
82+
const canvas = await html2canvas(el, { scale: 2, backgroundColor: '#ffffff', useCORS: true, logging: false });
83+
const png = await pdf.embedPng(canvas.toDataURL('image/png'));
84+
const [wPt, hPt] = pageSizePt(el.clientWidth, el.clientHeight);
85+
const page = pdf.addPage([wPt, hPt]);
86+
page.drawImage(png, { x: 0, y: 0, width: wPt, height: hPt });
87+
setProgress(Math.round(((i + 1) / pages.length) * 100));
88+
await new Promise((r) => requestAnimationFrame(r)); // let the progress bar paint
89+
}
90+
const bytes = await pdf.save();
91+
await downloadService.download(new Blob([bytes], { type: 'application/pdf' }), `${baseName}.pdf`);
92+
} catch {
93+
setError(t.errConvert);
94+
} finally {
95+
setConverting(false);
96+
}
97+
};
98+
99+
const reset = () => {
100+
if (containerRef.current) containerRef.current.innerHTML = '';
101+
setHasDoc(false);
102+
setConverting(false);
103+
setProgress(0);
104+
setError('');
105+
};
106+
107+
return (
108+
<div className="space-y-4">
109+
<p className="text-sm text-muted-foreground print:hidden">{t.intro}</p>
110+
111+
{!hasDoc && (
112+
<div className="print:hidden">
113+
<Dropzone onDrop={onDrop} accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document" multiple={false}>
114+
<div className="space-y-1">
115+
<p className="flex items-center justify-center gap-2 text-lg font-bold"><FileDown className="h-5 w-5" /> {busy ? t.opening : t.drop}</p>
116+
<p className="text-sm text-muted-foreground">{t.dropSub}</p>
117+
</div>
118+
</Dropzone>
119+
<p className="mt-2 text-xs text-muted-foreground">{t.how}</p>
120+
</div>
121+
)}
122+
123+
{error && <Alert variant="error">{error}</Alert>}
124+
125+
{hasDoc && (
126+
<div className="space-y-3">
127+
<div className="flex flex-wrap gap-2 print:hidden">
128+
<Button onClick={downloadPdf} disabled={converting}><FileDown className="h-4 w-4" /> {converting ? t.converting : t.download}</Button>
129+
<Button variant="secondary" onClick={() => window.print()} disabled={converting}><Printer className="h-4 w-4" /> {t.print}</Button>
130+
<Button variant="ghost" onClick={reset} disabled={converting}>{t.another}</Button>
131+
</div>
132+
{converting && <ProgressBar percent={progress} label={`${t.converting} ${progress}%`} />}
133+
<p className="text-xs text-muted-foreground print:hidden">{t.note}</p>
134+
</div>
135+
)}
136+
137+
{/* docx-preview renders the document into this container; the PDF is built from its pages. */}
138+
<div
139+
ref={containerRef}
140+
className={`docx-to-pdf ${hasDoc ? 'max-h-[70vh] overflow-auto border-2 border-border bg-neutral-200 p-3 dark:bg-neutral-800 print:max-h-none print:overflow-visible print:border-0 print:bg-white print:p-0' : ''}`}
141+
/>
142+
</div>
143+
);
144+
}

src/registry/tool-seo.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ import type { Lang } from '@/i18n/config';
77
* a locale entry is missing. Feeds on-page copy + HowTo/FAQPage structured data.
88
*/
99
const en: Record<string, ToolSeoContent> = {
10+
'docx-to-pdf': {
11+
title: 'Free DOCX to PDF Converter — Word to PDF Online',
12+
description: 'Convert Word (.docx) documents to PDF right in your browser — page-accurate, with your layout, tables and images. 100% private; nothing is uploaded.',
13+
intro: 'This free DOCX to PDF converter turns Word documents into PDF entirely in your browser — keeping your page layout, tables and images. There is no upload and no account: the file is converted on your device and never leaves it, so even confidential documents stay private.',
14+
howTo: [
15+
'Drop a .docx file (or click to browse) — it is rendered in your browser.',
16+
'Check the preview, then click Download PDF to save a page-accurate PDF.',
17+
'Prefer selectable, searchable text? Use Print / Save as PDF instead.',
18+
'Nothing is uploaded — the whole conversion happens on your device.',
19+
],
20+
faqs: [
21+
{ q: 'Is my document uploaded to a server?', a: 'No. The .docx is rendered and converted to PDF entirely in your browser with JavaScript. It never leaves your device, so it is safe for confidential files.' },
22+
{ q: 'Will the PDF text be selectable?', a: 'The one-click Download PDF produces a visual, page-perfect copy where text is rendered as images. For a PDF with selectable, searchable text, use the Print / Save as PDF button, which uses your browser’s own PDF export.' },
23+
{ q: 'Does it keep my layout, tables and images?', a: 'Yes — the document is rendered with its real layout, tables and images, and each page is placed into the PDF at its correct size.' },
24+
{ q: 'Does it support old .doc files?', a: 'No — only the modern .docx format. Open an old .doc in Word or Google Docs and save it as .docx first.' },
25+
],
26+
},
1027
'odt-viewer': {
1128
title: 'Free ODT Viewer — Open OpenDocument Files Online',
1229
description: 'A free ODT viewer to open and read OpenDocument Text (.odt) files in your browser — headings, lists, tables and images. 100% private; nothing is uploaded.',
@@ -1376,6 +1393,23 @@ const en: Record<string, ToolSeoContent> = {
13761393
};
13771394

13781395
const id: Record<string, ToolSeoContent> = {
1396+
'docx-to-pdf': {
1397+
title: 'Konverter DOCX ke PDF Gratis — Word ke PDF Online',
1398+
description: 'Konversi dokumen Word (.docx) ke PDF langsung di browser Anda — akurat per halaman, dengan tata letak, tabel, dan gambar. 100% privat; tidak ada yang diunggah.',
1399+
intro: 'Konverter DOCX ke PDF gratis ini mengubah dokumen Word menjadi PDF sepenuhnya di browser Anda — mempertahankan tata letak halaman, tabel, dan gambar. Tanpa unggahan dan tanpa akun: berkas dikonversi di perangkat Anda dan tidak pernah meninggalkannya, jadi dokumen rahasia pun tetap privat.',
1400+
howTo: [
1401+
'Letakkan berkas .docx (atau klik untuk menelusuri) — ditampilkan di browser Anda.',
1402+
'Periksa pratinjau, lalu klik Unduh PDF untuk menyimpan PDF yang akurat per halaman.',
1403+
'Ingin teks yang dapat dipilih dan dicari? Gunakan Cetak / Simpan PDF.',
1404+
'Tidak ada yang diunggah — seluruh konversi terjadi di perangkat Anda.',
1405+
],
1406+
faqs: [
1407+
{ q: 'Apakah dokumen saya diunggah ke server?', a: 'Tidak. Berkas .docx ditampilkan dan dikonversi ke PDF sepenuhnya di browser Anda dengan JavaScript. Berkas tidak pernah meninggalkan perangkat, jadi aman untuk berkas rahasia.' },
1408+
{ q: 'Apakah teks PDF dapat dipilih?', a: 'Unduh PDF sekali klik menghasilkan salinan visual yang akurat per halaman di mana teks ditampilkan sebagai gambar. Untuk PDF dengan teks yang dapat dipilih dan dicari, gunakan tombol Cetak / Simpan PDF yang memakai ekspor PDF bawaan browser Anda.' },
1409+
{ q: 'Apakah tata letak, tabel, dan gambar dipertahankan?', a: 'Ya — dokumen ditampilkan dengan tata letak, tabel, dan gambar aslinya, dan setiap halaman ditempatkan ke dalam PDF pada ukuran yang benar.' },
1410+
{ q: 'Apakah mendukung berkas .doc lama?', a: 'Tidak — hanya format .docx modern. Buka .doc lama di Word atau Google Docs lalu simpan sebagai .docx terlebih dahulu.' },
1411+
],
1412+
},
13791413
'odt-viewer': {
13801414
title: 'Penampil ODT Gratis — Buka Berkas OpenDocument Online',
13811415
description: 'Penampil ODT gratis untuk membuka dan membaca berkas OpenDocument Text (.odt) di browser Anda — judul, daftar, tabel, dan gambar. 100% privat; tidak ada yang diunggah.',

src/registry/tools.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2 } from 'lucide-react';
1+
import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown } from 'lucide-react';
22
import type { ToolDef } from '@/types/tool';
33

44
export const tools: ToolDef[] = [
@@ -201,6 +201,17 @@ export const tools: ToolDef[] = [
201201
load: () => import('@/islands/documents/OdtViewer'),
202202
status: 'beta'
203203
},
204+
{
205+
id: 'docx-to-pdf',
206+
name: 'Word (DOCX) to PDF',
207+
category: 'Documents',
208+
route: '/tools/docx-to-pdf',
209+
keywords: ['docx', 'word', 'pdf', 'convert', 'converter', 'doc to pdf', 'word to pdf', 'export', 'save as pdf'],
210+
icon: FileDown,
211+
summary: 'Convert Word .docx documents to PDF in your browser',
212+
load: () => import('@/islands/documents/DocxToPdf'),
213+
status: 'beta'
214+
},
204215
{
205216
id: 'markdown',
206217
name: 'Markdown Preview',
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { pxToPt, pageSizePt } from './docx-pdf.lib';
3+
4+
describe('pxToPt', () => {
5+
it('converts CSS pixels to PDF points at 96 DPI', () => {
6+
expect(pxToPt(96)).toBe(72); // 1 inch
7+
expect(pxToPt(0)).toBe(0);
8+
});
9+
it('honours a custom DPI', () => {
10+
expect(pxToPt(150, 150)).toBe(72);
11+
});
12+
});
13+
14+
describe('pageSizePt', () => {
15+
it('maps an A4 page in px (~794x1123 @96dpi) to ~595x842 pt', () => {
16+
const [w, h] = pageSizePt(794, 1123);
17+
expect(w).toBeCloseTo(595.5, 1);
18+
expect(h).toBeCloseTo(842.25, 1);
19+
});
20+
it('rounds to 2 decimals and preserves orientation (landscape)', () => {
21+
const [w, h] = pageSizePt(1123, 794);
22+
expect(w).toBeGreaterThan(h);
23+
expect(Number.isInteger(w * 100)).toBe(true); // at most 2 decimals
24+
});
25+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Geometry helpers for the DOCX→PDF converter. docx-preview lays each page out
3+
* in CSS pixels; a PDF works in points (1pt = 1/72 inch, and CSS assumes 96px =
4+
* 1 inch), so we convert page dimensions here. Kept pure and unit-tested; the
5+
* rasterization (html2canvas) and assembly (pdf-lib) live in the island.
6+
*/
7+
8+
/** CSS pixels → PDF points. Default 96 DPI is the CSS reference pixel density. */
9+
export function pxToPt(px: number, dpi = 96): number {
10+
return (px * 72) / dpi;
11+
}
12+
13+
/** A rendered page's pixel box → its [width, height] in PDF points (2 dp). */
14+
export function pageSizePt(widthPx: number, heightPx: number, dpi = 96): [number, number] {
15+
const round = (n: number) => Math.round(pxToPt(n, dpi) * 100) / 100;
16+
return [round(widthPx), round(heightPx)];
17+
}

0 commit comments

Comments
 (0)