diff --git a/astro.config.mjs b/astro.config.mjs index 6a1ddd9..0a4b054 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -132,6 +132,7 @@ export default defineConfig({ '**/xlsx*.js', '**/epubjs*.js', '**/jszip*.js', + '**/html2canvas*.js', 'og/*.png', ], runtimeCaching: [ diff --git a/package-lock.json b/package-lock.json index c99242f..3120c34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,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", @@ -9138,6 +9139,15 @@ "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", @@ -9961,6 +9971,15 @@ "node": ">=16" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-to-react-native": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", @@ -13082,6 +13101,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -19522,6 +19554,15 @@ "dev": true, "license": "MIT" }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -20304,6 +20345,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", diff --git a/package.json b/package.json index dc0250c..0d20f38 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/islands/documents/DocxToPdf.tsx b/src/islands/documents/DocxToPdf.tsx new file mode 100644 index 0000000..c9ca3b1 --- /dev/null +++ b/src/islands/documents/DocxToPdf.tsx @@ -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 = { + 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(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 ( +
+

{t.intro}

+ + {!hasDoc && ( +
+ +
+

{busy ? t.opening : t.drop}

+

{t.dropSub}

+
+
+

{t.how}

+
+ )} + + {error && {error}} + + {hasDoc && ( +
+
+ + + +
+ {converting && } +

{t.note}

+
+ )} + + {/* docx-preview renders the document into this container; the PDF is built from its pages. */} +
+
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index c474b69..295ac57 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -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 = { + '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.', @@ -1376,6 +1393,23 @@ const en: Record = { }; const id: Record = { + '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.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 634b245..c17feb7 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -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[] = [ @@ -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', diff --git a/src/tools/documents/docx-pdf.lib.test.ts b/src/tools/documents/docx-pdf.lib.test.ts new file mode 100644 index 0000000..cdd988b --- /dev/null +++ b/src/tools/documents/docx-pdf.lib.test.ts @@ -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 + }); +}); diff --git a/src/tools/documents/docx-pdf.lib.ts b/src/tools/documents/docx-pdf.lib.ts new file mode 100644 index 0000000..d1ab89d --- /dev/null +++ b/src/tools/documents/docx-pdf.lib.ts @@ -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)]; +}