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
1 change: 1 addition & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export default defineConfig({
'**/xlsx*.js',
'**/epubjs*.js',
'**/jszip*.js',
'**/html2canvas*.js',
'og/*.png',
],
runtimeCaching: [
Expand Down
50 changes: 50 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"hash-wasm": "^4.12.0",
"highlight.js": "^11.11.1",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"idb": "^8.0.0",
"jsqr": "^1.4.0",
"libarchive.js": "^2.0.2",
Expand Down
144 changes: 144 additions & 0 deletions src/islands/documents/DocxToPdf.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useRef, useState } from 'react';
import { FileDown, Printer } from 'lucide-react';
import { Dropzone } from '@/components/ui/Dropzone';
import { Button } from '@/components/ui/Button';
import { Alert } from '@/components/ui/Alert';
import { ProgressBar } from '@/components/ui/ProgressBar';
import { downloadService } from '@/services/download.service';
import { pageSizePt } from '@/tools/documents/docx-pdf.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, {
intro: string; drop: string; dropSub: string; how: string;
opening: string; another: string; download: string; converting: string; print: string;
note: string; errRead: string; errConvert: string;
}> = {
en: {
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.',
drop: 'Drop a Word document (.docx)', dropSub: 'Converted on your device — no upload.',
how: 'Older .doc (binary) files aren’t supported — save as .docx first.',
opening: 'Rendering…', another: 'Convert another', download: 'Download PDF', converting: 'Converting…', print: 'Print / Save as PDF',
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.',
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.',
},
id: {
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.',
drop: 'Letakkan dokumen Word (.docx)', dropSub: 'Dikonversi di perangkat Anda — tanpa unggahan.',
how: 'Berkas .doc lama (biner) tidak didukung — simpan sebagai .docx terlebih dahulu.',
opening: 'Menampilkan…', another: 'Konversi yang lain', download: 'Unduh PDF', converting: 'Mengonversi…', print: 'Cetak / Simpan PDF',
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.',
errRead: 'Tidak dapat membuka dokumen ini — apakah berkas .docx yang valid?', errConvert: 'Maaf, konversi dokumen ini ke PDF gagal. Coba Cetak / Simpan PDF.',
},
};

export default function DocxToPdf({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const containerRef = useRef<HTMLDivElement>(null);
const [hasDoc, setHasDoc] = useState(false);
const [busy, setBusy] = useState(false);
const [converting, setConverting] = useState(false);
const [progress, setProgress] = useState(0);
const [baseName, setBaseName] = useState('document');
const [error, setError] = useState('');

const onDrop = async (files: File[]) => {
const f = files[0];
if (!f) return;
setError('');
setBusy(true);
try {
const buf = await f.arrayBuffer();
const { renderAsync } = await import('docx-preview');
const container = containerRef.current!;
container.innerHTML = '';
await renderAsync(buf, container, undefined, {
className: 'docx', inWrapper: true, breakPages: true, ignoreLastRenderedPageBreak: false,
});
setBaseName(f.name.replace(/\.docx?$/i, '') || 'document');
setHasDoc(true);
} catch {
setError(t.errRead);
setHasDoc(false);
} finally {
setBusy(false);
}
};

const downloadPdf = async () => {
const container = containerRef.current;
if (!container) return;
setError('');
setConverting(true);
setProgress(0);
try {
const wrapper = (container.querySelector('.docx-wrapper') as HTMLElement | null) ?? (container.firstElementChild as HTMLElement | null);
const pages = wrapper ? (Array.from(wrapper.children).filter((el): el is HTMLElement => el instanceof HTMLElement)) : [];
if (!pages.length) throw new Error('no pages rendered');
const html2canvas = (await import('html2canvas')).default;
const { PDFDocument } = await import('pdf-lib');
const pdf = await PDFDocument.create();
for (let i = 0; i < pages.length; i++) {
const el = pages[i];
const canvas = await html2canvas(el, { scale: 2, backgroundColor: '#ffffff', useCORS: true, logging: false });
const png = await pdf.embedPng(canvas.toDataURL('image/png'));
const [wPt, hPt] = pageSizePt(el.clientWidth, el.clientHeight);
const page = pdf.addPage([wPt, hPt]);
page.drawImage(png, { x: 0, y: 0, width: wPt, height: hPt });
setProgress(Math.round(((i + 1) / pages.length) * 100));
await new Promise((r) => requestAnimationFrame(r)); // let the progress bar paint
}
const bytes = await pdf.save();
await downloadService.download(new Blob([bytes], { type: 'application/pdf' }), `${baseName}.pdf`);
} catch {
setError(t.errConvert);
} finally {
setConverting(false);
}
};

const reset = () => {
if (containerRef.current) containerRef.current.innerHTML = '';
setHasDoc(false);
setConverting(false);
setProgress(0);
setError('');
};

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground print:hidden">{t.intro}</p>

{!hasDoc && (
<div className="print:hidden">
<Dropzone onDrop={onDrop} accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document" multiple={false}>
<div className="space-y-1">
<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>
<p className="text-sm text-muted-foreground">{t.dropSub}</p>
</div>
</Dropzone>
<p className="mt-2 text-xs text-muted-foreground">{t.how}</p>
</div>
)}

{error && <Alert variant="error">{error}</Alert>}

{hasDoc && (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 print:hidden">
<Button onClick={downloadPdf} disabled={converting}><FileDown className="h-4 w-4" /> {converting ? t.converting : t.download}</Button>
<Button variant="secondary" onClick={() => window.print()} disabled={converting}><Printer className="h-4 w-4" /> {t.print}</Button>
<Button variant="ghost" onClick={reset} disabled={converting}>{t.another}</Button>
</div>
{converting && <ProgressBar percent={progress} label={`${t.converting} ${progress}%`} />}
<p className="text-xs text-muted-foreground print:hidden">{t.note}</p>
</div>
)}

{/* docx-preview renders the document into this container; the PDF is built from its pages. */}
<div
ref={containerRef}
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' : ''}`}
/>
</div>
);
}
34 changes: 34 additions & 0 deletions src/registry/tool-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ import type { Lang } from '@/i18n/config';
* a locale entry is missing. Feeds on-page copy + HowTo/FAQPage structured data.
*/
const en: Record<string, ToolSeoContent> = {
'docx-to-pdf': {
title: 'Free DOCX to PDF Converter — Word to PDF Online',
description: 'Convert Word (.docx) documents to PDF right in your browser — page-accurate, with your layout, tables and images. 100% private; nothing is uploaded.',
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.',
howTo: [
'Drop a .docx file (or click to browse) — it is rendered in your browser.',
'Check the preview, then click Download PDF to save a page-accurate PDF.',
'Prefer selectable, searchable text? Use Print / Save as PDF instead.',
'Nothing is uploaded — the whole conversion happens on your device.',
],
faqs: [
{ 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.' },
{ 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.' },
{ 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.' },
{ 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.' },
],
},
'odt-viewer': {
title: 'Free ODT Viewer — Open OpenDocument Files Online',
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.',
Expand Down Expand Up @@ -1376,6 +1393,23 @@ const en: Record<string, ToolSeoContent> = {
};

const id: Record<string, ToolSeoContent> = {
'docx-to-pdf': {
title: 'Konverter DOCX ke PDF Gratis — Word ke PDF Online',
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.',
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.',
howTo: [
'Letakkan berkas .docx (atau klik untuk menelusuri) — ditampilkan di browser Anda.',
'Periksa pratinjau, lalu klik Unduh PDF untuk menyimpan PDF yang akurat per halaman.',
'Ingin teks yang dapat dipilih dan dicari? Gunakan Cetak / Simpan PDF.',
'Tidak ada yang diunggah — seluruh konversi terjadi di perangkat Anda.',
],
faqs: [
{ 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.' },
{ 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.' },
{ 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.' },
{ 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.' },
],
},
'odt-viewer': {
title: 'Penampil ODT Gratis — Buka Berkas OpenDocument Online',
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.',
Expand Down
13 changes: 12 additions & 1 deletion src/registry/tools.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
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';
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';
import type { ToolDef } from '@/types/tool';

export const tools: ToolDef[] = [
Expand Down Expand Up @@ -201,6 +201,17 @@ export const tools: ToolDef[] = [
load: () => import('@/islands/documents/OdtViewer'),
status: 'beta'
},
{
id: 'docx-to-pdf',
name: 'Word (DOCX) to PDF',
category: 'Documents',
route: '/tools/docx-to-pdf',
keywords: ['docx', 'word', 'pdf', 'convert', 'converter', 'doc to pdf', 'word to pdf', 'export', 'save as pdf'],
icon: FileDown,
summary: 'Convert Word .docx documents to PDF in your browser',
load: () => import('@/islands/documents/DocxToPdf'),
status: 'beta'
},
{
id: 'markdown',
name: 'Markdown Preview',
Expand Down
25 changes: 25 additions & 0 deletions src/tools/documents/docx-pdf.lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { pxToPt, pageSizePt } from './docx-pdf.lib';

describe('pxToPt', () => {
it('converts CSS pixels to PDF points at 96 DPI', () => {
expect(pxToPt(96)).toBe(72); // 1 inch
expect(pxToPt(0)).toBe(0);
});
it('honours a custom DPI', () => {
expect(pxToPt(150, 150)).toBe(72);
});
});

describe('pageSizePt', () => {
it('maps an A4 page in px (~794x1123 @96dpi) to ~595x842 pt', () => {
const [w, h] = pageSizePt(794, 1123);
expect(w).toBeCloseTo(595.5, 1);
expect(h).toBeCloseTo(842.25, 1);
});
it('rounds to 2 decimals and preserves orientation (landscape)', () => {
const [w, h] = pageSizePt(1123, 794);
expect(w).toBeGreaterThan(h);
expect(Number.isInteger(w * 100)).toBe(true); // at most 2 decimals
});
});
17 changes: 17 additions & 0 deletions src/tools/documents/docx-pdf.lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Geometry helpers for the DOCX→PDF converter. docx-preview lays each page out
* in CSS pixels; a PDF works in points (1pt = 1/72 inch, and CSS assumes 96px =
* 1 inch), so we convert page dimensions here. Kept pure and unit-tested; the
* rasterization (html2canvas) and assembly (pdf-lib) live in the island.
*/

/** CSS pixels → PDF points. Default 96 DPI is the CSS reference pixel density. */
export function pxToPt(px: number, dpi = 96): number {
return (px * 72) / dpi;
}

/** A rendered page's pixel box → its [width, height] in PDF points (2 dp). */
export function pageSizePt(widthPx: number, heightPx: number, dpi = 96): [number, number] {
const round = (n: number) => Math.round(pxToPt(n, dpi) * 100) / 100;
return [round(widthPx), round(heightPx)];
}
Loading