From c1a8d3de61bdea28ba42462396f3cca27ef726f6 Mon Sep 17 00:00:00 2001 From: Kresna Date: Mon, 3 Aug 2026 06:58:41 +0700 Subject: [PATCH] =?UTF-8?q?feat(documents):=20OpenDocument=20(ODT)=20Viewe?= =?UTF-8?q?r=20=E2=80=94=20in-browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open and read .odt (OpenDocument Text) files entirely client-side — headings, lists, tables, images, links and inline formatting. Print / Save as PDF supported. Nothing is uploaded. License-clean by design: no AGPL WebODF and no heavy engine. A custom ODF->HTML parser unzips the .odt with fflate (already a dep) and parses content.xml/styles.xml with the browser's native DOMParser. Output is safe by construction — fixed tag whitelist, every text node and attribute escaped, hrefs scheme-checked (javascript: dropped), inline CSS values validated. Image blob URLs are tracked and revoked on unmount/reset/error. Pure lib src/tools/documents/odt.lib.ts with 9 unit tests. Bilingual (EN + ID) UI, SEO and OG. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ubfx4XocHcECaL8twp9zsr --- src/islands/documents/OdtViewer.tsx | 135 +++++++++++++++ src/registry/tool-seo.ts | 34 ++++ src/registry/tools.ts | 13 +- src/tools/documents/odt.lib.test.ts | 119 ++++++++++++++ src/tools/documents/odt.lib.ts | 246 ++++++++++++++++++++++++++++ 5 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 src/islands/documents/OdtViewer.tsx create mode 100644 src/tools/documents/odt.lib.test.ts create mode 100644 src/tools/documents/odt.lib.ts diff --git a/src/islands/documents/OdtViewer.tsx b/src/islands/documents/OdtViewer.tsx new file mode 100644 index 0000000..1a68ab0 --- /dev/null +++ b/src/islands/documents/OdtViewer.tsx @@ -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 = { + 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([]); + + 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 ( +
+ +

{t.intro}

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

{busy ? t.opening : t.drop}

+

{t.dropSub}

+
+
+

{t.how}

+
+ )} + + {error && {error}} + + {hasDoc && ( +
+
+ + +
+ {emptyDoc ? ( +

{t.empty}

+ ) : ( +
+
+
+ )} +
+ )} +
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 6c36373..c474b69 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 = { + '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.', @@ -1359,6 +1376,23 @@ const en: Record = { }; const id: Record = { + '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.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index c4072f1..634b245 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 } 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[] = [ @@ -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', diff --git a/src/tools/documents/odt.lib.test.ts b/src/tools/documents/odt.lib.test.ts new file mode 100644 index 0000000..d18e816 --- /dev/null +++ b/src/tools/documents/odt.lib.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { zipSync, strToU8 } from 'fflate'; +import { unzipOdt, odtToHtml } from './odt.lib'; + +const CONTENT = ` + + + + + + + + + + + + + + + + + + Big Title + Small Heading + Centered bold and italic-underline. + A <tag> & "quote" to escape. + + First + Second + + + Bullet + + + + R1C1 + R1C2 + + + + link evil + + +`; + +describe('odtToHtml', () => { + const html = odtToHtml(CONTENT, '', (href) => (href === 'Pictures/img1.png' ? 'blob:xyz' : null)); + + it('maps headings by outline level', () => { + expect(html).toContain('Big Title'); + expect(html).toContain('Small Heading'); + }); + + it('applies bold/italic/underline spans and paragraph alignment', () => { + expect(html).toMatch(/bold<\/span>/); + expect(html).toMatch(/italic-underline<\/span>/); + expect(html).toMatch(/

/); + }); + + it('escapes special characters in text', () => { + expect(html).toContain('A <tag> & "quote" to escape.'); + expect(html).not.toContain(''); + }); + + it('renders ordered vs unordered lists from list styles', () => { + expect(html).toContain('

    '); + expect(html).toContain('
  1. '); + expect(html).toContain('First'); + expect(html).toContain('
      '); // second list has no numbered style + }); + + it('renders tables with colspan', () => { + expect(html).toContain(''); + expect(html).toContain('R1C1'); + expect(html).toContain('colspan="2"'); + }); + + it('resolves images and drops unresolved ones', () => { + expect(html).toContain(' { + 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(''), + 'styles.xml': strToU8(''), + '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/); + }); +}); diff --git a/src/tools/documents/odt.lib.ts b/src/tools/documents/odt.lib.ts new file mode 100644 index 0000000..fbcb51f --- /dev/null +++ b/src/tools/documents/odt.lib.ts @@ -0,0 +1,246 @@ +import { unzipSync, strFromU8 } from 'fflate'; + +/** + * A license-clean ODF (OpenDocument Text) → HTML renderer. An .odt is a ZIP of + * XML; we unzip with fflate and parse content.xml/styles.xml with the browser's + * native DOMParser, then map a practical subset of ODF to HTML. Output is safe + * *by construction* — only a fixed whitelist of tags is emitted, every text node + * and attribute value is escaped, hrefs are scheme-checked, and inline CSS values + * are validated. No third-party rendering engine, no AGPL dependency. + */ + +export interface OdtParts { + contentXml: string; + stylesXml: string; + images: Record; +} + +/** Unzip an .odt and pull out the XML parts + embedded Pictures. */ +export function unzipOdt(bytes: Uint8Array): OdtParts { + const files = unzipSync(bytes); + const read = (name: string) => (files[name] ? strFromU8(files[name]) : ''); + const contentXml = read('content.xml'); + if (!contentXml) throw new Error('Not an ODT document: content.xml is missing.'); + const images: Record = {}; + for (const [name, data] of Object.entries(files)) { + if (name.startsWith('Pictures/')) images[name] = data; + } + return { contentXml, stylesXml: read('styles.xml'), images }; +} + +type Css = Record; +interface StyleEntry { css: Css; parent?: string } +interface Ctx { + styleMap: Map; + listOrdered: Map; + resolveImage: (href: string) => string | null; +} + +const ESC: Record = { '&': '&', '<': '<', '>': '>', '"': '"' }; +function esc(s: string): string { + return s.replace(/[&<>"]/g, (c) => ESC[c]); +} + +/** Reject CSS values that could break out of the style="" attribute or inject. */ +function safeVal(v: string | null): string | null { + if (!v) return null; + const t = v.trim(); + if (!t || /[<>"();{}]/.test(t)) return null; + return t; +} + +function childByLocal(parent: Element, local: string): Element | null { + for (const el of Array.from(parent.children)) if (el.localName === local) return el; + return null; +} +function deepFirstLocal(root: Element | Document, local: string): Element | null { + for (const el of Array.from(root.getElementsByTagName('*'))) if (el.localName === local) return el; + return null; +} + +/** Extract the CSS we support from a 's text/paragraph properties. */ +function extractCss(s: Element): Css { + const css: Css = {}; + const tp = childByLocal(s, 'text-properties'); + if (tp) { + const set = (prop: string, raw: string | null) => { + const v = safeVal(raw); + if (v) css[prop] = v; + }; + set('font-weight', tp.getAttribute('fo:font-weight')); + set('font-style', tp.getAttribute('fo:font-style')); + set('color', tp.getAttribute('fo:color')); + set('font-size', tp.getAttribute('fo:font-size')); + const bg = tp.getAttribute('fo:background-color'); + if (bg && bg !== 'transparent') set('background-color', bg); + const deco: string[] = []; + const ul = tp.getAttribute('style:text-underline-style'); + const lt = tp.getAttribute('style:text-line-through-style'); + if (ul && ul !== 'none') deco.push('underline'); + if (lt && lt !== 'none') deco.push('line-through'); + if (deco.length) css['text-decoration'] = deco.join(' '); + } + const pp = childByLocal(s, 'paragraph-properties'); + if (pp) { + const ta = pp.getAttribute('fo:text-align'); + if (ta) css['text-align'] = ta === 'start' ? 'left' : ta === 'end' ? 'right' : ta; + } + return css; +} + +function buildStyleMap(docs: (Document | null)[]): Map { + const map = new Map(); + for (const doc of docs) { + if (!doc) continue; + for (const s of Array.from(doc.getElementsByTagName('*'))) { + if (s.localName !== 'style') continue; // style:style + const name = s.getAttribute('style:name'); + if (!name) continue; + map.set(name, { css: extractCss(s), parent: s.getAttribute('style:parent-style-name') || undefined }); + } + } + return map; +} + +function buildListStyleMap(docs: (Document | null)[]): Map { + const map = new Map(); + for (const doc of docs) { + if (!doc) continue; + for (const ls of Array.from(doc.getElementsByTagName('*'))) { + if (ls.localName !== 'list-style') continue; // text:list-style + const name = ls.getAttribute('style:name'); + if (!name) continue; + map.set(name, !!deepFirstLocal(ls, 'list-level-style-number')); + } + } + return map; +} + +/** Merge a style with its parent chain into a single CSS object. */ +function resolvedCss(map: Map, name: string | null): Css { + const chain: StyleEntry[] = []; + const seen = new Set(); + let cur = name; + while (cur && map.has(cur) && !seen.has(cur)) { + seen.add(cur); + const entry = map.get(cur)!; + chain.unshift(entry); + cur = entry.parent; + } + const out: Css = {}; + for (const c of chain) Object.assign(out, c.css); + return out; +} + +function cssAttr(css: Css): string { + const s = Object.entries(css).map(([k, v]) => `${k}:${v}`).join(';'); + return s ? ` style="${esc(s)}"` : ''; +} +function styleAttrFor(ctx: Ctx, el: Element): string { + return cssAttr(resolvedCss(ctx.styleMap, el.getAttribute('text:style-name'))); +} + +function sanitizeHref(href: string): string | null { + const h = href.trim(); + if (/^(https?:|mailto:|tel:)/i.test(h)) return h; + if (h.startsWith('#') || h.startsWith('/') || !h.includes(':')) return h; // anchor / relative + return null; +} + +function renderFrame(el: Element, ctx: Ctx): string { + const img = deepFirstLocal(el, 'image'); + const href = img?.getAttribute('xlink:href') || ''; + const url = href ? ctx.resolveImage(href) : null; + if (!url) return ''; + const alt = esc(deepFirstLocal(el, 'desc')?.textContent?.trim() || deepFirstLocal(el, 'title')?.textContent?.trim() || ''); + const w = safeVal(el.getAttribute('svg:width')); + const h = safeVal(el.getAttribute('svg:height')); + let style = 'max-width:100%;height:auto;'; + if (w) style += `width:${w};`; + if (h) style += `height:${h};`; + return `${alt}`; +} + +function renderChildren(el: Element, ctx: Ctx, inheritedOrdered: boolean): string { + let out = ''; + for (const node of Array.from(el.childNodes)) { + if (node.nodeType === 3) out += esc(node.nodeValue ?? ''); + else if (node.nodeType === 1) out += renderEl(node as Element, ctx, inheritedOrdered); + } + return out; +} + +function renderEl(el: Element, ctx: Ctx, inheritedOrdered: boolean): string { + const kids = () => renderChildren(el, ctx, inheritedOrdered); + switch (el.localName) { + case 'h': { + const lvl = Math.min(6, Math.max(1, parseInt(el.getAttribute('text:outline-level') || '1', 10) || 1)); + return `${kids()}`; + } + case 'p': + return `${kids()}

      `; + case 'span': + return `${kids()}`; + case 'a': { + const href = sanitizeHref(el.getAttribute('xlink:href') || ''); + return href ? `${kids()}` : kids(); + } + case 'line-break': + return '
      '; + case 'tab': + return ' '; + case 's': { + const n = Math.min(50, Math.max(1, parseInt(el.getAttribute('text:c') || '1', 10) || 1)); + return ' '.repeat(n); + } + case 'list': { + const sn = el.getAttribute('text:style-name'); + const ordered = sn && ctx.listOrdered.has(sn) ? ctx.listOrdered.get(sn)! : inheritedOrdered; + const tag = ordered ? 'ol' : 'ul'; + return `<${tag}>${renderChildren(el, ctx, ordered)}`; + } + case 'list-item': + return `
    • ${renderChildren(el, ctx, inheritedOrdered)}
    • `; + case 'table': + return `${kids()}
      `; + case 'table-row': + return `${kids()}`; + case 'table-cell': { + const cs = parseInt(el.getAttribute('table:number-columns-spanned') || '1', 10); + const rs = parseInt(el.getAttribute('table:number-rows-spanned') || '1', 10); + const attrs = (cs > 1 ? ` colspan="${cs}"` : '') + (rs > 1 ? ` rowspan="${rs}"` : ''); + return `${kids()}`; + } + case 'covered-table-cell': + case 'table-column': + case 'sequence-decls': + case 'tracked-changes': + return ''; + case 'frame': + return renderFrame(el, ctx); + case 'image': + return ''; // handled by its enclosing frame + default: + return kids(); // unwrap unknown inline/containers, keep their text + } +} + +/** + * Convert ODF content.xml (+ optional styles.xml) into a safe HTML string. + * `resolveImage(href)` turns a `Pictures/…` reference into a usable URL (e.g. a + * blob URL the caller owns) or null to drop the image. + */ +export function odtToHtml(contentXml: string, stylesXml: string, resolveImage: (href: string) => string | null): string { + const parser = new DOMParser(); + const content = parser.parseFromString(contentXml, 'application/xml'); + if (content.getElementsByTagName('parsererror').length) return ''; + const styles = stylesXml ? parser.parseFromString(stylesXml, 'application/xml') : null; + const body = deepFirstLocal(content, 'text'); // office:text + if (!body) return ''; + const ctx: Ctx = { + styleMap: buildStyleMap([styles, content]), + listOrdered: buildListStyleMap([styles, content]), + resolveImage, + }; + return renderChildren(body, ctx, false); +}