|
| 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 | +} |
0 commit comments