From cd2fbf278e0f9ff71ff1bea582dab706b76ef282 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 30 Sep 2026 15:12:05 -0400 Subject: [PATCH] feat(text): plain-text (.txt) renderer makeTextBook: UTF-8 decode (BOM stripped), blank-line paragraph split, hard-wrapped lines joined (natural reflow under font-size changes), HTML escaped, sections capped at ~150KB at paragraph boundaries (small Part N TOC, avoids one giant DOM). Sections expose load/createDocument/size per the reflowable interface; positions for txt books are percentage-based (sections get fake CFIs from index, same as other non-EPUB books). makeBook: dispatches text/plain or .txt in the final non-zip/non-pdf branch. EPUB/PDF/CBZ/MOBI/FB2 routing untouched. Tests: offline node harness (isText, paragraph join/split, escaping, section capping, TOC shape, dispatch incl. zip-still-routes-to-EPUB). --- package.json | 2 +- text.js | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++ view.js | 4 +++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 text.js diff --git a/package.json b/package.json index 393e96b..3bfd2b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "foliate-js", - "version": "0.1.0", + "version": "0.2.0", "description": "Render e-books in the browser", "repository": { "type": "git", diff --git a/text.js b/text.js new file mode 100644 index 0000000..8062596 --- /dev/null +++ b/text.js @@ -0,0 +1,81 @@ +// Plain-text (.txt) support: the file is decoded as UTF-8, split into +// blank-line-separated paragraphs with hard-wrapped lines joined (so the +// text reflows naturally under font-size changes), and packed into +// sections of at most ~150KB at paragraph boundaries — one giant DOM +// would be memory-hungry, and the section cap yields a small +// "Part N" table of contents for free. +// +// Limitations (by design, MVP): UTF-8 only (no encoding sniffing), LTR, +// no chapter-title detection, no cover. + +const escapeHTML = s => s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + +export const isText = file => + file.type === 'text/plain' || /\.txt$/i.test(file.name) + +const SECTION_MAX = 150 * 1000 + +export const makeTextBook = async file => { + let text = await file.text() + if (text.charCodeAt(0) === 0xfeff) text = text.slice(1) // BOM + text = text.replace(/\r\n?/g, '\n') + const paras = text.split(/\n[ \t]*\n+/) + .map(p => p.replace(/\s+/g, ' ').trim()) + .filter(p => p) + + // pack paragraphs into sections at paragraph boundaries + const chunks = [] + let cur = [], len = 0 + for (const p of paras) { + if (len && len + p.length + 11 > SECTION_MAX) { chunks.push(cur); cur = []; len = 0 } + cur.push(p); len += p.length + 11 // '

' overhead + } + if (cur.length) chunks.push(cur) + + const urls = new Map() + const makeHTML = paras => `` + + `` + + paras.map(p => `

${escapeHTML(p)}

`).join('\n') + + `` + const load = i => { + if (!urls.has(i)) { + const html = chunks[i].map(p => `

${escapeHTML(p)}

`).join('\n') + const url = URL.createObjectURL(new Blob( + [`${html}`], + { type: 'text/html' })) + urls.set(i, url) + } + return urls.get(i) + } + const createDocument = i => { + const html = chunks[i].map(p => `

${escapeHTML(p)}

`).join('\n') + return new DOMParser().parseFromString( + `${html}`, + 'text/html') + } + + const book = {} + book.metadata = { title: file.name.replace(/\.[^.]+$/, '') } + book.sections = chunks.map((_, index) => ({ + id: index, + load: () => load(index), + createDocument: () => createDocument(index), + size: chunks[index].reduce((a, p) => a + p.length + 11, 0), + linear: 'yes', + })) + book.toc = chunks.length > 1 + ? chunks.map((_, i) => ({ label: `Part ${i + 1}`, href: String(i) })) + : [] + book.resolveHref = href => ({ index: parseInt(href, 10) || 0 }) + book.splitTOCHref = href => [href, null] + book.getTOCFragment = doc => doc.documentElement + book.format = 'text' + book.destroy = () => { + for (const url of urls.values()) URL.revokeObjectURL(url) + urls.clear() + } + return book +} diff --git a/view.js b/view.js index 385b4f8..e3cfdaa 100644 --- a/view.js +++ b/view.js @@ -117,6 +117,10 @@ export const makeBook = async (file, options = {}) => { const { makeFB2 } = await import('./fb2.js') book = await makeFB2(file) } + else { + const { isText, makeTextBook } = await import('./text.js') + if (isText(file)) book = await makeTextBook(file) + } } if (!book) throw new UnsupportedTypeError('File type not supported') return book