diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md
index 813121c..64905d9 100644
--- a/docs/WORKLOG.md
+++ b/docs/WORKLOG.md
@@ -1155,3 +1155,8 @@ Next: After running with reduced logs, gather traces for 'Chapter not found' and
- Why: Make first-time contributors productive quickly and capture actionable decomposition plans for known monoliths.
- Details: Add onboarding walkthrough (load → translate flow), refresh CONTRIBUTING references to match current code locations, and add draft decomposition plans for `services/epubService.ts`, `store/slices/imageSlice.ts`, and `services/navigationService.ts`.
- Tests: N/A (docs only)
+
+2025-12-22 04:45 UTC - Refactor: isolate `epubService.ts` decomposition modules
+- Files: services/epubService.ts; services/epubService/**; services/epub/types.ts; docs/WORKLOG.md
+- Why: Decompose the `services/epubService.ts` monolith without breaking the existing `services/epub/*` pipeline/types; keep new modules <300 LOC.
+- Tests: `npx tsc --noEmit`; `npm test -- --run tests/services/epubService.test.ts tests/epub/*.test.ts`
diff --git a/services/epubService.ts b/services/epubService.ts
index 1d07966..3a20312 100644
--- a/services/epubService.ts
+++ b/services/epubService.ts
@@ -1,1280 +1,54 @@
-import { SessionChapterData, AppSettings } from '../types';
-import JSZip from 'jszip';
-import { toStrictXhtml } from './translate/HtmlSanitizer';
-
-// XHTML/XML namespaces used for strict XML serialization
-const XHTML_NS = 'http://www.w3.org/1999/xhtml';
-const XML_NS = 'http://www.w3.org/XML/1998/namespace';
-const EPUB_NS = 'http://www.idpf.org/2007/ops';
-const XLINK_NS = 'http://www.w3.org/1999/xlink';
-
-// Simplified XML Name validation (sufficient for XHTML attribute names)
-const XML_NAME = /^[A-Za-z_][A-Za-z0-9._:-]*$/;
-
-// Basic bans for unsafe attributes
-function isBannedAttr(name: string) {
- return name.startsWith('on') || name === 'srcdoc';
-}
-
-// Very lightweight CSS sanitizer; keep as a single attribute
-function sanitizeStyle(value: string) {
- const v = (value ?? '').replace(/[\u0000-\u001F\u007F]/g, '');
- if (/url\s*\(\s*javascript:/i.test(v)) return '';
- if (/expression\s*\(/i.test(v)) return '';
- return v.trim();
-}
-
-function setAttrNS(el: Element, name: string, value: string) {
- if (name === 'xml:lang') { el.setAttributeNS(XML_NS, name, value); return; }
- if (name.startsWith('epub:')) { el.setAttributeNS(EPUB_NS, name, value); return; }
- if (name.startsWith('xlink:')) { el.setAttributeNS(XLINK_NS, name, value); return; }
- el.setAttribute(name, value);
-}
-
-function copyAttributesSafely(srcEl: Element, dstEl: Element) {
- for (const attr of Array.from(srcEl.attributes)) {
- let name = attr.name;
- let value = attr.value ?? '';
-
- // Keep style as a single attribute; do not expand/split
- if (name.toLowerCase() === 'style') {
- const s = sanitizeStyle(value);
- if (s) dstEl.setAttribute('style', s);
- continue;
- }
-
- // Drop unsafe attributes
- if (isBannedAttr(name)) continue;
-
- // Validate XML name to avoid InvalidCharacterError (e.g., 'down;')
- if (!XML_NAME.test(name)) {
- try { console.warn('[EPUB XClone] Dropping invalid attribute', name, 'on <' + srcEl.tagName + '>'); } catch {}
- continue;
- }
-
- // reject unknown namespace prefixes (avoid unbound prefixes)
- if (name.includes(':')) {
- const [prefix] = name.split(':', 1);
- const ok = prefix === 'xml' || prefix === 'epub' || prefix === 'xlink';
- if (!ok) continue;
- }
-
- // Normalize non-namespaced names to lowercase
- if (!name.includes(':')) name = name.toLowerCase();
-
- try {
- setAttrNS(dstEl, name, value);
- } catch (e) {
- try {
- const snippet = (srcEl as any).outerHTML ? (srcEl as any).outerHTML.slice(0, 160).replace(/\s+/g, ' ') : `<${srcEl.tagName}>`;
- console.warn('[EPUB XClone] Could not set attribute', name, 'value=', value, 'on', snippet, e);
- } catch {}
- // Continue without throwing
- }
- }
-}
-
-// Clone an HTML node tree into an XHTML XMLDocument parent
-function cloneIntoXhtml(srcNode: Node, xdoc: XMLDocument, dstParent: Element) {
- switch (srcNode.nodeType) {
- case Node.ELEMENT_NODE: {
- const srcEl = srcNode as Element;
- // Lowercase localName for XHTML consistency; guard invalid names
- const name = srcEl.localName.toLowerCase();
- const isValidXmlLocalName = /^[A-Za-z_][A-Za-z0-9._-]*$/.test(name);
- if (!isValidXmlLocalName) {
- // Skip invalid element; clone its children directly into parent
- for (const child of Array.from(srcEl.childNodes)) {
- cloneIntoXhtml(child, xdoc, dstParent);
- }
- break;
- }
- const el = xdoc.createElementNS(XHTML_NS, name);
- // Copy attributes safely (validated + namespaced)
- copyAttributesSafely(srcEl, el);
- // Ensure has alt for accessibility nicety
- if (el.localName === 'img' && !el.hasAttribute('alt')) {
- el.setAttribute('alt', '');
- }
- // Avoid scripts in EPUB content
- if (el.localName !== 'script') {
- for (const child of Array.from(srcEl.childNodes)) {
- cloneIntoXhtml(child, xdoc, el);
- }
- }
- dstParent.appendChild(el);
- break;
- }
- case Node.TEXT_NODE: {
- dstParent.appendChild(xdoc.createTextNode((srcNode as Text).data));
- break;
- }
- // Omit comments/CDATA by default for chapters
- default:
- break;
- }
-}
-
-// Convert an HTML fragment string into serialized XHTML fragment
-function htmlFragmentToXhtml(fragmentHtml: string): string {
- // Repair common broken void tags like
then the quote remains as text
- fragmentHtml = fragmentHtml
- .replace(/
')
- .replace(/
')
- .replace(/
This translation contains ${chapters.length} chapters
-`; - tocHtml += `Recorded via LexiconForge telemetry during preparation of this EPUB.
-`; - html += `| Activity | -`; - html += `Occurrences | -`; - html += `Total Duration | -`; - html += `Average Duration | -`; - html += `
|---|
${escapeXml(template.projectDescription || '')}
-`; - if (template.githubUrl) { - html += `Source Code: ${escapeXml(template.githubUrl)}
-`; - } - html += `| Provider | -`; - html += `Chapters | -`; - html += `Cost | -`; - html += `Time | -`; - html += `
|---|---|---|---|
| ${escapeXml(provider)} | -`; - html += `${providerStats.chapters} | -`; - html += `$${providerStats.cost.toFixed(4)} | -`; - html += `${Math.round(providerStats.time)}s | -`; - html += `
| Model | -`; - html += `Chapters | -`; - html += `Tokens | -`; - html += `
|---|---|---|
| ${escapeXml(model)} | -`; - html += `${modelStats.chapters} | -`; - html += `${modelStats.tokens.toLocaleString()} | -`; - html += `
${escapeXml(template.gratitudeMessage || '')}
-`; - if (template.additionalAcknowledgments) { - html += `${escapeXml(template.additionalAcknowledgments)}
-`; - } - html += `Translation completed on ${new Date().toLocaleDateString()}
-`; - html += `${escapeXml(image.prompt)}
- and convert line breaks to
- para = para.replace(/\n/g, '
'); // Use self-closing br tags for XHTML
- xhtmlContent += `
${para}
\n\n`; - } - } - - return xhtmlContent.trim(); -}; - -/** - * Build chapter XHTML using DOM nodes (footnotes visible inline and at end) - */ -const buildChapterXhtml = (chapter: ChapterForEpub): string => { - const root = document.createElement('div'); - // Title - const h1 = document.createElement('h1'); - h1.textContent = chapter.translatedTitle || chapter.title; - root.appendChild(h1); - - // 1) Inject placeholders for markers - const withIllu = chapter.content.replace(/\[(ILLUSTRATION-\d+[A-Za-z]*) \]/g, (_m, marker) => { - return ``; - }); - const withPlaceholders = withIllu.replace(/\[(\d+)\]/g, (_m, n) => ``); - - // 2) Sanitize with tight allowlist to preserve inline tags safely - const sanitized = sanitizeHtmlAllowlist(withPlaceholders); - - // 3) Materialize into a working container and normalize newlines to${escapeXml(image.prompt)}
+ and convert line breaks to
+ para = para.replace(/\n/g, '
'); // Use self-closing br tags for XHTML
+ xhtmlContent += `
${para}
\n\n`; + } + } + + return xhtmlContent.trim(); +}; + +/** + * Build chapter XHTML using DOM nodes (footnotes visible inline and at end) + */ +export const buildChapterXhtml = (chapter: ChapterForEpub): string => { + const root = document.createElement('div'); + // Title + const h1 = document.createElement('h1'); + h1.textContent = chapter.translatedTitle || chapter.title; + root.appendChild(h1); + + // 1) Inject placeholders for markers + const withIllu = chapter.content.replace(/\b(ILLUSTRATION-\d+[A-Za-z]*)\b/g, (_m, marker) => { + return ``; + }); + const withPlaceholders = withIllu.replace(/\((\d+)\)/g, (_m, n) => ``); + + // 2) Sanitize with tight allowlist to preserve inline tags safely + const sanitized = sanitizeHtmlAllowlist(withPlaceholders); + + // 3) Materialize into a working container and normalize newlines to${escapeXml(template.projectDescription || '')}
+`; + if (template.githubUrl) { + html += `Source Code: ${escapeXml(template.githubUrl)}
+`; + } + html += `| Provider | +`; + html += `Chapters | +`; + html += `Cost | +`; + html += `Time | +`; + html += `
|---|---|---|---|
| ${escapeXml(provider)} | +`; + html += `${providerStats.chapters} | +`; + html += `$${providerStats.cost.toFixed(4)} | +`; + html += `${Math.round(providerStats.time)}s | +`; + html += `
| Model | +`; + html += `Chapters | +`; + html += `Tokens | +`; + html += `
|---|---|---|
| ${escapeXml(model)} | +`; + html += `${modelStats.chapters} | +`; + html += `${modelStats.tokens.toLocaleString()} | +`; + html += `
${escapeXml(template.gratitudeMessage || '')}
+`; + if (template.additionalAcknowledgments) { + html += `${escapeXml(template.additionalAcknowledgments)}
+`; + } + html += `Translation completed on ${new Date().toLocaleDateString()}
+`; + html += `Recorded via LexiconForge telemetry during preparation of this EPUB.
+`; + html += `| Activity | +`; + html += `Occurrences | +`; + html += `Total Duration | +`; + html += `Average Duration | +`; + html += `
|---|
This translation contains ${chapters.length} chapters
+`; + tocHtml += `