import JSZip from 'jszip' function esc(str) { return String(str) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, ''') } function nodeToOdt(node) { if (!node) return '' const ch = () => (node.content || []).map(nodeToOdt).join('') switch (node.type) { case 'doc': return ch() case 'paragraph': return `${ch()}\n` case 'heading': { const l = node.attrs?.level || 1 return `${ch()}\n` } case 'text': { let t = esc(node.text || '') const marks = node.marks || [] const bold = marks.some(m => m.type === 'bold') const italic = marks.some(m => m.type === 'italic') if (bold && italic) return `${t}` if (bold) return `${t}` if (italic) return `${t}` return t } case 'bulletList': return (node.content || []).map(item => (item.content || []).map(p => `${(p.content || []).map(nodeToOdt).join('')}\n` ).join('') ).join('') case 'orderedList': return (node.content || []).map(item => (item.content || []).map(p => `${(p.content || []).map(nodeToOdt).join('')}\n` ).join('') ).join('') case 'blockquote': return (node.content || []).map(n => n.type === 'paragraph' ? `${(n.content || []).map(nodeToOdt).join('')}\n` : nodeToOdt(n) ).join('') case 'hardBreak': return '' case 'image': return '' // ODT image embedding is complex — skipped default: return ch() } } const STYLES = ` ` export async function generateOdt(story) { const zip = new JSZip() const doc = typeof story.content === 'string' ? JSON.parse(story.content) : (story.content || {}) const title = story.title || 'Untitled' const body = nodeToOdt(doc) zip.file('mimetype', 'application/vnd.oasis.opendocument.text', { compression: 'STORE' }) zip.file('META-INF/manifest.xml', ` `) zip.file('meta.xml', ` ${esc(title)} `) zip.file('styles.xml', ` ${STYLES} `) zip.file('content.xml', ` ${esc(title)} ${body} `) return zip.generateAsync({ type: 'nodebuffer' }) }