NexTools.IN By Pixelect
// % 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); }); }