write/server/lib/odt.js
chris 1f51504de9 Add story writer app with editor, auth, export, and polish features
- React + TipTap editor with formatting toolbar (bold, italic, underline,
  strikethrough, alignment, highlight, scene breaks)
- Custom image node view with resize and alignment controls; server-side
  WebP conversion via sharp
- Express + SQLite backend with JWT auth and admin user management
- Export to PDF, EPUB, and ODT
- Five themes (Midnight, Gothic Night, Enchanted Forest, Aged Manuscript,
  Neon Noir); Lora body font for readability
- Writing streak, daily word goal, milestones, and Ollama writing prompts
- Docker Compose setup for self-hosted deployment behind NPMplus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:47:55 -04:00

139 lines
6.4 KiB
JavaScript

import JSZip from 'jszip'
function esc(str) {
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&apos;')
}
function nodeToOdt(node) {
if (!node) return ''
const ch = () => (node.content || []).map(nodeToOdt).join('')
switch (node.type) {
case 'doc': return ch()
case 'paragraph': return `<text:p text:style-name="Text_20_Body">${ch()}</text:p>\n`
case 'heading': {
const l = node.attrs?.level || 1
return `<text:h text:style-name="Heading_20_${l}" text:outline-level="${l}">${ch()}</text:h>\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 `<text:span text:style-name="BoldItalic">${t}</text:span>`
if (bold) return `<text:span text:style-name="Strong_20_Emphasis">${t}</text:span>`
if (italic) return `<text:span text:style-name="Emphasis">${t}</text:span>`
return t
}
case 'bulletList': return (node.content || []).map(item =>
(item.content || []).map(p =>
`<text:p text:style-name="List_20_Bullet">${(p.content || []).map(nodeToOdt).join('')}</text:p>\n`
).join('')
).join('')
case 'orderedList': return (node.content || []).map(item =>
(item.content || []).map(p =>
`<text:p text:style-name="List_20_Number">${(p.content || []).map(nodeToOdt).join('')}</text:p>\n`
).join('')
).join('')
case 'blockquote': return (node.content || []).map(n =>
n.type === 'paragraph'
? `<text:p text:style-name="Quotations">${(n.content || []).map(nodeToOdt).join('')}</text:p>\n`
: nodeToOdt(n)
).join('')
case 'hardBreak': return '<text:line-break/>'
case 'image': return '' // ODT image embedding is complex — skipped
default: return ch()
}
}
const STYLES = `
<style:style style:name="Text_20_Body" style:display-name="Text Body" style:family="paragraph">
<style:paragraph-properties fo:margin-bottom="0.2cm" fo:line-height="150%"/>
<style:text-properties fo:font-size="12pt"/>
</style:style>
<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph">
<style:paragraph-properties fo:margin-top="1.5cm" fo:margin-bottom="0.5cm" fo:break-before="page"/>
<style:text-properties fo:font-size="18pt" fo:font-weight="bold"/>
</style:style>
<style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph">
<style:paragraph-properties fo:margin-top="1cm" fo:margin-bottom="0.3cm"/>
<style:text-properties fo:font-size="14pt" fo:font-weight="bold"/>
</style:style>
<style:style style:name="Heading_20_3" style:display-name="Heading 3" style:family="paragraph">
<style:paragraph-properties fo:margin-top="0.8cm" fo:margin-bottom="0.2cm"/>
<style:text-properties fo:font-size="12pt" fo:font-weight="bold" fo:font-style="italic"/>
</style:style>
<style:style style:name="Quotations" style:display-name="Quotations" style:family="paragraph">
<style:paragraph-properties fo:margin-left="1cm" fo:margin-right="1cm"/>
<style:text-properties fo:font-style="italic"/>
</style:style>
<style:style style:name="List_20_Bullet" style:display-name="List Bullet" style:family="paragraph">
<style:paragraph-properties fo:margin-left="1cm"/>
</style:style>
<style:style style:name="List_20_Number" style:display-name="List Number" style:family="paragraph">
<style:paragraph-properties fo:margin-left="1cm"/>
</style:style>
<style:style style:name="Strong_20_Emphasis" style:display-name="Strong Emphasis" style:family="text">
<style:text-properties fo:font-weight="bold"/>
</style:style>
<style:style style:name="Emphasis" style:display-name="Emphasis" style:family="text">
<style:text-properties fo:font-style="italic"/>
</style:style>
<style:style style:name="BoldItalic" style:display-name="Bold Italic" style:family="text">
<style:text-properties fo:font-weight="bold" fo:font-style="italic"/>
</style:style>
`
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', `<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3">
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
</manifest:manifest>`)
zip.file('meta.xml', `<?xml version="1.0" encoding="UTF-8"?>
<office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:dc="http://purl.org/dc/elements/1.1/" office:version="1.3">
<office:meta>
<dc:title>${esc(title)}</dc:title>
</office:meta>
</office:document-meta>`)
zip.file('styles.xml', `<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
office:version="1.3">
<office:styles>${STYLES}</office:styles>
</office:document-styles>`)
zip.file('content.xml', `<?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:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
office:version="1.3">
<office:body>
<office:text>
<text:h text:style-name="Heading_20_1" text:outline-level="1">${esc(title)}</text:h>
${body} </office:text>
</office:body>
</office:document-content>`)
return zip.generateAsync({ type: 'nodebuffer' })
}