From 56ba9e433760591cde25b9bdaeca7a430e094ea9 Mon Sep 17 00:00:00 2001
From: Chun Ming Lee <95391408+leecming82@users.noreply.github.com>
Date: Tue, 26 May 2026 04:31:05 +0800
Subject: [PATCH] feat: add image auto-cropping in web epub optimizer (#2139)
---
src/network/html/FilesPage.html | 322 ++++++++++++++++++++++++++++----
1 file changed, 290 insertions(+), 32 deletions(-)
diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html
index 1220f084..04a5b557 100644
--- a/src/network/html/FilesPage.html
+++ b/src/network/html/FilesPage.html
@@ -735,6 +735,10 @@
border-color: #9b59b6;
background: rgba(155, 89, 182, 0.1);
}
+ .image-item.crop-preview {
+ border-color: #16a085;
+ background: rgba(22, 160, 133, 0.1);
+ }
.image-item.cover-locked {
border-color: #e67e22;
background: rgba(230, 126, 34, 0.1);
@@ -790,6 +794,9 @@
.state-3 .image-state-badge {
background: #9b59b6;
}
+ .crop-preview .image-state-badge {
+ background: #16a085;
+ }
/* Preview Overlay (shown on hover) */
.image-preview-overlay {
position: absolute;
@@ -1574,6 +1581,19 @@
-->
+
+
📱 Target Device
@@ -2039,6 +2059,7 @@
// Clear image picker cache and reset layout
epubImagesCache = [];
imageStates = {};
+ autoCropProtectedPaths = new Set();
document.getElementById('imagePickerSection').style.display = 'none';
const imageGrid = document.getElementById('imageGrid');
if (imageGrid) imageGrid.innerHTML = '';
@@ -2058,6 +2079,8 @@
// Reset to defaults
document.getElementById('qualitySlider').value = 85;
document.getElementById('qualityInput').value = 85;
+ const autoCropToggle = document.getElementById('autoCropToggle');
+ if (autoCropToggle) autoCropToggle.checked = false;
setHandedness('right');
setOverlap(5);
// Update converter variables
@@ -2147,9 +2170,7 @@
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
+ const autoCropToggle = document.getElementById('autoCropToggle');
// Update displays (if element exists)
const qualityDisplay = document.getElementById('qualityDisplaySimple');
@@ -2160,6 +2181,10 @@
// Update converter variables (used by processImage and applyGrayscale)
JPEG_QUALITY = parseInt(quality, 10);
ENABLE_GRAYSCALE = true; // Always grayscale for e-ink
+ ENABLE_AUTO_CROP = autoCropToggle ? autoCropToggle.checked : false;
+ if (isImagePickerVisible()) {
+ renderImageGrid();
+ }
}
function setHandedness(value) {
@@ -2169,7 +2194,7 @@
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') {
+ if (isImagePickerVisible()) {
renderImageGrid();
}
}
@@ -2182,6 +2207,47 @@
});
}
+ function isImagePickerVisible() {
+ const pickerSection = document.getElementById('imagePickerSection');
+ return pickerSection && pickerSection.style.display !== 'none';
+ }
+
+ function loadPreviewImage(dataUrl) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = () => reject(new Error('Preview image load failed'));
+ img.src = dataUrl;
+ });
+ }
+
+ async function createAutoCropPreview(dataUrl, imagePath, width, height) {
+ const img = await loadPreviewImage(dataUrl);
+ const cropped = createAutoCroppedCanvas(img, imagePath, width, height, true);
+ if (!cropped.crop) return null;
+
+ const maxPreviewW = 220;
+ const maxPreviewH = 160;
+ const scale = Math.min(maxPreviewW / cropped.width, maxPreviewH / cropped.height, 1);
+ const previewW = Math.max(1, Math.round(cropped.width * scale));
+ const previewH = Math.max(1, Math.round(cropped.height * scale));
+ const previewCanvas = document.createElement('canvas');
+ previewCanvas.width = previewW;
+ previewCanvas.height = previewH;
+ const previewCtx = previewCanvas.getContext('2d');
+ previewCtx.imageSmoothingEnabled = true;
+ previewCtx.imageSmoothingQuality = 'high';
+ previewCtx.fillStyle = '#FFF';
+ previewCtx.fillRect(0, 0, previewW, previewH);
+ previewCtx.drawImage(cropped.canvas, 0, 0, previewW, previewH);
+
+ return {
+ dataUrl: previewCanvas.toDataURL('image/jpeg', 0.82),
+ width: cropped.width,
+ height: cropped.height
+ };
+ }
+
// ============================================================================
// Image Picker Functions
// ============================================================================
@@ -2385,6 +2451,15 @@
// Tiny images (<200x200) are locked like separators
const isTiny = (dims.width < 200 && dims.height < 200);
+ const isLockedImage = isCover || isSeparator || isTiny;
+ let autoCropPreview = null;
+ if (!isLockedImage) {
+ try {
+ autoCropPreview = await createAutoCropPreview(dataUrl, path, dims.width, dims.height);
+ } catch (e) {
+ console.warn('Auto-crop preview failed for', path, e);
+ }
+ }
// Images that fit screen can only rotate, not split
const fitsScreen = (dims.width <= MAX_WIDTH && dims.height <= MAX_HEIGHT);
@@ -2399,6 +2474,7 @@
path: path,
name: filename,
dataUrl: dataUrl,
+ autoCropPreview: autoCropPreview,
width: dims.width,
height: dims.height,
isCover: isCover,
@@ -2536,12 +2612,18 @@
// Reset state
imageStates = {};
epubImagesCache = [];
+ autoCropProtectedPaths = new Set();
pendingConversionFile = file;
// Extract images
try {
const images = await extractImagesForPreview(file);
epubImagesCache = images;
+ autoCropProtectedPaths = new Set(
+ images
+ .filter(img => img.isCover || img.isSeparator)
+ .map(img => img.path)
+ );
// Initialize all states to 0 (Normal)
images.forEach(img => {
@@ -2661,16 +2743,19 @@
// Build tooltip
const stateText = stateLabels[state] || 'Normal';
const partsText = numParts > 1 ? ` (${numParts} parts)` : '';
+ const showCropPreview = ENABLE_AUTO_CROP && img.autoCropPreview;
+ const previewSrc = showCropPreview ? img.autoCropPreview.dataUrl : img.dataUrl;
+ const cropText = showCropPreview ? ` | Auto-crop: ${img.autoCropPreview.width}×${img.autoCropPreview.height}` : '';
- item.className = `image-item ${stateClasses[state]} ${rotateClass}`.trim();
+ item.className = `image-item ${stateClasses[state]} ${rotateClass} ${showCropPreview ? 'crop-preview' : ''}`.trim();
item.onclick = () => cycleImageState(img.path);
- item.title = `${img.width}×${img.height} - ${stateText}${partsText}`;
+ item.title = `${img.width}×${img.height} - ${stateText}${partsText}${cropText}`;
item.innerHTML = `
-
${stateLabels[state] || '•'}
+
${showCropPreview ? '✂' : (stateLabels[state] || '•')}
${splitLinesHtml}
-

+
${img.name}
`;
}
@@ -2909,6 +2994,7 @@
function clearImagePicker() {
epubImagesCache = [];
imageStates = {};
+ autoCropProtectedPaths = new Set();
const imageGrid = document.getElementById('imageGrid');
if (imageGrid) imageGrid.innerHTML = '';
const pickerSection = document.getElementById('imagePickerSection');
@@ -3017,6 +3103,16 @@ const DEFAULT_MAX_WIDTH = DEVICE_PROFILES[DEFAULT_DEVICE].width;
const DEFAULT_MAX_HEIGHT = DEVICE_PROFILES[DEFAULT_DEVICE].height;
const DEFAULT_JPEG_QUALITY = 85;
const DEFAULT_ENABLE_GRAYSCALE = true;
+const DEFAULT_ENABLE_AUTO_CROP = false;
+// Auto-crop is opt-in because sparse title/logo pages can be zoomed aggressively.
+const CROP_WHITE_THRESHOLD = 245;
+const CROP_BACKGROUND_TOLERANCE = 28;
+const CROP_BACKGROUND_MAX_SPREAD = 24;
+const CROP_EDGE_SAMPLE_SIZE = 12;
+const CROP_PADDING_PX = 8;
+const MIN_CROP_SAVINGS_RATIO = 0.08;
+const MIN_COLOR_CROP_SAVINGS_RATIO = 0.20;
+const MIN_CROP_DIMENSION = 240;
// Note: Overlap is now always centered distribution (min 5%)
// Dynamic conversion settings (updated by UI)
@@ -3027,6 +3123,7 @@ 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 ENABLE_AUTO_CROP = DEFAULT_ENABLE_AUTO_CROP;
let HANDEDNESS = 'right'; // 'right' = clockwise (right-handed), 'left' = counter-clockwise (left-handed)
let OVERLAP_PERCENT = 5; // Minimum overlap percentage for splits (5%, 10%, 15%)
@@ -3036,6 +3133,7 @@ let OVERLAP_PERCENT = 5; // Minimum overlap percentage for splits (5%, 10%, 15%)
let imageStates = {}; // Map: imagePath -> state (0=Normal, 1=H-Split, 2=V-Split, 3=Rotate)
let epubImagesCache = []; // Cache of extracted images for preview
+let autoCropProtectedPaths = new Set(); // Picker-locked covers/separators should never be auto-cropped
let pendingConversionFile = null; // File awaiting conversion after image selection
// ============================================================================
@@ -3921,6 +4019,161 @@ function applyGrayscale(ctx, width, height) {
ctx.putImageData(imageData, 0, 0);
}
+function shouldSkipAutoCrop(imagePath, width, height, ignoreToggle = false) {
+ if (!ignoreToggle && !ENABLE_AUTO_CROP) return true;
+ if (autoCropProtectedPaths.has(imagePath)) return true;
+ if (width < MIN_CROP_DIMENSION || height < MIN_CROP_DIMENSION) return true;
+ return /(^|\/)(cover|thumbnail|thumb|icon)[^/]*\.(jpe?g|png|gif|webp|bmp)$/i.test(imagePath || '');
+}
+
+function blendedPixel(data, i) {
+ const alpha = data[i + 3] / 255;
+ return {
+ r: data[i] * alpha + 255 * (1 - alpha),
+ g: data[i + 1] * alpha + 255 * (1 - alpha),
+ b: data[i + 2] * alpha + 255 * (1 - alpha)
+ };
+}
+
+function estimateCropBackground(data, width, height) {
+ const sampleSize = Math.min(CROP_EDGE_SAMPLE_SIZE, Math.floor(width / 8), Math.floor(height / 8));
+ if (sampleSize < 2) return null;
+
+ // Use corners plus edge midpoints so colored margins work without trimming full-bleed art.
+ const samples = [];
+ const points = [
+ [0, 0],
+ [width - sampleSize, 0],
+ [0, height - sampleSize],
+ [width - sampleSize, height - sampleSize],
+ [Math.floor((width - sampleSize) / 2), 0],
+ [Math.floor((width - sampleSize) / 2), height - sampleSize],
+ [0, Math.floor((height - sampleSize) / 2)],
+ [width - sampleSize, Math.floor((height - sampleSize) / 2)]
+ ];
+
+ for (const [startX, startY] of points) {
+ let r = 0, g = 0, b = 0, count = 0;
+ for (let y = startY; y < startY + sampleSize; y++) {
+ const row = y * width * 4;
+ for (let x = startX; x < startX + sampleSize; x++) {
+ const px = blendedPixel(data, row + x * 4);
+ r += px.r; g += px.g; b += px.b; count++;
+ }
+ }
+ samples.push({ r: r / count, g: g / count, b: b / count });
+ }
+
+ const avg = samples.reduce((sum, px) => ({
+ r: sum.r + px.r,
+ g: sum.g + px.g,
+ b: sum.b + px.b
+ }), { r: 0, g: 0, b: 0 });
+ avg.r /= samples.length;
+ avg.g /= samples.length;
+ avg.b /= samples.length;
+
+ const maxSpread = samples.reduce((spread, px) => Math.max(
+ spread,
+ Math.abs(px.r - avg.r),
+ Math.abs(px.g - avg.g),
+ Math.abs(px.b - avg.b)
+ ), 0);
+
+ if (maxSpread > CROP_BACKGROUND_MAX_SPREAD) return null;
+ return avg;
+}
+
+function isCropContentPixel(px, background) {
+ if (background) {
+ return Math.abs(px.r - background.r) > CROP_BACKGROUND_TOLERANCE ||
+ Math.abs(px.g - background.g) > CROP_BACKGROUND_TOLERANCE ||
+ Math.abs(px.b - background.b) > CROP_BACKGROUND_TOLERANCE;
+ }
+ return px.r < CROP_WHITE_THRESHOLD || px.g < CROP_WHITE_THRESHOLD || px.b < CROP_WHITE_THRESHOLD;
+}
+
+function isNearWhiteBackground(background) {
+ return background &&
+ background.r >= CROP_WHITE_THRESHOLD &&
+ background.g >= CROP_WHITE_THRESHOLD &&
+ background.b >= CROP_WHITE_THRESHOLD;
+}
+
+function findNonWhiteBounds(ctx, width, height) {
+ const imageData = ctx.getImageData(0, 0, width, height);
+ const data = imageData.data;
+ const background = estimateCropBackground(data, width, height);
+ let left = width, top = height, right = -1, bottom = -1;
+
+ for (let y = 0; y < height; y++) {
+ const row = y * width * 4;
+ for (let x = 0; x < width; x++) {
+ const i = row + x * 4;
+ const px = blendedPixel(data, i);
+
+ if (isCropContentPixel(px, background)) {
+ if (x < left) left = x;
+ if (x > right) right = x;
+ if (y < top) top = y;
+ if (y > bottom) bottom = y;
+ }
+ }
+ }
+
+ if (right < left || bottom < top) return null;
+
+ left = Math.max(0, left - CROP_PADDING_PX);
+ top = Math.max(0, top - CROP_PADDING_PX);
+ right = Math.min(width - 1, right + CROP_PADDING_PX);
+ bottom = Math.min(height - 1, bottom + CROP_PADDING_PX);
+
+ const cropW = right - left + 1;
+ const cropH = bottom - top + 1;
+ const savedRatio = 1 - ((cropW * cropH) / (width * height));
+ // Colored-background crops need a bigger win to avoid shaving illustrated pages.
+ const minSavedRatio = background && !isNearWhiteBackground(background)
+ ? MIN_COLOR_CROP_SAVINGS_RATIO
+ : MIN_CROP_SAVINGS_RATIO;
+ if (savedRatio < minSavedRatio) return null;
+
+ return { x: left, y: top, width: cropW, height: cropH };
+}
+
+function createAutoCroppedCanvas(img, imagePath, width, height, ignoreToggle = false) {
+ // Build a white-backed source canvas, detect the uniform page/background color
+ // from its edges, crop to pixels that differ from that background, then let the
+ // normal resize/rotate/split pipeline fit that cropped content to the device.
+ // If the image is a cover/icon/small asset, the edges are not uniform, or the
+ // crop would save too little, return the original canvas unchanged.
+ const sourceCanvas = document.createElement('canvas');
+ sourceCanvas.width = width;
+ sourceCanvas.height = height;
+ const sourceCtx = sourceCanvas.getContext('2d');
+ sourceCtx.fillStyle = '#FFF';
+ sourceCtx.fillRect(0, 0, width, height);
+ sourceCtx.drawImage(img, 0, 0);
+
+ if (shouldSkipAutoCrop(imagePath, width, height, ignoreToggle)) {
+ return { canvas: sourceCanvas, width, height };
+ }
+
+ const crop = findNonWhiteBounds(sourceCtx, width, height);
+ if (!crop) {
+ return { canvas: sourceCanvas, width, height };
+ }
+
+ const croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = crop.width;
+ croppedCanvas.height = crop.height;
+ const croppedCtx = croppedCanvas.getContext('2d');
+ croppedCtx.fillStyle = '#FFF';
+ croppedCtx.fillRect(0, 0, crop.width, crop.height);
+ croppedCtx.drawImage(sourceCanvas, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height);
+
+ return { canvas: croppedCanvas, width: crop.width, height: crop.height, crop };
+}
+
// 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 = '') {
@@ -3938,6 +4191,11 @@ async function processImage(data, imageState = 0, imagePath = '') {
clearTimeout(timeoutId);
URL.revokeObjectURL(url);
const origW = img.width, origH = img.height;
+ const source = createAutoCroppedCanvas(img, imagePath, origW, origH);
+ const sourceCanvas = source.canvas;
+ const sourceW = source.width;
+ const sourceH = source.height;
+ const sourceWasCropped = !!source.crop;
// imageState: 0=Normal, 1=H-Split (CW/CCW), 2=V-Split, 3=Rotate & Fit
// ========================================================================
@@ -3948,9 +4206,9 @@ async function processImage(data, imageState = 0, imagePath = '') {
// ========================================================================
if (imageState === 1) {
// Step 1: Scale WIDTH to 800 (this is the key difference!)
- const scale = MAX_HEIGHT / origW; // 800 / origW
+ const scale = MAX_HEIGHT / sourceW; // 800 / sourceW
const scaledW = MAX_HEIGHT; // 800
- const scaledH = Math.round(origH * scale);
+ const scaledH = Math.round(sourceH * scale);
const scaledCanvas = document.createElement('canvas');
scaledCanvas.width = scaledW;
@@ -3960,7 +4218,7 @@ async function processImage(data, imageState = 0, imagePath = '') {
scaledCtx.imageSmoothingQuality = 'high';
scaledCtx.fillStyle = '#FFF';
scaledCtx.fillRect(0, 0, scaledW, scaledH);
- scaledCtx.drawImage(img, 0, 0, origW, origH, 0, 0, scaledW, scaledH);
+ scaledCtx.drawImage(sourceCanvas, 0, 0, sourceW, sourceH, 0, 0, scaledW, scaledH);
// Step 2: Rotate 90° CW or CCW
const rotW = scaledH;
@@ -4073,8 +4331,8 @@ async function processImage(data, imageState = 0, imagePath = '') {
// ========================================================================
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 scale = MAX_HEIGHT / sourceH; // 800 / sourceH
+ const scaledW = Math.round(sourceW * scale);
const scaledH = MAX_HEIGHT; // Always 800
const scaledCanvas = document.createElement('canvas');
@@ -4085,7 +4343,7 @@ async function processImage(data, imageState = 0, imagePath = '') {
scaledCtx.imageSmoothingQuality = 'high';
scaledCtx.fillStyle = '#FFF';
scaledCtx.fillRect(0, 0, scaledW, scaledH);
- scaledCtx.drawImage(img, 0, 0, origW, origH, 0, 0, scaledW, scaledH);
+ scaledCtx.drawImage(sourceCanvas, 0, 0, sourceW, sourceH, 0, 0, scaledW, scaledH);
applyGrayscale(scaledCtx, scaledW, scaledH);
// Check if split needed
@@ -4159,8 +4417,8 @@ async function processImage(data, imageState = 0, imagePath = '') {
// ========================================================================
else if (imageState === 3) {
// Step 1: Rotate 90° based on handedness
- const rotW = origH;
- const rotH = origW;
+ const rotW = sourceH;
+ const rotH = sourceW;
const rotCanvas = document.createElement('canvas');
rotCanvas.width = rotW;
@@ -4177,13 +4435,13 @@ async function processImage(data, imageState = 0, imagePath = '') {
rotCtx.translate(0, rotH);
rotCtx.rotate(-Math.PI / 2);
}
- rotCtx.drawImage(img, 0, 0);
+ rotCtx.drawImage(sourceCanvas, 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) {
+ if (fitsInScreen && !sourceWasCropped) {
// 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));
@@ -4222,30 +4480,30 @@ async function processImage(data, imageState = 0, imagePath = '') {
// ========================================================================
else {
// Normal processing: check if scaling is needed
- const fitsInScreen = origW <= MAX_WIDTH && origH <= MAX_HEIGHT;
+ const fitsInScreen = sourceW <= MAX_WIDTH && sourceH <= MAX_HEIGHT;
- if (fitsInScreen) {
+ if (fitsInScreen && !sourceWasCropped) {
// Image already fits - just convert to JPEG with grayscale
const c = document.createElement('canvas');
- c.width = origW;
- c.height = origH;
+ c.width = sourceW;
+ c.height = sourceH;
const ctx = c.getContext('2d');
ctx.fillStyle = '#FFF';
- ctx.fillRect(0, 0, origW, origH);
- ctx.drawImage(img, 0, 0);
- applyGrayscale(ctx, origW, origH);
+ ctx.fillRect(0, 0, sourceW, sourceH);
+ ctx.drawImage(sourceCanvas, 0, 0);
+ applyGrayscale(ctx, sourceW, sourceH);
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 }
+ parts: [{ data: arrBuf, suffix: '', width: sourceW, height: sourceH, size: arrBuf.byteLength }],
+ meta: { origW, origH, origSize, wasSplit: false, rotated: false, finalW: sourceW, finalH: sourceH, 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 scale = Math.min(MAX_WIDTH / sourceW, MAX_HEIGHT / sourceH);
+ const newW = Math.round(sourceW * scale);
+ const newH = Math.round(sourceH * scale);
const c = document.createElement('canvas');
c.width = newW;
@@ -4255,7 +4513,7 @@ async function processImage(data, imageState = 0, imagePath = '') {
ctx.imageSmoothingQuality = 'high';
ctx.fillStyle = '#FFF';
ctx.fillRect(0, 0, newW, newH);
- ctx.drawImage(img, 0, 0, newW, newH);
+ ctx.drawImage(sourceCanvas, 0, 0, newW, newH);
applyGrayscale(ctx, newW, newH);
const blob = await new Promise(res => c.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100));
@@ -4285,7 +4543,7 @@ async function convertEpubFile(file, progressCallback) {
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');
+ log(`Quality: ${JPEG_QUALITY}% | Overlap: ${OVERLAP_PERCENT}% | Rotation: ${HANDEDNESS === 'right' ? 'CW' : 'CCW'} | Grayscale: ${ENABLE_GRAYSCALE ? 'ON' : 'OFF'} | Auto-crop: ${ENABLE_AUTO_CROP ? 'ON' : 'OFF'}`, '', 'INFO');
const zip = await JSZip.loadAsync(file);
const renamed = {};