diff --git a/src/islands/documents/DocViewer.tsx b/src/islands/documents/DocViewer.tsx new file mode 100644 index 0000000..dcf55b2 --- /dev/null +++ b/src/islands/documents/DocViewer.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { CopyButton } from '@/components/ui/CopyButton'; +import type { Lang } from '@/i18n/config'; + +const TR: Record> = { + en: { + intro: 'Open an old Word .doc file (the pre-2007 binary format) and read its text — no Word needed. This is a best-effort text extractor: it recovers the words, not the formatting, tables or images. Your file is read in your browser and never uploaded.', + drop: 'Drop a .doc file or click to browse', dropSub: 'Opened on your device', + reading: 'Reading…', failed: 'Could not read this .doc file. It may be an unusual variant — try opening it in Word/LibreOffice and re-saving as .docx.', + note: 'Best-effort text only — formatting, tables and images are not preserved.', + download: 'Download .txt', another: 'Another file', words: 'words', + }, + id: { + intro: 'Buka berkas Word .doc lama (format biner sebelum 2007) dan baca teksnya — tanpa Word. Ini ekstraktor teks best-effort: memulihkan kata, bukan format, tabel, atau gambar. Berkas Anda dibaca di browser dan tidak pernah diunggah.', + drop: 'Letakkan berkas .doc atau klik untuk memilih', dropSub: 'Dibuka di perangkat Anda', + reading: 'Membaca…', failed: 'Tidak dapat membaca berkas .doc ini. Mungkin varian tak biasa — coba buka di Word/LibreOffice dan simpan ulang sebagai .docx.', + note: 'Hanya teks best-effort — format, tabel, dan gambar tidak dipertahankan.', + download: 'Unduh .txt', another: 'Berkas lain', words: 'kata', + }, +}; + +export default function DocViewer({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [text, setText] = useState(null); + const [name, setName] = useState('document'); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const onDrop = async (files: File[]) => { + const f = files.find(x => x.name.toLowerCase().endsWith('.doc') || x.type === 'application/msword'); + if (!f) return; + setBusy(true); setError(''); setText(null); + setName(f.name.replace(/\.doc$/i, '')); + try { + const { extractDocText } = await import('@/tools/documents/doc.lib'); + const out = extractDocText(new Uint8Array(await f.arrayBuffer())); + setText(out); + } catch (e) { + setError(e instanceof Error ? e.message : t.failed); + } finally { + setBusy(false); + } + }; + + const download = () => { + if (text == null) return; + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `${name}.txt`; a.click(); + URL.revokeObjectURL(url); + }; + + const wordCount = text ? text.split(/\s+/).filter(Boolean).length : 0; + + return ( +
+

{t.intro}

+ + {text == null && !busy && ( + +
+

{t.drop}

+

{t.dropSub}

+
+
+ )} + + {busy &&

{t.reading}

} + {error && {error}} + + {text != null && ( +
+
+ {wordCount} {t.words} + + + +
+

{t.note}

+
{text}
+
+ )} +
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index cc1cc97..8a52668 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -562,6 +562,24 @@ const en: Record = { { q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the redaction tool works with no internet connection.' }, ], }, + 'doc-viewer': { + title: 'Free Legacy .doc Viewer — Open Old Word Files Without Word', + description: 'Open an old Word .doc file (pre-2007 binary format) and read its text in your browser — no Word or Office needed. Best-effort text extraction, private, nothing uploaded.', + intro: 'Old archives are full of legacy Word .doc files — the pre-2007 binary format, not the modern zipped .docx. This free viewer opens them and extracts the readable text right in your browser, with no Word or Office install. It reads the OLE compound file and walks the Word piece table to recover the text; it is a best-effort text extractor, so formatting, tables and images are not preserved. Your file is read on your device and never uploaded.', + howTo: [ + 'Drop your .doc file to open it.', + 'Read the extracted text.', + 'Copy it, or download it as a .txt file.', + 'For an unusual file that won’t open, re-save it as .docx in Word/LibreOffice.', + ], + faqs: [ + { q: 'Is my document uploaded?', a: 'No. The .doc is parsed in your browser (OLE container + Word text), so its contents never leave your device.' }, + { q: 'Does it keep formatting and tables?', a: 'No. This is best-effort text extraction — it recovers the words in order, but not fonts, layout, tables or images.' }, + { q: 'What about .docx?', a: 'This tool is for the old binary .doc. For modern .docx use the DOCX Viewer, which preserves formatting.' }, + { q: 'Why did some text come out garbled or missing?', a: 'The legacy .doc format is complex and varies between Word versions. If a file doesn’t extract cleanly, open it in Word or LibreOffice and re-save as .docx.' }, + { q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the viewer works with no internet connection.' }, + ], + }, 'pptx-to-pdf': { title: 'Free PPTX to PDF — Convert PowerPoint to PDF Online', description: 'Convert a PowerPoint (.pptx) to PDF in your browser — one slide per page with real positions, text and images. Free and private; nothing is uploaded.', @@ -2931,6 +2949,24 @@ const id: Record = { { q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat tool sensor bekerja tanpa koneksi internet.' }, ], }, + 'doc-viewer': { + title: 'Penampil .doc Lama Gratis — Buka Berkas Word Lama Tanpa Word', + description: 'Buka berkas Word .doc lama (format biner sebelum 2007) dan baca teksnya di browser — tanpa Word atau Office. Ekstraksi teks best-effort, privat, tidak ada yang diunggah.', + intro: 'Arsip lama penuh berkas Word .doc lawas — format biner sebelum 2007, bukan .docx modern yang terkompresi ZIP. Penampil gratis ini membukanya dan mengekstrak teks yang terbaca langsung di browser Anda, tanpa instalasi Word atau Office. Tool membaca berkas OLE compound dan menelusuri piece table Word untuk memulihkan teks; ini ekstraktor teks best-effort, jadi format, tabel, dan gambar tidak dipertahankan. Berkas Anda dibaca di perangkat dan tidak pernah diunggah.', + howTo: [ + 'Letakkan berkas .doc Anda untuk membukanya.', + 'Baca teks yang diekstrak.', + 'Salin, atau unduh sebagai berkas .txt.', + 'Untuk berkas tak biasa yang gagal dibuka, simpan ulang sebagai .docx di Word/LibreOffice.', + ], + faqs: [ + { q: 'Apakah dokumen saya diunggah?', a: 'Tidak. Berkas .doc diurai di browser Anda (kontainer OLE + teks Word), jadi isinya tidak pernah meninggalkan perangkat.' }, + { q: 'Apakah mempertahankan format dan tabel?', a: 'Tidak. Ini ekstraksi teks best-effort — memulihkan kata secara berurutan, tetapi bukan font, tata letak, tabel, atau gambar.' }, + { q: 'Bagaimana dengan .docx?', a: 'Tool ini untuk .doc biner lama. Untuk .docx modern gunakan DOCX Viewer, yang mempertahankan format.' }, + { q: 'Mengapa sebagian teks kacau atau hilang?', a: 'Format .doc lawas rumit dan berbeda antar versi Word. Jika berkas tidak terekstrak bersih, buka di Word atau LibreOffice dan simpan ulang sebagai .docx.' }, + { q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat penampil bekerja tanpa koneksi internet.' }, + ], + }, 'pptx-to-pdf': { title: 'PPTX ke PDF Gratis — Konversi PowerPoint ke PDF Online', description: 'Konversi PowerPoint (.pptx) ke PDF di browser Anda — satu slide per halaman dengan posisi, teks, dan gambar aslinya. Gratis dan privat; tidak ada yang diunggah.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 6cf28f8..ba62e2d 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -300,6 +300,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/documents/PptxToPdf'), status: 'beta' }, + { + id: 'doc-viewer', + name: 'Legacy .doc Viewer', + category: 'Documents', + route: '/tools/doc-viewer', + keywords: ['doc', 'word 97', 'legacy doc', 'old word', 'open doc', 'binary doc', 'msword', 'buka doc', 'word lama'], + icon: FileText, + summary: 'Open and read old Word .doc (pre-2007 binary) files', + load: () => import('@/islands/documents/DocViewer'), + status: 'beta' + }, { id: 'gedcom-viewer', name: 'GEDCOM Viewer', diff --git a/src/tools/documents/cfb.lib.ts b/src/tools/documents/cfb.lib.ts new file mode 100644 index 0000000..6bd03aa --- /dev/null +++ b/src/tools/documents/cfb.lib.ts @@ -0,0 +1,122 @@ +/** + * Minimal reader for the OLE2 / Compound File Binary (CFB) container used by + * legacy Office formats (.doc, .xls, .ppt). Reassembles each stream from the + * FAT/mini-FAT sector chains and returns them by name. Pure — no I/O. + * + * Spec: [MS-CFB]. Only reading is implemented, enough to pull out named streams. + */ + +const FREE = 0xffffffff; +const MAXREGSECT = 0xfffffffa; + +function concat(parts: Uint8Array[]): Uint8Array { + let len = 0; + for (const p of parts) len += p.length; + const out = new Uint8Array(len); + let o = 0; + for (const p of parts) { out.set(p, o); o += p.length; } + return out; +} + +export function isCompoundFile(bytes: Uint8Array): boolean { + return bytes.length >= 8 && + bytes[0] === 0xd0 && bytes[1] === 0xcf && bytes[2] === 0x11 && bytes[3] === 0xe0 && + bytes[4] === 0xa1 && bytes[5] === 0xb1 && bytes[6] === 0x1a && bytes[7] === 0xe1; +} + +export function readCfb(bytes: Uint8Array): Map { + if (!isCompoundFile(bytes)) throw new Error('Not an OLE2 compound file.'); + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + const sectorSize = 1 << dv.getUint16(0x1e, true); + const miniSize = 1 << dv.getUint16(0x20, true); + const firstDirSector = dv.getUint32(0x30, true); + const miniCutoff = dv.getUint32(0x38, true); + const firstMiniFat = dv.getUint32(0x3c, true); + const firstDifat = dv.getUint32(0x44, true); + + const secOffset = (s: number) => (s + 1) * sectorSize; + + // Gather FAT sector locations from the header DIFAT (109 entries) then any + // DIFAT sectors chained after it. + const fatSectors: number[] = []; + for (let i = 0; i < 109; i++) { + const v = dv.getUint32(0x4c + i * 4, true); + if (v <= MAXREGSECT) fatSectors.push(v); + } + let ds = firstDifat; + const perSector = sectorSize / 4; + let guard = 0; + while (ds <= MAXREGSECT && guard++ < bytes.length) { + const base = secOffset(ds); + for (let i = 0; i < perSector - 1; i++) { + const v = dv.getUint32(base + i * 4, true); + if (v <= MAXREGSECT) fatSectors.push(v); + } + ds = dv.getUint32(base + (perSector - 1) * 4, true); + } + + // Build the FAT (next-sector table). + const fat: number[] = []; + for (const fs of fatSectors) { + const base = secOffset(fs); + for (let i = 0; i < perSector; i++) fat.push(dv.getUint32(base + i * 4, true)); + } + + const readChain = (start: number): Uint8Array => { + const parts: Uint8Array[] = []; + let s = start; + let g = 0; + while (s <= MAXREGSECT && g++ < fat.length + 2) { + const off = secOffset(s); + parts.push(bytes.subarray(off, off + sectorSize)); + s = fat[s]; + if (s === undefined || s === FREE) break; + } + return concat(parts); + }; + + // Directory entries. + const dir = readChain(firstDirSector); + const ddv = new DataView(dir.buffer, dir.byteOffset, dir.byteLength); + interface Entry { name: string; type: number; start: number; size: number; } + const entries: Entry[] = []; + for (let i = 0; (i + 1) * 128 <= dir.length; i++) { + const b = i * 128; + const type = dir[b + 0x42]; + if (type === 0) continue; // unused + const nameLen = ddv.getUint16(b + 0x40, true); + let name = ''; + for (let c = 0; c < Math.max(0, nameLen / 2 - 1); c++) name += String.fromCharCode(ddv.getUint16(b + c * 2, true)); + entries.push({ name, type, start: ddv.getUint32(b + 0x74, true), size: ddv.getUint32(b + 0x78, true) }); + } + + // Root storage (type 5) holds the mini stream. + const root = entries.find(e => e.type === 5); + const miniStream = root ? readChain(root.start) : new Uint8Array(0); + const miniFatBytes = firstMiniFat <= MAXREGSECT ? readChain(firstMiniFat) : new Uint8Array(0); + const mdv = new DataView(miniFatBytes.buffer, miniFatBytes.byteOffset, miniFatBytes.byteLength); + const miniFat: number[] = []; + for (let i = 0; i < miniFatBytes.length / 4; i++) miniFat.push(mdv.getUint32(i * 4, true)); + + const readMini = (start: number, size: number): Uint8Array => { + const parts: Uint8Array[] = []; + let s = start; + let g = 0; + while (s <= MAXREGSECT && g++ < miniFat.length + 2) { + const off = s * miniSize; + parts.push(miniStream.subarray(off, off + miniSize)); + s = miniFat[s]; + if (s === undefined || s === FREE) break; + } + return concat(parts).subarray(0, size); + }; + + const streams = new Map(); + for (const e of entries) { + if (e.type !== 2) continue; // streams only + const data = e.size < miniCutoff ? readMini(e.start, e.size) : readChain(e.start).subarray(0, e.size); + streams.set(e.name, data); + } + return streams; +} diff --git a/src/tools/documents/doc.lib.test.ts b/src/tools/documents/doc.lib.test.ts new file mode 100644 index 0000000..95613d6 --- /dev/null +++ b/src/tools/documents/doc.lib.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { cp1252Char, findPcdt, cleanDocText } from './doc.lib'; + +describe('cp1252Char', () => { + it('is identity for ASCII and Latin-1', () => { + expect(cp1252Char(0x41)).toBe(0x41); + expect(cp1252Char(0xe9)).toBe(0xe9); // é + }); + it('maps the Windows-1252 0x80–0x9F overrides', () => { + expect(cp1252Char(0x93)).toBe(0x201c); // left double quote + expect(cp1252Char(0x92)).toBe(0x2019); // right single quote + expect(cp1252Char(0x80)).toBe(0x20ac); // euro + }); +}); + +describe('findPcdt', () => { + it('locates a valid single-piece piece table', () => { + // 3 junk bytes, then 0x02, lcb=16, aCP=[0, 5], aPcd=8 bytes. + const buf = new Uint8Array(3 + 1 + 4 + 8 + 8); + const dv = new DataView(buf.buffer); + let o = 3; + buf[o] = 0x02; o += 1; + dv.setUint32(o, 16, true); o += 4; // lcb + dv.setUint32(o, 0, true); o += 4; // aCP[0] = 0 + dv.setUint32(o, 5, true); o += 4; // aCP[1] = 5 + // aPcd (8 bytes) left as zeros + const pcdt = findPcdt(buf, 5); + expect(pcdt).not.toBeNull(); + expect(pcdt!.aCP).toEqual([0, 5]); + expect(pcdt!.pcdBase).toBe(16); // plc(8) + (n+1)*4 = 8 + 8 + }); + + it('rejects buffers with no valid piece table', () => { + expect(findPcdt(new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]), 10)).toBeNull(); + }); +}); + +describe('cleanDocText', () => { + it('collapses blank lines and trims trailing spaces', () => { + expect(cleanDocText('a \n\n\n\nb \n')).toBe('a\n\nb'); + }); + it('normalises CRLF to LF', () => { + expect(cleanDocText('line1\r\nline2')).toBe('line1\nline2'); + }); +}); diff --git a/src/tools/documents/doc.lib.ts b/src/tools/documents/doc.lib.ts new file mode 100644 index 0000000..b90dc83 --- /dev/null +++ b/src/tools/documents/doc.lib.ts @@ -0,0 +1,147 @@ +/** + * Best-effort text extraction from a legacy Word 97-2003 .doc (the WordDocument + * stream inside an OLE2 compound file). It walks the piece table (found by + * scanning the table stream, which avoids depending on the version-specific FIB + * field offset) and decodes each piece as CP1252 or UTF-16LE. Formatting, tables + * and images are not preserved — this recovers the readable text. Pure. + * + * Spec: [MS-DOC]. `readCfb` (cfb.lib) provides the streams. + */ + +import { readCfb } from './cfb.lib'; + +// Windows-1252 overrides for 0x80–0x9F (the rest is Latin-1 / identity). +const CP1252: Record = { + 0x80: 0x20ac, 0x82: 0x201a, 0x83: 0x0192, 0x84: 0x201e, 0x85: 0x2026, 0x86: 0x2020, + 0x87: 0x2021, 0x88: 0x02c6, 0x89: 0x2030, 0x8a: 0x0160, 0x8b: 0x2039, 0x8c: 0x0152, + 0x8e: 0x017d, 0x91: 0x2018, 0x92: 0x2019, 0x93: 0x201c, 0x94: 0x201d, 0x95: 0x2022, + 0x96: 0x2013, 0x97: 0x2014, 0x98: 0x02dc, 0x99: 0x2122, 0x9a: 0x0161, 0x9b: 0x203a, + 0x9c: 0x0153, 0x9e: 0x017e, 0x9f: 0x0178, +}; + +export function cp1252Char(b: number): number { + return CP1252[b] ?? b; +} + +/** Map a raw Word character code to output text (or '' to drop it). */ +function mapChar(cp: number): string { + switch (cp) { + case 0x0d: // paragraph end + case 0x0e: // column/line break + case 0x0b: // hard line break + return '\n'; + case 0x07: // cell / row mark + return '\t'; + case 0x08: // backspace / drawn object anchor + case 0x01: // picture anchor + case 0x05: // annotation + case 0x1e: // non-breaking hyphen + return ''; + case 0x1f: // optional hyphen + return ''; + case 0xa0: + return ' '; + default: + return cp >= 0x20 || cp === 0x09 || cp === 0x0a ? String.fromCodePoint(cp) : ''; + } +} + +export interface Pcdt { aCP: number[]; pcdBase: number; } + +/** + * Scan a table stream for the piece table (Pcdt): a byte 0x02, a 4-byte length, + * then a PlcPcd whose CP array starts at 0 and ascends, with a matching size. + */ +export function findPcdt(table: Uint8Array, ccpText: number): Pcdt | null { + const dv = new DataView(table.buffer, table.byteOffset, table.byteLength); + for (let i = 0; i + 5 < table.length; i++) { + if (table[i] !== 0x02) continue; + const lcb = dv.getUint32(i + 1, true); + if (lcb < 16 || i + 5 + lcb > table.length) continue; + if ((lcb - 4) % 12 !== 0) continue; + const n = (lcb - 4) / 12; + if (n < 1 || n > 500000) continue; + const plc = i + 5; + // Validate the CP array: n+1 ascending uint32s starting at 0. + let ok = true; + let prev = -1; + const aCP: number[] = []; + for (let j = 0; j <= n; j++) { + const cp = dv.getUint32(plc + j * 4, true); + if ((j === 0 && cp !== 0) || cp <= prev) { ok = false; break; } + prev = cp; + aCP.push(cp); + } + if (!ok) continue; + if (aCP[n] < ccpText) continue; // must cover the main document text + return { aCP, pcdBase: plc + (n + 1) * 4 }; + } + return null; +} + +function decodePieces(wd: Uint8Array, table: Uint8Array, pcdt: Pcdt, ccpText: number): string { + const tdv = new DataView(table.buffer, table.byteOffset, table.byteLength); + let out = ''; + const { aCP, pcdBase } = pcdt; + for (let j = 0; j + 1 < aCP.length; j++) { + const cpStart = aCP[j]; + if (cpStart >= ccpText) break; + const cpEnd = Math.min(aCP[j + 1], ccpText); + const count = cpEnd - cpStart; + const fc = tdv.getUint32(pcdBase + j * 8 + 2, true); + const compressed = (fc & 0x40000000) !== 0; + const cleared = fc & 0x3fffffff; + if (compressed) { + const off = cleared / 2; + for (let k = 0; k < count; k++) out += mapChar(cp1252Char(wd[off + k] ?? 0)); + } else { + const off = cleared; + for (let k = 0; k < count; k++) { + const lo = wd[off + k * 2] ?? 0; + const hi = wd[off + k * 2 + 1] ?? 0; + out += mapChar(lo | (hi << 8)); + } + } + } + return out; +} + +/** Tidy extracted text: collapse runs of blank lines and trailing spaces. */ +export function cleanDocText(s: string): string { + return s + .replace(/\r\n?/g, '\n') + .replace(/[ \t]+\n/g, '\n') + .replace(/\uFEFF/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function extractDocText(bytes: Uint8Array): string { + const streams = readCfb(bytes); + const wd = streams.get('WordDocument'); + if (!wd) throw new Error('This is not a Word .doc file (no WordDocument stream).'); + const dv = new DataView(wd.buffer, wd.byteOffset, wd.byteLength); + const flags = dv.getUint16(0x0a, true); + const useTable1 = ((flags >> 9) & 1) === 1; + const table = (useTable1 ? streams.get('1Table') : streams.get('0Table')) + ?? streams.get('0Table') ?? streams.get('1Table'); + const ccpText = dv.getUint32(0x4c, true); + + if (table && ccpText > 0) { + const pcdt = findPcdt(table, ccpText); + if (pcdt) { + const text = cleanDocText(decodePieces(wd, table, pcdt, ccpText)); + if (text) return text; + } + } + + // Fallback: read the main text directly from fcMin as CP1252. + const fcMin = dv.getUint32(0x18, true); + if (ccpText > 0 && fcMin > 0 && fcMin < wd.length) { + let out = ''; + for (let k = 0; k < ccpText && fcMin + k < wd.length; k++) out += mapChar(cp1252Char(wd[fcMin + k])); + const text = cleanDocText(out); + if (text) return text; + } + throw new Error('Could not extract text from this .doc file.'); +}