// % reduction mode - calculate target from original size
return Math.round(originalSize * (1 - activePercent / 100));
}
// Size mode
const targetKB = getTargetSizeKB();
return targetKB ? targetKB * 1024 : null;
}
async function processOne(index, showDetailsAfter = true, onProgress = null) {
const item = filesState[index];
if (!item) return;
// Load page count if not loaded
if (item.pageCount === null) {
await loadPdfInfo(index);
}
const targetBytes = getTargetBytes(item.originalSize);
if (!targetBytes) {
showToast('Please choose a target size or reduction %', 'warning');
return;
}
const statusEl = document.getElementById(`status-${index}`);
statusEl.textContent = '⏳';
statusEl.className = 'text-[10px] text-logo-red px-1.5 py-0.5 rounded bg-red-50 flex-shrink-0';
try {
const result = await compressPDF(item.file, targetBytes, qualitySettings[activeQuality], onProgress);
item.processedBlob = result.blob;
item.processedSize = result.blob.size;
const sizeLabel = fileListEl.querySelector(`[data-size-index="${index}"]`);
if (sizeLabel) {
const reduction = Math.round((1 - result.blob.size / item.originalSize) * 100);
sizeLabel.innerHTML = `${formatSize(item.originalSize)} → ${formatSize(result.blob.size)} (-${reduction}%)`;
}
statusEl.textContent = '✓';
statusEl.className = 'text-[10px] text-emerald-600 px-1.5 py-0.5 rounded bg-emerald-50 flex-shrink-0 font-bold';
const dl = document.getElementById(`dl-${index}`);
if (dl) dl.disabled = false;
downloadZipBtn.disabled = !filesState.some(f => f.processedBlob);
if (showDetailsAfter) showDetails(index);
const targetKB = Math.round(targetBytes / 1024);
if (result.blob.size > targetBytes * 1.1) {
const actualKB = Math.round(result.blob.size / 1024);
const perPage = Math.round(actualKB / (item.pageCount || 1));
const suggestedKB = Math.max(50, perPage * (item.pageCount || 1));
showToast(`Reached ${actualKB}KB (${perPage}KB/page). For ${item.pageCount || '?'} pages, try ${suggestedKB}KB+ or Low quality.`, 'warning');
}
} catch (err) {
console.error(err);
statusEl.textContent = '✗';
statusEl.className = 'text-[10px] text-red-500 px-1.5 py-0.5 rounded bg-red-50 flex-shrink-0 font-bold';
showToast(`Error: ${err.message || 'Failed to process PDF'}`, 'error');
}
}
async function compressPDF(file, targetBytes, quality, onProgress = null) {
await loadPdfJs();
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFLib.PDFDocument.load(arrayBuffer, { ignoreEncryption: true });
const pages = pdfDoc.getPages();
const pageCount = pages.length;
// Pre-render all pages to pdf.js
const pdfJs = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
// Helper: create compressed PDF at given scale and jpeg quality
async function tryCompress(scale, jpegQuality, reportProgress = false) {
const newPdfDoc = await PDFLib.PDFDocument.create();
for (let i = 0; i < pageCount; i++) {
// Report progress if callback provided
if (reportProgress && onProgress) {
onProgress(i + 1, pageCount);
}
const page = pages[i];
const { width, height } = page.getSize();
const canvas = document.createElement('canvas');
const scaledWidth = Math.max(100, Math.round(width * scale));
const scaledHeight = Math.max(100, Math.round(height * scale));
canvas.width = scaledWidth;
canvas.height = scaledHeight;
try {
const pdfPage = await pdfJs.getPage(i + 1);
const viewport = pdfPage.getViewport({ scale: 1 });
const renderScale = Math.min(scaledWidth / viewport.width, scaledHeight / viewport.height);
const scaledViewport = pdfPage.getViewport({ scale: renderScale });
canvas.width = scaledViewport.width;
canvas.height = scaledViewport.height;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
await pdfPage.render({ canvasContext: ctx, viewport: scaledViewport }).promise;
} catch (e) {
const [copiedPage] = await newPdfDoc.copyPages(pdfDoc, [i]);
newPdfDoc.addPage(copiedPage);
continue;
}
const jpegDataUrl = canvas.toDataURL('image/jpeg', jpegQuality);
const jpegBytes = await fetch(jpegDataUrl).then(r => r.arrayBuffer());
const jpegImage = await newPdfDoc.embedJpg(jpegBytes);
const newPage = newPdfDoc.addPage([width, height]);
newPage.drawImage(jpegImage, { x: 0, y: 0, width, height });
}
const pdfBytes = await newPdfDoc.save({ useObjectStreams: true });
return new Blob([pdfBytes], { type: 'application/pdf' });
}
// Start with the base quality settings
let baseScale = quality.scale;
let baseJpeg = quality.jpegQuality;
// First try at base settings (with progress reporting)
let blob = await tryCompress(baseScale, baseJpeg, true);
// If already under target, we're done
if (blob.size <= targetBytes) {
return { blob };
}
// Binary search to find optimal scale that fits target
let lowScale = 0.05; // Allow very small scale for aggressive compression
let highScale = baseScale;
let bestBlob = blob;
// First: find a scale that works
for (let i = 0; i < 8; i++) {
const midScale = (lowScale + highScale) / 2;
blob = await tryCompress(midScale, baseJpeg);
if (blob.size <= targetBytes) {
bestBlob = blob;
lowScale = midScale; // Try higher scale
} else {
highScale = midScale; // Need smaller scale
}
if (highScale - lowScale < 0.05) break;
}
// If still over target, try reducing JPEG quality further
if (bestBlob.size > targetBytes) {
const finalScale = lowScale;
let lowQ = 0.1;
let highQ = baseJpeg;
for (let i = 0; i < 6; i++) {
const midQ = (lowQ + highQ) / 2;
blob = await tryCompress(finalScale, midQ);
if (blob.size <= targetBytes) {
bestBlob = blob;
lowQ = midQ; // Try higher quality
} else {
highQ = midQ; // Need lower quality
}
if (highQ - lowQ < 0.05) break;
}
}
return { blob: bestBlob };
}
// Load PDF.js for rendering
let pdfJsLoaded = false;
async function loadPdfJs() {
if (pdfJsLoaded) return;
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js');
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
pdfJsLoaded = true;
}
function formatSize(bytes) {
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(2)} MB`;
}
function downloadOne(index) {
const item = filesState[index];
if (!item || !item.processedBlob) return;
const a = document.createElement('a');
const url = URL.createObjectURL(item.processedBlob);
a.href = url;
a.download = addSuffix(item.file.name, '-compressed');
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
downloadOneBtn.addEventListener('click', () => {
const idx = parseInt(downloadOneBtn.dataset.index, 10);
if (!isNaN(idx)) downloadOne(idx);
});
processAllBtn.addEventListener('click', async () => {
if (!filesState.length) return;
// Check if we have valid settings
const hasTarget = activeMode === 'size' ? getTargetSizeKB() : activePercent;
if (!hasTarget) {
showToast('Please choose a target size or reduction %', 'warning');
return;
}
isProcessing = true;
processAllBtn.disabled = true;
downloadZipBtn.disabled = true;
const allRowBtns = fileListEl.querySelectorAll('button');
allRowBtns.forEach(b => b.disabled = true);
progressText.classList.remove('hidden');
compressBtnText.textContent = 'Processing...';
// Calculate total pages for time estimate
let totalPages = 0;
for (const item of filesState) {
if (item.pageCount === null) await loadPdfInfo(filesState.indexOf(item));
totalPages += item.pageCount || 1;
}
let processedPages = 0;
const startTime = Date.now();
for (let i = 0; i < filesState.length; i++) {
const item = filesState[i];
const itemPages = item.pageCount || 1;
// Progress callback with time estimate
const onProgress = (currentPage, totalPagesInFile) => {
const elapsed = (Date.now() - startTime) / 1000;
const pagesComplete = processedPages + currentPage;
const avgTimePerPage = elapsed / pagesComplete;
const remainingPages = totalPages - pagesComplete;
const estRemaining = Math.round(avgTimePerPage * remainingPages);
progressText.textContent = `File ${i + 1}/${filesState.length} • Page ${currentPage}/${totalPagesInFile} • ~${estRemaining}s left`;
};
progressText.textContent = `File ${i + 1}/${filesState.length}`;
await processOne(i, false, onProgress);
processedPages += itemPages;
}
showDetails(filesState.length - 1);
isProcessing = false;
processAllBtn.disabled = false;
compressBtnText.textContent = 'Compress All';
progressText.classList.add('hidden');
filesState.forEach((item, idx) => {
const dl = document.getElementById(`dl-${idx}`);
if (dl) dl.disabled = !item.processedBlob;
});
downloadZipBtn.disabled = !filesState.some(f => f.processedBlob);
showToast(`Compressed ${filesState.length} PDFs!`, 'success');
});
function addSuffix(name, suffix) {
const dot = name.lastIndexOf('.');
if (dot === -1) return name + suffix + '.pdf';
return name.slice(0, dot) + suffix + '.pdf';
}
downloadZipBtn.addEventListener('click', async () => {
const JSZipUrl = 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js';
if (!filesState.some(f => f.processedBlob)) return;
downloadZipBtn.disabled = true;
downloadZipBtn.textContent = 'Preparing ZIP...';
await loadScript(JSZipUrl);
const zip = new JSZip();
filesState.forEach((item) => {
if (!item.processedBlob) return;
zip.file(addSuffix(item.file.name, '-compressed'), item.processedBlob);
});
const blob = await zip.generateAsync({ type: 'blob' });
const a = document.createElement('a');
const url = URL.createObjectURL(blob);
a.href = url;
a.download = 'pdfs-compressed.zip';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
downloadZipBtn.innerHTML = ' Download ZIP';
downloadZipBtn.disabled = false;
lucide.createIcons();
});
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) return resolve();
const s = document.createElement('script');
s.src = src;
s.onload = resolve;
s.onerror = reject;
document.body.appendChild(s);
});
}