
Epub
- 205 installs
- 5 repo stars
- Updated December 22, 2025
- aliceisjustplaying/claude-resources-monorepo
Create, parse, restructure, and validate EPUB ebooks from manuscripts, chapters, and assets when producing publishable digital book deliverables.
About
Supports building and fixing EPUB ebooks by guiding agents through OPF metadata, spine ordering, XHTML chapter preparation, cover embedding, table-of-contents generation, and packaging steps needed to ship clean digital books from raw manuscript content.
- EPUB structure and OPF metadata handling
- Chapter HTML cleanup and packaging
- Cover, TOC, and navigation generation
- Validation against common reader constraints
- Manuscript-to-ebook conversion steps
Epub by the numbers
- 205 all-time installs (skills.sh)
- Ranked #242 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/aliceisjustplaying/claude-resources-monorepo --skill epubAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 205 |
|---|---|
| repo stars | ★ 5 |
| Last updated | December 22, 2025 |
| Repository | aliceisjustplaying/claude-resources-monorepo ↗ |
What it does
Create, parse, restructure, and validate EPUB ebooks from manuscripts, chapters, and assets when producing publishable digital book deliverables.
Files
EPUB Reader Skill
Read EPUB ebook files and extract content as clean Markdown.
Instructions
Use the epub-reader CLI tool to interact with EPUB files. The tool is located at: ~/.claude/skills/epub/scripts/epub-reader/dist/index.js
Available Commands
1. View Metadata
Get book information (title, author, publisher, date, description).
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js metadata "<path-to-epub>"2. List Table of Contents
View all chapters and their structure. Each entry shows [ch: N] indicating the chapter number to use with the chapter command.
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js toc "<path-to-epub>"3. Read Specific Chapter
Read a single chapter by number (1-indexed).
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js chapter "<path-to-epub>" <chapter-number>4. Read Entire Book
Extract the complete book as Markdown.
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js full "<path-to-epub>"5. Search Text
Find text occurrences with surrounding context.
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js search "<path-to-epub>" "<search-query>"Recommended Workflow
1. Start with metadata to understand what book you're working with 2. View the TOC to see available chapters and structure 3. Read specific chapters for targeted analysis, or use full for complete extraction 4. Use search to find specific topics, quotes, or references
Open-Ended Search
For broad or conceptual queries like "what are the main themes in this book?" or "find all references to the protagonist's childhood", use query expansion:
1. Expand the query into multiple specific search terms using domain knowledge
- Example: "protagonist's childhood" → search for character name, "young", "childhood", "memory", "father", "mother", "grew up", etc.
2. Run searches in parallel for each expanded term 3. Synthesize results by deduplicating and consolidating findings across searches
This approach leverages Claude's domain knowledge to catch synonyms, related concepts, and terminology variations that a simple keyword search would miss.
Example
User asks: "What does the book say about the author's research methodology?"
Expand to searches:
- "methodology"
- "research"
- "study"
- "analysis"
- "data"
- "findings"
- "evidence"
Then consolidate the results into a comprehensive answer.
Output Format
All output is clean Markdown:
- Headings preserved as
#,##, etc. - Lists, links, and emphasis converted properly
- Excessive whitespace cleaned up
- Chapter separators included for full extraction
Examples
# What book is this?
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js metadata "/path/to/book.epub"
# Show me the chapters
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js toc "/path/to/book.epub"
# Read chapter 3
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js chapter "/path/to/book.epub" 3
# Find all mentions of "democracy"
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js search "/path/to/book.epub" "democracy"Notes
- Chapter numbers are 1-indexed (first chapter is 1, not 0)
- Use the
[ch: N]reference from the TOC output to find the correct chapter number - Paths with spaces must be quoted
- Large books may produce substantial output with the
fullcommand - Search results show up to 5 matches per chapter with context
# Dependencies
node_modules/
# Build output - keep dist/ tracked since it's needed for the skill to work
# dist/
# OS files
.DS_Store
Thumbs.db
# Editor files
*.swp
*.swo
*~
.idea/
.vscode/
EPUB Reader Skill for Claude Code
A Claude Code skill that enables efficient reading of EPUB ebook files.
Capabilities
- Metadata extraction - title, author, publisher, date, language
- Table of contents - view chapter structure
- Chapter reading - read specific chapters by number
- Full extraction - extract entire book as markdown
- Search - find text with surrounding context
Directory Structure
~/.claude/skills/epub/
├── SKILL.md # Skill definition (triggers on EPUB-related requests)
├── AGENTS.md # This documentation
├── CLAUDE.md -> AGENTS.md # Symlink
└── scripts/epub-reader/
├── package.json
├── tsconfig.json
├── src/index.ts # TypeScript source
└── dist/ # Compiled JavaScriptTechnology Stack
- TypeScript - main implementation language
- jszip - extract EPUB contents (EPUBs are ZIP archives)
- xml2js - parse OPF/NCX metadata files
- turndown - convert HTML content to Markdown
- commander - CLI argument parsing
CLI Commands
# View metadata
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js metadata "<file.epub>"
# List table of contents
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js toc "<file.epub>"
# Read specific chapter (1-indexed)
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js chapter "<file.epub>" <number>
# Extract entire book
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js full "<file.epub>"
# Search for text
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js search "<file.epub>" "<query>"How the Skill Works
1. SKILL.md defines when Claude should use this skill (any EPUB-related request) 2. Claude automatically invokes the appropriate CLI command based on user intent 3. Output is clean Markdown suitable for reading and analysis
Rebuilding
If you need to modify and rebuild:
cd ~/.claude/skills/epub/scripts/epub-reader
npm install
npm run buildRestart Claude Code after any changes to SKILL.md for them to take effect.
Changelog
2025-11-26: TOC-to-Chapter Navigation Fix
Problem: The table of contents (TOC) display showed sequential numbering that didn't correspond to the chapter command's spine-based indexing. Users had no way to know which chapter number to use.
Solution:
- TOC now displays inline chapter references:
Chapter Five [ch: 14] - Users can see exactly which number to use with the
chaptercommand - Fixed title extraction to properly search the nested TOC tree by href instead of assuming index alignment
Changes made to `src/index.ts`: 1. Added buildHrefToSpineMap() - creates href-to-spine index mapping 2. Added findTocItemByHref() - recursively searches TOC tree for matching href 3. Updated formatToc() - shows [ch: N] inline with each entry 4. Fixed getChapterContent() - uses proper TOC lookup for title extraction
2025-11-26: Open-Ended Search Documentation
Added documentation for handling broad/conceptual queries using LLM-assisted query expansion. For queries like "what are the main themes?" or "find references to the protagonist's childhood", Claude expands the query into multiple specific search terms using domain knowledge, runs parallel searches, and synthesizes the results. This leverages Claude's knowledge to catch synonyms and related concepts without requiring fuzzy search infrastructure.
EPUB Reader Skill for Claude Code
A Claude Code skill that enables efficient reading of EPUB ebook files.
Features
- Metadata extraction - title, author, publisher, date, language
- Table of contents - view chapter structure with chapter references (
[ch: N]) - Chapter reading - read specific chapters by number
- Full extraction - extract entire book as markdown
- Search - find text with surrounding context
- Open-ended search - broad queries are automatically expanded using Claude's domain knowledge
Installation
The skill is installed at ~/.claude/skills/epub/. Restart Claude Code after installation for the skill to be discovered.
Usage
Just ask Claude naturally about EPUB files:
- "What's in this EPUB file?"
- "Show me the table of contents"
- "Read chapter 5"
- "Search for 'democracy' in the book"
- "Extract the entire book as markdown"
- "What does the book say about the main character's motivation?" (open-ended queries work too!)
Claude will automatically use this skill when it detects EPUB-related requests.
CLI Commands
The skill uses a TypeScript CLI tool under the hood:
# View metadata
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js metadata "book.epub"
# List table of contents
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js toc "book.epub"
# Read specific chapter (1-indexed)
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js chapter "book.epub" 3
# Extract entire book
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js full "book.epub"
# Search for text
node ~/.claude/skills/epub/scripts/epub-reader/dist/index.js search "book.epub" "query"Technology Stack
- TypeScript - main implementation language
- jszip - extract EPUB contents (EPUBs are ZIP archives)
- xml2js - parse OPF/NCX metadata files
- turndown - convert HTML content to Markdown
- commander - CLI argument parsing
Development
To modify and rebuild:
cd ~/.claude/skills/epub/scripts/epub-reader
npm install
npm run buildRestart Claude Code after any changes to SKILL.md.
License
MIT
{
"name": "epub-reader",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "epub-reader",
"version": "1.0.0",
"dependencies": {
"commander": "^12.1.0",
"jszip": "^3.10.1",
"turndown": "^7.2.0",
"xml2js": "^0.6.2"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/turndown": "^5.0.5",
"@types/xml2js": "^0.4.14",
"typescript": "^5.6.3"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@types/node": {
"version": "22.19.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/turndown": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/xml2js": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz",
"integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/turndown": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
"integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
}
}
}
{
"name": "epub-reader",
"version": "1.0.0",
"description": "CLI tool for reading EPUB files and extracting content as Markdown",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"commander": "^12.1.0",
"turndown": "^7.2.0",
"jszip": "^3.10.1",
"xml2js": "^0.6.2"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/turndown": "^5.0.5",
"@types/xml2js": "^0.4.14",
"typescript": "^5.6.3"
}
}
#!/usr/bin/env node
import { program } from "commander";
import * as fs from "fs";
import * as path from "path";
import JSZip from "jszip";
import { parseStringPromise } from "xml2js";
import TurndownService from "turndown";
interface EpubMetadata {
title?: string;
creator?: string;
author?: string;
language?: string;
publisher?: string;
date?: string;
description?: string;
subject?: string[];
identifier?: string;
}
interface ManifestItem {
id: string;
href: string;
mediaType: string;
}
interface SpineItem {
idref: string;
linear?: string;
}
interface TocItem {
label: string;
href: string;
children?: TocItem[];
}
interface ParsedEpub {
metadata: EpubMetadata;
manifest: Map<string, ManifestItem>;
spine: SpineItem[];
toc: TocItem[];
contentBasePath: string;
zip: JSZip;
}
const turndown = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
emDelimiter: "*",
});
// Improve turndown to handle more elements
turndown.addRule("preserveLineBreaks", {
filter: "br",
replacement: () => "\n",
});
async function loadEpub(filePath: string): Promise<ParsedEpub> {
const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) {
throw new Error(`File not found: ${absolutePath}`);
}
const data = fs.readFileSync(absolutePath);
const zip = await JSZip.loadAsync(data);
// Find container.xml
const containerXml = await zip.file("META-INF/container.xml")?.async("text");
if (!containerXml) {
throw new Error("Invalid EPUB: Missing META-INF/container.xml");
}
const container = await parseStringPromise(containerXml);
const rootfilePath =
container.container.rootfiles[0].rootfile[0].$["full-path"];
// Get content base path
const contentBasePath = path.dirname(rootfilePath);
// Parse OPF file
const opfContent = await zip.file(rootfilePath)?.async("text");
if (!opfContent) {
throw new Error(`Invalid EPUB: Missing OPF file at ${rootfilePath}`);
}
const opf = await parseStringPromise(opfContent);
const pkg = opf.package;
// Extract metadata
const metadata = extractMetadata(pkg.metadata[0]);
// Build manifest map
const manifest = new Map<string, ManifestItem>();
for (const item of pkg.manifest[0].item) {
manifest.set(item.$.id, {
id: item.$.id,
href: item.$.href,
mediaType: item.$["media-type"],
});
}
// Extract spine
const spine: SpineItem[] = pkg.spine[0].itemref.map(
(item: { $: { idref: string; linear?: string } }) => ({
idref: item.$.idref,
linear: item.$.linear,
})
);
// Try to extract TOC
const toc = await extractToc(zip, manifest, contentBasePath, pkg);
return {
metadata,
manifest,
spine,
toc,
contentBasePath,
zip,
};
}
function extractMetadata(metadataNode: Record<string, unknown>): EpubMetadata {
const metadata: EpubMetadata = {};
// Helper to get text content from various formats
const getText = (node: unknown): string | undefined => {
if (!node) return undefined;
if (Array.isArray(node)) {
const first = node[0];
if (typeof first === "string") return first;
if (typeof first === "object" && first !== null && "_" in first)
return (first as { _: string })._;
if (typeof first === "object" && first !== null)
return JSON.stringify(first);
}
if (typeof node === "string") return node;
return undefined;
};
// DC metadata (Dublin Core)
const dc = (key: string) =>
getText(metadataNode[`dc:${key}`]) || getText(metadataNode[key]);
metadata.title = dc("title");
metadata.creator = dc("creator");
metadata.author = metadata.creator;
metadata.language = dc("language");
metadata.publisher = dc("publisher");
metadata.date = dc("date");
metadata.description = dc("description");
metadata.identifier = dc("identifier");
// Handle multiple subjects
const subjects = metadataNode["dc:subject"] || metadataNode["subject"];
if (Array.isArray(subjects)) {
metadata.subject = subjects.map((s) =>
typeof s === "string" ? s : (s as { _?: string })._ || String(s)
);
}
return metadata;
}
async function extractToc(
zip: JSZip,
manifest: Map<string, ManifestItem>,
basePath: string,
pkg: Record<string, unknown>
): Promise<TocItem[]> {
const toc: TocItem[] = [];
// Try EPUB 3 nav document first
for (const [, item] of manifest) {
if (item.mediaType === "application/xhtml+xml") {
const fullPath =
basePath === "." ? item.href : `${basePath}/${item.href}`;
const content = await zip.file(fullPath)?.async("text");
if (content && content.includes('epub:type="toc"')) {
const navToc = await parseNavToc(content);
if (navToc.length > 0) return navToc;
}
}
}
// Try NCX file (EPUB 2)
const spine = pkg.spine as { $?: { toc?: string } }[] | undefined;
const tocId = spine?.[0]?.$?.toc;
if (tocId && manifest.has(tocId)) {
const ncxItem = manifest.get(tocId)!;
const ncxPath =
basePath === "." ? ncxItem.href : `${basePath}/${ncxItem.href}`;
const ncxContent = await zip.file(ncxPath)?.async("text");
if (ncxContent) {
return await parseNcxToc(ncxContent);
}
}
// Fallback: look for any .ncx file
for (const [, item] of manifest) {
if (item.href.endsWith(".ncx")) {
const ncxPath =
basePath === "." ? item.href : `${basePath}/${item.href}`;
const ncxContent = await zip.file(ncxPath)?.async("text");
if (ncxContent) {
return await parseNcxToc(ncxContent);
}
}
}
return toc;
}
async function parseNavToc(navContent: string): Promise<TocItem[]> {
const toc: TocItem[] = [];
// Simple regex-based parsing for nav document
const tocMatch = navContent.match(
/<nav[^>]*epub:type="toc"[^>]*>([\s\S]*?)<\/nav>/i
);
if (!tocMatch) return toc;
const linkRegex = /<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/gi;
let match;
while ((match = linkRegex.exec(tocMatch[1])) !== null) {
toc.push({
label: match[2].trim(),
href: match[1],
});
}
return toc;
}
async function parseNcxToc(ncxContent: string): Promise<TocItem[]> {
const ncx = await parseStringPromise(ncxContent);
const navMap = ncx.ncx?.navMap?.[0]?.navPoint;
if (!navMap) return [];
return parseNavPoints(navMap);
}
function parseNavPoints(
navPoints: Array<{
navLabel?: Array<{ text?: string[] }>;
content?: Array<{ $?: { src?: string } }>;
navPoint?: unknown[];
}>
): TocItem[] {
return navPoints.map((point) => {
const item: TocItem = {
label: point.navLabel?.[0]?.text?.[0] || "Untitled",
href: point.content?.[0]?.$?.src || "",
};
if (point.navPoint && Array.isArray(point.navPoint)) {
item.children = parseNavPoints(
point.navPoint as Array<{
navLabel?: Array<{ text?: string[] }>;
content?: Array<{ $?: { src?: string } }>;
navPoint?: unknown[];
}>
);
}
return item;
});
}
async function getChapterContent(
epub: ParsedEpub,
index: number
): Promise<{ title: string; content: string }> {
if (index < 0 || index >= epub.spine.length) {
throw new Error(
`Chapter index ${index + 1} out of range. Book has ${epub.spine.length} chapters.`
);
}
const spineItem = epub.spine[index];
const manifestItem = epub.manifest.get(spineItem.idref);
if (!manifestItem) {
throw new Error(`Could not find manifest item for spine entry: ${spineItem.idref}`);
}
const fullPath =
epub.contentBasePath === "."
? manifestItem.href
: `${epub.contentBasePath}/${manifestItem.href}`;
const content = await epub.zip.file(fullPath)?.async("text");
if (!content) {
throw new Error(`Could not read content file: ${fullPath}`);
}
// Extract title: search TOC tree for matching href, fallback to content extraction
const titleMatch = content.match(/<title>([^<]*)<\/title>/i);
const h1Match = content.match(/<h1[^>]*>([^<]*)<\/h1>/i);
const tocItem = findTocItemByHref(epub.toc, manifestItem.href);
const title =
tocItem?.label ||
h1Match?.[1] ||
titleMatch?.[1] ||
`Chapter ${index + 1}`;
// Convert HTML to Markdown
const markdown = htmlToMarkdown(content);
return { title, content: markdown };
}
function htmlToMarkdown(html: string): string {
// Extract body content if present
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
const content = bodyMatch ? bodyMatch[1] : html;
// Convert to markdown
let markdown = turndown.turndown(content);
// Clean up excessive whitespace
markdown = markdown.replace(/\n{3,}/g, "\n\n");
markdown = markdown.trim();
return markdown;
}
async function searchContent(
epub: ParsedEpub,
query: string
): Promise<Array<{ chapter: number; title: string; matches: string[] }>> {
const results: Array<{ chapter: number; title: string; matches: string[] }> =
[];
const searchRegex = new RegExp(`.{0,50}${escapeRegex(query)}.{0,50}`, "gi");
for (let i = 0; i < epub.spine.length; i++) {
const { title, content } = await getChapterContent(epub, i);
const matches = content.match(searchRegex);
if (matches && matches.length > 0) {
results.push({
chapter: i + 1,
title,
matches: matches.slice(0, 5).map((m) => `...${m.trim()}...`),
});
}
}
return results;
}
function escapeRegex(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Build a map from href (normalized, without fragment) to spine index (1-based).
*/
function buildHrefToSpineMap(epub: ParsedEpub): Map<string, number> {
const hrefToSpine = new Map<string, number>();
epub.spine.forEach((spineItem, index) => {
const manifestItem = epub.manifest.get(spineItem.idref);
if (manifestItem) {
// Normalize href: remove fragment and leading path components that might differ
const href = manifestItem.href.split("#")[0];
hrefToSpine.set(href, index + 1); // 1-based for user display
}
});
return hrefToSpine;
}
/**
* Recursively search TOC tree for an item matching the given href.
*/
function findTocItemByHref(toc: TocItem[], href: string): TocItem | undefined {
const normalizedHref = href.split("#")[0];
for (const item of toc) {
const itemHref = item.href.split("#")[0];
if (itemHref === normalizedHref) {
return item;
}
if (item.children) {
const found = findTocItemByHref(item.children, href);
if (found) return found;
}
}
return undefined;
}
function formatToc(toc: TocItem[], hrefToSpine: Map<string, number>, indent = 0): string {
let output = "";
toc.forEach((item, index) => {
const prefix = " ".repeat(indent);
const itemHref = item.href.split("#")[0];
const spineIndex = hrefToSpine.get(itemHref);
const chapterRef = spineIndex ? ` [ch: ${spineIndex}]` : "";
output += `${prefix}${indent === 0 ? index + 1 + "." : "-"} ${item.label}${chapterRef}\n`;
if (item.children) {
output += formatToc(item.children, hrefToSpine, indent + 1);
}
});
return output;
}
// CLI Commands
program
.name("epub-reader")
.description("CLI tool for reading EPUB files and extracting content as Markdown")
.version("1.0.0");
program
.command("metadata")
.description("Display EPUB metadata (title, author, etc.)")
.argument("<file>", "Path to EPUB file")
.action(async (file: string) => {
try {
const epub = await loadEpub(file);
const m = epub.metadata;
console.log("# EPUB Metadata\n");
if (m.title) console.log(`**Title:** ${m.title}`);
if (m.author) console.log(`**Author:** ${m.author}`);
if (m.publisher) console.log(`**Publisher:** ${m.publisher}`);
if (m.date) console.log(`**Date:** ${m.date}`);
if (m.language) console.log(`**Language:** ${m.language}`);
if (m.identifier) console.log(`**Identifier:** ${m.identifier}`);
if (m.subject && m.subject.length > 0) {
console.log(`**Subjects:** ${m.subject.join(", ")}`);
}
if (m.description) {
console.log(`\n## Description\n\n${m.description}`);
}
console.log(`\n**Total Chapters:** ${epub.spine.length}`);
} catch (error) {
console.error(
`Error: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
});
program
.command("toc")
.description("Display table of contents")
.argument("<file>", "Path to EPUB file")
.action(async (file: string) => {
try {
const epub = await loadEpub(file);
const hrefToSpine = buildHrefToSpineMap(epub);
console.log("# Table of Contents\n");
if (epub.toc.length > 0) {
console.log(formatToc(epub.toc, hrefToSpine));
} else {
// Fallback to spine-based listing
console.log("(No structured TOC found, listing spine items)\n");
for (let i = 0; i < epub.spine.length; i++) {
const item = epub.manifest.get(epub.spine[i].idref);
console.log(`${i + 1}. ${item?.href || `Chapter ${i + 1}`}`);
}
}
} catch (error) {
console.error(
`Error: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
});
program
.command("chapter")
.description("Read a specific chapter (1-indexed)")
.argument("<file>", "Path to EPUB file")
.argument("<number>", "Chapter number (starting from 1)")
.action(async (file: string, number: string) => {
try {
const chapterNum = parseInt(number, 10);
if (isNaN(chapterNum) || chapterNum < 1) {
throw new Error("Chapter number must be a positive integer");
}
const epub = await loadEpub(file);
const { title, content } = await getChapterContent(epub, chapterNum - 1);
console.log(`# ${title}\n`);
console.log(content);
} catch (error) {
console.error(
`Error: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
});
program
.command("full")
.description("Extract entire book as Markdown")
.argument("<file>", "Path to EPUB file")
.action(async (file: string) => {
try {
const epub = await loadEpub(file);
const m = epub.metadata;
// Print metadata header
console.log(`# ${m.title || "Untitled"}\n`);
if (m.author) console.log(`*By ${m.author}*\n`);
console.log("---\n");
// Print each chapter
for (let i = 0; i < epub.spine.length; i++) {
const { title, content } = await getChapterContent(epub, i);
console.log(`## ${title}\n`);
console.log(content);
console.log("\n---\n");
}
} catch (error) {
console.error(
`Error: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
});
program
.command("search")
.description("Search for text in the book")
.argument("<file>", "Path to EPUB file")
.argument("<query>", "Text to search for")
.action(async (file: string, query: string) => {
try {
const epub = await loadEpub(file);
const results = await searchContent(epub, query);
if (results.length === 0) {
console.log(`No matches found for "${query}"`);
return;
}
console.log(`# Search Results for "${query}"\n`);
console.log(`Found matches in ${results.length} chapter(s):\n`);
for (const result of results) {
console.log(`## Chapter ${result.chapter}: ${result.title}\n`);
for (const match of result.matches) {
console.log(`- ${match}`);
}
console.log();
}
} catch (error) {
console.error(
`Error: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
});
program.parse();
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}