From 7d56810ee63866259c265f5b72c23975f091bfcb Mon Sep 17 00:00:00 2001 From: pablohc Date: Sun, 22 Mar 2026 20:53:15 +0100 Subject: [PATCH] feat: integrated epub optimizer (#1224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Many e-ink readers have limited image decoder support natively. EPUBs with images in other formats than **baseline JPEG** frequently cause: - **Broken images**: pages render as blank, corrupted noise, or never load - **Slow rendering**: unoptimized images cause severe delays on e-ink hardware, up to 7 seconds per page turn, with cover images taking up to 59 seconds to render - **Broken covers**: the book thumbnail never generates Fixing this today requires external tools before uploading. --- ## What this PR does Adds an **optional, on-demand EPUB optimizer** to the file upload flow. When enabled, it converts all images to baseline JPEG directly in the browser — no server, no internet, no external tools needed. **Conversion is opt-in. The standard upload flow is unchanged.** --- ## Real-world impact The optimizer was applied in batch to **61 EPUBs**: - 60 standard EPUBs: 198 MB → 55 MB (**−72.2%**, 143 MB saved) - Text-dominant books: 8–46% smaller (covers and inline images converted) - Image-heavy / illustrated books: 65–93% smaller - 1 Large manga volume (594 MB): 594 MB → 72 MB (**−87.8%**, 522 MB saved) - EPUB structural integrity fully maintained — zero new validation issues introduced across all 61 books *Size and integrity analysis: [epub-comparator](https://github.com/pablohc/epub-comparator)* From that set, **17 books were selected** as a representative sample covering different content types: image-heavy novels, pure manga, light novels with broken images, and text-dominant books. Each was benchmarked on two devices running in parallel, one on `master` and one on `PR#1224` — measuring render time across ~30 pages per book on average. ### Rendering bugs fixed | Book | Problem (original) | After optimization | |------|--------------------|--------------------| | Fairy Tale — Stephen King | Cover took **59.7 s** to render | 2.1 s (−96%) | | Cycle of the Werewolf — Stephen King | Cover took **23.3 s** to render | 1.7 s (−93%) | | Tomie: Complete Deluxe Ed. — Junji Ito | Cover took **18.3 s** to render | 2.0 s (−89%) | | Joel Dicker — El tigre (Ed. Ilustrada) | Cover took **14.5 s** to render | 1.4 s (−90%) | | Jackson, Holly — Asesinato para principiantes | Cover failed completely (blank) | 2.0 s ✓ | | Sentenced to Be a Hero — Yen Press | Cover failed, **8 images failed to load** | All fixed ✓ | | Flynn, Gillian — Perdida | Cover failed completely (blank) | 1.6 s ✓ | | Chandler, Raymond — Asesino en la lluvia | Cover failed completely (blank) | 2.0 s ✓ | ### Page render times — image-heavy EPUBs (avg per page) | Book | Pages | Avg original | Avg optimized | Improvement | File size | |------|-------|-------------|---------------|-------------|-----------| | Fairy Tale — Stephen King | 30 | 3,028 ms | 1,066 ms | **−64.8%** | 32.4 MB → 9.1 MB (−72%) | | Cycle of the Werewolf — Stephen King | 33 | 3,026 ms | 1,558 ms | **−48.5%** | 35.1 MB → 2.9 MB (−92%) | | Joel Dicker — El tigre (Ed. Ilustrada) | 16 | 1,846 ms | 1,051 ms | **−43.1%** | 5.3 MB → 0.4 MB (−93%) | | Tomie: Complete Deluxe Ed. — Junji Ito | 30 | 4,817 ms | 2,802 ms | **−41.8%** | 593.8 MB → 72.2 MB (−87.8%) | | Sentenced to Be a Hero — Yen Press | 30 | 1,719 ms | 1,388 ms | **−19.2%** | 15.2 MB → 1.6 MB (−90%) | ### Text-heavy EPUBs — no regression | Book | Pages | Avg original | Avg optimized | Delta | |------|-------|-------------|---------------|-------| | Christie — Asesinato en el Orient Express | 30 | 1,672 ms | 1,646 ms | −1.6% | | Flynn — Perdida | 30 | 1,327 ms | 1,291 ms | −2.7% | | Dicker — La verdad sobre el caso Harry Quebert | 30 | 1,132 ms | 1,084 ms | −4.2% | | Hammett — El halcón maltés | 30 | 1,009 ms | 966 ms | −4.3% | | Chandler — Asesino en la lluvia | 30 | 989 ms | 1,007 ms | +1.8% | *Differences within ±5% — consistent with device measurement noise.* *Render time benchmark: [epub-optimization-benchmark](https://github.com/pablohc/epub-optimization-benchmark)* --- ## How to use it **Single file:** 1. Click **Upload** (top of the page) — a modal opens. Use **Choose files** to select one EPUB from your device. 2. Check **Optimize**. - *(Optional)* Expand **Advanced Mode** — adjust quality, rotation, or overlap; set individual images to H-Split / V-Split / Rotate. 3. Click **Optimize & Upload**. **Batch (2+ files):** 1. Click **Upload** (top of the page) — a modal opens. Use **Choose files** to select multiple EPUBs from your device. 2. Check **Optimize**. - *(Optional)* Expand **Advanced Mode** — adjust quality. 3. Click **Upload** — all files are converted and uploaded sequentially. Upload a batch of files, without optimization: image Batch file upload, with standard optimization: image Optimization Phase (1/2): image Upload Phase (2/2): image Batch upload successfully confirmed: image --- ## Options **Always active when the converter is enabled:** - Converts PNG, WebP, BMP, GIF → baseline JPEG - Smart downscaling to 480×800 px max (preserves aspect ratio) - True grayscale for e-ink (BT.709 luminance, always on) - SVG cover fix + OPF/NCX compliance repairs **Advanced Mode (opt-in) — single file:** - JPEG quality presets: 30% / 45% / 60% / 75% / **85%** (default) / 95% - Rotation direction for split images: CW (default) / CCW - Min overlap when splitting: 5% (default) / 10% / 15% - Auto-download conversion log toggle (detailed stats per image) - Per-image picker: set Normal / H-Split / V-Split / Rotate per image individually, with "Apply to all" for bulk assignment **Advanced Mode (opt-in) — batch (2+ files):** - JPEG quality presets: 30% / 45% / 60% / 75% / **85%** (default) / 95% - Auto-download conversion log toggle (aggregated stats for all files) --- ## ⚠️ Known limitations **KoReader hash-based sync will break** for converted files. The file content changes, so the hash no longer matches the original. Filename-based sync is unaffected. If you rely on KoReader hash sync, use the Calibre plugin or the web tool instead. --- ## Build size impact | Metric | master (53beeee) | PR #1224 (a2ba5db) | Delta | |---------------|------------------|--------------------|----------------| | Flash used | 5,557 KB | 5,616 KB | +59 KB (+1.1%) | | Flash free | 843 KB | 784 KB | −59 KB | | Flash usage | 86.8% | 87.7% | +0.9 pp | | RAM used | 95,156 B | 95,156 B | no change | > Both builds compiled with `gh_release` environment in release mode (ESP32-C3, 6,400 KB Flash). > The +59 KB increase is entirely due to `jszip.min.js` embedded as a > gzipped static asset served from Flash. RAM usage is identical, > confirming no runtime overhead — the library runs in the browser, > not on the ESP32. ~784 KB of Flash remain available. --- ## Alternatives considered | Approach | Friction | |----------|---------| | **This PR** — integrated in upload flow | Zero: convert + upload in one step, offline, any browser | | Calibre plugin (in parallel development) | Requires a computer with Calibre installed, same network | | Web converters | Requires extra upload / download / transfer steps | --- ## Credits Based on the converter algorithm developed by @zgredex. Co-authored-by: @zgredex --- ### AI Usage Did you use AI tools to help write this code? **PARTIALLY** --------- Co-authored-by: zgredex --- scripts/build_html.py | 45 +- src/network/CrossPointWebServer.cpp | 126 +- src/network/CrossPointWebServer.h | 2 + src/network/html/FilesPage.html | 3748 ++++++++++++++++++++++++++- src/network/html/js/jszip.min.js | 13 + 5 files changed, 3851 insertions(+), 83 deletions(-) create mode 100644 src/network/html/js/jszip.min.js diff --git a/scripts/build_html.py b/scripts/build_html.py index b144d5dc..f2cfb7e9 100644 --- a/scripts/build_html.py +++ b/scripts/build_html.py @@ -32,21 +32,41 @@ def minify_html(html: str) -> str: return html.strip() +def sanitize_identifier(name: str) -> str: + """Sanitize a filename to create a valid C identifier. + + C identifiers must: + - Start with a letter or underscore + - Contain only letters, digits, and underscores + """ + # Replace non-alphanumeric characters (including hyphens) with underscores + sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', name) + # Prefix with underscore if starts with a digit + if sanitized and sanitized[0].isdigit(): + sanitized = f"_{sanitized}" + return sanitized + for root, _, files in os.walk(SRC_DIR): for file in files: - if file.endswith(".html"): - html_path = os.path.join(root, file) - with open(html_path, "r", encoding="utf-8") as f: - html_content = f.read() + if file.endswith(".html") or file.endswith(".js"): + file_path = os.path.join(root, file) + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() - # minified = regex.sub("\g<1>", html_content) - minified = minify_html(html_content) + # Only minify HTML files; JS files are typically pre-minified (e.g., jszip.min.js) + if file.endswith(".html"): + processed = minify_html(content) + else: + processed = content # Compress with gzip (compresslevel 9 is maximum compression) # IMPORTANT: we don't use brotli because Firefox doesn't support brotli with insecured context (only supported on HTTPS) - compressed = gzip.compress(minified.encode('utf-8'), compresslevel=9) + compressed = gzip.compress(processed.encode('utf-8'), compresslevel=9) - base_name = f"{os.path.splitext(file)[0]}Html" + # Create valid C identifier from filename + # Use appropriate suffix based on file type + suffix = "Html" if file.endswith(".html") else "Js" + base_name = sanitize_identifier(f"{os.path.splitext(file)[0]}{suffix}") header_path = os.path.join(root, f"{base_name}.generated.h") with open(header_path, "w", encoding="utf-8") as h: @@ -65,10 +85,9 @@ for root, _, files in os.walk(SRC_DIR): h.write(f"}};\n\n") h.write(f"constexpr size_t {base_name}CompressedSize = {len(compressed)};\n") - h.write(f"constexpr size_t {base_name}OriginalSize = {len(minified)};\n") + h.write(f"constexpr size_t {base_name}OriginalSize = {len(processed)};\n") print(f"Generated: {header_path}") - print(f" Original: {len(html_content)} bytes") - print(f" Minified: {len(minified)} bytes ({100*len(minified)/len(html_content):.1f}%)") - print(f" Compressed: {len(compressed)} bytes ({100*len(compressed)/len(html_content):.1f}%)") - + print(f" Original: {len(content)} bytes") + print(f" Minified: {len(processed)} bytes ({100*len(processed)/len(content):.1f}%)") + print(f" Compressed: {len(compressed)} bytes ({100*len(compressed)/len(content):.1f}%)") diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index bb090604..dba8faa2 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -16,6 +16,7 @@ #include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" #include "html/SettingsPageHtml.generated.h" +#include "html/js/jszip_minJs.generated.h" namespace { // Folders/files to hide from the web interface file browser @@ -36,6 +37,8 @@ size_t wsUploadSize = 0; size_t wsUploadReceived = 0; unsigned long wsUploadStartTime = 0; bool wsUploadInProgress = false; +uint8_t wsUploadClientNum = 255; // 255 = no active upload client +size_t wsLastProgressSent = 0; String wsLastCompleteName; size_t wsLastCompleteSize = 0; unsigned long wsLastCompleteAt = 0; @@ -131,6 +134,7 @@ void CrossPointWebServer::begin() { LOG_DBG("WEB", "Setting up routes..."); server->on("/", HTTP_GET, [this] { handleRoot(); }); server->on("/files", HTTP_GET, [this] { handleFileList(); }); + server->on("/js/jszip.min.js", HTTP_GET, [this] { handleJszip(); }); server->on("/api/status", HTTP_GET, [this] { handleStatus(); }); server->on("/api/files", HTTP_GET, [this] { handleFileListData(); }); @@ -188,6 +192,21 @@ void CrossPointWebServer::begin() { LOG_DBG("WEB", "[MEM] Free heap after server.begin(): %d bytes", ESP.getFreeHeap()); } +void CrossPointWebServer::abortWsUpload(const char* tag) { + wsUploadFile.close(); + String filePath = wsUploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += wsUploadFileName; + if (Storage.remove(filePath.c_str())) { + LOG_DBG(tag, "Deleted incomplete upload: %s", filePath.c_str()); + } else { + LOG_DBG(tag, "Failed to delete incomplete upload: %s", filePath.c_str()); + } + wsUploadInProgress = false; + wsUploadClientNum = 255; + wsLastProgressSent = 0; +} + void CrossPointWebServer::stop() { if (!running || !server) { LOG_DBG("WEB", "stop() called but already stopped (running=%d, server=%p)", running, server.get()); @@ -199,10 +218,9 @@ void CrossPointWebServer::stop() { LOG_DBG("WEB", "[MEM] Free heap before stop: %d bytes", ESP.getFreeHeap()); - // Close any in-progress WebSocket upload + // Close any in-progress WebSocket upload and remove partial file if (wsUploadInProgress && wsUploadFile) { - wsUploadFile.close(); - wsUploadInProgress = false; + abortWsUpload("WEB"); } // Stop WebSocket server @@ -309,6 +327,12 @@ void CrossPointWebServer::handleRoot() const { LOG_DBG("WEB", "Served root page"); } +void CrossPointWebServer::handleJszip() const { + server->sendHeader("Content-Encoding", "gzip"); + server->send_P(200, "application/javascript", jszip_minJs, jszip_minJsCompressedSize); + LOG_DBG("WEB", "Served jszip.min.js"); +} + void CrossPointWebServer::handleNotFound() const { String message = "404 Not Found\n\n"; message += "URI: " + server->uri() + "\n"; @@ -505,7 +529,26 @@ void CrossPointWebServer::handleDownload() const { server->send(200, contentType.c_str(), ""); NetworkClient client = server->client(); - client.write(file); + const size_t chunkSize = 4096; + uint8_t buffer[chunkSize]; + + bool downloadOk = true; + while (downloadOk && file.available()) { + int result = file.read(buffer, chunkSize); + if (result <= 0) break; + size_t bytesRead = static_cast(result); + size_t totalWritten = 0; + while (totalWritten < bytesRead) { + esp_task_wdt_reset(); + size_t wrote = client.write(buffer + totalWritten, bytesRead - totalWritten); + if (wrote == 0) { + downloadOk = false; + break; + } + totalWritten += wrote; + } + } + client.clear(); file.close(); } @@ -1082,7 +1125,7 @@ void CrossPointWebServer::handleGetSettings() const { doc["type"] = "string"; if (s.stringGetter) { doc["value"] = s.stringGetter(); - } else if (s.stringOffset > 0) { + } else if (s.stringMaxLen > 0) { doc["value"] = reinterpret_cast(&SETTINGS) + s.stringOffset; } break; @@ -1166,7 +1209,7 @@ void CrossPointWebServer::handlePostSettings() { const std::string val = doc[s.key].as(); if (s.stringSetter) { s.stringSetter(val); - } else if (s.stringOffset > 0 && s.stringMaxLen > 0) { + } else if (s.stringMaxLen > 0) { char* ptr = reinterpret_cast(&SETTINGS) + s.stringOffset; strncpy(ptr, val.c_str(), s.stringMaxLen - 1); ptr[s.stringMaxLen - 1] = '\0'; @@ -1202,17 +1245,12 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* switch (type) { case WStype_DISCONNECTED: LOG_DBG("WS", "Client %u disconnected", num); - // Clean up any in-progress upload - if (wsUploadInProgress && wsUploadFile) { - wsUploadFile.close(); - // Delete incomplete file - String filePath = wsUploadPath; - if (!filePath.endsWith("/")) filePath += "/"; - filePath += wsUploadFileName; - Storage.remove(filePath.c_str()); - LOG_DBG("WS", "Deleted incomplete upload: %s", filePath.c_str()); + // Only clean up if this is the client that owns the active upload. + // A new client may have already started a fresh upload before this + // DISCONNECTED event fires (race condition on quick cancel + retry). + if (num == wsUploadClientNum && wsUploadInProgress && wsUploadFile) { + abortWsUpload("WS"); } - wsUploadInProgress = false; break; case WStype_CONNECTED: { @@ -1226,15 +1264,35 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* LOG_DBG("WS", "Text from client %u: %s", num, msg.c_str()); if (msg.startsWith("START:")) { + // Reject any START while an upload is already active to prevent + // leaking the open wsUploadFile handle (owning client re-START included) + if (wsUploadInProgress) { + wsServer->sendTXT(num, "ERROR:Upload already in progress"); + break; + } + // Parse: START::: int firstColon = msg.indexOf(':', 6); int secondColon = msg.indexOf(':', firstColon + 1); if (firstColon > 0 && secondColon > 0) { wsUploadFileName = msg.substring(6, firstColon); - wsUploadSize = msg.substring(firstColon + 1, secondColon).toInt(); + String sizeToken = msg.substring(firstColon + 1, secondColon); + bool sizeValid = sizeToken.length() > 0; + int digitStart = (sizeValid && sizeToken[0] == '+') ? 1 : 0; + if (digitStart > 0 && sizeToken.length() < 2) sizeValid = false; + for (int i = digitStart; i < (int)sizeToken.length() && sizeValid; i++) { + if (!isdigit((unsigned char)sizeToken[i])) sizeValid = false; + } + if (!sizeValid) { + LOG_DBG("WS", "START rejected: invalid size token '%s'", sizeToken.c_str()); + wsServer->sendTXT(num, "ERROR:Invalid START format"); + return; + } + wsUploadSize = sizeToken.toInt(); wsUploadPath = msg.substring(secondColon + 1); wsUploadReceived = 0; + wsLastProgressSent = 0; wsUploadStartTime = millis(); // Ensure path is valid @@ -1262,10 +1320,25 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) { wsServer->sendTXT(num, "ERROR:Failed to create file"); wsUploadInProgress = false; + wsUploadClientNum = 255; return; } esp_task_wdt_reset(); + // Zero-byte upload: complete immediately without waiting for BIN frames + if (wsUploadSize == 0) { + wsUploadFile.close(); + wsLastCompleteName = wsUploadFileName; + wsLastCompleteSize = 0; + wsLastCompleteAt = millis(); + LOG_DBG("WS", "Zero-byte upload complete: %s", filePath.c_str()); + clearEpubCacheIfNeeded(filePath); + wsServer->sendTXT(num, "DONE"); + wsLastProgressSent = 0; + break; + } + + wsUploadClientNum = num; wsUploadInProgress = true; wsServer->sendTXT(num, "READY"); } else { @@ -1276,19 +1349,24 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* } case WStype_BIN: { - if (!wsUploadInProgress || !wsUploadFile) { + if (!wsUploadInProgress || !wsUploadFile || num != wsUploadClientNum) { wsServer->sendTXT(num, "ERROR:No upload in progress"); return; } // Write binary data directly to file + size_t remaining = wsUploadSize - wsUploadReceived; + if (length > remaining) { + abortWsUpload("WS"); + wsServer->sendTXT(num, "ERROR:Upload overflow"); + return; + } esp_task_wdt_reset(); size_t written = wsUploadFile.write(payload, length); esp_task_wdt_reset(); if (written != length) { - wsUploadFile.close(); - wsUploadInProgress = false; + abortWsUpload("WS"); wsServer->sendTXT(num, "ERROR:Write failed - disk full?"); return; } @@ -1296,17 +1374,17 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* wsUploadReceived += written; // Send progress update (every 64KB or at end) - static size_t lastProgressSent = 0; - if (wsUploadReceived - lastProgressSent >= 65536 || wsUploadReceived >= wsUploadSize) { + if (wsUploadReceived - wsLastProgressSent >= 65536 || wsUploadReceived >= wsUploadSize) { String progress = "PROGRESS:" + String(wsUploadReceived) + ":" + String(wsUploadSize); wsServer->sendTXT(num, progress); - lastProgressSent = wsUploadReceived; + wsLastProgressSent = wsUploadReceived; } // Check if upload complete if (wsUploadReceived >= wsUploadSize) { wsUploadFile.close(); wsUploadInProgress = false; + wsUploadClientNum = 255; wsLastCompleteName = wsUploadFileName; wsLastCompleteSize = wsUploadSize; @@ -1325,7 +1403,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* clearEpubCacheIfNeeded(filePath); wsServer->sendTXT(num, "DONE"); - lastProgressSent = 0; + wsLastProgressSent = 0; } break; } diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index dc1fd0a6..6568d30a 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -81,6 +81,7 @@ class CrossPointWebServer { // WebSocket upload state void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length); static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length); + void abortWsUpload(const char* tag); // File scanning void scanFiles(const char* path, const std::function& callback) const; @@ -89,6 +90,7 @@ class CrossPointWebServer { // Request handlers void handleRoot() const; + void handleJszip() const; void handleNotFound() const; void handleStatus() const; void handleFileList() const; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 509c11b8..91c1967d 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -4,6 +4,7 @@ CrossPoint Reader - Files + @@ -655,7 +1478,7 @@
- +
@@ -691,16 +1514,159 @@ @@ -712,8 +1678,9 @@

📁 New Folder

Create a new folder in

- - + + +
@@ -768,6 +1735,25 @@ // get current path from query parameter const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/'); + // Network status monitoring + let isNetworkOnline = navigator.onLine; + + // Add network status listeners + window.addEventListener('online', () => { + console.log('[Network] Online event fired'); + isNetworkOnline = true; + showNotification('Network connection restored', 'success'); + }); + + window.addEventListener('offline', () => { + console.log('[Network] Offline event fired'); + isNetworkOnline = false; + showNotification('Network connection lost', 'warning'); + }); + + // Initialize network status + console.log('[Network] Initial status:', isNetworkOnline ? 'online' : 'offline'); + function escapeHtml(unsafe) { return unsafe .replaceAll("&", "&") @@ -777,6 +1763,54 @@ .replaceAll("'", "'"); } + function showNotification(message, type = 'info') { + // Create notification element if it doesn't exist + let notification = document.getElementById('notification'); + if (!notification) { + notification = document.createElement('div'); + notification.id = 'notification'; + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 12px 20px; + border-radius: 4px; + color: white; + font-weight: 500; + z-index: 10000; + max-width: 300px; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + transition: all 0.3s ease; + `; + document.body.appendChild(notification); + } + + // Set styles based on type + const styles = { + 'success': 'background-color: #27ae60;', + 'error': 'background-color: #e74c3c;', + 'warning': 'background-color: #f39c12;', + 'info': 'background-color: #3498db;' + }; + notification.style.cssText += styles[type] || styles['info']; + notification.textContent = message; + + // Show notification + notification.style.opacity = '1'; + notification.style.transform = 'translateX(0)'; + + // Auto-hide after 5 seconds + setTimeout(() => { + notification.style.opacity = '0'; + notification.style.transform = 'translateX(100%)'; + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 300); + }, 5000); + } + function formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; @@ -786,10 +1820,19 @@ } async function hydrate() { - // Close modals when clicking overlay + // Fetch CrossPoint version + fetchVersion(); + + // Close modals when clicking overlay - call proper cleanup functions document.querySelectorAll('.modal-overlay').forEach(function(overlay) { overlay.addEventListener('click', function(e) { if (e.target === overlay) { + // Call the appropriate close function for each modal to ensure cleanup + if (overlay.id === 'uploadModal') return closeUploadModal(); + if (overlay.id === 'folderModal') return closeFolderModal(); + if (overlay.id === 'deleteModal') return closeDeleteModal(); + if (overlay.id === 'renameModal') return closeRenameModal(); + if (overlay.id === 'moveModal') return closeMoveModal(); overlay.classList.remove('open'); } }); @@ -814,7 +1857,7 @@ let files = []; try { - const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath)); + const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath) + '&_=' + Date.now()); if (!response.ok) { throw new Error('Failed to load files: ' + response.status + ' ' + response.statusText); } @@ -896,23 +1939,859 @@ // Modal functions function openUploadModal() { + // Reset converter variables to defaults + ENABLE_GRAYSCALE = true; + JPEG_QUALITY = 85; + HANDEDNESS = 'right'; + OVERLAP_PERCENT = 5; + imageStates = {}; + + // Hide convert options when opening modal (no files selected initially) + const convertOptions = document.getElementById('convertOptions'); + if (convertOptions) { + convertOptions.style.display = 'none'; + } + + // Reset rotation and overlap UI + setHandedness('right'); + setOverlap(5); + + // Hide log section from previous session + const logSection = document.getElementById('log-section'); + if (logSection) logSection.classList.remove('visible'); + const logContainer = document.getElementById('log-container'); + if (logContainer) logContainer.innerHTML = ''; + document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath; document.getElementById('uploadModal').classList.add('open'); } + function handleCancelUploadModal() { + if (!isUploadInProgress) { + // No process running: close the modal normally + closeUploadModal(); + return; + } + // Process running: stop it, keep modal open for retry + operationCancelled = true; + if (currentUploadWs) { currentUploadWs.close(); currentUploadWs = null; } + if (currentUploadXhr) { currentUploadXhr.abort(); currentUploadXhr = null; } + // isUploadInProgress and UI are restored by restoreAfterCancel() from the async handlers + } + + function restoreAfterCancel() { + operationCancelled = false; + isUploadInProgress = false; + document.getElementById('uploadModalClose').classList.remove('disabled'); + const progressFill = document.getElementById('progress-fill'); + const progressText = document.getElementById('progress-text'); + progressFill.style.width = '0%'; + progressFill.style.backgroundColor = '#e74c3c'; + progressText.style.color = '#e74c3c'; + progressText.textContent = 'Upload cancelled by User!'; + // Re-enable the action button (uploadBtn is always visible at this point; + // its text is already "Upload" or "Optimize & Upload" depending on checkbox state) + document.getElementById('uploadBtn').disabled = false; + } + function closeUploadModal() { + // Prevent closing during upload/conversion + if (isUploadInProgress) { + return; + } document.getElementById('uploadModal').classList.remove('open'); - document.getElementById('fileInput').value = ''; - document.getElementById('uploadBtn').disabled = true; + const fileInput = document.getElementById('fileInput'); + fileInput.value = ''; + fileInput.classList.remove('has-files'); + const uploadBtn = document.getElementById('uploadBtn'); + uploadBtn.disabled = true; + uploadBtn.style.display = 'block'; + uploadBtn.textContent = 'Upload'; + uploadBtn.classList.remove('optimize'); + document.getElementById('startConversionBtn').style.display = 'none'; document.getElementById('progress-container').style.display = 'none'; document.getElementById('progress-fill').style.width = '0%'; document.getElementById('progress-fill').style.backgroundColor = '#27ae60'; + document.getElementById('convertBeforeUpload').checked = false; + document.getElementById('convertInfo').style.display = 'none'; + document.getElementById('convertWarning').style.display = 'none'; + // Clear image picker cache and reset layout + epubImagesCache = []; + imageStates = {}; + document.getElementById('imagePickerSection').style.display = 'none'; + const imageGrid = document.getElementById('imageGrid'); + if (imageGrid) imageGrid.innerHTML = ''; + document.querySelector('#uploadModal .modal').classList.remove('picker-mode'); + document.getElementById('pickerColumns').classList.remove('picker-active'); + // Hide log section + if (logSection) logSection.classList.remove('visible'); + // Reset advanced options + const advancedSettingsContent = document.getElementById('advancedSettingsContent'); + if (advancedSettingsContent) advancedSettingsContent.classList.remove('visible'); + document.getElementById('advancedOptionsArrow').classList.remove('expanded'); + const advancedOptionsToggle = document.getElementById('advancedOptionsToggle'); + if (advancedOptionsToggle) { + advancedOptionsToggle.style.opacity = '0.5'; + advancedOptionsToggle.style.pointerEvents = 'none'; + } + // Reset to defaults + document.getElementById('qualitySlider').value = 85; + document.getElementById('qualityInput').value = 85; + setHandedness('right'); + setOverlap(5); + // Update converter variables + updateQualitySettings(); } + function updateBatchModeUI(isBatch) { + const rotationRow = document.getElementById('rotationSettingRow'); + const overlapRow = document.getElementById('overlapSettingRow'); + if (rotationRow) rotationRow.style.display = isBatch ? 'none' : ''; + if (overlapRow) overlapRow.style.display = isBatch ? 'none' : ''; + } + + function toggleConvertOptions() { + const checked = document.getElementById('convertBeforeUpload').checked; + const uploadBtn = document.getElementById('uploadBtn'); + document.getElementById('convertWarning').style.display = checked ? 'block' : 'none'; + document.getElementById('convertInfo').style.display = checked ? 'block' : 'none'; + // Update button text and style + if (checked) { + uploadBtn.textContent = 'Optimize & Upload'; + uploadBtn.classList.add('optimize'); + } else { + uploadBtn.textContent = 'Upload'; + uploadBtn.classList.remove('optimize'); + // Clear image picker when unchecking + clearImagePicker(); + } + // Reset advanced options when toggling off + if (!checked) { + const advancedSettingsContent = document.getElementById('advancedSettingsContent'); + if (advancedSettingsContent) advancedSettingsContent.classList.remove('visible'); + document.getElementById('advancedOptionsArrow').classList.remove('expanded'); + } + // Disable/enable advanced mode toggle + const advancedOptionsToggle = document.getElementById('advancedOptionsToggle'); + if (advancedOptionsToggle) { + advancedOptionsToggle.style.opacity = checked ? '1' : '0.5'; + advancedOptionsToggle.style.pointerEvents = checked ? 'auto' : 'none'; + } + } + + function toggleAdvancedOptions() { + // Check if advanced options toggle is enabled (optimize EPUB must be checked) + const convertEnabled = document.getElementById('convertBeforeUpload').checked; + if (!convertEnabled) { + return; // Don't toggle if optimization is disabled + } + + const content = document.getElementById('advancedSettingsContent'); + const arrow = document.getElementById('advancedOptionsArrow'); + const isExpanding = !content.classList.contains('visible'); + + content.classList.toggle('visible'); + arrow.classList.toggle('expanded'); + + // When expanding, show image picker if an EPUB is selected + if (isExpanding) { + const fileInput = document.getElementById('fileInput'); + const files = fileInput.files; + if (files.length === 1 && files[0].name.toLowerCase().endsWith('.epub')) { + showImagePicker(files[0]).catch(err => console.error('Image picker error:', err)); + } + } else { + // When collapsing, hide the picker and startConversionBtn + document.getElementById('imagePickerSection').style.display = 'none'; + document.getElementById('startConversionBtn').style.display = 'none'; + document.getElementById('uploadBtn').style.display = 'block'; + document.getElementById('uploadBtn').disabled = false; + document.querySelector('#uploadModal .modal').classList.remove('picker-mode'); + document.getElementById('pickerColumns').classList.remove('picker-active'); + } + } + + function setQualityPreset(value) { + document.getElementById('qualitySlider').value = value; + document.getElementById('qualityInput').value = value; + // Update active preset + document.querySelectorAll('.quality-preset').forEach(btn => { + btn.classList.remove('active'); + if (parseInt(btn.dataset.value, 10) === value) { + btn.classList.add('active'); + } + }); + updateQualitySettings(); + } + + function updateQualitySettings() { + const quality = document.getElementById('qualitySlider').value; + // Check if grayscaleToggle exists (may be hidden for compatibility with other devices) + const grayscaleToggle = document.getElementById('grayscaleToggle'); + const grayscale = grayscaleToggle ? grayscaleToggle.checked : true; // Default to true for e-ink + + // Update displays (if element exists) + const qualityDisplay = document.getElementById('qualityDisplaySimple'); + if (qualityDisplay) { + qualityDisplay.textContent = '📦 ' + quality + '% JPEG'; + } + + // Update converter variables (used by processImage and applyGrayscale) + JPEG_QUALITY = parseInt(quality, 10); + ENABLE_GRAYSCALE = true; // Always grayscale for e-ink + } + + function setHandedness(value) { + HANDEDNESS = value; + // Update UI + document.getElementById('rotationCW').classList.remove('active'); + document.getElementById('rotationCCW').classList.remove('active'); + document.getElementById(value === 'right' ? 'rotationCW' : 'rotationCCW').classList.add('active'); + // Re-render grid to update rotation arrows + if (document.getElementById('imagePickerSection').style.display !== 'none') { + renderImageGrid(); + } + } + + function setOverlap(value) { + OVERLAP_PERCENT = value; + // Update UI + document.querySelectorAll('.overlap-btn').forEach(btn => { + btn.classList.toggle('active', parseInt(btn.dataset.value) === value); + }); + } + + // ============================================================================ + // Image Picker Functions + // ============================================================================ + + /** + * Extract images from EPUB for preview + * Returns array of {path, name, dataUrl, width, height} + */ + async function extractImagesForPreview(file) { + const zip = await JSZip.loadAsync(file); + const imageExtensions = ['.png', '.gif', '.webp', '.bmp', '.jpg', '.jpeg']; + + // Collect all image paths first + const allImages = []; + for (const [path, fileObj] of Object.entries(zip.files)) { + if (fileObj.dir) continue; + const ext = path.substring(path.lastIndexOf('.')).toLowerCase(); + if (imageExtensions.includes(ext)) { + allImages.push(path); + } + } + + // Try to get images in reading order from OPF spine + let orderedImages = []; + let coverImagePath = null; // Track cover image + try { + // Find OPF file + let opfPath = null; + zip.forEach(p => { if (p.toLowerCase().endsWith('.opf')) opfPath = p; }); + + if (opfPath) { + const opfContent = await zip.files[opfPath].async('string'); + const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/')) : ''; + + // Detect cover image from OPF + let coverId = null; + let m; + // Try 1: properties="cover-image" + if (m = opfContent.match(/]+id=["']([^"']+)["'][^>]+properties="[^"]*cover-image[^"]*"/)) coverId = m[1]; + if (!coverId && (m = opfContent.match(/]+properties="[^"]*cover-image[^"]*"[^>]+id=["']([^"']+)["']/))) coverId = m[1]; + // Try 2: meta name="cover" content="id" + if (!coverId && (m = opfContent.match(/ href mapping + const manifest = {}; + const manifestRegex = /]+id=["']([^"']+)["'][^>]+href=["']([^"']+)["'][^>]*>/gi; + let match; + while ((match = manifestRegex.exec(opfContent)) !== null) { + const id = match[1]; + const href = match[2]; + const fullPath = opfDir ? opfDir + '/' + href : href; + manifest[id] = fullPath; + if (id === coverId) coverImagePath = fullPath; + } + // Also check reversed attribute order + const manifestRegex2 = /]+href=["']([^"']+)["'][^>]+id=["']([^"']+)["'][^>]*>/gi; + while ((match = manifestRegex2.exec(opfContent)) !== null) { + const href = match[1]; + const id = match[2]; + const fullPath = opfDir ? opfDir + '/' + href : href; + manifest[id] = fullPath; + if (id === coverId) coverImagePath = fullPath; + } + + // Cover-page reconciliation — if the cover XHTML references a different + // but byte-identical image, prefer the one actually displayed on the page. + if (coverImagePath) { + try { + let coverXhtmlPath = null; + const guideM = opfContent.match(/<(?:\w+:)?reference[^>]+type=["']cover["'][^>]+href=["']([^"']+)["']/i) || + opfContent.match(/<(?:\w+:)?reference[^>]+href=["']([^"']+)["'][^>]+type=["']cover["']/i); + if (guideM) { + coverXhtmlPath = opfDir ? opfDir + '/' + decodeHref(guideM[1]) : decodeHref(guideM[1]); + } + if (!coverXhtmlPath) { + const spineM = opfContent.match(/<(?:\w+:)?itemref[^>]+idref=["']([^"']+)["']/i); + if (spineM && manifest[spineM[1]]) coverXhtmlPath = manifest[spineM[1]]; + } + if (coverXhtmlPath && zip.files[coverXhtmlPath]) { + const coverXhtml = await zip.files[coverXhtmlPath].async('string'); + const imgM = coverXhtml.match(/(?:src|xlink:href)=["']([^"']+)["']/i); + if (imgM) { + const href = imgM[1]; + const xDir = coverXhtmlPath.includes('/') ? coverXhtmlPath.substring(0, coverXhtmlPath.lastIndexOf('/')) : ''; + let pageImgPath = href.startsWith('../') ? xDir.split('/').slice(0, -1).join('/') + '/' + href.substring(3) + : href.startsWith('/') ? href.substring(1) + : xDir ? xDir + '/' + href : href; + pageImgPath = pageImgPath.replace(/\/+/g, '/'); + for (const realPath of allImages) { + if (realPath === pageImgPath || realPath.endsWith('/' + href) || realPath.endsWith(href)) { + pageImgPath = realPath; break; + } + } + if (pageImgPath !== coverImagePath && allImages.includes(pageImgPath) && zip.files[pageImgPath]) { + const coverData = await zip.files[coverImagePath].async('arraybuffer'); + const pageData = await zip.files[pageImgPath].async('arraybuffer'); + if (coverData.byteLength === pageData.byteLength) { + const a = new Uint8Array(coverData); + const b = new Uint8Array(pageData); + let identical = true; + for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { identical = false; break; } } + if (identical) coverImagePath = pageImgPath; + } + } + } + } + } catch (e) { /* non-critical */ } + } + + // Parse spine to get reading order + const spineOrder = []; + const spineRegex = /]+idref=["']([^"']+)["'][^>]*>/gi; + while ((match = spineRegex.exec(opfContent)) !== null) { + const idref = match[1]; + if (manifest[idref]) spineOrder.push(manifest[idref]); + } + + // For each spine item (XHTML), extract images in order + const seenImages = new Set(); + for (const xhtmlPath of spineOrder) { + if (!zip.files[xhtmlPath]) continue; + const xhtmlContent = await zip.files[xhtmlPath].async('string'); + const xhtmlDir = xhtmlPath.includes('/') ? xhtmlPath.substring(0, xhtmlPath.lastIndexOf('/')) : ''; + + // Find all image references + const imgRegex = /(?:src|xlink:href)=["']([^"']+)["']/gi; + while ((match = imgRegex.exec(xhtmlContent)) !== null) { + let imgHref = match[1]; + // Skip non-image references + if (!imageExtensions.some(ext => imgHref.toLowerCase().endsWith(ext))) continue; + + // Resolve relative path + let imgPath; + if (imgHref.startsWith('../')) { + // Go up from xhtmlDir + const parts = xhtmlDir.split('/'); + parts.pop(); + imgPath = parts.join('/') + '/' + imgHref.substring(3); + } else if (imgHref.startsWith('/')) { + imgPath = imgHref.substring(1); + } else { + imgPath = xhtmlDir ? xhtmlDir + '/' + imgHref : imgHref; + } + // Normalize path + imgPath = imgPath.replace(/\/+/g, '/'); + + // Check if this is actually an image in our list + for (const realPath of allImages) { + if (realPath === imgPath || realPath.endsWith('/' + imgHref) || realPath.endsWith(imgHref)) { + if (!seenImages.has(realPath)) { + seenImages.add(realPath); + orderedImages.push(realPath); + } + break; + } + } + } + } + + // Add any remaining images that weren't in XHTML files (e.g., unused images) + for (const imgPath of allImages) { + if (!seenImages.has(imgPath)) { + orderedImages.push(imgPath); + } + } + } + } catch (e) { + console.warn('Failed to parse reading order, using default:', e); + } + + // Fallback to alphabetical if parsing failed + if (orderedImages.length === 0) { + orderedImages = [...allImages].sort(); + } + + // Load image data in order + const images = []; + for (const path of orderedImages) { + const data = await zip.files[path].async('arraybuffer'); + const blob = new Blob([data]); + const dataUrl = URL.createObjectURL(blob); + + // Get dimensions + const dims = await getImageDimensions(data); + + // Check if this is the cover image + const isCover = (path === coverImagePath) || + path.toLowerCase().includes('cover') && images.length === 0; + + // Check if this is a separator/ornament (skip cover check) + const filename = path.split('/').pop(); + let isSeparator = false; + if (!isCover) { + try { + isSeparator = await isSeparatorImage(dataUrl, dims.width, dims.height, filename); + } catch (e) { + console.warn('Separator check failed for', filename, e); + } + } + + // Tiny images (<200x200) are locked like separators + const isTiny = (dims.width < 200 && dims.height < 200); + + // Images that fit screen can only rotate, not split + const fitsScreen = (dims.width <= 480 && dims.height <= 800); + + // Split capability - no upscaling allowed + // H-Split scales width to 800, so needs width >= 800 + // V-Split scales height to 800, so needs height >= 800 + const canHSplit = dims.width >= 800; + const canVSplit = dims.height >= 800; + + images.push({ + path: path, + name: filename, + dataUrl: dataUrl, + width: dims.width, + height: dims.height, + isCover: isCover, + isSeparator: isSeparator || isTiny, + fitsScreen: fitsScreen, + canHSplit: canHSplit, + canVSplit: canVSplit + }); + } + + return images; + } + + /** + * Get image dimensions from array buffer + */ + function getImageDimensions(data) { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(new Blob([data])); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + resolve({ width: img.width, height: img.height }); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + resolve({ width: 0, height: 0 }); + }; + img.src = url; + }); + } + + /** + * Check if image is a separator/ornament + * Criteria: small size AND (filename match OR symmetric OR extreme aspect ratio) + */ + async function isSeparatorImage(dataUrl, width, height, filename) { + const MAX_DIMENSION = 150; + const SYMMETRY_THRESHOLD = 0.85; + + // First check: must be small in at least one dimension + const isSmall = (height < MAX_DIMENSION || width < MAX_DIMENSION); + if (!isSmall) return false; + + // Filename hints (instant match if small + named correctly) + const separatorNames = ['separator', 'divider', 'ornament', 'break', 'flourish', 'scene', 'divid', 'decor']; + const lowerName = filename.toLowerCase(); + if (separatorNames.some(n => lowerName.includes(n))) return true; + + // Extreme aspect ratio check (>10:1 or <1:10) - these are definitely separators/lines + const aspectRatio = width / height; + if (aspectRatio > 10 || aspectRatio < 0.1) return true; + + // Symmetry check (skip for very thin images - too few pixels) + if (width < 10 || height < 10) return true; // Very small = separator + + try { + const isSymmetric = await checkHorizontalSymmetry(dataUrl, width, height, SYMMETRY_THRESHOLD); + return isSymmetric; + } catch (e) { + console.warn('Symmetry check failed:', e); + return false; + } + } + + /** + * Check horizontal symmetry by comparing left and right halves + */ + function checkHorizontalSymmetry(dataUrl, width, height, threshold) { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + // Use small canvas for performance (max 100px wide) + const scale = Math.min(1, 100 / width); + const w = Math.max(2, Math.floor(width * scale)); // Minimum 2px + const h = Math.max(1, Math.floor(height * scale)); // Minimum 1px + + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + + // Draw scaled image + ctx.drawImage(img, 0, 0, w, h); + const imageData = ctx.getImageData(0, 0, w, h); + const pixels = imageData.data; + + // Compare left half with flipped right half + const halfW = Math.floor(w / 2); + let matchingPixels = 0; + let totalPixels = 0; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < halfW; x++) { + const leftIdx = (y * w + x) * 4; + const rightIdx = (y * w + (w - 1 - x)) * 4; + + // Compare RGB (ignore alpha) + const rDiff = Math.abs(pixels[leftIdx] - pixels[rightIdx]); + const gDiff = Math.abs(pixels[leftIdx + 1] - pixels[rightIdx + 1]); + const bDiff = Math.abs(pixels[leftIdx + 2] - pixels[rightIdx + 2]); + + // Allow some tolerance for JPEG artifacts (threshold of 30) + if (rDiff < 30 && gDiff < 30 && bDiff < 30) { + matchingPixels++; + } + totalPixels++; + } + } + + const symmetryScore = matchingPixels / totalPixels; + resolve(symmetryScore >= threshold); + }; + img.onerror = () => resolve(false); + img.src = dataUrl; + }); + } + + /** + * Show image picker after EPUB file selection + */ + async function showImagePicker(file) { + // Check if JSZip is available + if (typeof JSZip === 'undefined') { + console.error('JSZip not loaded'); + alert('JSZip library not available. Conversion will proceed without image picker.'); + startConversionWithImageStates(); + return; + } + + const pickerSection = document.getElementById('imagePickerSection'); + const imageGrid = document.getElementById('imageGrid'); + const countDisplay = document.getElementById('imagePickerCount'); + + // Reset state + imageStates = {}; + epubImagesCache = []; + pendingConversionFile = file; + + // Extract images + try { + const images = await extractImagesForPreview(file); + epubImagesCache = images; + + // Initialize all states to 0 (Normal) + images.forEach(img => { + imageStates[img.path] = 0; + }); + + // Build UI + renderImageGrid(); + + // Count images - covers and separators are locked + const coverCount = images.filter(img => img.isCover).length; + const separatorCount = images.filter(img => img.isSeparator).length; + const lockedCount = coverCount + separatorCount; + const selectableCount = images.length - lockedCount; + + if (lockedCount > 0) { + const lockedParts = []; + if (coverCount > 0) lockedParts.push(`${coverCount} cover`); + if (separatorCount > 0) lockedParts.push(`${separatorCount} separator${separatorCount !== 1 ? 's' : ''}`); + countDisplay.textContent = `${images.length} images (${selectableCount} selectable, ${lockedParts.join(', ')})`; + } else { + countDisplay.textContent = `${images.length} image${images.length !== 1 ? 's' : ''} (all selectable)`; + } + + // Show picker, hide upload button, show start conversion button + pickerSection.style.display = 'block'; + document.getElementById('uploadBtn').style.display = 'none'; + document.getElementById('startConversionBtn').style.display = 'block'; + // Enable two-column layout + document.querySelector('#uploadModal .modal').classList.add('picker-mode'); + document.getElementById('pickerColumns').classList.add('picker-active'); + + } catch (error) { + console.error('Failed to extract images:', error); + alert('Failed to preview images: ' + error.message + '\n\nConversion will proceed normally.'); + // Fallback: start conversion directly + startConversionWithImageStates(); + } + } + + /** + * Render the image grid with current states + */ + function renderImageGrid() { + const imageGrid = document.getElementById('imageGrid'); + imageGrid.innerHTML = ''; + + const stateLabels = ['', 'H-Split', 'V-Split', 'Rotate']; + const stateClasses = ['state-0', 'state-1', 'state-2', 'state-3']; + + epubImagesCache.forEach(img => { + // Cover images and separators are always locked (no splitting/rotation) + const isCover = img.isCover; + const isSeparator = img.isSeparator; + const state = imageStates[img.path] || 0; + + const item = document.createElement('div'); + + if (isCover) { + // Cover image - locked, cannot be split + item.className = 'image-item cover-locked'; + item.title = `${img.width}×${img.height} - Cover image (locked)`; + item.innerHTML = ` + 🔒 + ${img.name} +
${img.name}
+ `; + } else if (isSeparator) { + // Separator/ornament - locked, cannot be split + item.className = 'image-item separator-locked'; + item.title = `${img.width}×${img.height} - Separator (locked)`; + item.innerHTML = ` + + ${img.name} +
${img.name}
+ `; + } else { + // All other images are selectable - all modes allowed + + // Determine which overlay to show based on state + const showRotation = (state === 1 || state === 3); // H-Split or Rotate + const showSplitLines = (state === 1 || state === 2); // H-Split or V-Split + const rotateClass = showRotation ? (HANDEDNESS === 'right' ? 'rotate-cw' : 'rotate-ccw') : ''; + + // Calculate actual number of parts for split preview + let numParts = 1; + if (showSplitLines) { + let finalWidth; + if (state === 1) { + // H-Split: scale width to 800, rotate, then check width + const scaledH = Math.round(img.height * (800 / img.width)); + finalWidth = scaledH; // After rotation, height becomes width + } else { + // V-Split: scale height to 800, then check width + finalWidth = Math.round(img.width * (800 / img.height)); + } + if (finalWidth > 480) { + const minOverlapPx = Math.round(480 * (OVERLAP_PERCENT / 100)); + const maxStep = 480 - minOverlapPx; + numParts = Math.ceil((finalWidth - minOverlapPx) / maxStep); + if (numParts < 2) numParts = 2; + } + } + + // Generate split line elements (numParts - 1 lines at evenly distributed positions) + let splitLinesHtml = ''; + if (showSplitLines && numParts > 1) { + const lines = []; + const splitClass = state === 1 ? 'split-h' : 'split-v'; + for (let i = 1; i < numParts; i++) { + const pos = (i / numParts) * 100; + lines.push(`
`); + } + splitLinesHtml = `
${lines.join('')}
`; + } + + // Build tooltip + const stateText = stateLabels[state] || 'Normal'; + const partsText = numParts > 1 ? ` (${numParts} parts)` : ''; + + item.className = `image-item ${stateClasses[state]} ${rotateClass}`.trim(); + item.onclick = () => cycleImageState(img.path); + item.title = `${img.width}×${img.height} - ${stateText}${partsText}`; + item.innerHTML = ` + ${stateLabels[state] || '•'} +
+ ${splitLinesHtml} +
+ ${img.name} +
${img.name}
+ `; + } + + imageGrid.appendChild(item); + }); + } + + /** + * Cycle state for a single image + * All non-locked images: 0 -> 1 -> 2 -> 3 -> 0 + */ + function cycleImageState(imagePath) { + const currentState = imageStates[imagePath] || 0; + imageStates[imagePath] = (currentState + 1) % 4; + renderImageGrid(); + } + + /** + * Apply state to all eligible images based on smart rules + * 0 = Normal (all selectable images) + * 1 = H-Split (landscapes that canHSplit) + * 2 = V-Split (all images that canVSplit - portraits AND landscapes) + * 3 = Rotate (landscapes that don't fit screen) + */ + function applyStateToAll(state) { + epubImagesCache.forEach(img => { + // Skip locked images + if (img.isCover || img.isSeparator) return; + + const canHSplit = img.canHSplit && !img.fitsScreen; + const canVSplit = img.canVSplit && !img.fitsScreen; + const isLandscape = img.width > img.height; + + if (state === 0) { + // Normal - applies to all selectable + imageStates[img.path] = 0; + } else if (state === 1) { + // H-Split - all landscapes that can H-Split + if (isLandscape && canHSplit) { + imageStates[img.path] = 1; + } + } else if (state === 2) { + // V-Split - all images that can V-Split (portrait and landscape) + if (canVSplit) { + imageStates[img.path] = 2; + } + } else if (state === 3) { + // Rotate - landscapes that exceed screen + if (isLandscape && !img.fitsScreen) { + imageStates[img.path] = 3; + } + } + }); + renderImageGrid(); + } + + /** + * Start conversion with configured image states + */ + function startConversionWithImageStates() { + const pickerSection = document.getElementById('imagePickerSection'); + const uploadBtn = document.getElementById('uploadBtn'); + const startConversionBtn = document.getElementById('startConversionBtn'); + + // Hide picker and start conversion button, remove two-column layout + pickerSection.style.display = 'none'; + startConversionBtn.style.display = 'none'; + document.querySelector('#uploadModal .modal').classList.remove('picker-mode'); + document.getElementById('pickerColumns').classList.remove('picker-active'); + + // Show upload button and trigger upload + uploadBtn.style.display = 'block'; + uploadBtn.disabled = false; + uploadFile(); + } + + /** + * Get processing state for an image path + * Returns 0 (Normal), 1 (H-Split), 2 (V-Split), or 3 (Rotate) + */ + function getImageState(imagePath) { + return imageStates[imagePath] || 0; + } + + /** + * Get state label for logging + */ + function getStateLabel(state) { + const labels = ['Normal', 'H-Split', 'V-Split', 'Rotate']; + return labels[state] || 'Normal'; + } + + // Initialize quality settings handlers + document.addEventListener('DOMContentLoaded', function() { + const qualitySlider = document.getElementById('qualitySlider'); + const qualityInput = document.getElementById('qualityInput'); + + // Initialize advanced mode toggle state based on Optimize EPUB checkbox + const convertCheckbox = document.getElementById('convertBeforeUpload'); + const advancedToggle = document.getElementById('advancedOptionsToggle'); + if (convertCheckbox && advancedToggle) { + const isOptimizeEnabled = convertCheckbox.checked; + advancedToggle.style.opacity = isOptimizeEnabled ? '1' : '0.5'; + advancedToggle.style.pointerEvents = isOptimizeEnabled ? 'auto' : 'none'; + } + + if (qualitySlider && qualityInput) { + // Initialize converter variables with UI default values + updateQualitySettings(); + + // Deselect all presets when slider is manually changed + const deselectPresets = function() { + document.querySelectorAll('.quality-preset').forEach(btn => { + btn.classList.remove('active'); + }); + }; + + qualitySlider.oninput = function() { + qualityInput.value = this.value; + deselectPresets(); + updateQualitySettings(); + }; + qualityInput.onchange = function() { + let v = Math.max(1, Math.min(95, parseInt(this.value, 10) || 85)); + this.value = v; + qualitySlider.value = v; + deselectPresets(); + updateQualitySettings(); + }; + qualityInput.onblur = function() { + this.value = qualitySlider.value; + }; + } + }); + function openFolderModal() { document.getElementById('folderPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath; document.getElementById('folderModal').classList.add('open'); document.getElementById('folderName').value = ''; + setTimeout(() => document.getElementById('folderName').focus(), 50); + document.getElementById('folderName').onkeydown = (e) => { if (e.key === 'Enter' && e.target.value.trim()) createFolder(); }; } function closeFolderModal() { @@ -1005,18 +2884,1634 @@ }); } + // Helper to clear image picker state + function clearImagePicker() { + epubImagesCache = []; + imageStates = {}; + const imageGrid = document.getElementById('imageGrid'); + if (imageGrid) imageGrid.innerHTML = ''; + const pickerSection = document.getElementById('imagePickerSection'); + if (pickerSection) pickerSection.style.display = 'none'; + // Reset two-column layout + document.querySelector('#uploadModal .modal')?.classList.remove('picker-mode'); + document.getElementById('pickerColumns')?.classList.remove('picker-active'); + // Hide start conversion, show upload + const startBtn = document.getElementById('startConversionBtn'); + if (startBtn) startBtn.style.display = 'none'; + const uploadBtn = document.getElementById('uploadBtn'); + if (uploadBtn) uploadBtn.style.display = 'block'; + } + + // Set up file input click listener once + (function setupFileInputListener() { + const fileInput = document.getElementById('fileInput'); + if (!fileInput) return; + + fileInput.addEventListener('click', function() { + // Small delay to allow browser to process click before checking files + setTimeout(() => { + if (fileInput.files.length === 0) { + clearImagePicker(); + document.getElementById('uploadBtn').disabled = true; + } + }, 10); + }); + })(); + function validateFile() { const fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); const files = fileInput.files; - uploadBtn.disabled = !(files.length > 0); + const convertOptions = document.getElementById('convertOptions'); + fileInput.classList.toggle('has-files', files.length > 0); + + // Show/hide convert options based on file selection + if (files.length > 0) { + convertOptions.style.display = 'block'; + } else { + clearImagePicker(); + } + + if (files.length > 0) { + // If advanced settings is expanded and single EPUB, show image picker + const advancedContent = document.getElementById('advancedSettingsContent'); + const convertEnabled = document.getElementById('convertBeforeUpload').checked; + if (advancedContent.classList.contains('visible') && files.length === 1 && convertEnabled && files[0].name.toLowerCase().endsWith('.epub')) { + showImagePicker(files[0]).catch(err => console.error('Image picker error:', err)); + } + + // If multiple files with conversion, inform user about batch mode + if (files.length > 1 && convertEnabled) { + const epubCount = Array.from(files).filter(f => f.name.toLowerCase().endsWith('.epub')).length; + if (epubCount > 0) { + console.log(`Batch mode: ${epubCount} EPUB(s) will use auto settings`); + } + } + + updateBatchModeUI(files.length > 1); + uploadBtn.disabled = false; + } else { + updateBatchModeUI(false); + uploadBtn.disabled = true; + } } let failedUploadsGlobal = []; let wsConnection = null; +let isUploadInProgress = false; // Prevent modal close during upload/conversion +let operationCancelled = false; // Set by Cancel to stop conversion loops and upload async handlers +let uploadGeneration = 0; // Incremented each uploadFile() call; guards stale restoreAfterCancel() +let currentUploadWs = null; // Active WebSocket reference for external abort +let currentUploadXhr = null; // Active XHR reference for external abort const WS_PORT = 81; const WS_CHUNK_SIZE = 4096; // 4KB chunks - smaller for ESP32 stability +// ============================================================================ +// EPUB Image Conversion Functions (from Baseline JPEG Converter) +// ============================================================================ + +// Default conversion settings +const DEFAULT_MAX_WIDTH = 480; +const DEFAULT_MAX_HEIGHT = 800; +const DEFAULT_JPEG_QUALITY = 85; +const DEFAULT_ENABLE_GRAYSCALE = true; +// Note: Overlap is now always centered distribution (min 5%) + +// Dynamic conversion settings (updated by UI) +let MAX_WIDTH = DEFAULT_MAX_WIDTH; +let MAX_HEIGHT = DEFAULT_MAX_HEIGHT; +let JPEG_QUALITY = DEFAULT_JPEG_QUALITY; +let ENABLE_GRAYSCALE = DEFAULT_ENABLE_GRAYSCALE; +let HANDEDNESS = 'right'; // 'right' = clockwise (right-handed), 'left' = counter-clockwise (left-handed) +let OVERLAP_PERCENT = 5; // Minimum overlap percentage for splits (5%, 10%, 15%) + +// ============================================================================ +// Image Picker State Management +// ============================================================================ + +let imageStates = {}; // Map: imagePath -> state (0=Normal, 1=H-Split, 2=V-Split, 3=Rotate) +let epubImagesCache = []; // Cache of extracted images for preview +let pendingConversionFile = null; // File awaiting conversion after image selection + +// ============================================================================ +// Enhanced Logging System +// ============================================================================ + +let logStartTime = null; +let conversionStats = { images: 0, splits: 0, splitParts: 0, fixes: 0, skipped: 0, errors: 0, originalSize: 0, newSize: 0 }; +const logSection = document.getElementById('log-section'); +const logContainer = document.getElementById('log-container'); +const exportLogCheckbox = document.getElementById('export-log-checkbox'); + +// CrossPoint version (fetched from API) +let crosspointVersion = 'Unknown'; + +// Fetch version from API +async function fetchVersion() { + try { + const response = await fetch('/api/status'); + if (response.ok) { + const data = await response.json(); + crosspointVersion = data.version || 'Unknown'; + } + } catch (e) { + console.error('Failed to fetch version:', e); + } +} + +// Batch logging system for multiple files +let batchLogEntries = []; +let batchStats = { filesProcessed: 0, filesSucceeded: 0, filesFailed: 0, totalImages: 0, totalSplits: 0, totalFixes: 0, totalErrors: 0, totalOriginalSize: 0, totalNewSize: 0 }; +let batchStartTime = null; +let isBatchMode = false; + +// Format bytes to human-readable size (for logging) +function formatBytes(b) { + if (!b) return '0 B'; + const k = 1024; + const s = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(b) / Math.log(k)); + return (b / Math.pow(k, i)).toFixed(1) + ' ' + s[i]; +} + +// Get elapsed timestamp since log start +function getTimestamp() { + if (!logStartTime) return '[00:00.0]'; + const elapsed = (Date.now() - logStartTime) / 1000; + const mins = Math.floor(elapsed / 60).toString().padStart(2, '0'); + const secs = (elapsed % 60).toFixed(1).padStart(4, '0'); + return `[${mins}:${secs}]`; +} + +// Main logging function +function log(message, type = '', tag = '') { + const entry = document.createElement('div'); + entry.className = 'log-entry ' + type; + + // Timestamp + const timestamp = document.createElement('span'); + timestamp.className = 'log-timestamp'; + timestamp.textContent = getTimestamp(); + entry.appendChild(timestamp); + + // Tag (if provided) + if (tag) { + const tagEl = document.createElement('span'); + tagEl.className = 'log-tag ' + tag.toLowerCase(); + tagEl.textContent = tag; + entry.appendChild(tagEl); + } + + // Message + const msg = document.createElement('span'); + msg.className = 'log-message'; + msg.innerHTML = message; + entry.appendChild(msg); + + logContainer.appendChild(entry); + logContainer.scrollTop = logContainer.scrollHeight; +} + +// Log image processing details +function logImage(name, origW, origH, origFormat, origSize, newW, newH, newSize, wasSplit = false, splitCount = 0, partsInfo = null, imageState = 0) { + const saved = origSize - newSize; + const savedPct = ((saved / origSize) * 100).toFixed(0); + const dims = `${origW}×${origH}`; + const newDims = `${newW}×${newH}`; + + // Get state label and color + const stateLabels = ['', 'H-Split', 'V-Split', 'Rotate']; + const stateColors = ['', '#3498db', '#e74c3c', '#9b59b6']; + const stateLabel = stateLabels[imageState] || ''; + const stateColor = stateColors[imageState] || ''; + + if (wasSplit) { + conversionStats.splits++; + conversionStats.splitParts += splitCount; + // Build parts detail string + let partsDetail = ''; + if (partsInfo && partsInfo.length > 0) { + const baseName = name.replace(/\.[^.]+$/, ''); + partsDetail = partsInfo.map(p => + `${baseName}${p.suffix}.jpg (${p.width}×${p.height}, ${formatBytes(p.size)})` + ).join(', '); + } + const savedInfo = saved > 0 ? `, -${savedPct}%` : ''; + const stateIndicator = imageState > 0 ? ` [${stateLabel}]` : ''; + log(`${escapeHtml(name)}${stateIndicator} (${dims} ${origFormat.toUpperCase()}, ${formatBytes(origSize)}) → ${splitCount} parts${savedInfo}`, '', 'SPLIT'); + if (partsDetail) { + log(`↳ ${partsDetail}`, '', ''); + } + } else { + conversionStats.images++; + const stateIndicator = imageState > 0 ? ` [${stateLabel}]` : ''; + const detail = saved > 0 + ? `(${dims} → ${newDims}, ${formatBytes(origSize)} → ${formatBytes(newSize)}, -${savedPct}%)` + : `(${dims} → ${newDims}, ${formatBytes(newSize)})`; + log(`${escapeHtml(name)}${stateIndicator} ${detail}`, '', 'CONVERT'); + } +} + +// Log fix applied +function logFix(type, detail) { + conversionStats.fixes++; + log(`${type}: ${detail}`, 'success', 'FIX'); +} + +// Log skipped item +function logSkip(name, reason) { + conversionStats.skipped++; + log(`${escapeHtml(name)} (${reason})`, '', 'SKIP'); +} + +// Log error +function logError(message) { + conversionStats.errors++; + log(message, 'error', 'ERROR'); +} + +// Log summary table +function logSummary(originalSize, newSize, timeElapsed) { + const saved = originalSize - newSize; + const savedPct = ((saved / originalSize) * 100).toFixed(1); + const totalImages = conversionStats.images + conversionStats.splits; + const totalOutput = conversionStats.images + conversionStats.splitParts; + + const summaryHtml = ` +
+
📊 Conversion Summary
+ + + + + ${conversionStats.errors > 0 ? `` : ''} + + + + +
Images found${totalImages}
Images processed${totalOutput}${conversionStats.splitParts > conversionStats.splits ? ` (+${conversionStats.splitParts - conversionStats.splits} from splits)` : ''}
EPUB repairs${conversionStats.fixes > 0 ? conversionStats.fixes + ' fixes applied' : 'None needed'}
Errors${conversionStats.errors}
Original size${formatBytes(originalSize)}
Optimized size${formatBytes(newSize)}
Saved${saved > 0 ? formatBytes(saved) + ' (' + savedPct + '%)' : '+' + formatBytes(-saved)}
Time${timeElapsed.toFixed(1)}s
+
+ `; + logContainer.insertAdjacentHTML('beforeend', summaryHtml); + logContainer.scrollTop = logContainer.scrollHeight; +} + +// Clear log +function clearLog() { + logContainer.innerHTML = ''; + logStartTime = Date.now(); + conversionStats = { images: 0, splits: 0, splitParts: 0, fixes: 0, skipped: 0, errors: 0, originalSize: 0, newSize: 0 }; +} + +// Start batch logging mode +function startBatchLog(fileCount) { + isBatchMode = true; + batchStartTime = Date.now(); + batchLogEntries = []; + batchStats = { filesProcessed: 0, filesSucceeded: 0, filesFailed: 0, totalImages: 0, totalSplits: 0, totalFixes: 0, totalErrors: 0, totalOriginalSize: 0, totalNewSize: 0 }; + clearLog(); + logContainer.innerHTML = ''; // Clear display + log(`Starting batch conversion: ${fileCount} file(s)`, '', 'INFO'); +} + +// Save current file's log to batch entries +function saveToFileBatchLog(fileName, succeeded) { + if (!isBatchMode) return; + + const entries = Array.from(logContainer.querySelectorAll('.log-entry')); + batchLogEntries.push({ + fileName: fileName, + succeeded: succeeded, + entries: entries, + stats: { ...conversionStats } + }); + + // Update batch stats + batchStats.filesProcessed++; + if (succeeded) { + batchStats.filesSucceeded++; + } else { + batchStats.filesFailed++; + } + batchStats.totalImages += conversionStats.images; + batchStats.totalSplits += conversionStats.splits; + batchStats.totalFixes += conversionStats.fixes; + batchStats.totalErrors += conversionStats.errors; + + // Clear for next file + logContainer.innerHTML = ''; + conversionStats = { images: 0, splits: 0, splitParts: 0, fixes: 0, skipped: 0, errors: 0, originalSize: 0, newSize: 0 }; +} + +// Finalize batch log and export +function finalizeBatchLog() { + if (!isBatchMode) return; + + const batchTime = (Date.now() - batchStartTime) / 1000; + + // Build consolidated log display + logContainer.innerHTML = ''; + log(`Starting batch conversion: ${batchStats.filesProcessed} file(s)`, '', 'INFO'); + + // Add all file entries + batchLogEntries.forEach((fileLog, index) => { + const fileHeader = document.createElement('div'); + fileHeader.className = 'log-entry'; + fileHeader.style.marginTop = index > 0 ? '15px' : '5px'; + fileHeader.style.borderTop = index > 0 ? '1px solid var(--border-color)' : 'none'; + fileHeader.style.paddingTop = index > 0 ? '10px' : '0'; + fileHeader.innerHTML = `${escapeHtml(fileLog.fileName)} — ${fileLog.succeeded ? '✓ Success' : '✗ Failed'}`; + logContainer.appendChild(fileHeader); + + fileLog.entries.forEach(entry => { + const clone = entry.cloneNode(true); + logContainer.appendChild(clone); + }); + }); + + // Add batch summary + const batchSummaryHtml = ` +
+
📊 Batch Conversion Summary
+ + + + + + + + ${batchStats.totalErrors > 0 ? `` : ''} + +
Files processed${batchStats.filesProcessed}
Successful${batchStats.filesSucceeded}
Failed${batchStats.filesFailed}
Total images processed${batchStats.totalImages}
Total splits${batchStats.totalSplits}
Total fixes applied${batchStats.totalFixes}
Total errors${batchStats.totalErrors}
Total time${batchTime.toFixed(1)}s
+
+ `; + logContainer.insertAdjacentHTML('beforeend', batchSummaryHtml); + logContainer.scrollTop = logContainer.scrollHeight; + + // Auto-export if checkbox is checked + if (exportLogCheckbox && exportLogCheckbox.checked) { + setTimeout(() => { + exportLogToFile(null, true); // isBatch = true + }, 200); + } + + // Reset batch mode + isBatchMode = false; + batchLogEntries = []; +} + +// Show/hide log section +function showLog() { + logSection.classList.add('visible'); +} + +function hideLog() { + logSection.classList.remove('visible'); +} + +// Generate standardized log filename with date +function generateLogFilename(isBatch = false) { + const now = new Date(); + const date = now.toISOString().split('T')[0]; // YYYY-MM-DD + const time = now.toTimeString().split(' ')[0].replace(/:/g, '-'); // HH-MM-SS + const prefix = isBatch ? 'batch' : 'epub'; + return `${prefix}-conversion-log-${date}_${time}.txt`; +} + +// Export log as text file (can be called automatically) +function exportLogToFile(filename = null, isBatch = false) { + // Use standardized filename if none provided + if (!filename) { + filename = generateLogFilename(isBatch); + } + // Extract text from log entries + const entries = logContainer.querySelectorAll('.log-entry'); + let logText = `CrossPoint Reader ${crosspointVersion} - EPUB Conversion Log\n`; + logText += `Generated: ${new Date().toLocaleString()}\n`; + logText += `${'='.repeat(60)}\n\n`; + + entries.forEach(entry => { + const timestamp = entry.querySelector('.log-timestamp')?.textContent || ''; + const tag = entry.querySelector('.log-tag')?.textContent || ''; + const message = entry.querySelector('.log-message')?.textContent || entry.textContent; + + if (tag) { + logText += `${timestamp} [${tag}] ${message}\n`; + } else { + logText += `${timestamp} ${message}\n`; + } + }); + + // Extract summary table if present + const summary = logContainer.querySelector('.log-summary'); + if (summary) { + logText += `\n${'='.repeat(60)}\n`; + const title = summary.querySelector('.log-summary-title')?.textContent || 'Summary'; + logText += `${title}\n`; + logText += `${'-'.repeat(40)}\n`; + + const rows = summary.querySelectorAll('tr'); + rows.forEach(row => { + const cells = row.querySelectorAll('td'); + if (cells.length >= 2) { + logText += `${cells[0].textContent.padEnd(25)} ${cells[1].textContent}\n`; + } + }); + } + + // Create download link + const blob = new Blob([logText], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename || `epub-conversion-log-${Date.now()}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ═══════════════════════════════════════════════════════════════════ +// EPUB Utilities — ported from EPUB Optimizer Pro +// ═══════════════════════════════════════════════════════════════════ + +/** Defensive CSS injected into every XHTML — prevents e-ink overflow. */ +const DEFENSIVE_STYLE = ''; + +/** Escape a string for safe insertion into XML attribute values / text content. */ +function xmlEscape(str) { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + +/** + * Decode a URI-encoded href (e.g., "my%20image.jpg" → "my image.jpg"). + * Handles double-encoding gracefully. + */ +function decodeHref(href) { + try { return decodeURIComponent(href); } + catch (e) { return href; } +} + +/** + * Safely read a text file from the zip, handling BOM and encoding. + * Strips UTF-8 BOM. Detects encoding from XML declaration or meta tag. + */ +async function safeReadText(fileObj) { + const raw = await fileObj.async('uint8array'); + + // Detect and strip UTF-8 BOM (EF BB BF) + let offset = 0; + if (raw.length >= 3 && raw[0] === 0xEF && raw[1] === 0xBB && raw[2] === 0xBF) { + offset = 3; + } + + // Try UTF-8 first (vast majority of EPUBs) + const utf8 = new TextDecoder('utf-8', { fatal: true }); + try { + return utf8.decode(raw.subarray(offset)); + } catch (e) { /* not valid UTF-8 */ } + + // Peek at XML declaration or meta charset for encoding hint + const ascii = new TextDecoder('ascii', { fatal: false }).decode(raw.subarray(offset, offset + 512)); + const encodingMatch = ascii.match(/encoding=["']([^"']+)["']/i) || + ascii.match(/charset=["']?([^"'\s;]+)/i); + const encoding = encodingMatch ? encodingMatch[1].toLowerCase() : 'windows-1252'; + + try { + return new TextDecoder(encoding, { fatal: false }).decode(raw.subarray(offset)); + } catch (e) { + // Last resort: lossy latin1 + return new TextDecoder('iso-8859-1', { fatal: false }).decode(raw.subarray(offset)); + } +} + +/** + * Find the canonical OPF path by parsing META-INF/container.xml. + * Falls back to scanning for any .opf file. + */ +async function findOPFPath(zip) { + try { + const containerPath = Object.keys(zip.files).find(p => p.toLowerCase() === 'meta-inf/container.xml'); + if (containerPath) { + const containerXml = await zip.files[containerPath].async('string'); + const match = containerXml.match(/]+full-path=["']([^"']+)["']/i); + if (match && zip.files[match[1]]) return match[1]; + } + } catch (e) { /* fall through */ } + let fallback = null; + zip.forEach(p => { if (!fallback && p.toLowerCase().endsWith('.opf')) fallback = p; }); + return fallback; +} + +/** + * Resolve a relative href against a base file path. + * Handles multiple ../, ./, absolute /, and bare relative paths. + */ +function resolvePath(basePath, href) { + if (href.startsWith('/')) return href.substring(1); + href = href.replace(/^\.\//, ''); + const baseDir = basePath.includes('/') ? basePath.substring(0, basePath.lastIndexOf('/')) : ''; + const baseParts = baseDir ? baseDir.split('/') : []; + const hrefParts = href.split('/'); + while (hrefParts.length > 0 && hrefParts[0] === '..') { + hrefParts.shift(); + if (baseParts.length > 0) baseParts.pop(); + } + const resolved = [...baseParts, ...hrefParts].join('/'); + return resolved.replace(/\/+/g, '/'); +} + +/** + * Serialize an XML doc back to string, preserving the original declaration + * and cleaning up XMLSerializer namespace prefix noise (xmlns:ns0 etc). + */ +function safeSerialize(doc, originalContent) { + let result = new XMLSerializer().serializeToString(doc); + + // Restore declaration if original had one + if (originalContent && /^\s*<\?xml\b/.test(originalContent) && !/^\s*<\?xml\b/.test(result)) { + const declMatch = originalContent.match(/^\s*(<\?xml[^?]*\?>)/); + if (declMatch) result = declMatch[1] + '\n' + result; + } + + // Clean up XMLSerializer namespace prefix noise (xmlns:ns0="..." ns0:attr="...") + result = result.replace(/ xmlns:ns\d+="[^"]*"/g, ''); + result = result.replace(/ ns\d+:/g, ' '); + + return result; +} + +/** + * Extract main identifier from OPF for NCX sync. DOMParser with regex fallback. + */ +function extractIdentifier(opfContent) { + let mainIdentifier = null; + try { + const doc = new DOMParser().parseFromString(opfContent, 'application/xml'); + if (!doc.querySelector('parsererror')) { + const pkg = doc.getElementsByTagNameNS('*', 'package')[0]; + const uid = pkg ? pkg.getAttribute('unique-identifier') : null; + if (uid) { + const el = [...doc.getElementsByTagNameNS('*', 'identifier')].find(e => e.getAttribute('id') === uid); + if (el) mainIdentifier = (el.textContent || '').trim(); + } + if (!mainIdentifier) { + const el = doc.getElementsByTagNameNS('*', 'identifier')[0]; + if (el) mainIdentifier = (el.textContent || '').trim(); + } + } + } catch (e) { /* fall through to regex */ } + if (!mainIdentifier) { + const uniqueIdMatch = opfContent.match(/<(?:\w+:)?package[^>]*unique-identifier=["']([^"']+)["']/i); + if (uniqueIdMatch) { + const idRegex = new RegExp(`]*id=["']${uniqueIdMatch[1]}["'][^>]*>([^<]+)`, 'i'); + const idMatch = opfContent.match(idRegex); + if (idMatch) mainIdentifier = idMatch[1].trim(); + } + if (!mainIdentifier) { + const firstIdMatch = opfContent.match(/]*>([^<]+) m.getAttribute('name') === 'dtb:uid'); + if (meta) { + meta.setAttribute('content', mainIdentifier); + t = safeSerialize(doc, ncxText); + } + } + } catch (e) { + t = t.replace(//gi, ``); + } + return t; +} + +/** + * Fix OPF content: fix media-types, strip svg properties, + * update split image manifest entries, ensure cover meta. + * DOMParser with regex fallback. + */ +function fixOPF(opfText, opfOriginal, opfDir, splitImages = {}) { + let t = opfText; + + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(t, 'application/xml'); + if (doc.querySelector('parsererror')) throw new Error('OPF parse failed'); + + const items = [...doc.getElementsByTagNameNS('*', 'item')]; + const manifestEl = doc.getElementsByTagNameNS('*', 'manifest')[0]; + + // Fix media-types for converted images + for (const item of items) { + const href = item.getAttribute('href') || ''; + const type = item.getAttribute('media-type') || ''; + if (href.endsWith('.jpg') && type.match(/^image\/(png|gif|webp|bmp)$/)) { + item.setAttribute('media-type', 'image/jpeg'); + } + } + + // Remove 'svg' from properties + for (const item of items) { + const props = item.getAttribute('properties') || ''; + if (props.includes('svg')) { + const newProps = props.split(/\s+/).filter(p => p !== 'svg').join(' ').trim(); + if (newProps) item.setAttribute('properties', newProps); + else item.removeAttribute('properties'); + } + } + + // Update split image hrefs and add manifest entries for parts + for (const [splitKey, splitInfo] of Object.entries(splitImages)) { + const parts = splitInfo.parts || splitInfo; + let origHref = opfDir && splitKey.startsWith(opfDir + '/') ? splitKey.substring(opfDir.length + 1) : splitKey; + const origHrefJpg = origHref.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg'); + const part1Href = origHrefJpg.replace(/\.jpg$/i, '_part1.jpg'); + + for (const item of items) { + const h = item.getAttribute('href') || ''; + if (h === origHref || h === origHrefJpg || decodeHref(h) === origHref || decodeHref(h) === origHrefJpg) { + item.setAttribute('href', part1Href); + break; + } + } + + if (manifestEl) { + const ns = manifestEl.namespaceURI || 'http://www.idpf.org/2007/opf'; + for (let j = 1; j < parts.length; j++) { + const p = parts[j]; + const href = opfDir && p.path.startsWith(opfDir + '/') ? p.path.substring(opfDir.length + 1) : p.path; + const newItem = doc.createElementNS(ns, 'item'); + newItem.setAttribute('id', `img-${p.id}`); + newItem.setAttribute('href', href); + newItem.setAttribute('media-type', 'image/jpeg'); + manifestEl.appendChild(newItem); + } + } + } + + t = safeSerialize(doc, opfOriginal); + } catch (e) { + // Regex fallback + t = t.replace(/(<(?:\w+:)?item\b[^>]*href="[^"]+\.jpg"[^>]*)media-type="image\/(png|gif|webp|bmp)"/g, '$1media-type="image/jpeg"'); + t = t.replace(/(<(?:\w+:)?item\b[^>]*)media-type="image\/(png|gif|webp|bmp)"([^>]*href="[^"]+\.jpg")/g, '$1media-type="image/jpeg"$3'); + t = t.replace(/\s+svg(?=["'\s>])/g, ''); + for (const [splitKey, splitInfo] of Object.entries(splitImages)) { + const parts = splitInfo.parts || splitInfo; + let origHref = opfDir && splitKey.startsWith(opfDir + '/') ? splitKey.substring(opfDir.length + 1) : splitKey; + const origHrefJpg = origHref.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg'); + const part1Href = origHrefJpg.replace(/\.jpg$/i, '_part1.jpg'); + const origImgRegex = new RegExp(`(href=["'])(${escapeRegex(origHref)}|${escapeRegex(origHrefJpg)})(["'])`, 'gi'); + t = t.replace(origImgRegex, `$1${part1Href}$3`); + let adds = ''; + for (let j = 1; j < parts.length; j++) { + const p = parts[j]; + const href = opfDir && p.path.startsWith(opfDir + '/') ? p.path.substring(opfDir.length + 1) : p.path; + adds += `\n`; + } + if (adds && t.includes('')) t = t.replace('', adds + ''); + } + } + + // Ensure cover meta + const cm = ensureCoverMeta(t); + if (cm.fixed) t = cm.o; + + return t; +} + +// Fix SVG cover - converts SVG-wrapped covers to plain HTML img tags +function fixSvgCover(content) { + const hasSvg = content.includes('Cover')) return { c: content, fixed: false, count: 0 }; + + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(content, 'application/xhtml+xml'); + + if (doc.querySelector('parsererror')) { + // Fallback to regex + const m = content.match(/xlink:href=["']([^"']+)["']/); + if (!m) return { c: content, fixed: false, count: 0 }; + return { c: ` + + +Cover +
Cover
+`, fixed: true, count: 1 }; + } + + // Find SVG elements - check both standard and namespaced variants + let imgHref = null; + const svgNS = 'http://www.w3.org/2000/svg'; + const xlinkNS = 'http://www.w3.org/1999/xlink'; + + // Try to find all SVG elements + const svgs = [ + ...doc.getElementsByTagName('svg'), + ...doc.getElementsByTagNameNS(svgNS, 'svg'), + ...doc.getElementsByTagName('svg:svg') + ]; + + for (const svg of svgs) { + // Find image element inside - try all variants + const imageEl = svg.getElementsByTagName('image')[0] || + svg.getElementsByTagNameNS(svgNS, 'image')[0] || + svg.getElementsByTagName('svg:image')[0]; + + if (imageEl) { + imgHref = imageEl.getAttributeNS(xlinkNS, 'href') || + imageEl.getAttribute('xlink:href') || + imageEl.getAttribute('href'); + if (imgHref) break; + } + } + + if (!imgHref) { + // Fallback to regex + const m = content.match(/xlink:href=["']([^"']+)["']/); + if (!m) return { c: content, fixed: false, count: 0 }; + imgHref = m[1]; + } + + return { + c: ` + + +Cover +
Cover
+`, + fixed: true, + count: 1 + }; + } catch (e) { + // Fallback to regex + const m = content.match(/xlink:href=["']([^"']+)["']/); + if (!m) return { c: content, fixed: false, count: 0 }; + return { c: ` + + +Cover +
Cover
+`, fixed: true, count: 1 }; + } +} + +// Fix SVG-wrapped images - unwrap SVG and replace with plain img +function fixSvgWrappedImages(content) { + const hasSvg = content.includes(']*>[\s\S]*?<(?:svg:)?image\b[^>]*xlink:href=["']([^"']+)["'][^>]*\/?>\s*<\/(?:svg:)?svg>/gi; + const newContent = content.replace(svgImageRegex, (match, href) => { fixedCount++; return ``; }); + return { c: newContent, fixed: fixedCount > 0, count: fixedCount }; + } + + const svgNS = 'http://www.w3.org/2000/svg'; + const xlinkNS = 'http://www.w3.org/1999/xlink'; + + const svgElements = [...doc.querySelectorAll('svg'), ...doc.getElementsByTagNameNS(svgNS, 'svg')]; + const uniqueSvgs = [...new Set(svgElements)]; + let fixedCount = 0; + + for (const svg of uniqueSvgs) { + const imageEl = svg.querySelector('image[*|href]') || svg.getElementsByTagNameNS(svgNS, 'image')[0] || svg.getElementsByTagNameNS('*', 'image')[0]; + if (!imageEl) continue; + const href = imageEl.getAttributeNS(xlinkNS, 'href') || imageEl.getAttribute('xlink:href') || imageEl.getAttribute('href'); + if (!href) continue; + const width = imageEl.getAttribute('width') || svg.getAttribute('width'); + const height = imageEl.getAttribute('height') || svg.getAttribute('height'); + const img = doc.createElementNS('http://www.w3.org/1999/xhtml', 'img'); + img.setAttribute('src', href); + img.setAttribute('alt', ''); + img.setAttribute('style', 'max-width:100%;height:auto'); + if (width) img.setAttribute('width', width); + if (height) img.setAttribute('height', height); + svg.parentNode.replaceChild(img, svg); + fixedCount++; + } + + if (fixedCount === 0) return { c: content, fixed: false, count: 0 }; + return { c: safeSerialize(doc, content), fixed: true, count: fixedCount }; + + } catch (e) { + // Fallback to regex + let fixedCount = 0; + const svgImageRegex = /<(?:svg:)?svg\b[^>]*>[\s\S]*?<(?:svg:)?image\b[^>]*xlink:href=["']([^"']+)["'][^>]*\/?>\s*<\/(?:svg:)?svg>/gi; + const newContent = content.replace(svgImageRegex, (match, href) => { fixedCount++; return ``; }); + return { c: newContent, fixed: fixedCount > 0, count: fixedCount }; + } +} + +// Ensure cover meta tag exists in OPF — DOMParser with regex fallback +function ensureCoverMeta(opfString) { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(opfString, 'application/xml'); + if (doc.querySelector('parsererror')) throw new Error('Parse failed'); + + // Find cover image id: properties="cover-image", or id/href containing "cover" + let coverId = null; + const items = [...doc.getElementsByTagNameNS('*', 'item')]; + for (const item of items) { + const props = item.getAttribute('properties') || ''; + const id = item.getAttribute('id') || ''; + const type = item.getAttribute('media-type') || ''; + if (!type.startsWith('image/')) continue; + if (props.includes('cover-image')) { coverId = id; break; } + } + if (!coverId) { + for (const item of items) { + const id = item.getAttribute('id') || ''; + const href = item.getAttribute('href') || ''; + const type = item.getAttribute('media-type') || ''; + if (!type.startsWith('image/')) continue; + if (id.toLowerCase().includes('cover') || href.toLowerCase().includes('cover')) { coverId = id; break; } + } + } + if (!coverId) return { o: opfString, fixed: false }; + + // Find or create + const metas = [...doc.getElementsByTagNameNS('*', 'meta')]; + const coverMeta = metas.find(m => m.getAttribute('name') === 'cover'); + if (coverMeta) { + if (coverMeta.getAttribute('content') === coverId) return { o: opfString, fixed: false }; + coverMeta.setAttribute('content', coverId); + } else { + const metadata = doc.getElementsByTagNameNS('*', 'metadata')[0]; + if (!metadata) return { o: opfString, fixed: false }; + const ns = metadata.namespaceURI || 'http://www.idpf.org/2007/opf'; + const newMeta = doc.createElementNS(ns, 'meta'); + newMeta.setAttribute('name', 'cover'); + newMeta.setAttribute('content', coverId); + metadata.appendChild(newMeta); + } + return { o: safeSerialize(doc, opfString), fixed: true }; + } catch (e) { + // Regex fallback + return ensureCoverMetaRegex(opfString); + } +} + +function ensureCoverMetaRegex(o) { + let coverId = null, m; + if (!coverId && (m = o.match(/<\w+:?item[^>]+id="([^"]+)"[^>]+properties="[^"]*cover-image[^"]*"/i))) coverId = m[1]; + if (!coverId && (m = o.match(/<\w+:?item[^>]+properties="[^"]*cover-image[^"]*"[^>]+id="([^"]+)"/i))) coverId = m[1]; + if (!coverId && (m = o.match(/<\w+:?item[^>]*id="([^"]+)"[^>]*href="[^"]*cover[^"]*"[^>]*media-type="image\//i))) coverId = m[1]; + if (!coverId && (m = o.match(/<\w+:?item[^>]*href="[^"]*cover[^"]*"[^>]*id="([^"]+)"[^>]*media-type="image\//i))) coverId = m[1]; + if (!coverId && (m = o.match(/<\w+:?item[^>]*id="([^"]*cover[^"]*)"[^>]*media-type="image\//i))) coverId = m[1]; + if (!coverId && (m = o.match(/<\w+:?item[^>]*media-type="image\/[^"]*"[^>]*id="([^"]*cover[^"]*)"/i))) coverId = m[1]; + if (!coverId) return { o, fixed: false }; + const metaMatch = o.match(/<\w+:?meta\s+name=["']cover["']\s+content=["']([^"']+)["']/i) || o.match(/<\w+:?meta\s+content=["']([^"']+)["']\s+name=["']cover["']/i); + if (metaMatch) { + if (metaMatch[1] === coverId && !metaMatch[1].includes('/')) return { o, fixed: false }; + const esc = xmlEscape(coverId); + o = o.replace(/<\w+:?meta\s+name=["']cover["']\s+content=["'][^"']+["']\s*\/?>/gi, ``); + o = o.replace(/<\w+:?meta\s+content=["'][^"']+["']\s+name=["']cover["']\s*\/?>/gi, ``); + return { o, fixed: true }; + } + const idx = o.indexOf(''); + if (idx !== -1) return { o: o.substring(0, idx) + ` \n ` + o.substring(idx + 11), fixed: true }; + return { o, fixed: false }; +} + +// Apply grayscale to canvas image data +function applyGrayscale(ctx, width, height) { + if (!ENABLE_GRAYSCALE) return; + const imageData = ctx.getImageData(0, 0, width, height); + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + // Alpha-blend against white background before grayscaling (handles transparent PNGs) + const a = data[i + 3] / 255; + const blendedR = data[i] * a + 255 * (1 - a); + const blendedG = data[i + 1] * a + 255 * (1 - a); + const blendedB = data[i + 2] * a + 255 * (1 - a); + const gray = Math.round(blendedR * 0.299 + blendedG * 0.587 + blendedB * 0.114); + data[i] = gray; data[i + 1] = gray; data[i + 2] = gray; data[i + 3] = 255; + } + ctx.putImageData(imageData, 0, 0); +} + +// Process single image - returns array of {data, suffix} objects +const IMAGE_LOAD_TIMEOUT_MS = 30000; // 30 second timeout for image loading +async function processImage(data, imageState = 0, imagePath = '') { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(new Blob([data])); + const img = new Image(); + const origSize = data.byteLength; + // Set up timeout to handle cases where image never loads + const timeoutId = setTimeout(() => { + URL.revokeObjectURL(url); + reject(new Error('Image load timeout')); + }, IMAGE_LOAD_TIMEOUT_MS); + + img.onload = async () => { + clearTimeout(timeoutId); + URL.revokeObjectURL(url); + const origW = img.width, origH = img.height; + + // imageState: 0=Normal, 1=H-Split (CW/CCW), 2=V-Split, 3=Rotate & Fit + // ======================================================================== + // STATE 1: H-Split (Rotate + Split) - EXACT COPY FROM index.html + // Step 1: Scale WIDTH to 800px (keep aspect ratio) + // Step 2: Rotate 90° CW or CCW based on HANDEDNESS + // Step 3: If WIDTH > 480, split vertically with overlap + // ======================================================================== + if (imageState === 1) { + // Step 1: Scale WIDTH to 800 (this is the key difference!) + const scale = MAX_HEIGHT / origW; // 800 / origW + const scaledW = MAX_HEIGHT; // 800 + const scaledH = Math.round(origH * scale); + + const scaledCanvas = document.createElement('canvas'); + scaledCanvas.width = scaledW; + scaledCanvas.height = scaledH; + const scaledCtx = scaledCanvas.getContext('2d'); + scaledCtx.imageSmoothingEnabled = true; + scaledCtx.imageSmoothingQuality = 'high'; + scaledCtx.fillStyle = '#FFF'; + scaledCtx.fillRect(0, 0, scaledW, scaledH); + scaledCtx.drawImage(img, 0, 0, origW, origH, 0, 0, scaledW, scaledH); + + // Step 2: Rotate 90° CW or CCW + const rotW = scaledH; + const rotH = scaledW; // 800 + + const rotCanvas = document.createElement('canvas'); + rotCanvas.width = rotW; + rotCanvas.height = rotH; + const rotCtx = rotCanvas.getContext('2d'); + rotCtx.fillStyle = '#FFF'; + rotCtx.fillRect(0, 0, rotW, rotH); + + const isClockwise = HANDEDNESS === 'right'; + if (isClockwise) { + // Rotate 90° CW + rotCtx.translate(rotW, 0); + rotCtx.rotate(Math.PI / 2); + } else { + // Rotate 90° CCW + rotCtx.translate(0, rotH); + rotCtx.rotate(-Math.PI / 2); + } + rotCtx.drawImage(scaledCanvas, 0, 0); + rotCtx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform + applyGrayscale(rotCtx, rotW, rotH); + + // Step 3: If WIDTH > 480, split vertically + if (rotW <= MAX_WIDTH) { + const blob = await new Promise(res => rotCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); + const arrBuf = await blob.arrayBuffer(); + resolve({ + parts: [{ data: arrBuf, suffix: '', width: rotW, height: rotH, size: arrBuf.byteLength }], + meta: { origW, origH, origSize, wasSplit: false, rotated: true, finalW: rotW, finalH: rotH, finalSize: arrBuf.byteLength, imageState: 1 } + }); + } else { + // Split by WIDTH (vertical cuts) - from RIGHT to LEFT for CW, LEFT to RIGHT for CCW + const parts = []; + const maxW = MAX_WIDTH; // 480 + + // Centered distribution: calculate numParts first, then distribute evenly + let overlapPx, step, numParts; + const minOverlapPx = Math.round(maxW * (OVERLAP_PERCENT / 100)); // Configurable overlap + const maxStep = maxW - minOverlapPx; + numParts = Math.ceil((rotW - minOverlapPx) / maxStep); + if (numParts < 2) numParts = 2; + // Now calculate step to distribute evenly + step = Math.round((rotW - maxW) / (numParts - 1)); + overlapPx = maxW - step; + // Ensure minimum overlap + if (overlapPx < minOverlapPx) { + overlapPx = minOverlapPx; + step = maxW - overlapPx; + } + + // Calculate all x positions first to ensure consistency + const positions = []; + for (let i = 0; i < numParts; i++) { + let x; + if (isClockwise) { + // CW: right to left - start from right edge + x = rotW - maxW - (i * step); + } else { + // CCW: left to right - start from left edge + x = i * step; + } + // Clamp to valid range + x = Math.max(0, Math.min(x, rotW - maxW)); + positions.push(x); + } + + // Ensure first and last positions are at edges + if (isClockwise) { + positions[0] = rotW - maxW; // First part at right edge + positions[numParts - 1] = 0; // Last part at left edge + } else { + positions[0] = 0; // First part at left edge + positions[numParts - 1] = rotW - maxW; // Last part at right edge + } + + for (let i = 0; i < numParts; i++) { + const x = positions[i]; + const partW = maxW; // Always full width for consistency + + const partCanvas = document.createElement('canvas'); + partCanvas.width = partW; + partCanvas.height = rotH; + const partCtx = partCanvas.getContext('2d'); + // Clear canvas first + partCtx.fillStyle = '#FFFFFF'; + partCtx.fillRect(0, 0, partW, rotH); + // Draw the slice + partCtx.drawImage(rotCanvas, x, 0, partW, rotH, 0, 0, partW, rotH); + + const blob = await new Promise(res => partCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); + const arrBuf = await blob.arrayBuffer(); + parts.push({ data: arrBuf, suffix: `_part${i + 1}`, width: partW, height: rotH, size: arrBuf.byteLength }); + } + + const totalSize = parts.reduce((sum, p) => sum + p.size, 0); + resolve({ + parts, + meta: { origW, origH, origSize, wasSplit: true, splitCount: numParts, rotated: true, finalW: parts[0].width, finalH: parts[0].height, finalSize: totalSize, imageState: 1 } + }); + } + } + // ======================================================================== + // STATE 2: V-Split (Vertical Split, no rotation) + // Step 1: Scale HEIGHT to 800px (up or down) + // Step 2: If WIDTH > 480, split vertically with overlap + // ======================================================================== + else if (imageState === 2) { + // ALWAYS scale height to 800 (up or down) + const scale = MAX_HEIGHT / origH; // 800 / origH + const scaledW = Math.round(origW * scale); + const scaledH = MAX_HEIGHT; // Always 800 + + const scaledCanvas = document.createElement('canvas'); + scaledCanvas.width = scaledW; + scaledCanvas.height = scaledH; + const scaledCtx = scaledCanvas.getContext('2d'); + scaledCtx.imageSmoothingEnabled = true; + scaledCtx.imageSmoothingQuality = 'high'; + scaledCtx.fillStyle = '#FFF'; + scaledCtx.fillRect(0, 0, scaledW, scaledH); + scaledCtx.drawImage(img, 0, 0, origW, origH, 0, 0, scaledW, scaledH); + applyGrayscale(scaledCtx, scaledW, scaledH); + + // Check if split needed + if (scaledW <= MAX_WIDTH) { + const blob = await new Promise(res => scaledCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); + const arrBuf = await blob.arrayBuffer(); + resolve({ + parts: [{ data: arrBuf, suffix: '', width: scaledW, height: scaledH, size: arrBuf.byteLength }], + meta: { origW, origH, origSize, wasSplit: false, rotated: false, finalW: scaledW, finalH: scaledH, finalSize: arrBuf.byteLength, imageState: 2 } + }); + } else { + // Split by WIDTH (vertical cuts) - LEFT to RIGHT (natural reading order) + const parts = []; + const maxW = MAX_WIDTH; + + // Centered distribution: calculate numParts first, then distribute evenly + let overlapPx, step, numParts; + const minOverlapPx = Math.round(maxW * (OVERLAP_PERCENT / 100)); // Configurable overlap + const maxStep = maxW - minOverlapPx; + numParts = Math.ceil((scaledW - minOverlapPx) / maxStep); + if (numParts < 2) numParts = 2; + // Now calculate step to distribute evenly + step = Math.round((scaledW - maxW) / (numParts - 1)); + overlapPx = maxW - step; + // Ensure minimum overlap + if (overlapPx < minOverlapPx) { + overlapPx = minOverlapPx; + step = maxW - overlapPx; + } + + // Calculate all x positions first to ensure consistency + const positions = []; + for (let i = 0; i < numParts; i++) { + let x = i * step; + // Clamp to valid range + x = Math.max(0, Math.min(x, scaledW - maxW)); + positions.push(x); + } + // Ensure last position is at right edge + positions[0] = 0; + positions[numParts - 1] = scaledW - maxW; + + for (let i = 0; i < numParts; i++) { + const x = positions[i]; + const partW = maxW; // Always full width for consistency + + const partCanvas = document.createElement('canvas'); + partCanvas.width = partW; + partCanvas.height = scaledH; + const partCtx = partCanvas.getContext('2d'); + // Clear canvas first + partCtx.fillStyle = '#FFFFFF'; + partCtx.fillRect(0, 0, partW, scaledH); + // Draw the slice + partCtx.drawImage(scaledCanvas, x, 0, partW, scaledH, 0, 0, partW, scaledH); + + const blob = await new Promise(res => partCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); + const arrBuf = await blob.arrayBuffer(); + parts.push({ data: arrBuf, suffix: `_part${i + 1}`, width: partW, height: scaledH, size: arrBuf.byteLength }); + } + + const totalSize = parts.reduce((sum, p) => sum + p.size, 0); + resolve({ + parts, + meta: { origW, origH, origSize, wasSplit: true, splitCount: numParts, rotated: false, finalW: parts[0].width, finalH: parts[0].height, finalSize: totalSize, imageState: 2 } + }); + } + } + // ======================================================================== + // STATE 3: Rotate & Fit (Rotate 90°, then scale to fit 480x800, no split) + // ======================================================================== + else if (imageState === 3) { + // Step 1: Rotate 90° based on handedness + const rotW = origH; + const rotH = origW; + + const rotCanvas = document.createElement('canvas'); + rotCanvas.width = rotW; + rotCanvas.height = rotH; + const rotCtx = rotCanvas.getContext('2d'); + rotCtx.fillStyle = '#FFF'; + rotCtx.fillRect(0, 0, rotW, rotH); + + const isClockwise = HANDEDNESS === 'right'; + if (isClockwise) { + rotCtx.translate(rotW, 0); + rotCtx.rotate(Math.PI / 2); + } else { + rotCtx.translate(0, rotH); + rotCtx.rotate(-Math.PI / 2); + } + rotCtx.drawImage(img, 0, 0); + rotCtx.setTransform(1, 0, 0, 1, 0, 0); + + // Step 2: Scale to fit 480x800 (if needed) + const fitsInScreen = rotW <= MAX_WIDTH && rotH <= MAX_HEIGHT; + + if (fitsInScreen) { + // Already fits after rotation - just apply grayscale + applyGrayscale(rotCtx, rotW, rotH); + const blob = await new Promise(res => rotCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); + const arrBuf = await blob.arrayBuffer(); + resolve({ + parts: [{ data: arrBuf, suffix: '', width: rotW, height: rotH, size: arrBuf.byteLength }], + meta: { origW, origH, origSize, wasSplit: false, rotated: true, finalW: rotW, finalH: rotH, finalSize: arrBuf.byteLength, imageState: 3 } + }); + } else { + // Scale to fit 480x800 + const scale = Math.min(MAX_WIDTH / rotW, MAX_HEIGHT / rotH); + const newW = Math.round(rotW * scale); + const newH = Math.round(rotH * scale); + + const scaledCanvas = document.createElement('canvas'); + scaledCanvas.width = newW; + scaledCanvas.height = newH; + const scaledCtx = scaledCanvas.getContext('2d'); + scaledCtx.imageSmoothingEnabled = true; + scaledCtx.imageSmoothingQuality = 'high'; + scaledCtx.fillStyle = '#FFF'; + scaledCtx.fillRect(0, 0, newW, newH); + scaledCtx.drawImage(rotCanvas, 0, 0, newW, newH); + applyGrayscale(scaledCtx, newW, newH); + + const blob = await new Promise(res => scaledCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); + const arrBuf = await blob.arrayBuffer(); + resolve({ + parts: [{ data: arrBuf, suffix: '', width: newW, height: newH, size: arrBuf.byteLength }], + meta: { origW, origH, origSize, wasSplit: false, rotated: true, finalW: newW, finalH: newH, finalSize: arrBuf.byteLength, imageState: 3 } + }); + } + } + // ======================================================================== + // STATE 0: Normal processing (scale to fit, no split/rotation) + // ======================================================================== + else { + // Normal processing: check if scaling is needed + const fitsInScreen = origW <= MAX_WIDTH && origH <= MAX_HEIGHT; + + if (fitsInScreen) { + // Image already fits - just convert to JPEG with grayscale + const c = document.createElement('canvas'); + c.width = origW; + c.height = origH; + const ctx = c.getContext('2d'); + ctx.fillStyle = '#FFF'; + ctx.fillRect(0, 0, origW, origH); + ctx.drawImage(img, 0, 0); + applyGrayscale(ctx, origW, origH); + + const blob = await new Promise(res => c.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); + const arrBuf = await blob.arrayBuffer(); + resolve({ + parts: [{ data: arrBuf, suffix: '', width: origW, height: origH, size: arrBuf.byteLength }], + meta: { origW, origH, origSize, wasSplit: false, rotated: false, finalW: origW, finalH: origH, finalSize: arrBuf.byteLength, imageState: 0 } + }); + } else { + // Scale to fit 480x800 + const scale = Math.min(MAX_WIDTH / origW, MAX_HEIGHT / origH); + const newW = Math.round(origW * scale); + const newH = Math.round(origH * scale); + + const c = document.createElement('canvas'); + c.width = newW; + c.height = newH; + const ctx = c.getContext('2d'); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.fillStyle = '#FFF'; + ctx.fillRect(0, 0, newW, newH); + ctx.drawImage(img, 0, 0, newW, newH); + applyGrayscale(ctx, newW, newH); + + const blob = await new Promise(res => c.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); + const arrBuf = await blob.arrayBuffer(); + resolve({ + parts: [{ data: arrBuf, suffix: '', width: newW, height: newH, size: arrBuf.byteLength }], + meta: { origW, origH, origSize, wasSplit: false, rotated: false, finalW: newW, finalH: newH, finalSize: arrBuf.byteLength, imageState: 0 } + }); + } + } + }; + img.onerror = () => { + clearTimeout(timeoutId); + URL.revokeObjectURL(url); + reject(new Error('Image load failed')); + }; + img.src = url; + }); +} + +// Convert EPUB file - returns converted blob +async function convertEpubFile(file, progressCallback) { + const startTime = Date.now(); + const originalSize = file.size; + let totalImageSize = 0; + let totalNewSize = 0; + + // Initialize logging + clearLog(); + showLog(); + log(`${file.name} (${formatBytes(originalSize)})`, '', 'INFO'); + log(`Quality: ${JPEG_QUALITY}% | Overlap: ${OVERLAP_PERCENT}% | Rotation: ${HANDEDNESS === 'right' ? 'CW' : 'CCW'} | Grayscale: ${ENABLE_GRAYSCALE ? 'ON' : 'OFF'}`, '', 'INFO'); + + const zip = await JSZip.loadAsync(file); + const renamed = {}; + zip.forEach(p => { + const l = p.toLowerCase(); + if (l.match(/\.(png|gif|webp|bmp|jpeg)$/)) { + renamed[p] = p.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg'); + } + }); + + const out = new JSZip(); + const entries = Object.entries(zip.files); + const splitImages = {}; + const xhtmlFiles = {}; + let opfPath = null, opfContent = null; + let mainIdentifier = null; + + // Write mimetype FIRST per EPUB OCF spec + if (zip.files['mimetype']) { + const mimetypeData = await zip.files['mimetype'].async('arraybuffer'); + out.file('mimetype', mimetypeData, { compression: 'STORE', createFolders: false }); + } + + // First pass: process images + for (let i = 0; i < entries.length; i++) { + if (operationCancelled) throw new Error('Cancelled by user'); + const [path, fileObj] = entries[i]; + if (fileObj.dir || path === 'mimetype') continue; + const low = path.toLowerCase(); + + if (low.match(/\.(png|gif|webp|bmp|jpg|jpeg)$/)) { + const data = await fileObj.async('arraybuffer'); + const imageState = getImageState(path); + + let result; + try { + result = await processImage(data, imageState, path); + } catch (imageError) { + // Log error but continue with original image + console.error(`Failed to process image ${path}:`, imageError); + log(`Warning: Failed to process ${path.split('/').pop()}, using original`, 'warning', 'IMG-ERR'); + + // Use original image data as fallback + result = { + parts: [{ + data: data, + suffix: '', + width: 0, + height: 0, + size: data.byteLength + }], + meta: { + origW: 0, + origH: 0, + origSize: data.byteLength, + wasSplit: false, + rotated: false, + finalW: 0, + finalH: 0, + finalSize: data.byteLength, + imageState: imageState, + processingError: true + } + }; + } + + const parts = result.parts; + const meta = result.meta; + + const baseName = path.replace(/\.[^.]+$/, ''); + const newExt = '.jpg'; + + // Log image processing + const imgName = path.split('/').pop(); + const origFormat = path.split('.').pop(); + logImage(imgName, meta.origW, meta.origH, origFormat, meta.origSize, meta.finalW, meta.finalH, meta.finalSize, meta.wasSplit, meta.splitCount || 0, parts, meta.imageState || 0); + + totalImageSize += meta.origSize; + totalNewSize += meta.finalSize; + + if (parts.length === 1 && parts[0].suffix === '') { + const newPath = renamed[path] || path.replace(/\.[^.]+$/, newExt); + out.file(newPath, parts[0].data, { compression: 'STORE', createFolders: false }); + } else { + // Store with full path for collision prevention, but also keep original filename + const origName = path.split('/').pop(); + const origDir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : ''; + + // Key by full path to avoid collisions + splitImages[path] = { + origName: origName, + origDir: origDir, + parts: [] + }; + + for (const part of parts) { + const partName = baseName.split('/').pop() + part.suffix + newExt; + const partPath = (path.includes('/') ? path.substring(0, path.lastIndexOf('/') + 1) : '') + partName; + out.file(partPath, part.data, { compression: 'STORE', createFolders: false }); + // Store metadata for XHTML/OPF updates + splitImages[path].parts.push({ + path: partPath, + imgName: partName, + id: baseName.split('/').pop() + part.suffix, + suffix: part.suffix + }); + } + } + } else if (low.match(/\.(xhtml|html|htm)$/)) { + xhtmlFiles[path] = await safeReadText(fileObj); + } else if (low.endsWith('.opf')) { + opfPath = path; + opfContent = await safeReadText(fileObj); + } + + if (progressCallback) progressCallback((i / entries.length) * 60); + } + + // Second pass: update XHTML using DOMParser + for (const [xhtmlPath, content] of Object.entries(xhtmlFiles)) { + if (operationCancelled) throw new Error('Cancelled by user'); + let t = content; + const r = fixSvgCover(t); + if (r.fixed) { t = r.c; logFix('SVG cover', xhtmlPath.split('/').pop()); } + + const r2 = fixSvgWrappedImages(t); + if (r2.fixed) { t = r2.c; logFix(`SVG images (${r2.count})`, xhtmlPath.split('/').pop()); } + + for (const [o, n] of Object.entries(renamed)) { + t = t.split(o.split('/').pop()).join(n.split('/').pop()); + } + + // Use DOMParser for all img modifications: remove width/height and handle split images + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(t, 'application/xhtml+xml'); + const parseError = doc.querySelector('parsererror'); + + if (!parseError) { + let modified = false; + + // Remove width/height attributes from ALL img tags (dimensions may have changed) + // This prevents CrossPoint and other readers from using wrong dimensions + const allImgElements = doc.querySelectorAll('img'); + for (const img of allImgElements) { + if (img.hasAttribute('width')) { img.removeAttribute('width'); modified = true; } + if (img.hasAttribute('height')) { img.removeAttribute('height'); modified = true; } + } + + // Handle split images with path collision prevention + if (Object.keys(splitImages).length > 0) { + // Get XHTML directory for resolving relative paths + const xhtmlDir = xhtmlPath.includes('/') ? xhtmlPath.substring(0, xhtmlPath.lastIndexOf('/')) : ''; + const rootFolders = ['ops', 'oebps', 'epub', 'content']; + + for (const [fullPath, splitInfo] of Object.entries(splitImages)) { + const origName = splitInfo.origName; + const origDir = splitInfo.origDir; + const parts = splitInfo.parts; + const newName = origName.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg'); + + // Extract immediate parent directory for collision prevention + const splitDirParts = origDir.split('/').filter(p => p); + const lastDir = splitDirParts.length > 0 ? splitDirParts[splitDirParts.length - 1].toLowerCase() : null; + const immediateParent = (lastDir && !rootFolders.includes(lastDir)) ? splitDirParts[splitDirParts.length - 1] : null; + + // Get XHTML's parent directory parts for relative path resolution + const xhtmlDirParts = xhtmlDir.split('/').filter(p => p); + + // Find all img elements + const allImgs = doc.querySelectorAll('img'); + const matchingImgs = []; + + for (const img of allImgs) { + const src = img.getAttribute('src') || ''; + const srcParts = src.split('/').filter(p => p && p !== '..' && p !== '.'); + const srcName = srcParts.pop() || ''; + + // Check filename match + if (srcName !== origName && srcName !== newName) continue; + + // Path collision prevention with root folder handling + if (immediateParent) { + // Image is in a specific subfolder (not root like OPS/OEBPS) + if (srcParts.length === 0) { + // src has no path - check if XHTML is in same folder as image + const xhtmlLastDir = xhtmlDirParts.length > 0 ? xhtmlDirParts[xhtmlDirParts.length - 1] : null; + if (xhtmlLastDir !== immediateParent) continue; + } else { + // src has path - verify parent directory matches + if (srcParts[srcParts.length - 1] !== immediateParent) continue; + } + } else { + // Image is in root folder (like OEBPS/cover.jpg) + // Only match if src has NO subfolder path OR points to root folder + if (srcParts.length > 0) { + const srcLastDir = srcParts[srcParts.length - 1].toLowerCase(); + if (!rootFolders.includes(srcLastDir)) continue; + } + } + + matchingImgs.push(img); + } + + // Process each matching img — Pro's strip+inject approach + for (const img of matchingImgs) { + const src = img.getAttribute('src') || ''; + + // Part 1: update src in-place, strip original sizing + img.setAttribute('src', src.replace(origName, parts[0].imgName).replace(newName, parts[0].imgName)); + + if (parts.length > 1) { + // Strip original width/height/class that were sized for the unsplit image + img.removeAttribute('width'); + img.removeAttribute('height'); + img.removeAttribute('class'); + img.setAttribute('style', 'max-width:100%;height:auto'); + + // Neutralize container height constraints that were sized for the original + let container = img.parentElement; + const safeContainers = ['div', 'p', 'figure', 'aside', 'section', 'body']; + while (container && !safeContainers.includes(container.tagName.toLowerCase())) container = container.parentElement; + const insertTarget = container || img.parentElement; + // Strip constraining classes/styles from container — they were for the unsplit image + if (insertTarget && insertTarget.tagName.toLowerCase() !== 'body') { + insertTarget.removeAttribute('class'); + insertTarget.removeAttribute('style'); + } + const insertParent = insertTarget.parentElement; + const insertRef = insertTarget.nextSibling; + const ns = doc.documentElement.namespaceURI || 'http://www.w3.org/1999/xhtml'; + + // Insert new minimal wrappers for parts 2+ in reading order + for (let pi = 1; pi < parts.length; pi++) { + const wrapper = doc.createElementNS(ns, 'div'); + const newImg = doc.createElementNS(ns, 'img'); + const partSrc = src.replace(origName, parts[pi].imgName).replace(newName, parts[pi].imgName); + newImg.setAttribute('src', partSrc); + newImg.setAttribute('alt', ''); + newImg.setAttribute('style', 'max-width:100%;height:auto'); + wrapper.appendChild(newImg); + if (insertRef) insertParent.insertBefore(wrapper, insertRef); + else insertParent.appendChild(wrapper); + } + } + modified = true; + } + } + } + + // Only serialize if we made changes + if (modified) { + t = safeSerialize(doc, content); + } + } + } catch (e) { + console.warn('DOMParser error for', xhtmlPath, e.message); + } + + // Inject universal image constraint — prevents overflow on e-ink displays + if (t.includes('')) { + t = t.replace('', DEFENSIVE_STYLE + ''); + } + + out.file(xhtmlPath, t, { compression: 'DEFLATE', compressionOptions: { level: 8 }, createFolders: false }); + } + + // Extract main identifier from OPF using DOMParser with regex fallback + if (opfContent) { + mainIdentifier = extractIdentifier(opfContent); + } + + // Third pass: update OPF using fixOPF (DOMParser with regex fallback) + if (opfContent) { + let t = opfContent; + for (const [o, n] of Object.entries(renamed)) { + t = t.split(o.split('/').pop()).join(n.split('/').pop()); + } + const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/')) : ''; + t = fixOPF(t, opfContent, opfDir, splitImages); + if (t !== opfContent) logFix('OPF', 'manifest updated'); + out.file(opfPath, t, { compression: 'DEFLATE', compressionOptions: { level: 8 }, createFolders: false }); + } + + // Copy remaining files + for (const [path, fileObj] of entries) { + if (operationCancelled) throw new Error('Cancelled by user'); + if (fileObj.dir || path === 'mimetype') continue; + const low = path.toLowerCase(); + if (low.match(/\.(png|gif|webp|bmp|jpg|jpeg)$/) || low.match(/\.(xhtml|html|htm)$/) || low.endsWith('.opf')) continue; + + let data = await fileObj.async('arraybuffer'); + if (low.endsWith('.css')) { + let t = await safeReadText(fileObj); + for (const [o, n] of Object.entries(renamed)) { + t = t.split(o.split('/').pop()).join(n.split('/').pop()); + } + data = new TextEncoder().encode(t); + } else if (low.endsWith('.ncx')) { + let t = await safeReadText(fileObj); + for (const [o, n] of Object.entries(renamed)) { + t = t.split(o.split('/').pop()).join(n.split('/').pop()); + } + const oldT = t; + t = syncNCXIdentifier(t, mainIdentifier); + if (t !== oldT) logFix('NCX identifier', 'Synced with OPF'); + data = new TextEncoder().encode(t); + } + out.file(path, data, { compression: 'DEFLATE', compressionOptions: { level: 8 }, createFolders: false }); + } + + if (progressCallback) progressCallback(100); + + // Generate final blob + const newBlob = await out.generateAsync({ type: 'blob', mimeType: 'application/epub+zip' }); + const newSize = newBlob.size; + const timeElapsed = (Date.now() - startTime) / 1000; + + // Log completion + log('Conversion complete!', 'success', 'DONE'); + logSummary(totalImageSize > 0 ? totalImageSize : originalSize, totalNewSize > 0 ? totalNewSize : newSize, timeElapsed); + + // Auto-export only if NOT in batch mode (batch mode exports at the end) + if (!isBatchMode && exportLogCheckbox && exportLogCheckbox.checked) { + setTimeout(() => { + exportLogToFile(null, false); // isBatch = false for single file + }, 100); + } + + return newBlob; +} + // Get WebSocket URL based on current page location function getWsUrl() { const host = window.location.hostname; @@ -1027,8 +4522,10 @@ function getWsUrl() { function uploadFileWebSocket(file, onProgress, onComplete, onError) { return new Promise((resolve, reject) => { const ws = new WebSocket(getWsUrl()); + currentUploadWs = ws; let uploadStarted = false; let sendingChunks = false; + let uploadComplete = false; // set only when DONE is received and resolve() called ws.binaryType = 'arraybuffer'; @@ -1071,11 +4568,10 @@ function uploadFileWebSocket(file, onProgress, onComplete, onError) { ws.send(buffer); offset += chunkSize; - // Update local progress - cap at 95% since server still needs to write - // Final 100% shown when server confirms DONE + // Update local progress with real transfer progress + // Server will confirm 100% with DONE message if (onProgress) { - const cappedOffset = Math.min(offset, Math.floor(totalSize * 0.95)); - onProgress(cappedOffset, totalSize); + onProgress(offset, totalSize); } } @@ -1094,6 +4590,8 @@ function uploadFileWebSocket(file, onProgress, onComplete, onError) { } else if (msg === 'DONE') { // Show 100% when server confirms completion if (onProgress) onProgress(file.size, file.size); + uploadComplete = true; + currentUploadWs = null; ws.close(); if (onComplete) onComplete(); resolve(); @@ -1107,17 +4605,23 @@ function uploadFileWebSocket(file, onProgress, onComplete, onError) { ws.onerror = function(event) { console.error('[WS] Error:', event); + currentUploadWs = null; if (!uploadStarted) { reject(new Error('WebSocket connection failed')); } else if (!sendingChunks) { reject(new Error('WebSocket error during upload')); + } else { + // Error during chunk sending - reject with appropriate message + reject(new Error('WebSocket error during file transfer')); } }; ws.onclose = function(event) { console.log('[WS] Connection closed, code:', event.code, 'reason:', event.reason); - if (sendingChunks) { - reject(new Error('WebSocket closed unexpectedly')); + // Reject for any close before upload was confirmed complete (covers both + // mid-chunk-send closes and the "all chunks sent, waiting for DONE" window) + if (!uploadComplete) { + reject(new Error('WebSocket closed during upload')); } }; }); @@ -1130,6 +4634,7 @@ function uploadFileHTTP(file, onProgress, onComplete, onError) { formData.append('file', file); const xhr = new XMLHttpRequest(); + currentUploadXhr = xhr; xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true); xhr.upload.onprogress = function(e) { @@ -1139,6 +4644,7 @@ function uploadFileHTTP(file, onProgress, onComplete, onError) { }; xhr.onload = function() { + currentUploadXhr = null; if (xhr.status === 200) { if (onComplete) onComplete(); resolve(); @@ -1150,11 +4656,17 @@ function uploadFileHTTP(file, onProgress, onComplete, onError) { }; xhr.onerror = function() { + currentUploadXhr = null; const error = 'Network error'; if (onError) onError(error); reject(new Error(error)); }; + xhr.onabort = function() { + currentUploadXhr = null; + reject(new Error('Upload aborted')); + }; + xhr.send(formData); }); } @@ -1162,12 +4674,19 @@ function uploadFileHTTP(file, onProgress, onComplete, onError) { function uploadFile() { const fileInput = document.getElementById('fileInput'); const files = Array.from(fileInput.files); + const convertEnabled = document.getElementById('convertBeforeUpload').checked; if (files.length === 0) { alert('Please select at least one file!'); return; } + // Prevent modal close during upload + isUploadInProgress = true; + uploadGeneration++; + const myGeneration = uploadGeneration; + document.getElementById('uploadModalClose').classList.add('disabled'); + const progressContainer = document.getElementById('progress-container'); const progressFill = document.getElementById('progress-fill'); const progressText = document.getElementById('progress-text'); @@ -1180,64 +4699,201 @@ function uploadFile() { const failedFiles = []; let useWebSocket = true; // Try WebSocket first + // Check if we should use batch logging mode + const epubFilesToConvert = files.filter(f => f.name.toLowerCase().endsWith('.epub') && convertEnabled); + const useBatchLog = epubFilesToConvert.length > 1 && exportLogCheckbox && exportLogCheckbox.checked; + + // Start batch log mode if needed + if (useBatchLog) { + startBatchLog(epubFilesToConvert.length); + showLog(); + } + async function uploadNextFile() { if (currentIndex >= files.length) { // All files processed - show summary if (failedFiles.length === 0) { progressFill.style.backgroundColor = '#4caf50'; progressText.textContent = 'All uploads complete!'; - setTimeout(() => { - closeUploadModal(); - hydrate(); - }, 1000); + + // Finalize batch log if in batch mode + if (useBatchLog) { + finalizeBatchLog(); + setTimeout(() => { + window.location.reload(); + }, 2000); + } else { + setTimeout(() => { + window.location.reload(); + }, 1000); + } } else { progressFill.style.backgroundColor = '#e74c3c'; const failedList = failedFiles.map(f => f.name).join(', '); progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`; - failedUploadsGlobal = failedFiles; - setTimeout(() => { + + // Add upload errors to batch log + if (useBatchLog) { + failedFiles.forEach(ff => { + logError(`Upload failed for ${ff.name}: ${ff.error}`); + }); + finalizeBatchLog(); + } + + // Only show banner if THIS upload session had failures + // Use local failedFiles, not the global shared variable + if (failedFiles.length > 0) { + // Accumulate failed uploads to global (don't replace) + failedUploadsGlobal = failedUploadsGlobal.concat(failedFiles); + // Clear flag and close modal, then show banner with retry options + isUploadInProgress = false; + document.getElementById('uploadModalClose').classList.remove('disabled'); closeUploadModal(); showFailedUploadsBanner(); - hydrate(); - }, 2000); + } } return; } - const file = files[currentIndex]; + let file = files[currentIndex]; + const originalFile = file; + // Reset progress bar instantly without transition when starting a new file + progressFill.classList.add('no-transition'); progressFill.style.width = '0%'; progressFill.style.backgroundColor = '#27ae60'; + // Re-enable transition after a brief delay + setTimeout(() => progressFill.classList.remove('no-transition'), 50); + + // Check if file is an EPUB and conversion is enabled + const isEpub = file.name.toLowerCase().endsWith('.epub'); + const needsConversion = isEpub && convertEnabled; + let conversionSucceeded = false; + let conversionFailed = false; // Track if conversion actually failed + const methodText = useWebSocket ? ' [WS]' : ' [HTTP]'; - progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText}`; + const stageText = needsConversion ? 'Converting & uploading' : 'Uploading'; + progressText.style.color = ''; + progressText.textContent = `${stageText} ${file.name} (${currentIndex + 1}/${files.length})${methodText}`; const onProgress = (loaded, total) => { - const percent = Math.round((loaded / total) * 100); - progressFill.style.width = percent + '%'; - const speed = ''; // Could calculate speed here - progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText} — ${percent}%`; + const uploadPercent = Math.round((loaded / total) * 100); + // If conversion succeeded, display goes from 50-100%, otherwise 0-100% + const displayPercent = conversionSucceeded ? 50 + Math.round(uploadPercent / 2) : uploadPercent; + progressFill.style.width = displayPercent + '%'; + const prefix = conversionSucceeded ? 'Converting & uploading' : 'Uploading'; + progressText.textContent = `${prefix} ${file.name} (${currentIndex + 1}/${files.length})${methodText} — ${uploadPercent}%`; }; const onComplete = () => { + // Save file log to batch if in batch mode and this file was converted + // Consider it successful only if conversion didn't fail + if (useBatchLog && needsConversion) { + saveToFileBatchLog(file.name, !conversionFailed && conversionSucceeded); + } + currentIndex++; uploadNextFile(); }; const onError = (error) => { - failedFiles.push({ name: file.name, error: error, file: file }); + // Save failed file log to batch if in batch mode + if (useBatchLog && needsConversion) { + logError(`Upload failed: ${error}`); + saveToFileBatchLog(file.name, false); + } + + failedFiles.push({ name: file.name, error: error, file: originalFile }); + + // If network error, mark all remaining files as failed and show retry banner + if (error.includes('connection failed') || error.includes('Network error') || + error.includes('timeout') || error.includes('disconnected')) { + console.log(`[Network] Network error detected: ${error}`); + + // Add all remaining files to failed list + const remainingFiles = files.slice(currentIndex + 1); + remainingFiles.forEach(remainingFile => { + failedFiles.push({ + name: remainingFile.name, + error: 'Network error - upload interrupted', + file: remainingFile + }); + }); + + // Show retry banner immediately with all failed files + failedUploadsGlobal = failedUploadsGlobal.concat(failedFiles); + isUploadInProgress = false; + document.getElementById('uploadModalClose').classList.remove('disabled'); + closeUploadModal(); + showFailedUploadsBanner(); + return; // Stop processing + } + currentIndex++; uploadNextFile(); }; try { + // Convert EPUB if needed + if (needsConversion) { + progressFill.style.backgroundColor = '#9b59b6'; // Purple for conversion + progressText.textContent = `Converting ${file.name} (${currentIndex + 1}/${files.length})...`; + + // Clear log for single file mode, or just add separator for batch mode + if (!useBatchLog) { + clearLog(); + showLog(); + } + + try { + const convertedBlob = await convertEpubFile(file, (percent) => { + // Pass current quality setting to converter + progressFill.style.width = (percent * 0.5) + '%'; // Conversion takes first 50% + }); + + // Create new File from converted blob + file = new File([convertedBlob], file.name, { type: 'application/epub+zip' }); + progressFill.style.backgroundColor = '#27ae60'; // Back to green for upload + conversionSucceeded = true; + } catch (convError) { + if (operationCancelled) { if (uploadGeneration === myGeneration) restoreAfterCancel(); return; } + console.error('Conversion error:', convError); + // Log the error + logError(`Conversion failed: ${convError.message}`); + log('Uploading original file instead...', 'warning', 'INFO'); + conversionFailed = true; + + // In single file mode, export error log + if (!useBatchLog && exportLogCheckbox && exportLogCheckbox.checked) { + setTimeout(() => { + exportLogToFile(null, false); // isBatch = false for single file + }, 100); + } + + // If conversion fails, try uploading original file + progressText.textContent = `Conversion failed, uploading original ${file.name}...`; + progressFill.style.backgroundColor = '#e67e22'; // Orange for fallback + // Reset progress bar to 0% for original file upload + progressFill.style.width = '0%'; + } + } + if (useWebSocket) { await uploadFileWebSocket(file, onProgress, null, null); - onComplete(); } else { await uploadFileHTTP(file, onProgress, null, null); - onComplete(); } + // Ensure progress bar shows 100% before moving to next file + progressFill.style.width = '100%'; + progressText.textContent = `Upload complete: ${file.name}`; + onComplete(); } catch (error) { + if (operationCancelled) { if (uploadGeneration === myGeneration) restoreAfterCancel(); return; } console.error('Upload error:', error); + // Log upload error if conversion succeeded but upload failed + if (conversionSucceeded) { + logError(`Upload failed: ${error.message}`); + } + if (useWebSocket && error.message === 'WebSocket connection failed') { // Fall back to HTTP for all subsequent uploads console.log('WebSocket failed, falling back to HTTP'); @@ -1261,9 +4917,9 @@ function uploadFile() { function showFailedUploadsBanner() { const banner = document.getElementById('failedUploadsBanner'); const filesList = document.getElementById('failedFilesList'); - + filesList.innerHTML = ''; - + failedUploadsGlobal.forEach((failedFile, index) => { const item = document.createElement('div'); item.className = 'failed-file-item'; @@ -1276,11 +4932,11 @@ function showFailedUploadsBanner() { `; filesList.appendChild(item); }); - + // Ensure retry all button is visible const retryAllBtn = banner.querySelector('.retry-all-btn'); if (retryAllBtn) retryAllBtn.style.display = ''; - + banner.classList.add('show'); } @@ -1293,22 +4949,22 @@ function dismissFailedUploads() { function retrySingleUpload(index) { const failedFile = failedUploadsGlobal[index]; if (!failedFile) return; - + // Create a DataTransfer to set the file input const dt = new DataTransfer(); dt.items.add(failedFile.file); - + const fileInput = document.getElementById('fileInput'); fileInput.files = dt.files; - + // Remove this file from failed list failedUploadsGlobal.splice(index, 1); - + // If no more failed files, hide banner if (failedUploadsGlobal.length === 0) { dismissFailedUploads(); } - + // Open modal and trigger upload openUploadModal(); validateFile(); @@ -1316,20 +4972,20 @@ function retrySingleUpload(index) { function retryAllFailedUploads() { if (failedUploadsGlobal.length === 0) return; - + // Create a DataTransfer with all failed files const dt = new DataTransfer(); failedUploadsGlobal.forEach(failedFile => { dt.items.add(failedFile.file); }); - + const fileInput = document.getElementById('fileInput'); fileInput.files = dt.files; - + // Clear failed files list failedUploadsGlobal = []; dismissFailedUploads(); - + // Open modal and trigger upload openUploadModal(); validateFile(); diff --git a/src/network/html/js/jszip.min.js b/src/network/html/js/jszip.min.js new file mode 100644 index 00000000..ff4cfd5e --- /dev/null +++ b/src/network/html/js/jszip.min.js @@ -0,0 +1,13 @@ +/*! + +JSZip v3.10.1 - A JavaScript class for generating and reading zip files + + +(c) 2009-2016 Stuart Knightley +Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/main/LICENSE.markdown. + +JSZip uses the library pako released under the MIT license : +https://github.com/nodeca/pako/blob/main/LICENSE +*/ + +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=e()}}(function(){return function s(a,o,h){function u(r,e){if(!o[r]){if(!a[r]){var t="function"==typeof require&&require;if(!e&&t)return t(r,!0);if(l)return l(r,!0);var n=new Error("Cannot find module '"+r+"'");throw n.code="MODULE_NOT_FOUND",n}var i=o[r]={exports:{}};a[r][0].call(i.exports,function(e){var t=a[r][1][e];return u(t||e)},i,i.exports,s,a,o,h)}return o[r].exports}for(var l="function"==typeof require&&require,e=0;e>2,s=(3&t)<<4|r>>4,a=1>6:64,o=2>4,r=(15&i)<<4|(s=p.indexOf(e.charAt(o++)))>>2,n=(3&s)<<6|(a=p.indexOf(e.charAt(o++))),l[h++]=t,64!==s&&(l[h++]=r),64!==a&&(l[h++]=n);return l}},{"./support":30,"./utils":32}],2:[function(e,t,r){"use strict";var n=e("./external"),i=e("./stream/DataWorker"),s=e("./stream/Crc32Probe"),a=e("./stream/DataLengthProbe");function o(e,t,r,n,i){this.compressedSize=e,this.uncompressedSize=t,this.crc32=r,this.compression=n,this.compressedContent=i}o.prototype={getContentWorker:function(){var e=new i(n.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),t=this;return e.on("end",function(){if(this.streamInfo.data_length!==t.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),e},getCompressedWorker:function(){return new i(n.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(e,t,r){return e.pipe(new s).pipe(new a("uncompressedSize")).pipe(t.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",t)},t.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(e,t,r){"use strict";var n=e("./stream/GenericWorker");r.STORE={magic:"\0\0",compressWorker:function(){return new n("STORE compression")},uncompressWorker:function(){return new n("STORE decompression")}},r.DEFLATE=e("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(e,t,r){"use strict";var n=e("./utils");var o=function(){for(var e,t=[],r=0;r<256;r++){e=r;for(var n=0;n<8;n++)e=1&e?3988292384^e>>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t){return void 0!==e&&e.length?"string"!==n.getTypeOf(e)?function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}(0|t,e,e.length,0):function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t.charCodeAt(a))];return-1^e}(0|t,e,e.length,0):0}},{"./utils":32}],5:[function(e,t,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(e,t,r){"use strict";var n=null;n="undefined"!=typeof Promise?Promise:e("lie"),t.exports={Promise:n}},{lie:37}],7:[function(e,t,r){"use strict";var n="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,i=e("pako"),s=e("./utils"),a=e("./stream/GenericWorker"),o=n?"uint8array":"array";function h(e,t){a.call(this,"FlateWorker/"+e),this._pako=null,this._pakoAction=e,this._pakoOptions=t,this.meta={}}r.magic="\b\0",s.inherits(h,a),h.prototype.processChunk=function(e){this.meta=e.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,e.data),!1)},h.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},h.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},h.prototype._createPako=function(){this._pako=new i[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var t=this;this._pako.onData=function(e){t.push({data:e,meta:t.meta})}},r.compressWorker=function(e){return new h("Deflate",e)},r.uncompressWorker=function(){return new h("Inflate",{})}},{"./stream/GenericWorker":28,"./utils":32,pako:38}],8:[function(e,t,r){"use strict";function A(e,t){var r,n="";for(r=0;r>>=8;return n}function n(e,t,r,n,i,s){var a,o,h=e.file,u=e.compression,l=s!==O.utf8encode,f=I.transformTo("string",s(h.name)),c=I.transformTo("string",O.utf8encode(h.name)),d=h.comment,p=I.transformTo("string",s(d)),m=I.transformTo("string",O.utf8encode(d)),_=c.length!==h.name.length,g=m.length!==d.length,b="",v="",y="",w=h.dir,k=h.date,x={crc32:0,compressedSize:0,uncompressedSize:0};t&&!r||(x.crc32=e.crc32,x.compressedSize=e.compressedSize,x.uncompressedSize=e.uncompressedSize);var S=0;t&&(S|=8),l||!_&&!g||(S|=2048);var z=0,C=0;w&&(z|=16),"UNIX"===i?(C=798,z|=function(e,t){var r=e;return e||(r=t?16893:33204),(65535&r)<<16}(h.unixPermissions,w)):(C=20,z|=function(e){return 63&(e||0)}(h.dosPermissions)),a=k.getUTCHours(),a<<=6,a|=k.getUTCMinutes(),a<<=5,a|=k.getUTCSeconds()/2,o=k.getUTCFullYear()-1980,o<<=4,o|=k.getUTCMonth()+1,o<<=5,o|=k.getUTCDate(),_&&(v=A(1,1)+A(B(f),4)+c,b+="up"+A(v.length,2)+v),g&&(y=A(1,1)+A(B(p),4)+m,b+="uc"+A(y.length,2)+y);var E="";return E+="\n\0",E+=A(S,2),E+=u.magic,E+=A(a,2),E+=A(o,2),E+=A(x.crc32,4),E+=A(x.compressedSize,4),E+=A(x.uncompressedSize,4),E+=A(f.length,2),E+=A(b.length,2),{fileRecord:R.LOCAL_FILE_HEADER+E+f+b,dirRecord:R.CENTRAL_FILE_HEADER+A(C,2)+E+A(p.length,2)+"\0\0\0\0"+A(z,4)+A(n,4)+f+b+p}}var I=e("../utils"),i=e("../stream/GenericWorker"),O=e("../utf8"),B=e("../crc32"),R=e("../signature");function s(e,t,r,n){i.call(this,"ZipFileWorker"),this.bytesWritten=0,this.zipComment=t,this.zipPlatform=r,this.encodeFileName=n,this.streamFiles=e,this.accumulate=!1,this.contentBuffer=[],this.dirRecords=[],this.currentSourceOffset=0,this.entriesCount=0,this.currentFile=null,this._sources=[]}I.inherits(s,i),s.prototype.push=function(e){var t=e.meta.percent||0,r=this.entriesCount,n=this._sources.length;this.accumulate?this.contentBuffer.push(e):(this.bytesWritten+=e.data.length,i.prototype.push.call(this,{data:e.data,meta:{currentFile:this.currentFile,percent:r?(t+100*(r-n-1))/r:100}}))},s.prototype.openedSource=function(e){this.currentSourceOffset=this.bytesWritten,this.currentFile=e.file.name;var t=this.streamFiles&&!e.file.dir;if(t){var r=n(e,t,!1,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);this.push({data:r.fileRecord,meta:{percent:0}})}else this.accumulate=!0},s.prototype.closedSource=function(e){this.accumulate=!1;var t=this.streamFiles&&!e.file.dir,r=n(e,t,!0,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);if(this.dirRecords.push(r.dirRecord),t)this.push({data:function(e){return R.DATA_DESCRIPTOR+A(e.crc32,4)+A(e.compressedSize,4)+A(e.uncompressedSize,4)}(e),meta:{percent:100}});else for(this.push({data:r.fileRecord,meta:{percent:0}});this.contentBuffer.length;)this.push(this.contentBuffer.shift());this.currentFile=null},s.prototype.flush=function(){for(var e=this.bytesWritten,t=0;t=this.index;t--)r=(r<<8)+this.byteAt(t);return this.index+=e,r},readString:function(e){return n.transformTo("string",this.readData(e))},readData:function(){},lastIndexOfSignature:function(){},readAndCheckSignature:function(){},readDate:function(){var e=this.readInt(4);return new Date(Date.UTC(1980+(e>>25&127),(e>>21&15)-1,e>>16&31,e>>11&31,e>>5&63,(31&e)<<1))}},t.exports=i},{"../utils":32}],19:[function(e,t,r){"use strict";var n=e("./Uint8ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./Uint8ArrayReader":21}],20:[function(e,t,r){"use strict";var n=e("./DataReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.byteAt=function(e){return this.data.charCodeAt(this.zero+e)},i.prototype.lastIndexOfSignature=function(e){return this.data.lastIndexOf(e)-this.zero},i.prototype.readAndCheckSignature=function(e){return e===this.readData(4)},i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./DataReader":18}],21:[function(e,t,r){"use strict";var n=e("./ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){if(this.checkOffset(e),0===e)return new Uint8Array(0);var t=this.data.subarray(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./ArrayReader":17}],22:[function(e,t,r){"use strict";var n=e("../utils"),i=e("../support"),s=e("./ArrayReader"),a=e("./StringReader"),o=e("./NodeBufferReader"),h=e("./Uint8ArrayReader");t.exports=function(e){var t=n.getTypeOf(e);return n.checkSupport(t),"string"!==t||i.uint8array?"nodebuffer"===t?new o(e):i.uint8array?new h(n.transformTo("uint8array",e)):new s(n.transformTo("array",e)):new a(e)}},{"../support":30,"../utils":32,"./ArrayReader":17,"./NodeBufferReader":19,"./StringReader":20,"./Uint8ArrayReader":21}],23:[function(e,t,r){"use strict";r.LOCAL_FILE_HEADER="PK",r.CENTRAL_FILE_HEADER="PK",r.CENTRAL_DIRECTORY_END="PK",r.ZIP64_CENTRAL_DIRECTORY_LOCATOR="PK",r.ZIP64_CENTRAL_DIRECTORY_END="PK",r.DATA_DESCRIPTOR="PK\b"},{}],24:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../utils");function s(e){n.call(this,"ConvertWorker to "+e),this.destType=e}i.inherits(s,n),s.prototype.processChunk=function(e){this.push({data:i.transformTo(this.destType,e.data),meta:e.meta})},t.exports=s},{"../utils":32,"./GenericWorker":28}],25:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../crc32");function s(){n.call(this,"Crc32Probe"),this.withStreamInfo("crc32",0)}e("../utils").inherits(s,n),s.prototype.processChunk=function(e){this.streamInfo.crc32=i(e.data,this.streamInfo.crc32||0),this.push(e)},t.exports=s},{"../crc32":4,"../utils":32,"./GenericWorker":28}],26:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataLengthProbe for "+e),this.propName=e,this.withStreamInfo(e,0)}n.inherits(s,i),s.prototype.processChunk=function(e){if(e){var t=this.streamInfo[this.propName]||0;this.streamInfo[this.propName]=t+e.data.length}i.prototype.processChunk.call(this,e)},t.exports=s},{"../utils":32,"./GenericWorker":28}],27:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataWorker");var t=this;this.dataIsReady=!1,this.index=0,this.max=0,this.data=null,this.type="",this._tickScheduled=!1,e.then(function(e){t.dataIsReady=!0,t.data=e,t.max=e&&e.length||0,t.type=n.getTypeOf(e),t.isPaused||t._tickAndRepeat()},function(e){t.error(e)})}n.inherits(s,i),s.prototype.cleanUp=function(){i.prototype.cleanUp.call(this),this.data=null},s.prototype.resume=function(){return!!i.prototype.resume.call(this)&&(!this._tickScheduled&&this.dataIsReady&&(this._tickScheduled=!0,n.delay(this._tickAndRepeat,[],this)),!0)},s.prototype._tickAndRepeat=function(){this._tickScheduled=!1,this.isPaused||this.isFinished||(this._tick(),this.isFinished||(n.delay(this._tickAndRepeat,[],this),this._tickScheduled=!0))},s.prototype._tick=function(){if(this.isPaused||this.isFinished)return!1;var e=null,t=Math.min(this.max,this.index+16384);if(this.index>=this.max)return this.end();switch(this.type){case"string":e=this.data.substring(this.index,t);break;case"uint8array":e=this.data.subarray(this.index,t);break;case"array":case"nodebuffer":e=this.data.slice(this.index,t)}return this.index=t,this.push({data:e,meta:{percent:this.max?this.index/this.max*100:0}})},t.exports=s},{"../utils":32,"./GenericWorker":28}],28:[function(e,t,r){"use strict";function n(e){this.name=e||"default",this.streamInfo={},this.generatedError=null,this.extraStreamInfo={},this.isPaused=!0,this.isFinished=!1,this.isLocked=!1,this._listeners={data:[],end:[],error:[]},this.previous=null}n.prototype={push:function(e){this.emit("data",e)},end:function(){if(this.isFinished)return!1;this.flush();try{this.emit("end"),this.cleanUp(),this.isFinished=!0}catch(e){this.emit("error",e)}return!0},error:function(e){return!this.isFinished&&(this.isPaused?this.generatedError=e:(this.isFinished=!0,this.emit("error",e),this.previous&&this.previous.error(e),this.cleanUp()),!0)},on:function(e,t){return this._listeners[e].push(t),this},cleanUp:function(){this.streamInfo=this.generatedError=this.extraStreamInfo=null,this._listeners=[]},emit:function(e,t){if(this._listeners[e])for(var r=0;r "+e:e}},t.exports=n},{}],29:[function(e,t,r){"use strict";var h=e("../utils"),i=e("./ConvertWorker"),s=e("./GenericWorker"),u=e("../base64"),n=e("../support"),a=e("../external"),o=null;if(n.nodestream)try{o=e("../nodejs/NodejsStreamOutputAdapter")}catch(e){}function l(e,o){return new a.Promise(function(t,r){var n=[],i=e._internalType,s=e._outputType,a=e._mimeType;e.on("data",function(e,t){n.push(e),o&&o(t)}).on("error",function(e){n=[],r(e)}).on("end",function(){try{var e=function(e,t,r){switch(e){case"blob":return h.newBlob(h.transformTo("arraybuffer",t),r);case"base64":return u.encode(t);default:return h.transformTo(e,t)}}(s,function(e,t){var r,n=0,i=null,s=0;for(r=0;r>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t}(e)},s.utf8decode=function(e){return h.nodebuffer?o.transformTo("nodebuffer",e).toString("utf-8"):function(e){var t,r,n,i,s=e.length,a=new Array(2*s);for(t=r=0;t>10&1023,a[r++]=56320|1023&n)}return a.length!==r&&(a.subarray?a=a.subarray(0,r):a.length=r),o.applyFromCharCode(a)}(e=o.transformTo(h.uint8array?"uint8array":"array",e))},o.inherits(a,n),a.prototype.processChunk=function(e){var t=o.transformTo(h.uint8array?"uint8array":"array",e.data);if(this.leftOver&&this.leftOver.length){if(h.uint8array){var r=t;(t=new Uint8Array(r.length+this.leftOver.length)).set(this.leftOver,0),t.set(r,this.leftOver.length)}else t=this.leftOver.concat(t);this.leftOver=null}var n=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+u[e[r]]>t?r:t}(t),i=t;n!==t.length&&(h.uint8array?(i=t.subarray(0,n),this.leftOver=t.subarray(n,t.length)):(i=t.slice(0,n),this.leftOver=t.slice(n,t.length))),this.push({data:s.utf8decode(i),meta:e.meta})},a.prototype.flush=function(){this.leftOver&&this.leftOver.length&&(this.push({data:s.utf8decode(this.leftOver),meta:{}}),this.leftOver=null)},s.Utf8DecodeWorker=a,o.inherits(l,n),l.prototype.processChunk=function(e){this.push({data:s.utf8encode(e.data),meta:e.meta})},s.Utf8EncodeWorker=l},{"./nodejsUtils":14,"./stream/GenericWorker":28,"./support":30,"./utils":32}],32:[function(e,t,a){"use strict";var o=e("./support"),h=e("./base64"),r=e("./nodejsUtils"),u=e("./external");function n(e){return e}function l(e,t){for(var r=0;r>8;this.dir=!!(16&this.externalFileAttributes),0==e&&(this.dosPermissions=63&this.externalFileAttributes),3==e&&(this.unixPermissions=this.externalFileAttributes>>16&65535),this.dir||"/"!==this.fileNameStr.slice(-1)||(this.dir=!0)},parseZIP64ExtraField:function(){if(this.extraFields[1]){var e=n(this.extraFields[1].value);this.uncompressedSize===s.MAX_VALUE_32BITS&&(this.uncompressedSize=e.readInt(8)),this.compressedSize===s.MAX_VALUE_32BITS&&(this.compressedSize=e.readInt(8)),this.localHeaderOffset===s.MAX_VALUE_32BITS&&(this.localHeaderOffset=e.readInt(8)),this.diskNumberStart===s.MAX_VALUE_32BITS&&(this.diskNumberStart=e.readInt(4))}},readExtraFields:function(e){var t,r,n,i=e.index+this.extraFieldsLength;for(this.extraFields||(this.extraFields={});e.index+4>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t},r.buf2binstring=function(e){return l(e,e.length)},r.binstring2buf=function(e){for(var t=new h.Buf8(e.length),r=0,n=t.length;r>10&1023,o[n++]=56320|1023&i)}return l(o,n)},r.utf8border=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+u[e[r]]>t?r:t}},{"./common":41}],43:[function(e,t,r){"use strict";t.exports=function(e,t,r,n){for(var i=65535&e|0,s=e>>>16&65535|0,a=0;0!==r;){for(r-=a=2e3>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}},{}],46:[function(e,t,r){"use strict";var h,c=e("../utils/common"),u=e("./trees"),d=e("./adler32"),p=e("./crc32"),n=e("./messages"),l=0,f=4,m=0,_=-2,g=-1,b=4,i=2,v=8,y=9,s=286,a=30,o=19,w=2*s+1,k=15,x=3,S=258,z=S+x+1,C=42,E=113,A=1,I=2,O=3,B=4;function R(e,t){return e.msg=n[t],t}function T(e){return(e<<1)-(4e.avail_out&&(r=e.avail_out),0!==r&&(c.arraySet(e.output,t.pending_buf,t.pending_out,r,e.next_out),e.next_out+=r,t.pending_out+=r,e.total_out+=r,e.avail_out-=r,t.pending-=r,0===t.pending&&(t.pending_out=0))}function N(e,t){u._tr_flush_block(e,0<=e.block_start?e.block_start:-1,e.strstart-e.block_start,t),e.block_start=e.strstart,F(e.strm)}function U(e,t){e.pending_buf[e.pending++]=t}function P(e,t){e.pending_buf[e.pending++]=t>>>8&255,e.pending_buf[e.pending++]=255&t}function L(e,t){var r,n,i=e.max_chain_length,s=e.strstart,a=e.prev_length,o=e.nice_match,h=e.strstart>e.w_size-z?e.strstart-(e.w_size-z):0,u=e.window,l=e.w_mask,f=e.prev,c=e.strstart+S,d=u[s+a-1],p=u[s+a];e.prev_length>=e.good_match&&(i>>=2),o>e.lookahead&&(o=e.lookahead);do{if(u[(r=t)+a]===p&&u[r+a-1]===d&&u[r]===u[s]&&u[++r]===u[s+1]){s+=2,r++;do{}while(u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&sh&&0!=--i);return a<=e.lookahead?a:e.lookahead}function j(e){var t,r,n,i,s,a,o,h,u,l,f=e.w_size;do{if(i=e.window_size-e.lookahead-e.strstart,e.strstart>=f+(f-z)){for(c.arraySet(e.window,e.window,f,f,0),e.match_start-=f,e.strstart-=f,e.block_start-=f,t=r=e.hash_size;n=e.head[--t],e.head[t]=f<=n?n-f:0,--r;);for(t=r=f;n=e.prev[--t],e.prev[t]=f<=n?n-f:0,--r;);i+=f}if(0===e.strm.avail_in)break;if(a=e.strm,o=e.window,h=e.strstart+e.lookahead,u=i,l=void 0,l=a.avail_in,u=x)for(s=e.strstart-e.insert,e.ins_h=e.window[s],e.ins_h=(e.ins_h<=x&&(e.ins_h=(e.ins_h<=x)if(n=u._tr_tally(e,e.strstart-e.match_start,e.match_length-x),e.lookahead-=e.match_length,e.match_length<=e.max_lazy_match&&e.lookahead>=x){for(e.match_length--;e.strstart++,e.ins_h=(e.ins_h<=x&&(e.ins_h=(e.ins_h<=x&&e.match_length<=e.prev_length){for(i=e.strstart+e.lookahead-x,n=u._tr_tally(e,e.strstart-1-e.prev_match,e.prev_length-x),e.lookahead-=e.prev_length-1,e.prev_length-=2;++e.strstart<=i&&(e.ins_h=(e.ins_h<e.pending_buf_size-5&&(r=e.pending_buf_size-5);;){if(e.lookahead<=1){if(j(e),0===e.lookahead&&t===l)return A;if(0===e.lookahead)break}e.strstart+=e.lookahead,e.lookahead=0;var n=e.block_start+r;if((0===e.strstart||e.strstart>=n)&&(e.lookahead=e.strstart-n,e.strstart=n,N(e,!1),0===e.strm.avail_out))return A;if(e.strstart-e.block_start>=e.w_size-z&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):(e.strstart>e.block_start&&(N(e,!1),e.strm.avail_out),A)}),new M(4,4,8,4,Z),new M(4,5,16,8,Z),new M(4,6,32,32,Z),new M(4,4,16,16,W),new M(8,16,32,32,W),new M(8,16,128,128,W),new M(8,32,128,256,W),new M(32,128,258,1024,W),new M(32,258,258,4096,W)],r.deflateInit=function(e,t){return Y(e,t,v,15,8,0)},r.deflateInit2=Y,r.deflateReset=K,r.deflateResetKeep=G,r.deflateSetHeader=function(e,t){return e&&e.state?2!==e.state.wrap?_:(e.state.gzhead=t,m):_},r.deflate=function(e,t){var r,n,i,s;if(!e||!e.state||5>8&255),U(n,n.gzhead.time>>16&255),U(n,n.gzhead.time>>24&255),U(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),U(n,255&n.gzhead.os),n.gzhead.extra&&n.gzhead.extra.length&&(U(n,255&n.gzhead.extra.length),U(n,n.gzhead.extra.length>>8&255)),n.gzhead.hcrc&&(e.adler=p(e.adler,n.pending_buf,n.pending,0)),n.gzindex=0,n.status=69):(U(n,0),U(n,0),U(n,0),U(n,0),U(n,0),U(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),U(n,3),n.status=E);else{var a=v+(n.w_bits-8<<4)<<8;a|=(2<=n.strategy||n.level<2?0:n.level<6?1:6===n.level?2:3)<<6,0!==n.strstart&&(a|=32),a+=31-a%31,n.status=E,P(n,a),0!==n.strstart&&(P(n,e.adler>>>16),P(n,65535&e.adler)),e.adler=1}if(69===n.status)if(n.gzhead.extra){for(i=n.pending;n.gzindex<(65535&n.gzhead.extra.length)&&(n.pending!==n.pending_buf_size||(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending!==n.pending_buf_size));)U(n,255&n.gzhead.extra[n.gzindex]),n.gzindex++;n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),n.gzindex===n.gzhead.extra.length&&(n.gzindex=0,n.status=73)}else n.status=73;if(73===n.status)if(n.gzhead.name){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.gzindex=0,n.status=91)}else n.status=91;if(91===n.status)if(n.gzhead.comment){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.status=103)}else n.status=103;if(103===n.status&&(n.gzhead.hcrc?(n.pending+2>n.pending_buf_size&&F(e),n.pending+2<=n.pending_buf_size&&(U(n,255&e.adler),U(n,e.adler>>8&255),e.adler=0,n.status=E)):n.status=E),0!==n.pending){if(F(e),0===e.avail_out)return n.last_flush=-1,m}else if(0===e.avail_in&&T(t)<=T(r)&&t!==f)return R(e,-5);if(666===n.status&&0!==e.avail_in)return R(e,-5);if(0!==e.avail_in||0!==n.lookahead||t!==l&&666!==n.status){var o=2===n.strategy?function(e,t){for(var r;;){if(0===e.lookahead&&(j(e),0===e.lookahead)){if(t===l)return A;break}if(e.match_length=0,r=u._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++,r&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):e.last_lit&&(N(e,!1),0===e.strm.avail_out)?A:I}(n,t):3===n.strategy?function(e,t){for(var r,n,i,s,a=e.window;;){if(e.lookahead<=S){if(j(e),e.lookahead<=S&&t===l)return A;if(0===e.lookahead)break}if(e.match_length=0,e.lookahead>=x&&0e.lookahead&&(e.match_length=e.lookahead)}if(e.match_length>=x?(r=u._tr_tally(e,1,e.match_length-x),e.lookahead-=e.match_length,e.strstart+=e.match_length,e.match_length=0):(r=u._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++),r&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):e.last_lit&&(N(e,!1),0===e.strm.avail_out)?A:I}(n,t):h[n.level].func(n,t);if(o!==O&&o!==B||(n.status=666),o===A||o===O)return 0===e.avail_out&&(n.last_flush=-1),m;if(o===I&&(1===t?u._tr_align(n):5!==t&&(u._tr_stored_block(n,0,0,!1),3===t&&(D(n.head),0===n.lookahead&&(n.strstart=0,n.block_start=0,n.insert=0))),F(e),0===e.avail_out))return n.last_flush=-1,m}return t!==f?m:n.wrap<=0?1:(2===n.wrap?(U(n,255&e.adler),U(n,e.adler>>8&255),U(n,e.adler>>16&255),U(n,e.adler>>24&255),U(n,255&e.total_in),U(n,e.total_in>>8&255),U(n,e.total_in>>16&255),U(n,e.total_in>>24&255)):(P(n,e.adler>>>16),P(n,65535&e.adler)),F(e),0=r.w_size&&(0===s&&(D(r.head),r.strstart=0,r.block_start=0,r.insert=0),u=new c.Buf8(r.w_size),c.arraySet(u,t,l-r.w_size,r.w_size,0),t=u,l=r.w_size),a=e.avail_in,o=e.next_in,h=e.input,e.avail_in=l,e.next_in=0,e.input=t,j(r);r.lookahead>=x;){for(n=r.strstart,i=r.lookahead-(x-1);r.ins_h=(r.ins_h<>>=y=v>>>24,p-=y,0===(y=v>>>16&255))C[s++]=65535&v;else{if(!(16&y)){if(0==(64&y)){v=m[(65535&v)+(d&(1<>>=y,p-=y),p<15&&(d+=z[n++]<>>=y=v>>>24,p-=y,!(16&(y=v>>>16&255))){if(0==(64&y)){v=_[(65535&v)+(d&(1<>>=y,p-=y,(y=s-a)>3,d&=(1<<(p-=w<<3))-1,e.next_in=n,e.next_out=s,e.avail_in=n>>24&255)+(e>>>8&65280)+((65280&e)<<8)+((255&e)<<24)}function s(){this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new I.Buf16(320),this.work=new I.Buf16(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}function a(e){var t;return e&&e.state?(t=e.state,e.total_in=e.total_out=t.total=0,e.msg="",t.wrap&&(e.adler=1&t.wrap),t.mode=P,t.last=0,t.havedict=0,t.dmax=32768,t.head=null,t.hold=0,t.bits=0,t.lencode=t.lendyn=new I.Buf32(n),t.distcode=t.distdyn=new I.Buf32(i),t.sane=1,t.back=-1,N):U}function o(e){var t;return e&&e.state?((t=e.state).wsize=0,t.whave=0,t.wnext=0,a(e)):U}function h(e,t){var r,n;return e&&e.state?(n=e.state,t<0?(r=0,t=-t):(r=1+(t>>4),t<48&&(t&=15)),t&&(t<8||15=s.wsize?(I.arraySet(s.window,t,r-s.wsize,s.wsize,0),s.wnext=0,s.whave=s.wsize):(n<(i=s.wsize-s.wnext)&&(i=n),I.arraySet(s.window,t,r-n,i,s.wnext),(n-=i)?(I.arraySet(s.window,t,r-n,n,0),s.wnext=n,s.whave=s.wsize):(s.wnext+=i,s.wnext===s.wsize&&(s.wnext=0),s.whave>>8&255,r.check=B(r.check,E,2,0),l=u=0,r.mode=2;break}if(r.flags=0,r.head&&(r.head.done=!1),!(1&r.wrap)||(((255&u)<<8)+(u>>8))%31){e.msg="incorrect header check",r.mode=30;break}if(8!=(15&u)){e.msg="unknown compression method",r.mode=30;break}if(l-=4,k=8+(15&(u>>>=4)),0===r.wbits)r.wbits=k;else if(k>r.wbits){e.msg="invalid window size",r.mode=30;break}r.dmax=1<>8&1),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=3;case 3:for(;l<32;){if(0===o)break e;o--,u+=n[s++]<>>8&255,E[2]=u>>>16&255,E[3]=u>>>24&255,r.check=B(r.check,E,4,0)),l=u=0,r.mode=4;case 4:for(;l<16;){if(0===o)break e;o--,u+=n[s++]<>8),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=5;case 5:if(1024&r.flags){for(;l<16;){if(0===o)break e;o--,u+=n[s++]<>>8&255,r.check=B(r.check,E,2,0)),l=u=0}else r.head&&(r.head.extra=null);r.mode=6;case 6:if(1024&r.flags&&(o<(d=r.length)&&(d=o),d&&(r.head&&(k=r.head.extra_len-r.length,r.head.extra||(r.head.extra=new Array(r.head.extra_len)),I.arraySet(r.head.extra,n,s,d,k)),512&r.flags&&(r.check=B(r.check,n,d,s)),o-=d,s+=d,r.length-=d),r.length))break e;r.length=0,r.mode=7;case 7:if(2048&r.flags){if(0===o)break e;for(d=0;k=n[s+d++],r.head&&k&&r.length<65536&&(r.head.name+=String.fromCharCode(k)),k&&d>9&1,r.head.done=!0),e.adler=r.check=0,r.mode=12;break;case 10:for(;l<32;){if(0===o)break e;o--,u+=n[s++]<>>=7&l,l-=7&l,r.mode=27;break}for(;l<3;){if(0===o)break e;o--,u+=n[s++]<>>=1)){case 0:r.mode=14;break;case 1:if(j(r),r.mode=20,6!==t)break;u>>>=2,l-=2;break e;case 2:r.mode=17;break;case 3:e.msg="invalid block type",r.mode=30}u>>>=2,l-=2;break;case 14:for(u>>>=7&l,l-=7&l;l<32;){if(0===o)break e;o--,u+=n[s++]<>>16^65535)){e.msg="invalid stored block lengths",r.mode=30;break}if(r.length=65535&u,l=u=0,r.mode=15,6===t)break e;case 15:r.mode=16;case 16:if(d=r.length){if(o>>=5,l-=5,r.ndist=1+(31&u),u>>>=5,l-=5,r.ncode=4+(15&u),u>>>=4,l-=4,286>>=3,l-=3}for(;r.have<19;)r.lens[A[r.have++]]=0;if(r.lencode=r.lendyn,r.lenbits=7,S={bits:r.lenbits},x=T(0,r.lens,0,19,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid code lengths set",r.mode=30;break}r.have=0,r.mode=19;case 19:for(;r.have>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=_,l-=_,r.lens[r.have++]=b;else{if(16===b){for(z=_+2;l>>=_,l-=_,0===r.have){e.msg="invalid bit length repeat",r.mode=30;break}k=r.lens[r.have-1],d=3+(3&u),u>>>=2,l-=2}else if(17===b){for(z=_+3;l>>=_)),u>>>=3,l-=3}else{for(z=_+7;l>>=_)),u>>>=7,l-=7}if(r.have+d>r.nlen+r.ndist){e.msg="invalid bit length repeat",r.mode=30;break}for(;d--;)r.lens[r.have++]=k}}if(30===r.mode)break;if(0===r.lens[256]){e.msg="invalid code -- missing end-of-block",r.mode=30;break}if(r.lenbits=9,S={bits:r.lenbits},x=T(D,r.lens,0,r.nlen,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid literal/lengths set",r.mode=30;break}if(r.distbits=6,r.distcode=r.distdyn,S={bits:r.distbits},x=T(F,r.lens,r.nlen,r.ndist,r.distcode,0,r.work,S),r.distbits=S.bits,x){e.msg="invalid distances set",r.mode=30;break}if(r.mode=20,6===t)break e;case 20:r.mode=21;case 21:if(6<=o&&258<=h){e.next_out=a,e.avail_out=h,e.next_in=s,e.avail_in=o,r.hold=u,r.bits=l,R(e,c),a=e.next_out,i=e.output,h=e.avail_out,s=e.next_in,n=e.input,o=e.avail_in,u=r.hold,l=r.bits,12===r.mode&&(r.back=-1);break}for(r.back=0;g=(C=r.lencode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,r.length=b,0===g){r.mode=26;break}if(32&g){r.back=-1,r.mode=12;break}if(64&g){e.msg="invalid literal/length code",r.mode=30;break}r.extra=15&g,r.mode=22;case 22:if(r.extra){for(z=r.extra;l>>=r.extra,l-=r.extra,r.back+=r.extra}r.was=r.length,r.mode=23;case 23:for(;g=(C=r.distcode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,64&g){e.msg="invalid distance code",r.mode=30;break}r.offset=b,r.extra=15&g,r.mode=24;case 24:if(r.extra){for(z=r.extra;l>>=r.extra,l-=r.extra,r.back+=r.extra}if(r.offset>r.dmax){e.msg="invalid distance too far back",r.mode=30;break}r.mode=25;case 25:if(0===h)break e;if(d=c-h,r.offset>d){if((d=r.offset-d)>r.whave&&r.sane){e.msg="invalid distance too far back",r.mode=30;break}p=d>r.wnext?(d-=r.wnext,r.wsize-d):r.wnext-d,d>r.length&&(d=r.length),m=r.window}else m=i,p=a-r.offset,d=r.length;for(hd?(m=R[T+a[v]],A[I+a[v]]):(m=96,0),h=1<>S)+(u-=h)]=p<<24|m<<16|_|0,0!==u;);for(h=1<>=1;if(0!==h?(E&=h-1,E+=h):E=0,v++,0==--O[b]){if(b===w)break;b=t[r+a[v]]}if(k>>7)]}function U(e,t){e.pending_buf[e.pending++]=255&t,e.pending_buf[e.pending++]=t>>>8&255}function P(e,t,r){e.bi_valid>d-r?(e.bi_buf|=t<>d-e.bi_valid,e.bi_valid+=r-d):(e.bi_buf|=t<>>=1,r<<=1,0<--t;);return r>>>1}function Z(e,t,r){var n,i,s=new Array(g+1),a=0;for(n=1;n<=g;n++)s[n]=a=a+r[n-1]<<1;for(i=0;i<=t;i++){var o=e[2*i+1];0!==o&&(e[2*i]=j(s[o]++,o))}}function W(e){var t;for(t=0;t>1;1<=r;r--)G(e,s,r);for(i=h;r=e.heap[1],e.heap[1]=e.heap[e.heap_len--],G(e,s,1),n=e.heap[1],e.heap[--e.heap_max]=r,e.heap[--e.heap_max]=n,s[2*i]=s[2*r]+s[2*n],e.depth[i]=(e.depth[r]>=e.depth[n]?e.depth[r]:e.depth[n])+1,s[2*r+1]=s[2*n+1]=i,e.heap[1]=i++,G(e,s,1),2<=e.heap_len;);e.heap[--e.heap_max]=e.heap[1],function(e,t){var r,n,i,s,a,o,h=t.dyn_tree,u=t.max_code,l=t.stat_desc.static_tree,f=t.stat_desc.has_stree,c=t.stat_desc.extra_bits,d=t.stat_desc.extra_base,p=t.stat_desc.max_length,m=0;for(s=0;s<=g;s++)e.bl_count[s]=0;for(h[2*e.heap[e.heap_max]+1]=0,r=e.heap_max+1;r<_;r++)p<(s=h[2*h[2*(n=e.heap[r])+1]+1]+1)&&(s=p,m++),h[2*n+1]=s,u>=7;n>>=1)if(1&r&&0!==e.dyn_ltree[2*t])return o;if(0!==e.dyn_ltree[18]||0!==e.dyn_ltree[20]||0!==e.dyn_ltree[26])return h;for(t=32;t>>3,(s=e.static_len+3+7>>>3)<=i&&(i=s)):i=s=r+5,r+4<=i&&-1!==t?J(e,t,r,n):4===e.strategy||s===i?(P(e,2+(n?1:0),3),K(e,z,C)):(P(e,4+(n?1:0),3),function(e,t,r,n){var i;for(P(e,t-257,5),P(e,r-1,5),P(e,n-4,4),i=0;i>>8&255,e.pending_buf[e.d_buf+2*e.last_lit+1]=255&t,e.pending_buf[e.l_buf+e.last_lit]=255&r,e.last_lit++,0===t?e.dyn_ltree[2*r]++:(e.matches++,t--,e.dyn_ltree[2*(A[r]+u+1)]++,e.dyn_dtree[2*N(t)]++),e.last_lit===e.lit_bufsize-1},r._tr_align=function(e){P(e,2,3),L(e,m,z),function(e){16===e.bi_valid?(U(e,e.bi_buf),e.bi_buf=0,e.bi_valid=0):8<=e.bi_valid&&(e.pending_buf[e.pending++]=255&e.bi_buf,e.bi_buf>>=8,e.bi_valid-=8)}(e)}},{"../utils/common":41}],53:[function(e,t,r){"use strict";t.exports=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0}},{}],54:[function(e,t,r){(function(e){!function(r,n){"use strict";if(!r.setImmediate){var i,s,t,a,o=1,h={},u=!1,l=r.document,e=Object.getPrototypeOf&&Object.getPrototypeOf(r);e=e&&e.setTimeout?e:r,i="[object process]"==={}.toString.call(r.process)?function(e){process.nextTick(function(){c(e)})}:function(){if(r.postMessage&&!r.importScripts){var e=!0,t=r.onmessage;return r.onmessage=function(){e=!1},r.postMessage("","*"),r.onmessage=t,e}}()?(a="setImmediate$"+Math.random()+"$",r.addEventListener?r.addEventListener("message",d,!1):r.attachEvent("onmessage",d),function(e){r.postMessage(a+e,"*")}):r.MessageChannel?((t=new MessageChannel).port1.onmessage=function(e){c(e.data)},function(e){t.port2.postMessage(e)}):l&&"onreadystatechange"in l.createElement("script")?(s=l.documentElement,function(e){var t=l.createElement("script");t.onreadystatechange=function(){c(e),t.onreadystatechange=null,s.removeChild(t),t=null},s.appendChild(t)}):function(e){setTimeout(c,0,e)},e.setImmediate=function(e){"function"!=typeof e&&(e=new Function(""+e));for(var t=new Array(arguments.length-1),r=0;r