Skip to content

Instantly share code, notes, and snippets.

@kevincolten
Last active August 19, 2025 19:28
Show Gist options
  • Save kevincolten/97293ffbb284e2f9058327f43bd95d86 to your computer and use it in GitHub Desktop.
Save kevincolten/97293ffbb284e2f9058327f43bd95d86 to your computer and use it in GitHub Desktop.
Convert B&N Ebook JSON to EPUB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HAR to EPUB Converter</title>
<!-- External Dependencies -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/epub.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: rgba(255, 255, 255, 0.95);
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header h1 {
text-align: center;
color: #333;
font-size: 28px;
}
.header p {
text-align: center;
color: #666;
margin-top: 10px;
}
.container {
flex: 1;
display: flex;
padding: 20px;
gap: 20px;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
.left-panel {
width: 350px;
display: flex;
flex-direction: column;
gap: 20px;
}
.dropzone {
background: white;
border-radius: 10px;
padding: 40px 20px;
text-align: center;
border: 3px dashed #ddd;
transition: all 0.3s ease;
cursor: pointer;
}
.dropzone:hover {
border-color: #667eea;
background: #f8f9ff;
}
.dropzone.active {
border-color: #764ba2;
background: #f0f0ff;
}
.dropzone.processing {
border-color: #4CAF50;
background: #f0fff0;
cursor: not-allowed;
}
.dropzone-icon {
font-size: 48px;
margin-bottom: 15px;
}
.dropzone h3 {
color: #333;
margin-bottom: 10px;
}
.dropzone p {
color: #666;
font-size: 14px;
}
#fileInput {
display: none;
}
.status-panel {
background: white;
border-radius: 10px;
padding: 20px;
max-height: 300px;
overflow-y: auto;
}
.status-panel h3 {
margin-bottom: 15px;
color: #333;
}
.status-message {
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 5px;
font-size: 13px;
}
.status-info {
background: #e3f2fd;
color: #1976d2;
}
.status-success {
background: #e8f5e9;
color: #2e7d32;
}
.status-warning {
background: #fff3e0;
color: #f57c00;
}
.status-error {
background: #ffebee;
color: #c62828;
}
.controls {
background: white;
border-radius: 10px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.btn {
padding: 12px 20px;
border: none;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a67d8;
}
.btn-success {
background: #4CAF50;
color: white;
}
.btn-success:hover {
background: #45a049;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.reader-container {
flex: 1;
background: white;
border-radius: 10px;
padding: 20px;
position: relative;
min-height: 600px;
}
#reader {
width: 100%;
height: 100%;
min-height: 550px;
}
.reader-controls {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
background: rgba(255, 255, 255, 0.95);
padding: 10px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.reader-controls button {
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s ease;
}
.reader-controls button:hover {
background: #f5f5f5;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: #666;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-right: 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
color: #999;
}
.empty-state-icon {
font-size: 64px;
margin-bottom: 20px;
}
.empty-state h3 {
color: #666;
margin-bottom: 10px;
}
.info-section {
background: white;
border-radius: 10px;
padding: 20px;
margin-top: 20px;
}
.info-section h3 {
color: #333;
margin-bottom: 15px;
}
.info-section ol {
color: #666;
font-size: 14px;
line-height: 1.8;
padding-left: 20px;
}
@media (max-width: 768px) {
.container {
flex-direction: column;
}
.left-panel {
width: 100%;
}
}
</style>
</head>
<body>
<div class="header">
<h1>📚 HAR to EPUB Converter</h1>
<p>Convert Barnes & Noble web reader HAR files to EPUB format</p>
</div>
<div class="container">
<div class="left-panel">
<div class="dropzone" id="dropzone">
<div class="dropzone-icon">📁</div>
<h3>Drop HAR file here</h3>
<p>or click to select</p>
<input type="file" id="fileInput" accept=".har" />
</div>
<div class="status-panel">
<h3>Status</h3>
<div id="statusMessages">
<div class="status-message status-info">Ready to process HAR files</div>
</div>
</div>
<div class="controls">
<button id="downloadBtn" class="btn btn-success" disabled>
💾 Download EPUB
</button>
<button id="resetBtn" class="btn btn-primary">
🔄 Reset
</button>
</div>
<div class="info-section">
<h3>How to capture HAR:</h3>
<ol>
<li>Open B&N web reader</li>
<li>Open DevTools (F12)</li>
<li>Go to Network tab</li>
<li>Refresh browser</li>
<li>Navigate through book</li>
<li>Save all as HAR</li>
</ol>
</div>
</div>
<div class="reader-container">
<div id="reader">
<div class="empty-state">
<div class="empty-state-icon">📖</div>
<h3>No EPUB loaded</h3>
<p>Drop a HAR file to get started</p>
</div>
</div>
<div class="reader-controls" style="display: none;">
<button id="prevBtn">← Previous</button>
<button id="nextBtn">Next →</button>
</div>
</div>
</div>
<script>
// ============================================================================
// HAR to EPUB Converter Class
// ============================================================================
class HarToEpubConverter {
constructor() {
this.parsedJson = {};
this.images = {};
this.spine = null;
this.contentFiles = new Map();
this.metadata = {};
}
async parseHar(harContent) {
const har = JSON.parse(harContent);
this.parsedJson = {};
this.images = {};
let jsonCount = 0;
let imageCount = 0;
for (const entry of har.log.entries) {
const url = entry.request.url;
const response = entry.response;
if (response.content && response.content.text && response.content.mimeType === 'application/json') {
try {
const jsonContent = JSON.parse(response.content.text);
const filename = url.split('/').pop();
const timestamp = new Date(entry.startedDateTime).getTime();
const key = `${timestamp}_${filename}`;
this.parsedJson[key] = jsonContent;
if (filename === 'spine.json') {
this.spine = jsonContent;
this.metadata = jsonContent.metadata || {};
}
jsonCount++;
} catch (e) {
console.warn('Failed to parse JSON:', url);
}
}
if (response.status === 200 && response.content && response.content.text &&
(url.includes('.jpg') || url.includes('.png') || url.includes('.gif'))) {
const filename = url.split('/').pop();
const mimeType = response.content.mimeType || this.getMimeType(filename);
this.images[filename] = {
data: response.content.text,
mimeType: mimeType,
encoding: response.content.encoding || 'base64'
};
imageCount++;
}
}
return { jsonCount, imageCount };
}
getMimeType(filename) {
const ext = filename.split('.').pop().toLowerCase();
const mimeTypes = {
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'svg': 'image/svg+xml'
};
return mimeTypes[ext] || 'image/jpeg';
}
async generateEpub(onProgress) {
if (!this.spine) {
throw new Error('No spine.json found in HAR file');
}
const zip = new JSZip();
// Add mimetype at root level uncompressed (EPUB requirement)
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
// Create META-INF folder and add container.xml
const metaInf = zip.folder('META-INF');
metaInf.file('container.xml', this.getContainerXml());
// Create OEBPS folder
const oebps = zip.folder('OEBPS');
let converted = 0;
let skipped = 0;
const totalItems = this.spine.spine.length;
for (const item of this.spine.spine) {
if (item.T.includes('love_') || item.T.includes('love ')) {
skipped++;
continue;
}
const jsonFile = this.findJsonFile(item.O);
if (jsonFile) {
const content = this.parsedJson[jsonFile];
const xhtml = this.jsonToXhtml(content, item.T);
// Add files to OEBPS folder
const filename = item.T.replace('OEBPS/', '');
oebps.file(filename, xhtml);
const title = this.getPageTitle(item.T, content);
const filePath = `OEBPS/${filename}`;
this.contentFiles.set(item.X, { path: filePath, title: title });
converted++;
if (onProgress) {
onProgress(converted, totalItems, `Converting: ${title}`);
}
}
}
// Add OPF, NCX and CSS to OEBPS folder
oebps.file('content.opf', this.generateOPF());
oebps.file('toc.ncx', this.generateNCX());
oebps.file('styles.css', this.getDefaultCSS());
const epubBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 9 }
});
return epubBlob;
}
findJsonFile(filename) {
if (filename.startsWith('OEBPS/')) {
filename = filename.substring(6);
}
for (const key in this.parsedJson) {
if (key.endsWith(filename)) {
return key;
}
}
return null;
}
jsonToXhtml(json, filename) {
const elem = json.E || json;
let xhtml = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html`;
if (elem.A) {
for (const attr of elem.A) {
xhtml += ` ${attr.T}="${attr.d}"`;
}
} else {
xhtml += ` xmlns="http://www.w3.org/1999/xhtml"`;
}
xhtml += '>\n<head>\n';
xhtml += ` <title>${filename}</title>\n`;
xhtml += ` <link rel="stylesheet" type="text/css" href="styles.css"/>\n`;
xhtml += '</head>\n<body>\n';
if (elem.L) {
xhtml += this.convertElements(elem.L, 1);
}
xhtml += '</body>\n</html>';
return xhtml;
}
convertElements(elements, indent = 0) {
let html = '';
const spaces = ' '.repeat(indent);
for (const elem of elements) {
if (elem.B === '#text') {
let text = elem.J || '';
if (text.includes('<img')) {
text = text.replace(/<img([^>]*?)>/g, (match) => {
const xsrcMatch = match.match(/xsrc="([^"]*)"/);
if (xsrcMatch) {
const imageName = xsrcMatch[1].split('/').pop();
if (this.images[imageName]) {
const imageData = this.images[imageName];
const widthMatch = match.match(/width="([^"]*)"/);
const heightMatch = match.match(/height="([^"]*)"/);
const altMatch = match.match(/alt="([^"]*)"/);
let imgTag = `<img src="data:${imageData.mimeType};base64,${imageData.data}"`;
if (altMatch) imgTag += ` alt="${altMatch[1]}"`;
if (widthMatch) imgTag += ` width="${widthMatch[1]}"`;
if (heightMatch) imgTag += ` height="${heightMatch[1]}"`;
imgTag += '/>';
return imgTag;
} else {
return '';
}
}
let cleaned = match
.replace(/\s*xsrc="[^"]*"/g, '')
.replace(/\s*xwidth="[^"]*"/g, '')
.replace(/\s*xheight="[^"]*"/g, '');
if (!cleaned.endsWith('/>')) {
cleaned = cleaned.replace(/>$/, '/>');
}
return cleaned;
});
}
html += text;
} else if (elem.B === 'bookbody') {
if (elem.D && elem.D.length > 0) {
html += this.convertElements(elem.D, indent);
}
} else if (elem.B) {
html += `${spaces}<${elem.B}`;
if (elem.A) {
for (const attr of elem.A) {
html += ` ${attr.T}="${attr.d}"`;
}
}
if (elem.D && elem.D.length > 0) {
html += '>';
const childContent = this.convertElements(elem.D, 0);
const onlyText = elem.D.every(child => child.B === '#text');
if (onlyText) {
html += childContent;
} else {
html += '\n' + this.convertElements(elem.D, indent + 1) + spaces;
}
html += `</${elem.B}>\n`;
} else if (elem.J) {
let text = elem.J;
if (text.includes('<img')) {
text = text.replace(/<img([^>]*?)>/g, (match) => {
const xsrcMatch = match.match(/xsrc="([^"]*)"/);
if (xsrcMatch) {
const imageName = xsrcMatch[1].split('/').pop();
if (this.images[imageName]) {
const imageData = this.images[imageName];
const widthMatch = match.match(/width="([^"]*)"/);
const heightMatch = match.match(/height="([^"]*)"/);
const altMatch = match.match(/alt="([^"]*)"/);
let imgTag = `<img src="data:${imageData.mimeType};base64,${imageData.data}"`;
if (altMatch) imgTag += ` alt="${altMatch[1]}"`;
if (widthMatch) imgTag += ` width="${widthMatch[1]}"`;
if (heightMatch) imgTag += ` height="${heightMatch[1]}"`;
imgTag += '/>';
return imgTag;
} else {
return '';
}
}
let cleaned = match
.replace(/\s*xsrc="[^"]*"/g, '')
.replace(/\s*xwidth="[^"]*"/g, '')
.replace(/\s*xheight="[^"]*"/g, '');
if (!cleaned.endsWith('/>')) {
cleaned = cleaned.replace(/>$/, '/>');
}
return cleaned;
});
}
html += `>${text}</${elem.B}>\n`;
} else {
html += '/>\n';
}
}
}
return html;
}
getPageTitle(filename, content) {
const lowerFilename = filename.toLowerCase();
const pageTypes = {
'_tp_': 'Title Page', '_tp.': 'Title Page', 'tpb.': 'Title Page', 'tpc.': 'Title Page',
'_cop_': 'Copyright', '_cop.': 'Copyright', 'copb.': 'Copyright', 'copc.': 'Copyright',
'_ded_': 'Dedication', '_ded.': 'Dedication', 'dedb.': 'Dedication', 'dedc.': 'Dedication',
'_fm_': 'Epigraph', '_fm.': 'Epigraph', 'fm1b.': 'Epigraph', 'fm1c.': 'Epigraph',
'_toc_': 'Contents', '_toc.': 'Contents', 'tocb.': 'Contents', 'tocc.': 'Contents',
'_ack_': 'Acknowledgments', 'ackb.': 'Acknowledgments', 'ackc.': 'Acknowledgments',
'_prl_': 'Prologue', 'prob.': 'Prologue',
'_epl_': 'Epilogue',
'_atn_': "Author's Note",
'nts.': 'Notes',
'appc.': 'Appendix',
'_cvi_': 'Cover', '_cvi.': 'Cover'
};
for (const [pattern, title] of Object.entries(pageTypes)) {
if (lowerFilename.includes(pattern)) {
return title;
}
}
let title = this.extractTitle(content);
return this.cleanChapterTitle(title);
}
extractTitle(content) {
const elem = content.E || content;
if (elem.L) {
for (const child of elem.L) {
if (child.B === 'bookbody' && child.D) {
for (const bodyChild of child.D) {
if (bodyChild.B && ['h1', 'h2', 'h3', 'title'].includes(bodyChild.B.toLowerCase())) {
const title = this.extractText(bodyChild);
if (title && title.trim()) {
return title.trim();
}
}
}
} else if (child.B && ['h1', 'h2', 'h3', 'title'].includes(child.B.toLowerCase())) {
const title = this.extractText(child);
if (title && title.trim()) {
return title.trim();
}
}
}
}
const filename = (elem.T || 'Chapter').replace(/_/g, ' ').replace(/\..*$/, '');
return filename;
}
extractText(elem) {
if (elem.J) return elem.J;
if (elem.D) {
return elem.D.map(child => {
if (child.B === '#text' && child.J) return child.J;
return this.extractText(child);
}).join('');
}
return '';
}
cleanChapterTitle(title) {
title = title.replace(/<br\s*\/?>/gi, ' ');
title = title.replace(/<[^>]+>/g, '');
title = title.replace(/^(\d+)([A-Z])/, '$1. $2');
title = title.replace(/^(\d+\.)([A-Z])/, '$1 $2');
title = title.replace(/^(\d+)([A-Z]{2,})/, '$1. $2');
title = title.replace(/^(\d+\.)([A-Z]{2,})/, '$1 $2');
if (title.length > 4 && title === title.toUpperCase()) {
const numberWords = ['ONE', 'TWO', 'THREE', 'FOUR', 'FIVE', 'SIX', 'SEVEN', 'EIGHT', 'NINE', 'TEN',
'ELEVEN', 'TWELVE', 'THIRTEEN', 'FOURTEEN', 'FIFTEEN', 'SIXTEEN', 'SEVENTEEN',
'EIGHTEEN', 'NINETEEN', 'TWENTY', 'THIRTY', 'FORTY', 'FIFTY',
'PART ONE', 'PART TWO', 'PART THREE', 'PART FOUR', 'PART FIVE'];
if (!numberWords.includes(title)) {
title = title.split(' ').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ');
}
}
return title.trim().replace(/\s+/g, ' ');
}
generateOPF() {
const metadata = this.metadata;
let opf = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookID" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>${metadata.title || 'Unknown Title'}</dc:title>
<dc:creator opf:role="aut">${metadata.creator || 'Unknown Author'}</dc:creator>
<dc:language>${metadata.language || 'en'}</dc:language>
<dc:rights>${metadata.rights || ''}</dc:rights>
<dc:publisher>${metadata.publisher || ''}</dc:publisher>
<dc:identifier id="BookID" opf:scheme="ISBN">${metadata.identifier || 'unknown'}</dc:identifier>
</metadata>
<manifest>\n`;
for (const [id, file] of this.contentFiles) {
const filename = file.path.split('/').pop();
opf += ` <item id="${id}" href="${filename}" media-type="application/xhtml+xml"/>\n`;
}
opf += ` <item id="css" href="styles.css" media-type="text/css"/>\n`;
opf += ` <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>\n`;
opf += ` </manifest>
<spine toc="ncx">\n`;
for (const item of this.spine.spine) {
if (!item.T.includes('love_') && !item.T.includes('love ') && this.contentFiles.has(item.X)) {
opf += ` <itemref idref="${item.X}"/>\n`;
}
}
opf += ` </spine>
</package>`;
return opf;
}
generateNCX() {
const metadata = this.metadata;
let ncx = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content="${metadata.identifier || 'unknown'}"/>
<meta name="dtb:depth" content="1"/>
<meta name="dtb:totalPageCount" content="0"/>
<meta name="dtb:maxPageNumber" content="0"/>
</head>
<docTitle>
<text>${metadata.title || 'Unknown Title'}</text>
</docTitle>
<navMap>\n`;
let playOrder = 1;
for (const item of this.spine.spine) {
if (!item.T.includes('love_') && !item.T.includes('love ') && this.contentFiles.has(item.X)) {
const file = this.contentFiles.get(item.X);
const filename = file.path.split('/').pop();
ncx += ` <navPoint id="navPoint-${playOrder}" playOrder="${playOrder}">
<navLabel>
<text>${file.title || `Chapter ${playOrder}`}</text>
</navLabel>
<content src="${filename}"/>
</navPoint>\n`;
playOrder++;
}
}
ncx += ` </navMap>
</ncx>`;
return ncx;
}
getContainerXml() {
return `<?xml version="1.0" encoding="UTF-8"?>
<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>`;
}
getDefaultCSS() {
return `body {
font-family: Georgia, serif;
margin: 5%;
text-align: justify;
line-height: 1.6;
}
h1, h2, h3, h4, h5, h6 {
text-align: left;
font-family: sans-serif;
margin-top: 1em;
margin-bottom: 0.5em;
}
h1 {
font-size: 1.8em;
margin-top: 0;
page-break-before: always;
}
h2 { font-size: 1.4em; }
h3 { font-size: 1.2em; }
p {
margin-top: 0;
margin-bottom: 0.5em;
text-indent: 1.5em;
}
p.first, p.nonindent {
text-indent: 0;
}
.center { text-align: center; }
.italic { font-style: italic; }
.bold { font-weight: bold; }
blockquote {
margin: 1em 2em;
font-style: italic;
}
img {
max-width: 100%;
height: auto;
}
a {
color: #000080;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}`;
}
}
// ============================================================================
// Application Logic
// ============================================================================
let converter = null;
let currentEpubBlob = null;
let book = null;
let rendition = null;
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const statusMessages = document.getElementById('statusMessages');
const downloadBtn = document.getElementById('downloadBtn');
const resetBtn = document.getElementById('resetBtn');
const reader = document.getElementById('reader');
const readerControls = document.querySelector('.reader-controls');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
function addStatus(message, type = 'info') {
const div = document.createElement('div');
div.className = `status-message status-${type}`;
div.textContent = message;
statusMessages.appendChild(div);
statusMessages.scrollTop = statusMessages.scrollHeight;
}
function clearStatus() {
statusMessages.innerHTML = '<div class="status-message status-info">Ready to process HAR files</div>';
}
dropzone.addEventListener('click', () => {
fileInput.click();
});
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('active');
});
dropzone.addEventListener('dragleave', () => {
dropzone.classList.remove('active');
});
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('active');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
async function handleFile(file) {
if (!file.name.endsWith('.har')) {
addStatus('Please select a HAR file', 'error');
return;
}
clearStatus();
addStatus(`Loading ${file.name}...`, 'info');
dropzone.classList.add('processing');
try {
const harContent = await readFile(file);
converter = new HarToEpubConverter();
addStatus('Parsing HAR file...', 'info');
const { jsonCount, imageCount } = await converter.parseHar(harContent);
addStatus(`Found ${jsonCount} JSON files and ${imageCount} images`, 'success');
if (!converter.spine) {
throw new Error('No spine.json found - this may not be a B&N EPUB HAR file');
}
const metadata = converter.metadata;
addStatus(`Book: ${metadata.title || 'Unknown'}`, 'info');
addStatus(`Author: ${metadata.creator || 'Unknown'}`, 'info');
addStatus('Generating EPUB...', 'info');
currentEpubBlob = await converter.generateEpub((current, total, message) => {
if (current % 10 === 0 || current === total) {
addStatus(`Progress: ${current}/${total} - ${message}`, 'info');
}
});
addStatus('EPUB generated successfully!', 'success');
downloadBtn.disabled = false;
await renderEpub(currentEpubBlob);
} catch (error) {
console.error('Error:', error);
addStatus(`Error: ${error.message}`, 'error');
} finally {
dropzone.classList.remove('processing');
}
}
function readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = reject;
reader.readAsText(file);
});
}
// Simple reader implementation that doesn't rely on epub.js
let currentChapterIndex = 0;
let chapters = [];
let chaptersByFilename = {};
async function renderEpub(blob) {
try {
// Use simple reader instead of epub.js
await renderSimpleReader();
} catch (error) {
console.error('Error with simple reader, trying epub.js:', error);
// Fallback to epub.js
try {
await renderEpubJs(blob);
} catch (epubError) {
console.error('Error rendering with epub.js:', epubError);
addStatus(`Preview not available, but EPUB can be downloaded`, 'warning');
reader.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📚</div>
<h3>EPUB Generated Successfully</h3>
<p>Preview is not available in browser.</p>
<p>Click the download button to save your EPUB file.</p>
</div>
`;
}
}
}
async function renderSimpleReader() {
// Build chapters array from converted content
chapters = [];
chaptersByFilename = {};
for (const item of converter.spine.spine) {
if (!item.T.includes('love_') && !item.T.includes('love ') && converter.contentFiles.has(item.X)) {
const file = converter.contentFiles.get(item.X);
const jsonFile = converter.findJsonFile(item.O);
if (jsonFile) {
const content = converter.parsedJson[jsonFile];
const xhtml = converter.jsonToXhtml(content, item.T);
// Extract body content from XHTML
const bodyMatch = xhtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
const bodyContent = bodyMatch ? bodyMatch[1] : xhtml;
const chapterIndex = chapters.length;
chapters.push({
title: file.title || `Chapter ${chapterIndex + 1}`,
content: bodyContent,
filename: item.T
});
// Store mapping for link navigation
const filename = item.T.replace('OEBPS/', '');
chaptersByFilename[filename] = chapterIndex;
}
}
}
if (chapters.length === 0) {
throw new Error('No chapters found');
}
currentChapterIndex = 0;
displayChapter(currentChapterIndex);
readerControls.style.display = 'flex';
addStatus('Book preview loaded', 'success');
}
function displayChapter(index) {
if (index < 0 || index >= chapters.length) return;
currentChapterIndex = index;
const chapter = chapters[index];
// Add inline styles for the content
reader.innerHTML = `
<div style="padding: 20px; max-width: 800px; margin: 0 auto; font-family: Georgia, serif; line-height: 1.6;">
<h2 style="text-align: center; margin-bottom: 30px; color: #333;">${chapter.title}</h2>
<div style="text-align: justify;">
${chapter.content}
</div>
<div style="margin-top: 30px; text-align: center; color: #666; font-size: 14px;">
Chapter ${index + 1} of ${chapters.length}
</div>
</div>
`;
// Style images in the content
const images = reader.querySelectorAll('img');
images.forEach(img => {
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.display = 'block';
img.style.margin = '20px auto';
});
// Handle internal links (table of contents, etc.)
const links = reader.querySelectorAll('a[href]');
links.forEach(link => {
const href = link.getAttribute('href');
if (href && !href.startsWith('http')) {
// Internal link
link.style.color = '#0066cc';
link.style.cursor = 'pointer';
link.addEventListener('click', (e) => {
e.preventDefault();
// Extract filename from href (remove anchors)
const filename = href.split('#')[0];
// Find the chapter index for this filename
if (chaptersByFilename[filename] !== undefined) {
displayChapter(chaptersByFilename[filename]);
}
});
}
});
}
async function renderEpubJs(blob) {
// Clean up previous instance
if (book) {
book.destroy();
book = null;
rendition = null;
}
reader.innerHTML = '<div class="loading"><div class="spinner"></div>Loading EPUB...</div>';
// Convert blob to ArrayBuffer for epub.js
const arrayBuffer = await blob.arrayBuffer();
console.log('Converted to ArrayBuffer, size:', arrayBuffer.byteLength);
// Initialize epub.js with ArrayBuffer
book = ePub(arrayBuffer);
// Wait for the book to be ready
await book.ready;
console.log('Book is ready');
// Clear the loading message
reader.innerHTML = '';
// Create a div for the viewer
const viewerDiv = document.createElement('div');
viewerDiv.id = 'viewer';
viewerDiv.style.width = '100%';
viewerDiv.style.height = '100%';
reader.appendChild(viewerDiv);
// Render to the viewer div
rendition = book.renderTo(viewerDiv, {
width: "100%",
height: "100%",
spread: "none"
});
console.log('Rendition created');
// Display the first page
await rendition.display();
console.log('Content displayed');
// Show controls
readerControls.style.display = 'flex';
addStatus('EPUB rendered successfully', 'success');
}
prevBtn.addEventListener('click', () => {
if (rendition) {
rendition.prev();
} else if (chapters.length > 0) {
if (currentChapterIndex > 0) {
displayChapter(currentChapterIndex - 1);
}
}
});
nextBtn.addEventListener('click', () => {
if (rendition) {
rendition.next();
} else if (chapters.length > 0) {
if (currentChapterIndex < chapters.length - 1) {
displayChapter(currentChapterIndex + 1);
}
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') {
if (rendition) {
rendition.prev();
} else if (chapters.length > 0 && currentChapterIndex > 0) {
displayChapter(currentChapterIndex - 1);
}
} else if (e.key === 'ArrowRight') {
if (rendition) {
rendition.next();
} else if (chapters.length > 0 && currentChapterIndex < chapters.length - 1) {
displayChapter(currentChapterIndex + 1);
}
}
});
downloadBtn.addEventListener('click', () => {
if (currentEpubBlob) {
const metadata = converter.metadata;
const filename = metadata.title ?
`${metadata.title.replace(/[^a-z0-9]/gi, '_')}.epub` :
'converted.epub';
const url = URL.createObjectURL(currentEpubBlob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
addStatus(`Downloaded: ${filename}`, 'success');
}
});
resetBtn.addEventListener('click', () => {
converter = null;
currentEpubBlob = null;
chapters = [];
chaptersByFilename = {};
currentChapterIndex = 0;
if (book) {
book.destroy();
book = null;
rendition = null;
}
clearStatus();
downloadBtn.disabled = true;
readerControls.style.display = 'none';
reader.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📖</div>
<h3>No EPUB loaded</h3>
<p>Drop a HAR file to get started</p>
</div>
`;
fileInput.value = '';
});
clearStatus();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment