Last active
August 19, 2025 19:28
-
-
Save kevincolten/97293ffbb284e2f9058327f43bd95d86 to your computer and use it in GitHub Desktop.
Convert B&N Ebook JSON to EPUB
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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