- 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>
96 lines
3.2 KiB
JavaScript
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' })
|
|
}
|