commit 9db1bfdb31a73c0c3f92a9d286223a6e60b69128 equwal <truex@equwal.com> 2026-09-21 01:32:29 -0700 Make a read-along epub (EPUB 3 Media Overlays) in the browser The epub gets the narration inside it, and each line of text is tied to its stretch of audio. Thorium, Storyteller and other EPUB 3 readers play it and highlight each line. This output serves all people who read along with an audiobook, not only users of one reader app. The engine finds the words of each cue again in the text of the pages, in order, and puts a span around them. A cue that crosses markup gets several spans, and its time is divided among them by their length. An EPUB 2 book becomes EPUB 3. The zip is written without compression from Blob parts, so the audio is not copied into memory. The test follows each overlay as a reader does, and the W3C epubcheck accepts the result. jsdom is a new development dependency: Node has no DOM, and the engine uses DOMParser and XMLSerializer.
.gitignore | 4 + README.md | 11 + frontend/app.js | 17 ++ frontend/engine/book.js | 21 +- frontend/engine/epub.js | 376 +++++++++++++++++++++++++++ frontend/engine/job.js | 17 ++ frontend/index.html | 3 + frontend/lab.html | 6 + package-lock.json | 632 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 11 + tests/engine/epub.test.mjs | 252 ++++++++++++++++++ 11 files changed, 1344 insertions(+), 6 deletions(-)
diff --git a/.gitignore b/.gitignore index 354a1ba..a846ed3 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ tests/engine/golden-local/ # Browser-side libraries; tools/fetch_vendor.py downloads them, pinned frontend/vendor/ +tests/engine/excerpt-local/ + +# Test tooling for the browser engine +node_modules/ diff --git a/README.md b/README.md index 6d8a9ae..cbfa1dd 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,17 @@ drop corrupt frames instead of letting a single bad chapter abort the whole run. | `metadata.json` | language, model, splitter, cue count, timing span | | `subplz.log` | the full run log — the only way to debug a bad alignment | +A job that runs in the browser tab can also make `<name>.read-along.epub`: the +epub with the narration inside it, as EPUB 3 Media Overlays +(`frontend/engine/epub.js`). Thorium, Storyteller and other EPUB 3 readers play +it and highlight each line. The cues do not say where in the pages their words +are, so the engine finds each cue's words again in the text of the pages, in +order, and puts a `<span id>` around them. An EPUB 2 book becomes EPUB 3 (a +navigation document is made from the NCX). The audio must be MP3 or AAC in +m4a/m4b, which are the types an EPUB 3 reader must play. `tests/engine/epub.test.mjs` +follows each overlay as a reader does, and gives the result to the W3C +`epubcheck` when `EPUBCHECK` points to its jar. + Uploaded media is deleted once a job succeeds. A **failed** job keeps its inputs so you can fix the language and retry without re-uploading. diff --git a/frontend/app.js b/frontend/app.js index a8b4f96..7ae6a1d 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -495,6 +495,10 @@ function renderResultButtons() { }; button('⬇ Subtitles (.srt)', 'For Hoshi Reader, or to upload alongside the YouTube video', '', () => save(new File([r.srt], r.srtName, { type: 'application/x-subrip' }))); + if (/\.epub$/i.test(running.job.bookFile.name)) { + button('⬇ Read-along book (.epub)', 'The book with the narration inside: Thorium, Storyteller and other EPUB 3 readers highlight each line as it is read', '', + (b) => makeEpub(b)); + } button('⬇ Video with subs built in (.mkv)', 'Subtitles inside the file, for MPV or VLC', '', (b) => makeVideo('mkv', b)); button(paid ? '⬇ Video for YouTube (.mp4)' : '🔒 Unlock the YouTube video (.mp4)', @@ -503,6 +507,19 @@ function renderResultButtons() { paid ? '' : 'locked', (b) => (paid ? makeVideo('mp4', b) : unlockRunning(b))); } +async function makeEpub(btn) { + const label = btn.textContent; + btn.disabled = true; + btn.textContent = 'Making the read-along book…'; + try { + const { file, located, of } = await running.job.epub(); + save(file); + if (located < of) toast(`${of - located} of ${of} lines could not be placed in the book's pages; the rest are in.`); + } catch (e) { showError(`Could not make the read-along book: ${e.message}`); } + btn.textContent = label; + btn.disabled = false; +} + async function makeVideo(kind, btn) { const label = btn.textContent; btn.disabled = true; diff --git a/frontend/engine/book.js b/frontend/engine/book.js index 3387daf..9581b9f 100644 --- a/frontend/engine/book.js +++ b/frontend/engine/book.js @@ -59,7 +59,7 @@ async function firstEntry(bytes, pattern) { } /** UTF-8 unless it plainly is not; then whatever the file declares, or Shift_JIS. */ -function decode(bytes) { +export function decode(bytes) { try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/^/, ''); } catch { /* not UTF-8 */ } @@ -74,7 +74,7 @@ function decode(bytes) { /* ---------------------------------------------------------------------- epub */ -function resolve(base, href) { +export function resolve(base, href) { const parts = base ? base.split('/') : []; for (const seg of href.split('/')) { if (seg === '' || seg === '.') continue; @@ -88,7 +88,18 @@ function resolve(base, href) { * a self-closing <title/> never closes, and swallows the whole book as its text. * Plenty of epubs are not well-formed, though, so HTML is the fallback. */ -function page(source) { +/** + * The blocks of text of a page, in reading order. A quote holding paragraphs + * would yield its text twice, so only the leaves count. The sort is for DOM + * implementations that give the matches of a selector list out of order. + */ +export function leafBlocks(body) { + const blocks = [...body.querySelectorAll(BLOCKS)].filter((b) => !b.querySelector(BLOCKS)) + .sort((x, y) => (x.compareDocumentPosition(y) & 4 ? -1 : 1)); + return blocks.length ? blocks : [body]; +} + +export function page(source) { const xml = new DOMParser().parseFromString(source, 'application/xhtml+xml'); if (!xml.querySelector('parsererror')) return xml; return new DOMParser().parseFromString(source, 'text/html'); @@ -123,9 +134,7 @@ async function epub(bytes) { const body = doc.querySelector('body') ?? doc.documentElement; // Furigana would otherwise be read twice: once as kanji, once as kana. doc.querySelectorAll('rt, rp').forEach((n) => n.remove()); - // A quote holding paragraphs would yield its text twice; keep the leaves. - const blocks = [...body.querySelectorAll(BLOCKS)].filter((b) => !b.querySelector(BLOCKS)); - for (const b of blocks.length ? blocks : [body]) { + for (const b of leafBlocks(body)) { const t = b.textContent.replace(/\s+/g, ' ').trim(); if (t) out.push(t); } diff --git a/frontend/engine/epub.js b/frontend/engine/epub.js new file mode 100644 index 0000000..850ff71 --- /dev/null +++ b/frontend/engine/epub.js @@ -0,0 +1,376 @@ +/* A read-along book: the epub, with the narration inside it and each line of + * text tied to its stretch of audio (EPUB 3 Media Overlays). Thorium, + * Storyteller and other EPUB 3 readers play it and highlight the text. + * + * The cues say what was read and when, but not where in the book's pages the + * words are. That is found again by text: a cue's words, without white space, + * are looked for in the book's words, from the end of the cue before. Each + * find is wrapped in <span id>. A cue that crosses markup (<em>, ruby, the end + * of a paragraph) gets several spans, and its time is divided among them by + * their length. + * + * Made in the browser, like everything else: nothing is uploaded. + */ +import { zipEntries, leafBlocks, page, resolve, decode } from './book.js'; +import { UNMATCHED } from './align.js'; + +const XHTML = 'http://www.w3.org/1999/xhtml'; +const OPF = 'http://www.idpf.org/2007/opf'; +const ACTIVE = '-epub-media-overlay-active'; +const DIR = 'subread'; // what this adds, beside the package file +const SHOW_TEXT = 4; + +// A jump this far ahead is believed only from a cue this long: a short line +// ("Yes.") is found by chance somewhere in any book. +const FAR = 4000; +const LONG_ENOUGH = 12; + +/** Audio an EPUB 3 reader must play, by codec: file extension and media type. */ +const AUDIO = { mp3: ['mp3', 'audio/mpeg'], aac: ['m4a', 'audio/mp4'] }; + +export class NotAnEpub extends Error {} + +/** + * @param book the epub, a File + * @param cues [{ text, start, end }], seconds on the clock of all the audio + * @param parts [{ file, duration, codec }], in playback order + * @returns {{ file: File, located: number, of: number }} + */ +export async function syncedEpub({ book, cues, parts, stem }) { + for (const p of parts) { + if (!AUDIO[p.codec]) throw new Error(`A read-along book needs MP3 or AAC (m4a, m4b) audio; this is ${p.codec}.`); + } + const entries = zipEntries(new Uint8Array(await book.arrayBuffer())); + const text = async (name) => decode(await entries.get(name)()); + const xml = (s) => new DOMParser().parseFromString(s, 'application/xml'); + + if (!entries.has('META-INF/container.xml')) throw new NotAnEpub('This epub has no package file.'); + const opfPath = xml(await text('META-INF/container.xml')).querySelector('rootfile')?.getAttribute('full-path'); + if (!opfPath || !entries.has(opfPath)) throw new NotAnEpub('This epub has no package file.'); + const opf = xml(await text(opfPath)); + if (opf.querySelector('parsererror')) throw new NotAnEpub('The package file of this epub cannot be read.'); + const base = opfPath.includes('/') ? opfPath.slice(0, opfPath.lastIndexOf('/')) : ''; + const inBase = (p) => (base ? `${base}/${p}` : p); + + const items = new Map(); // zip path -> manifest item + for (const item of opf.querySelectorAll('manifest > item')) { + items.set(resolve(base, decodeURIComponent((item.getAttribute('href') ?? '').split('#')[0])), item); + } + const byId = new Map([...items].map(([p, item]) => [item.getAttribute('id'), p])); + const order = [...opf.querySelectorAll('spine > itemref')] + .filter((r) => r.getAttribute('linear') !== 'no') + .map((r) => byId.get(r.getAttribute('idref'))) + .filter((p) => p && entries.has(p)); + if (!order.length) throw new NotAnEpub('This epub has no reading order.'); + + // The book's words, without white space, and where each one is. + const pages = []; + const runs = []; + const words = []; + let g = 0; + for (const path of order) { + const doc = page(await text(path)); + const pg = { path, doc, spans: [] }; + pages.push(pg); + for (const node of textNodes(doc)) { + const offsets = []; + for (let i = 0; i < node.data.length; i++) if (/\S/.test(node.data[i])) offsets.push(i); + if (!offsets.length) continue; + runs.push({ page: pg, node, g0: g, offsets, wraps: [] }); + words.push(offsets.map((i) => node.data[i]).join('')); + g += offsets.length; + } + } + const all = words.join(''); + + // Where each cue is. + let cursor = 0, r = 0, located = 0, of = 0; + cues.forEach((cue, index) => { + if (cue.text.startsWith(UNMATCHED) || !(cue.end > cue.start)) return; + const want = cue.text.replace(/\s+/g, ''); + if (!want) return; + of++; + const at = all.indexOf(want, cursor); + if (at < 0 || (at - cursor > FAR && want.length < LONG_ENOUGH)) return; + located++; + cursor = at + want.length; + + while (runs[r].g0 + runs[r].offsets.length <= at) r++; + const pieces = []; + for (let k = r; k < runs.length && runs[k].g0 < cursor; k++) { + const run = runs[k]; + const s = Math.max(at, run.g0) - run.g0, e = Math.min(cursor, run.g0 + run.offsets.length) - run.g0; + pieces.push({ run, from: run.offsets[s], to: run.offsets[e - 1] + 1, chars: e - s }); + } + let t = cue.start; + pieces.forEach((piece, k) => { + const id = `subread-${index}${k ? `-${k}` : ''}`; + const until = k === pieces.length - 1 ? cue.end : t + (cue.end - cue.start) * piece.chars / want.length; + piece.run.wraps.push({ from: piece.from, to: piece.to, id }); + piece.run.page.spans.push({ id, start: t, end: until }); + t = until; + }); + }); + if (!located) throw new Error('None of the subtitles could be found in the pages of this epub.'); + + for (const run of runs) if (run.wraps.length) wrap(run); + + // The new files. + const out = new Map(); // zip path -> Uint8Array | Blob + const manifest = opf.querySelector('manifest'); + const metadata = opf.querySelector('metadata'); + const add = (parent, name, attributes, content) => { + const el = opf.createElementNS(OPF, name); + for (const [k, v] of Object.entries(attributes)) el.setAttribute(k, v); + if (content != null) el.textContent = content; + parent.appendChild(el); + return el; + }; + const encoder = new TextEncoder(); + + const audio = parts.map((p, i) => { + const [extension, type] = AUDIO[p.codec]; + const path = `${DIR}/audio-${i + 1}.${extension}`; + out.set(inBase(path), p.file); + add(manifest, 'item', { id: `subread-audio-${i + 1}`, href: path, 'media-type': type }); + return path; + }); + out.set(inBase(`${DIR}/overlay.css`), encoder.encode(`.${ACTIVE} { background: #ffe08a; color: #000; }\n`)); + add(manifest, 'item', { id: 'subread-css', href: `${DIR}/overlay.css`, 'media-type': 'text/css' }); + + let total = 0; + pages.forEach((pg, n) => { + if (!pg.spans.length) return; + const smilPath = `${DIR}/page-${n + 1}.smil`; + const smilDir = inBase(DIR); + const lines = pg.spans.map((s, k) => { + const clip = clipOf(s, parts); + total += clip.end - clip.begin; + return `<par id="par-${k + 1}"><text src="${attr(relative(smilDir, pg.path))}#${s.id}"/>` + + `<audio src="${attr(relative(smilDir, inBase(audio[clip.part])))}" clipBegin="${clip.begin.toFixed(3)}s" clipEnd="${clip.end.toFixed(3)}s"/></par>`; + }); + out.set(inBase(smilPath), encoder.encode( + '<?xml version="1.0" encoding="utf-8"?>\n' + + '<smil xmlns="http://www.w3.org/ns/SMIL" xmlns:epub="http://www.idpf.org/2007/ops" version="3.0">\n<body>\n' + + `<seq id="seq" epub:textref="${attr(relative(smilDir, pg.path))}" epub:type="bodymatter">\n${lines.join('\n')}\n</seq>\n</body>\n</smil>\n`)); + + const id = `subread-smil-${n + 1}`; + add(manifest, 'item', { id, href: smilPath, 'media-type': 'application/smil+xml' }); + items.get(pg.path).setAttribute('media-overlay', id); + const seconds = pg.spans.reduce((sum, s) => { const c = clipOf(s, parts); return sum + c.end - c.begin; }, 0); + add(metadata, 'meta', { property: 'media:duration', refines: `#${id}` }, clock(seconds)); + + const head = pg.doc.querySelector('head'); + if (head) { + const link = pg.doc.createElementNS(XHTML, 'link'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('type', 'text/css'); + link.setAttribute('href', relative(dirOf(pg.path), inBase(`${DIR}/overlay.css`))); + head.appendChild(link); + } + out.set(pg.path, encoder.encode(serialize(pg.doc))); + }); + add(metadata, 'meta', { property: 'media:duration' }, clock(total)); + add(metadata, 'meta', { property: 'media:active-class' }, ACTIVE); + + await toEpub3(opf, { entries, items, order, pages, base, out, add, text }); + out.set(opfPath, encoder.encode(serialize(opf))); + + // mimetype first and not compressed: that is how a reader knows an epub. + const files = [{ name: 'mimetype', data: encoder.encode('application/epub+zip') }]; + for (const [name, read] of entries) { + if (name !== 'mimetype' && !out.has(name)) files.push({ name, data: await read() }); + } + for (const [name, data] of out) files.push({ name, data }); + const zip = await writeZip(files); + return { file: new File([zip], `${stem}.read-along.epub`, { type: 'application/epub+zip' }), located, of }; +} + +/* --------------------------------------------------------------------- pages */ + +/** The text nodes that readBook() reads, in its order: leaf blocks, without furigana. */ +function textNodes(doc) { + const body = doc.querySelector('body') ?? doc.documentElement; + const out = []; + for (const b of leafBlocks(body)) { + const walker = doc.createTreeWalker(b, SHOW_TEXT); + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + if (!n.parentElement?.closest('rt, rp')) out.push(n); + } + } + return out; +} + +/** Replaces the text node with its pieces: plain text, and a <span id> for each find. */ +function wrap({ node, wraps }) { + const doc = node.ownerDocument, parent = node.parentNode, data = node.data; + let at = 0; + for (const w of wraps) { + if (w.from > at) parent.insertBefore(doc.createTextNode(data.slice(at, w.from)), node); + const span = doc.createElementNS(XHTML, 'span'); + span.setAttribute('id', w.id); + span.textContent = data.slice(w.from, w.to); + parent.insertBefore(span, node); + at = w.to; + } + if (at < data.length) parent.insertBefore(doc.createTextNode(data.slice(at)), node); + parent.removeChild(node); +} + +function serialize(doc) { + const s = new XMLSerializer().serializeToString(doc); + return s.startsWith('<?xml') ? s : `<?xml version="1.0" encoding="utf-8"?>\n${s}`; +} + +/** The stretch of one audio file that a span is read in. */ +function clipOf(span, parts) { + let base = 0, part = 0; + while (part < parts.length - 1 && span.start >= base + parts[part].duration) base += parts[part++].duration; + const begin = Math.max(0, span.start - base); + // A line that runs past the end of its file stops there. + const end = Math.max(begin + 0.001, Math.min(span.end - base, parts[part].duration || Infinity)); + return { part, begin, end }; +} + +export function clock(seconds) { + const ms = Math.round(seconds * 1000); + const p = (n, w = 2) => String(n).padStart(w, '0'); + return `${Math.floor(ms / 3600000)}:${p(Math.floor(ms / 60000) % 60)}:${p(Math.floor(ms / 1000) % 60)}.${p(ms % 1000, 3)}`; +} + +const dirOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : ''); +const attr = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<'); + +/** The way from a directory to a file, both as zip paths. */ +export function relative(fromDir, to) { + const a = fromDir ? fromDir.split('/') : [], b = to.split('/'); + while (a.length && b.length > 1 && a[0] === b[0]) { a.shift(); b.shift(); } + return encodeURI('../'.repeat(a.length) + b.join('/')); +} + +/* ----------------------------------------------------------- EPUB 2 to EPUB 3 */ + +/** + * Media overlays are EPUB 3. Most epubs in the wild are EPUB 2, and an EPUB 3 + * package must have two things that those do not: a modification date and a + * navigation document. The old table of contents (NCX) is kept too. + */ +async function toEpub3(opf, { entries, items, order, pages, base, out, add, text }) { + const pkg = opf.documentElement; + const metadata = opf.querySelector('metadata'); + if (!(parseFloat(pkg.getAttribute('version')) >= 3)) { + pkg.setAttribute('version', '3.0'); + // EPUB 2 attributes that EPUB 3 does not have. + for (const el of metadata.querySelectorAll('*')) { + for (const a of [...el.attributes]) if (a.name.startsWith('opf:')) el.removeAttribute(a.name); + } + const dates = [...metadata.children].filter((el) => el.localName === 'date'); + dates.slice(1).forEach((el) => el.remove()); + // EPUB 3 wants to be told which pages hold these. + for (const pg of pages) { + const has = { svg: 'svg', mathml: 'math', scripted: 'script' }; + const found = Object.keys(has).filter((k) => pg.doc.getElementsByTagName(has[k]).length); + const item = items.get(pg.path); + const was = (item.getAttribute('properties') ?? '').split(/\s+/).filter(Boolean); + const now = [...new Set([...was, ...found])]; + if (now.length) item.setAttribute('properties', now.join(' ')); + } + } + if (![...metadata.querySelectorAll('meta')].some((m) => m.getAttribute('property') === 'dcterms:modified')) { + add(metadata, 'meta', { property: 'dcterms:modified' }, new Date().toISOString().replace(/\.\d+Z$/, 'Z')); + } + if ([...items.values()].some((i) => (i.getAttribute('properties') ?? '').split(/\s+/).includes('nav'))) return; + + // The contents: from the NCX when there is one, else one line for each page. + let contents = []; + const ncxPath = [...items].find(([, i]) => i.getAttribute('media-type') === 'application/x-dtbncx+xml')?.[0]; + if (ncxPath && entries.has(ncxPath)) { + const ncx = new DOMParser().parseFromString(await text(ncxPath), 'application/xml'); + contents = [...ncx.querySelectorAll('navPoint')].map((p) => ({ + label: p.querySelector('navLabel > text')?.textContent.trim(), + path: resolve(dirOf(ncxPath), decodeURIComponent(p.querySelector('content')?.getAttribute('src') ?? '')), + })).filter((c) => c.label && c.path); + } + if (!contents.length) contents = order.map((path, n) => ({ label: `Part ${n + 1}`, path })); + + const navPath = `${DIR}/nav.xhtml`; + const dir = base ? `${base}/${DIR}` : DIR; + const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<'); + out.set(`${dir}/nav.xhtml`, new TextEncoder().encode( + '<?xml version="1.0" encoding="utf-8"?>\n' + + `<html xmlns="${XHTML}" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>Contents</title></head><body>\n` + + '<nav epub:type="toc"><h1>Contents</h1><ol>\n' + + contents.map((c) => { + const [file, fragment] = c.path.split('#'); + return `<li><a href="${attr(relative(dir, file))}${fragment ? `#${attr(fragment)}` : ''}">${esc(c.label)}</a></li>`; + }).join('\n') + + '\n</ol></nav>\n</body></html>\n')); + add(opf.querySelector('manifest'), 'item', + { id: 'subread-nav', href: navPath, 'media-type': 'application/xhtml+xml', properties: 'nav' }); +} + +/* ----------------------------------------------------------------------- zip */ + +const CRC = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +function crc32(bytes, crc = 0) { + let c = ~crc; + for (let i = 0; i < bytes.length; i++) c = CRC[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + return ~c >>> 0; +} + +/** + * A zip with nothing compressed: the audio, which is nearly all of it, is + * compressed already. A Blob of parts, so a book of audio is never copied + * into memory. + * + * @param files [{ name, data: Uint8Array | Blob }] + */ +export async function writeZip(files) { + const parts = [], directory = []; + let offset = 0; + for (const { name, data } of files) { + const size = data instanceof Blob ? data.size : data.length; + let crc = 0; + if (data instanceof Blob) { + const reader = data.stream().getReader(); + for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) crc = crc32(chunk.value, crc); + } else crc = crc32(data); + + const nameBytes = new TextEncoder().encode(name); + const header = (signature, extra) => { + const b = new DataView(new ArrayBuffer(extra ? 46 : 30)); + let o = 0; + const u16 = (v) => { b.setUint16(o, v, true); o += 2; }; + const u32 = (v) => { b.setUint32(o, v, true); o += 4; }; + u32(signature); + if (extra) u16(20); // version made by + u16(20); u16(0x0800); u16(0); // version needed; UTF-8 names; stored + u16(0); u16(0x21); // 1980-01-01 00:00 + u32(crc); u32(size); u32(size); + u16(nameBytes.length); u16(0); + if (extra) { u16(0); u16(0); u16(0); u32(0); u32(offset); } + return new Uint8Array(b.buffer); + }; + directory.push(header(0x02014b50, true), nameBytes); + parts.push(header(0x04034b50, false), nameBytes, data); + offset += 30 + nameBytes.length + size; + if (offset > 0xffffffff) throw new Error('The book and its audio are over 4 GB, which is more than an epub can hold.'); + } + const directorySize = directory.reduce((n, p) => n + p.length, 0); + const end = new DataView(new ArrayBuffer(22)); + end.setUint32(0, 0x06054b50, true); + end.setUint16(8, files.length, true); + end.setUint16(10, files.length, true); + end.setUint32(12, directorySize, true); + end.setUint32(16, offset, true); + return new Blob([...parts, ...directory, new Uint8Array(end.buffer)]); +} diff --git a/frontend/engine/job.js b/frontend/engine/job.js index c2c6c16..95e8be7 100644 --- a/frontend/engine/job.js +++ b/frontend/engine/job.js @@ -10,6 +10,7 @@ * the same files again carries on from the last chunk, and aligning the same * audio against a different edition of the book costs seconds. */ +import { syncedEpub } from './epub.js'; import { Media, quietestPoint, SAMPLE_RATE } from './media.js'; import { Recogniser } from './asr.js'; import { readBook } from './book.js'; @@ -190,6 +191,22 @@ export class Job { } } + /** + * The book with the narration inside it (EPUB 3 Media Overlays): a reader + * such as Thorium or Storyteller plays it and highlights each line. + */ + async epub() { + const r = this.result; + if (!/\.epub$/i.test(this.bookFile.name)) throw new Error('A read-along book is made from an epub; this book is not one.'); + const parts = r.parts.map((p, i) => { + const file = this.audio[i]; + // AAC counts only inside an MP4 file; a bare .aac stream is not audio an epub may hold. + const codec = p.codec === 'aac' && !/\.(m4a|m4b|mp4)$/i.test(file.name) ? 'aac (not in an m4a file)' : p.codec; + return { file, duration: p.duration, codec }; + }); + return syncedEpub({ book: this.bookFile, cues: r.cues, parts, stem: r.stem }); + } + close() { this.#media?.close(); } } diff --git a/frontend/index.html b/frontend/index.html index 9fe5dfe..71cd84a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -37,6 +37,9 @@ <ul class="uses"> <li><strong>HoshiReader read-along</strong> — the .srt timing file, to match against your epub. <span class="tag free">free</span></li> + <li><strong>Read-along book</strong> — your epub with the narration inside it + (EPUB 3 Media Overlays). Thorium, Storyteller and other EPUB 3 readers + highlight each line as it is read. <span class="tag free">free</span></li> <li><strong>Local video</strong> — an .mkv with the subtitles built in, for MPV or VLC. <span class="tag free">free</span></li> <li><strong>YouTube video</strong> — a clean .mp4, plus the .srt to upload as diff --git a/frontend/lab.html b/frontend/lab.html index 24bd965..03be07a 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -78,6 +78,12 @@ window.fullJob = async () => { log(`job done in ${((performance.now() - t) / 1000).toFixed(1)}s on ${job.device}: ${r.cues.length} cues, ` + `${(r.matchRate * 100).toFixed(0)}% matched, ${r.paragraphsDropped}/${r.paragraphsDropped + r.paragraphsUsed} paragraphs dropped`); log(r.srt.split('\n').slice(0, 16).join('\n')); + t = performance.now(); + const made = await job.epub(); + log(`epub: ${made.file.name} ${(made.file.size / 1e6).toFixed(2)} MB, ${made.located} of ${made.of} lines placed, in ${((performance.now() - t) / 1000).toFixed(1)}s`); + window.lab.results.epub = made.file; + const again = await readBook(made.file); + log(`epub reads back: ${again.paragraphs.length} paragraphs, same text: ${JSON.stringify(again.paragraphs) === JSON.stringify(parsed.paragraphs)}`); for (const kind of ['mkv', 'mp4']) { t = performance.now(); const file = await job.video(kind); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ca64b53 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,632 @@ +{ + "name": "subplz-web-engine-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "subplz-web-engine-tests", + "devDependencies": { + "jsdom": "^27.0.0" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..acd359d --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "subplz-web-engine-tests", + "private": true, + "type": "module", + "scripts": { + "test": "node --test tests/engine/*.test.mjs" + }, + "devDependencies": { + "jsdom": "^27.0.0" + } +} diff --git a/tests/engine/epub.test.mjs b/tests/engine/epub.test.mjs new file mode 100644 index 0000000..ab025db --- /dev/null +++ b/tests/engine/epub.test.mjs @@ -0,0 +1,252 @@ +/* The read-along epub: made from a small EPUB 2 book with the markup that makes + * this hard (inline elements, furigana, pages in folders, a space in a file + * name), then read back and checked the way a reader would use it. + * + * node --test tests/engine + * + * With EPUBCHECK=<path to epubcheck.jar> the W3C validator judges the result too. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { JSDOM } from 'jsdom'; + +const { window } = new JSDOM(''); +Object.assign(globalThis, { DOMParser: window.DOMParser, XMLSerializer: window.XMLSerializer }); + +const { syncedEpub, writeZip, relative, clock } = await import('../../frontend/engine/epub.js'); +const { zipEntries, readBook, decode } = await import('../../frontend/engine/book.js'); +const E = await import('../../frontend/engine/align.js'); + +const enc = new TextEncoder(); +const bare = (s) => s.replace(/\s+/g, ''); + +const page = (body) => `<?xml version="1.0" encoding="utf-8"?> +<html xmlns="http://www.w3.org/1999/xhtml"><head><title>A Test</title></head><body>${body}</body></html>`; + +async function book() { + const files = { + mimetype: 'application/epub+zip', + 'META-INF/container.xml': `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"> + <rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`, + 'OEBPS/content.opf': `<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="id"> + <metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf"> + <dc:title>A Test</dc:title><dc:language>en</dc:language><dc:identifier id="id">urn:uuid:0e6a7c8e-1111-4222-8333-444455556666</dc:identifier> + <dc:creator opf:role="aut">Nobody</dc:creator><dc:date opf:event="publication">2001-01-01</dc:date><dc:date opf:event="modification">2002-02-02</dc:date> + </metadata> + <manifest> + <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/> + <item id="front" href="front.xhtml" media-type="application/xhtml+xml"/> + <item id="one" href="text/chapter%20one.xhtml" media-type="application/xhtml+xml"/> + <item id="two" href="text/two.xhtml" media-type="application/xhtml+xml"/> + </manifest> + <spine toc="ncx"><itemref idref="front"/><itemref idref="one"/><itemref idref="two"/></spine></package>`, + 'OEBPS/toc.ncx': `<?xml version="1.0"?><ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1"><head> + <meta name="dtb:uid" content="urn:uuid:0e6a7c8e-1111-4222-8333-444455556666"/></head><docTitle><text>A Test</text></docTitle><navMap> + <navPoint id="n1" playOrder="1"><navLabel><text>One & only</text></navLabel><content src="text/chapter%20one.xhtml"/></navPoint> + <navPoint id="n2" playOrder="2"><navLabel><text>Two</text></navLabel><content src="text/two.xhtml#top"/></navPoint></navMap></ncx>`, + 'OEBPS/front.xhtml': page('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><title>Cover</title><rect width="10" height="10"/></svg><h1>A Test</h1><p>Printed somewhere, by someone. Nobody reads this aloud.</p>'), + 'OEBPS/text/chapter one.xhtml': page('<h2>Chapter one</h2><p>It was a <em>dark and stormy</em> night; the rain fell in torrents.</p>' + + '<p>Yes.</p><p>She said <no> & left.</p>'), + 'OEBPS/text/two.xhtml': page('<h2 id="top">Two</h2><p><ruby>吾輩<rt>わがはい</rt></ruby>は<ruby>猫<rt>ねこ</rt></ruby>である。名前はまだ無い。</p><p>Yes.</p>'), + }; + const zip = await writeZip(Object.entries(files).map(([name, s]) => ({ name, data: enc.encode(s) }))); + return new File([zip], 'test.epub'); +} + +const cues = [ + { text: 'Chapter one It was a dark', start: 1, end: 3 }, // a heading, a paragraph, and into <em> + { text: 'and stormy night; the rain fell in torrents.', start: 3, end: 6 }, + { text: E.UNMATCHED + 'mumble', start: 6, end: 7 }, // not from the book: no place in it + { text: 'Yes.', start: 7, end: 8 }, + { text: 'She said <no> & left.', start: 8, end: 10 }, + { text: 'Two 吾輩は猫である。', start: 61, end: 64 }, // in the second audio file + { text: '名前はまだ無い。', start: 64, end: 66 }, + { text: 'Yes.', start: 66, end: 67 }, +]; +const audio = (n) => new File([new Uint8Array(2000).fill(n)], `part${n}.mp3`); +const parts = [{ file: audio(1), duration: 60, codec: 'mp3' }, { file: audio(2), duration: 30, codec: 'mp3' }]; + +test('each cue is tied to its own words in the pages, and to its stretch of audio', async () => { + const { file, located, of } = await syncedEpub({ book: await book(), cues, parts, stem: 'test' }); + assert.equal(of, 7); + assert.equal(located, 7); + assert.equal(file.name, 'test.read-along.epub'); + + const bytes = new Uint8Array(await file.arrayBuffer()); + // A reader knows an epub by these bytes at these places. + assert.equal(decode(bytes.subarray(30, 38)), 'mimetype'); + assert.equal(decode(bytes.subarray(38, 58)), 'application/epub+zip'); + + const entries = zipEntries(bytes); + const read = async (name) => decode(await entries.get(name)()); + const xml = (s) => new window.DOMParser().parseFromString(s, 'application/xml'); + + const opf = xml(await read('OEBPS/content.opf')); + assert.equal(opf.documentElement.getAttribute('version'), '3.0'); + const items = [...opf.querySelectorAll('manifest > item')]; + const item = (id) => items.find((i) => i.getAttribute('id') === id); + assert.equal(item('front').getAttribute('media-overlay'), null, 'nothing of the front page is read'); + assert.equal(item('front').getAttribute('properties'), 'svg'); + assert.equal(item('one').getAttribute('properties'), null); + + // Follow each overlay as a reader does: SMIL -> the element in the page, and the clip. + const said = new Map(); // cue index -> the words its spans hold + const clips = []; + for (const id of ['one', 'two']) { + const smilItem = item(item(id).getAttribute('media-overlay')); + assert.equal(smilItem.getAttribute('media-type'), 'application/smil+xml'); + const smilPath = `OEBPS/${smilItem.getAttribute('href')}`; + const smil = xml(await read(smilPath)); + assert.equal(smil.querySelector('parsererror'), null); + const from = path.posix.dirname(smilPath); + for (const par of smil.querySelectorAll('par')) { + const [src, fragment] = par.querySelector('text').getAttribute('src').split('#'); + const pagePath = path.posix.normalize(`${from}/${decodeURI(src)}`); + assert.equal(pagePath, `OEBPS/${decodeURIComponent(item(id).getAttribute('href'))}`); + const doc = xml(await read(pagePath)); + assert.equal(doc.querySelector('parsererror'), null, `${pagePath} is not well-formed`); + const el = doc.getElementById(fragment); + assert.ok(el, `${fragment} is not in ${pagePath}`); + const index = Number(fragment.split('-')[1]); + said.set(index, (said.get(index) ?? '') + el.textContent); + + const a = par.querySelector('audio'); + const audioPath = path.posix.normalize(`${from}/${decodeURI(a.getAttribute('src'))}`); + assert.ok(entries.has(audioPath), audioPath); + clips.push({ index, audioPath, begin: parseFloat(a.getAttribute('clipBegin')), end: parseFloat(a.getAttribute('clipEnd')) }); + } + } + cues.forEach((cue, i) => { + if (cue.text.startsWith(E.UNMATCHED)) assert.ok(!said.has(i)); + else assert.equal(bare(said.get(i)), bare(cue.text), `cue ${i}`); + }); + + // The pieces of a cue share its time, in order, inside the right file. + for (const [i, cue] of cues.entries()) { + const mine = clips.filter((c) => c.index === i); + if (!mine.length) continue; + const base = cue.start >= 60 ? 60 : 0; + assert.ok(mine.every((c) => c.audioPath === `OEBPS/subread/audio-${base ? 2 : 1}.mp3`)); + assert.ok(Math.abs(mine[0].begin - (cue.start - base)) < 0.002); + assert.ok(Math.abs(mine.at(-1).end - (cue.end - base)) < 0.002); + mine.forEach((c, k) => { assert.ok(c.end > c.begin); if (k) assert.ok(Math.abs(c.begin - mine[k - 1].end) < 0.002); }); + } + assert.equal(clips.filter((c) => c.index === 0).length, 3, 'heading, paragraph text, and the text inside <em>'); + + // The second "Yes." is the one in chapter two, not the first again. + const two = xml(await read('OEBPS/text/two.xhtml')); + assert.equal(two.getElementById('subread-7').textContent, 'Yes.'); + // Furigana is still in the book, and outside the spans. + assert.equal(two.querySelectorAll('rt').length, 2); + assert.equal([...two.querySelectorAll('span[id^=subread]')].some((s) => s.querySelector('rt') || s.closest('rt')), false); + // The words of the book are as they were. + const before = await readBook(await book()); + const after = await readBook(file); + assert.deepEqual(after.paragraphs, before.paragraphs); + + // The audio is in the book, byte for byte. + assert.deepEqual(await entries.get('OEBPS/subread/audio-2.mp3')(), new Uint8Array(2000).fill(2)); + + // What EPUB 3 asks for that EPUB 2 did not have. + const metas = [...opf.querySelectorAll('metadata > meta')]; + const meta = (property) => metas.filter((m) => m.getAttribute('property') === property); + assert.equal(meta('dcterms:modified').length, 1); + assert.equal(meta('media:active-class')[0].textContent, '-epub-media-overlay-active'); + assert.equal(meta('media:duration').length, 3); // two overlays and the total + assert.equal(meta('media:duration').find((m) => !m.getAttribute('refines')).textContent, clock(2 + 3 + 1 + 2 + 3 + 2 + 1)); + const nav = xml(await read(`OEBPS/${items.find((i) => i.getAttribute('properties') === 'nav').getAttribute('href')}`)); + assert.deepEqual([...nav.querySelectorAll('a')].map((a) => [a.textContent, a.getAttribute('href')]), + [['One & only', '../text/chapter%20one.xhtml'], ['Two', '../text/two.xhtml#top']]); + + const jar = process.env.EPUBCHECK; + if (jar && existsSync(jar)) { + const dir = mkdtempSync(path.join(tmpdir(), 'subread-')); + try { + const out = path.join(dir, 'test.epub'); + writeFileSync(out, bytes); + const r = spawnSync('java', ['-jar', jar, out], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stdout + r.stderr); + } finally { rmSync(dir, { recursive: true, force: true }); } + } +}); + +test('audio that an EPUB 3 reader need not play is refused', async () => { + await assert.rejects( + syncedEpub({ book: await book(), cues, parts: [{ file: audio(1), duration: 90, codec: 'flac' }], stem: 'x' }), + /MP3 or AAC/); +}); + +test('subtitles of another book are refused', async () => { + await assert.rejects( + syncedEpub({ book: await book(), cues: [{ text: 'Call me Ishmael. Some years ago', start: 0, end: 2 }], parts, stem: 'x' }), + /None of the subtitles/); +}); + +test('a short line is not looked for far ahead', async () => { + // "Yes." is next only in chapter two... but here chapter one's is near, so it is found there. + const r = await syncedEpub({ book: await book(), cues: [{ text: 'Yes.', start: 0, end: 1 }], parts, stem: 'x' }); + assert.equal(r.located, 1); +}); + +test('a zip that is written reads back the same, whatever is in it', async () => { + let seed = 7; + const random = () => (seed = (seed * 1103515245 + 12345) >>> 0) / 2 ** 32; + for (let round = 0; round < 25; round++) { + const files = Array.from({ length: 1 + Math.floor(random() * 6) }, (_, i) => { + const data = Uint8Array.from({ length: Math.floor(random() * 3000) }, () => Math.floor(random() * 256)); + return { name: `dir ${i}/ファイル-${round}-${i}.bin`, data: i % 2 ? new Blob([data]) : data, bytes: data }; + }); + const back = zipEntries(new Uint8Array(await (await writeZip(files)).arrayBuffer())); + assert.deepEqual([...back.keys()], files.map((f) => f.name)); + for (const f of files) assert.deepEqual(await back.get(f.name)(), f.bytes); + } + // And a standard tool agrees about the checksums. + const dir = mkdtempSync(path.join(tmpdir(), 'subread-')); + try { + const out = path.join(dir, 'a.zip'); + writeFileSync(out, new Uint8Array(await (await writeZip([{ name: 'a.txt', data: enc.encode('hello') }])).arrayBuffer())); + const r = spawnSync('python', ['-c', 'import sys,zipfile; z=zipfile.ZipFile(sys.argv[1]); assert z.testzip() is None; print(z.read("a.txt").decode())', out], { encoding: 'utf8' }); + if (!r.error) assert.equal(r.stdout.trim(), 'hello', r.stderr); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('relative paths', () => { + assert.equal(relative('OEBPS/subread', 'OEBPS/text/a b.xhtml'), '../text/a%20b.xhtml'); + assert.equal(relative('OEBPS/text', 'OEBPS/text/two.xhtml'), 'two.xhtml'); + assert.equal(relative('', 'subread/overlay.css'), 'subread/overlay.css'); + assert.equal(relative('a/b', 'c.css'), '../../c.css'); +}); + +/* A real book, when it is on this machine (it is in copyright, so it is not in the repository). */ +const local = path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/(\w:)/, '$1')), 'excerpt-local'); +const realBook = path.join(local, 'moskva.epub'), realAudio = path.join(local, 'ru.mp3'); +test('a real book: nearly each line is found, and the validator accepts the result', + { skip: !(existsSync(realBook) && existsSync(realAudio)) && 'no local book' }, async () => { + const fixture = JSON.parse(readFileSync(path.join(local, 'moskva_device.json'), 'utf8')); + const bookFile = new File([readFileSync(realBook)], 'moskva.epub'); + const { paragraphs } = await readBook(bookFile); + const { cues } = E.alignBook(fixture.transcript, paragraphs, E.language(fixture.language)); + const duration = fixture.transcript.at(-1).end + 1; + const r = await syncedEpub({ + book: bookFile, cues, stem: 'moskva', + parts: [{ file: new File([readFileSync(realAudio)], 'ru.mp3'), duration, codec: 'mp3' }], + }); + console.log(`located ${r.located} of ${r.of} cues; ${r.file.size} bytes`); + assert.ok(r.located >= 0.95 * r.of, `${r.located} of ${r.of}`); + assert.deepEqual((await readBook(r.file)).paragraphs, paragraphs); + + const out = process.env.SUBREAD_KEEP_EPUB; + if (out) writeFileSync(out, new Uint8Array(await r.file.arrayBuffer())); + const jar = process.env.EPUBCHECK; + if (jar && existsSync(jar) && out) { + const before = spawnSync('java', ['-jar', jar, realBook], { encoding: 'utf8' }); + const after = spawnSync('java', ['-jar', jar, out], { encoding: 'utf8' }); + const errors = (s) => Number(/(\d+) errors?/.exec(s.stdout + s.stderr)?.[1] ?? -1); + console.log(`epubcheck errors: ${errors(before)} in the source, ${errors(after)} in the result`); + console.log((after.stdout + after.stderr).split('\n').filter((l) => /ERROR|FATAL/.test(l)).slice(0, 12).join('\n')); + } + });