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
135 changes: 135 additions & 0 deletions src/islands/documents/OdtViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { useEffect, useRef, useState } from 'react';
import { FileType2, Printer } from 'lucide-react';
import { Dropzone } from '@/components/ui/Dropzone';
import { Button } from '@/components/ui/Button';
import { Alert } from '@/components/ui/Alert';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, {
intro: string; drop: string; dropSub: string; how: string;
opening: string; another: string; print: string; errRead: string; empty: string;
}> = {
en: {
intro: 'Open and read an OpenDocument Text file (.odt) right here — headings, lists, tables and images. It is rendered on your device; nothing is uploaded.',
drop: 'Drop an OpenDocument Text (.odt)', dropSub: 'Rendered on your device — no upload.',
how: 'Made by LibreOffice, OpenOffice or Google Docs (exported as .odt). Common formatting is preserved; very complex layouts may differ.',
opening: 'Rendering…', another: 'Open another', print: 'Print / Save as PDF',
errRead: 'Could not open this document — is it a valid .odt file?', empty: 'This document appears to be empty.',
},
id: {
intro: 'Buka dan baca berkas OpenDocument Text (.odt) langsung di sini — judul, daftar, tabel, dan gambar. Ditampilkan di perangkat Anda; tidak ada yang diunggah.',
drop: 'Letakkan OpenDocument Text (.odt)', dropSub: 'Ditampilkan di perangkat Anda — tanpa unggahan.',
how: 'Dibuat oleh LibreOffice, OpenOffice, atau Google Docs (diekspor sebagai .odt). Pemformatan umum dipertahankan; tata letak yang sangat rumit mungkin berbeda.',
opening: 'Menampilkan…', another: 'Buka yang lain', print: 'Cetak / Simpan PDF',
errRead: 'Tidak dapat membuka dokumen ini — apakah berkas .odt yang valid?', empty: 'Dokumen ini tampaknya kosong.',
},
};

const DOC_CSS = `
.odt-doc{color:#111;font-family:Georgia,'Times New Roman',serif;line-height:1.6}
.odt-doc h1,.odt-doc h2,.odt-doc h3,.odt-doc h4,.odt-doc h5,.odt-doc h6{font-weight:bold;line-height:1.25;margin:0.7em 0 0.35em}
.odt-doc h1{font-size:1.8em}.odt-doc h2{font-size:1.5em}.odt-doc h3{font-size:1.3em}
.odt-doc h4{font-size:1.1em}.odt-doc h5{font-size:1em}.odt-doc h6{font-size:0.9em}
.odt-doc p{margin:0.5em 0}
.odt-doc ul,.odt-doc ol{margin:0.5em 0 0.5em 1.5em}
.odt-doc li{margin:0.15em 0}
.odt-doc table.odt-table{border-collapse:collapse;margin:0.6em 0;max-width:100%}
.odt-doc table.odt-table td{border:1px solid #999;padding:4px 8px;vertical-align:top}
.odt-doc a{color:#2563eb;text-decoration:underline}
.odt-doc img{max-width:100%;height:auto}
`;

export default function OdtViewer({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [html, setHtml] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [emptyDoc, setEmptyDoc] = useState(false);
const blobUrls = useRef<string[]>([]);

const revoke = () => {
blobUrls.current.forEach((u) => URL.revokeObjectURL(u));
blobUrls.current = [];
};
useEffect(() => revoke, []); // release image blob URLs on unmount

const onDrop = async (files: File[]) => {
const f = files[0];
if (!f) return;
setError('');
setEmptyDoc(false);
setBusy(true);
try {
const buf = await f.arrayBuffer();
const { unzipOdt, odtToHtml } = await import('@/tools/documents/odt.lib');
const { contentXml, stylesXml, images } = unzipOdt(new Uint8Array(buf));
revoke();
const resolveImage = (href: string): string | null => {
const key = href.replace(/^\.?\//, '');
const bytes = images[key] || images['Pictures/' + (key.split('/').pop() ?? '')];
if (!bytes) return null;
const url = URL.createObjectURL(new Blob([bytes]));
blobUrls.current.push(url);
return url;
};
const out = odtToHtml(contentXml, stylesXml, resolveImage);
setHtml(out);
setEmptyDoc(out.trim() === '');
} catch {
setError(t.errRead);
setHtml('');
revoke(); // don't orphan a previous document's image blob URLs
} finally {
setBusy(false);
}
};

const reset = () => {
revoke();
setHtml('');
setEmptyDoc(false);
setError('');
};

const hasDoc = html !== '' || emptyDoc;

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

{!hasDoc && (
<div className="print:hidden">
<Dropzone onDrop={onDrop} accept=".odt,application/vnd.oasis.opendocument.text" multiple={false}>
<div className="space-y-1">
<p className="flex items-center justify-center gap-2 text-lg font-bold"><FileType2 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 variant="secondary" onClick={() => window.print()}><Printer className="h-4 w-4" /> {t.print}</Button>
<Button variant="ghost" onClick={reset}>{t.another}</Button>
</div>
{emptyDoc ? (
<p className="text-sm text-muted-foreground">{t.empty}</p>
) : (
<div className="max-h-[78vh] 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
className="odt-doc mx-auto max-w-3xl bg-white p-8 shadow-sm print:p-0 print:shadow-none"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
)}
</div>
)}
</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> = {
'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.',
intro: 'This free ODT viewer opens OpenDocument Text files — the format used by LibreOffice, OpenOffice and Google Docs — right in your browser, with headings, lists, tables and images intact. No office suite and no account needed, and the file is read on your device and never uploaded.',
howTo: [
'Drop an .odt file (or click to browse) — it is read entirely in your browser.',
'The document renders with its formatting: headings, lists, tables and pictures.',
'Scroll to read, or use Print / Save as PDF to keep a copy.',
'Nothing is uploaded — the file stays on your device.',
],
faqs: [
{ q: 'Is my document uploaded anywhere?', a: 'No. The .odt is unzipped and rendered entirely in your browser with JavaScript. It never leaves your device, so it is safe for confidential documents.' },
{ q: 'What is an .odt file?', a: 'ODT (OpenDocument Text) is the open, standardised word-processor format used by LibreOffice and OpenOffice Writer, and available as an export from Google Docs and Microsoft Word.' },
{ q: 'Can I turn it into a PDF?', a: 'Yes, indirectly: open it here and use Print / Save as PDF in your browser to produce a PDF copy.' },
{ q: 'Will it look exactly like LibreOffice?', a: 'Common formatting — headings, bold and italic, lists, tables, links and images — is preserved. This is a lightweight viewer, so very complex page layouts or unusual fonts may render slightly differently.' },
],
},
'epub-reader': {
title: 'Free EPUB Reader — Read E-Books in Your Browser',
description: 'A free online EPUB reader to open and read .epub e-books in your browser — chapters, table of contents and adjustable text size. 100% private; nothing is uploaded.',
Expand Down Expand Up @@ -1359,6 +1376,23 @@ const en: Record<string, ToolSeoContent> = {
};

const id: Record<string, ToolSeoContent> = {
'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.',
intro: 'Penampil ODT gratis ini membuka berkas OpenDocument Text — format yang digunakan oleh LibreOffice, OpenOffice, dan Google Docs — langsung di browser Anda, lengkap dengan judul, daftar, tabel, dan gambar. Tanpa aplikasi kantor atau akun, dan berkas dibaca di perangkat Anda serta tidak pernah diunggah.',
howTo: [
'Letakkan berkas .odt (atau klik untuk menelusuri) — dibaca sepenuhnya di browser Anda.',
'Dokumen ditampilkan dengan pemformatannya: judul, daftar, tabel, dan gambar.',
'Gulir untuk membaca, atau gunakan Cetak / Simpan PDF untuk menyimpan salinan.',
'Tidak ada yang diunggah — berkas tetap di perangkat Anda.',
],
faqs: [
{ q: 'Apakah dokumen saya diunggah ke suatu tempat?', a: 'Tidak. Berkas .odt diekstrak dan ditampilkan sepenuhnya di browser Anda dengan JavaScript. Berkas tidak pernah meninggalkan perangkat, jadi aman untuk dokumen rahasia.' },
{ q: 'Apa itu berkas .odt?', a: 'ODT (OpenDocument Text) adalah format pengolah kata terbuka dan terstandarisasi yang digunakan oleh LibreOffice dan OpenOffice Writer, serta tersedia sebagai ekspor dari Google Docs dan Microsoft Word.' },
{ q: 'Bisakah saya mengubahnya menjadi PDF?', a: 'Ya, secara tidak langsung: buka di sini lalu gunakan Cetak / Simpan PDF di browser untuk membuat salinan PDF.' },
{ q: 'Apakah akan tampak persis seperti LibreOffice?', a: 'Pemformatan umum — judul, tebal dan miring, daftar, tabel, tautan, dan gambar — dipertahankan. Ini penampil ringan, jadi tata letak halaman yang sangat rumit atau font tidak biasa mungkin tampil sedikit berbeda.' },
],
},
'epub-reader': {
title: 'Pembaca EPUB Gratis — Baca E-Book di Browser',
description: 'Pembaca EPUB gratis untuk membuka dan membaca e-book .epub di browser Anda — bab, daftar isi, dan ukuran teks yang dapat disesuaikan. 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 } 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 } from 'lucide-react';
import type { ToolDef } from '@/types/tool';

export const tools: ToolDef[] = [
Expand Down Expand Up @@ -190,6 +190,17 @@ export const tools: ToolDef[] = [
load: () => import('@/islands/documents/EpubReader'),
status: 'beta'
},
{
id: 'odt-viewer',
name: 'OpenDocument (ODT) Viewer',
category: 'Documents',
route: '/tools/odt-viewer',
keywords: ['odt', 'opendocument', 'libreoffice', 'openoffice', 'writer', 'viewer', 'open', 'read', 'document', 'word processor'],
icon: FileType2,
summary: 'Open and read OpenDocument Text .odt files in your browser',
load: () => import('@/islands/documents/OdtViewer'),
status: 'beta'
},
{
id: 'markdown',
name: 'Markdown Preview',
Expand Down
119 changes: 119 additions & 0 deletions src/tools/documents/odt.lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest';
import { zipSync, strToU8 } from 'fflate';
import { unzipOdt, odtToHtml } from './odt.lib';

const CONTENT = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-content
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
xmlns:xlink="http://www.w3.org/1999/xlink">
<office:automatic-styles>
<style:style style:name="T1" style:family="text">
<style:text-properties fo:font-weight="bold"/>
</style:style>
<style:style style:name="T2" style:family="text">
<style:text-properties fo:font-style="italic" style:text-underline-style="solid"/>
</style:style>
<style:style style:name="P1" style:family="paragraph">
<style:paragraph-properties fo:text-align="center"/>
</style:style>
<text:list-style style:name="L1">
<text:list-level-style-number text:level="1"/>
</text:list-style>
</office:automatic-styles>
<office:body>
<office:text>
<text:h text:outline-level="1">Big Title</text:h>
<text:h text:outline-level="3">Small Heading</text:h>
<text:p text:style-name="P1">Centered <text:span text:style-name="T1">bold</text:span> and <text:span text:style-name="T2">italic-underline</text:span>.</text:p>
<text:p>A &lt;tag&gt; &amp; "quote" to escape.</text:p>
<text:list text:style-name="L1">
<text:list-item><text:p>First</text:p></text:list-item>
<text:list-item><text:p>Second</text:p></text:list-item>
</text:list>
<text:list>
<text:list-item><text:p>Bullet</text:p></text:list-item>
</text:list>
<table:table>
<table:table-row>
<table:table-cell><text:p>R1C1</text:p></table:table-cell>
<table:table-cell table:number-columns-spanned="2"><text:p>R1C2</text:p></table:table-cell>
</table:table-row>
</table:table>
<text:p><draw:frame svg:width="3cm"><draw:image xlink:href="Pictures/img1.png"/></draw:frame></text:p>
<text:p><text:a xlink:href="https://example.com">link</text:a> <text:a xlink:href="javascript:alert(1)">evil</text:a></text:p>
</office:text>
</office:body>
</office:document-content>`;

describe('odtToHtml', () => {
const html = odtToHtml(CONTENT, '', (href) => (href === 'Pictures/img1.png' ? 'blob:xyz' : null));

it('maps headings by outline level', () => {
expect(html).toContain('<h1');
expect(html).toContain('>Big Title</h1>');
expect(html).toContain('<h3');
expect(html).toContain('>Small Heading</h3>');
});

it('applies bold/italic/underline spans and paragraph alignment', () => {
expect(html).toMatch(/<span style="[^"]*font-weight:bold[^"]*">bold<\/span>/);
expect(html).toMatch(/<span style="[^"]*font-style:italic[^"]*text-decoration:underline[^"]*">italic-underline<\/span>/);
expect(html).toMatch(/<p style="[^"]*text-align:center[^"]*">/);
});

it('escapes special characters in text', () => {
expect(html).toContain('A &lt;tag&gt; &amp; &quot;quote&quot; to escape.');
expect(html).not.toContain('<tag>');
});

it('renders ordered vs unordered lists from list styles', () => {
expect(html).toContain('<ol>');
expect(html).toContain('<li>');
expect(html).toContain('First');
expect(html).toContain('<ul>'); // second list has no numbered style
});

it('renders tables with colspan', () => {
expect(html).toContain('<table');
expect(html).toContain('<tr>');
expect(html).toContain('R1C1');
expect(html).toContain('colspan="2"');
});

it('resolves images and drops unresolved ones', () => {
expect(html).toContain('<img src="blob:xyz"');
expect(html).toContain('max-width:100%');
});

it('allows safe links but strips javascript: URLs', () => {
expect(html).toContain('href="https://example.com"');
expect(html).not.toContain('javascript:alert');
});
});

describe('unzipOdt', () => {
it('extracts content.xml, styles.xml and Pictures/', () => {
const bytes = zipSync({
'mimetype': strToU8('application/vnd.oasis.opendocument.text'),
'content.xml': strToU8('<office:document-content xmlns:office="urn:x"/>'),
'styles.xml': strToU8('<office:document-styles xmlns:office="urn:x"/>'),
'Pictures/img1.png': new Uint8Array([1, 2, 3]),
});
const parts = unzipOdt(bytes);
expect(parts.contentXml).toContain('document-content');
expect(parts.stylesXml).toContain('document-styles');
expect(Object.keys(parts.images)).toEqual(['Pictures/img1.png']);
expect(parts.images['Pictures/img1.png']).toEqual(new Uint8Array([1, 2, 3]));
});

it('throws when content.xml is missing (not an ODT)', () => {
const bytes = zipSync({ 'random.txt': strToU8('nope') });
expect(() => unzipOdt(bytes)).toThrow(/content\.xml/);
});
});
Loading
Loading