write/server/lib/epub.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

96 lines
3.2 KiB
JavaScript

import JSZip from 'jszip'
import { splitByChapters } from './tiptap-to-html.js'
const EPUB_CSS = `
body{font-family:Georgia,"Times New Roman",serif;font-size:1em;line-height:1.8;max-width:34em;margin:0 auto;padding:1em 1.5em}
h1{font-size:1.5em;margin-top:2.5em;padding-bottom:0.3em;border-bottom:1px solid #ccc;page-break-before:always}
h1:first-of-type{page-break-before:avoid}
h2{font-size:1.2em;margin-top:1.5em}
h3{font-size:1.05em;font-style:italic}
p{margin:0.5em 0;text-indent:1.4em}
p:first-child,h1+p,h2+p,h3+p{text-indent:0}
blockquote{border-left:3px solid #aaa;padding-left:1em;margin:1em 0;font-style:italic;color:#444}
img{max-width:100%;display:block;margin:1em auto}
`
function xhtml(title, body) {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<meta charset="UTF-8"/>
<title>${title}</title>
<link rel="stylesheet" type="text/css" href="styles.css"/>
</head>
<body>
${body}
</body>
</html>`
}
export async function generateEpub(story) {
const zip = new JSZip()
const doc = typeof story.content === 'string' ? JSON.parse(story.content) : (story.content || {})
const title = story.title || 'Untitled'
const chapters = splitByChapters(doc, title)
const uid = `story-${story.id}`
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' })
zip.file('META-INF/container.xml', `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="EPUB/package.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`)
zip.file('EPUB/styles.css', EPUB_CSS.trim())
const items = []
const itemrefs = []
const navEntries = []
chapters.forEach((ch, i) => {
const n = String(i + 1).padStart(3, '0')
const fname = `ch${n}.xhtml`
zip.file(`EPUB/${fname}`, xhtml(ch.title, ch.html))
items.push(` <item id="c${n}" href="${fname}" media-type="application/xhtml+xml"/>`)
itemrefs.push(` <itemref idref="c${n}"/>`)
navEntries.push(` <li><a href="${fname}">${ch.title}</a></li>`)
})
zip.file('EPUB/nav.xhtml', `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en">
<head><meta charset="UTF-8"/><title>Contents</title></head>
<body>
<nav epub:type="toc" id="toc">
<h1>Contents</h1>
<ol>
${navEntries.join('\n')}
</ol>
</nav>
</body>
</html>`)
zip.file('EPUB/package.opf', `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">${uid}</dc:identifier>
<dc:title>${title}</dc:title>
<dc:language>en</dc:language>
<meta property="dcterms:modified">${new Date().toISOString()}</meta>
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
<item id="css" href="styles.css" media-type="text/css"/>
${items.join('\n')}
</manifest>
<spine>
${itemrefs.join('\n')}
</spine>
</package>`)
return zip.generateAsync({ type: 'nodebuffer' })
}