Spaces:
Running
Running
Download image-processor.js from smmisha/omniconvert: direct link, hf CLI and curl.
- Browser
- Download file 30.5 kB
-
https://huggingface.co/spaces/smmisha/omniconvert/resolve/main/image-processor.js
- Command line
-
hf download hf://spaces/smmisha/omniconvert/image-processor.js
-
curl -L -o image-processor.js https://huggingface.co/spaces/smmisha/omniconvert/resolve/main/image-processor.js
30.5 kB
| // ========================================== | |
| // OmniConvert: Image Processing Module | |
| // ========================================== | |
| const ImageHub = { | |
| // Module State | |
| files: [], // Array of { id, file, name, srcUrl, srcSize, srcDims, status, out: {blob, url, size, name, fmt, dims}, error } | |
| nextId: 1, | |
| // Options (bound to UI controls) | |
| options: { | |
| targetFormat: 'png', // png, jpeg, webp, avif, bmp, ico, pdf | |
| targetSizeLimit: false, // binary search compression to KB | |
| targetSizeValue: 100, // in KB | |
| quality: 0.85, // 0.1 to 1.0 (lossy formats only) | |
| maxDimension: 0, // 0 for original, or max side length in px | |
| bgWhite: true, // fill transparency with white for jpg/bmp | |
| autoDownload: false // immediately trigger individual download | |
| }, | |
| // Initialize event bindings and drag-n-drop | |
| init() { | |
| this.bindEvents(); | |
| // Setup dropzone using app shell helper | |
| App.setupDropzone('imageDropzone', 'imageFileInput', (fileList) => { | |
| this.handleFilesAdded(fileList); | |
| }); | |
| }, | |
| // Bind controls to State | |
| bindEvents() { | |
| const $ = (id) => document.getElementById(id); | |
| // Target format selection | |
| const fmtButtons = document.querySelectorAll('#targetFormatGrid .fmt-btn'); | |
| fmtButtons.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| fmtButtons.forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| this.options.targetFormat = btn.dataset.fmt; | |
| this.updateControlVisibility(); | |
| }); | |
| }); | |
| // Target size toggle | |
| $('targetSizeToggle').addEventListener('change', (e) => { | |
| this.options.targetSizeLimit = e.target.checked; | |
| this.updateControlVisibility(); | |
| }); | |
| // Target size input | |
| $('targetSizeKBInput').addEventListener('input', (e) => { | |
| let val = parseInt(e.target.value, 10); | |
| if (isNaN(val) || val < 5) val = 5; | |
| this.options.targetSizeValue = val; | |
| }); | |
| // Quality slider | |
| const qSlider = $('qualitySlider'); | |
| const qDisplay = $('qualityValDisplay'); | |
| qSlider.addEventListener('input', (e) => { | |
| const pct = e.target.value; | |
| qDisplay.textContent = pct + '%'; | |
| this.options.quality = parseInt(pct, 10) / 100; | |
| }); | |
| // Max side slider | |
| const sSlider = $('maxSizeSlider'); | |
| const sDisplay = $('maxSizeValDisplay'); | |
| sSlider.addEventListener('input', (e) => { | |
| const val = parseInt(e.target.value, 10); | |
| sDisplay.textContent = val === 0 ? 'Исходный' : val + 'px'; | |
| this.options.maxDimension = val; | |
| }); | |
| // Other settings | |
| $('bgWhiteToggle').addEventListener('change', (e) => this.options.bgWhite = e.target.checked); | |
| $('autoDownloadToggle').addEventListener('change', (e) => this.options.autoDownload = e.target.checked); | |
| // Queue buttons | |
| $('clearAllImagesBtn').addEventListener('click', () => this.clearQueue()); | |
| $('processAllImagesBtn').addEventListener('click', () => this.processAll()); | |
| $('downloadZipBtn').addEventListener('click', () => this.downloadAllAsZip()); | |
| }, | |
| // Toggle visible sliders based on active options | |
| updateControlVisibility() { | |
| const qualityCtrl = document.getElementById('qualityControl'); | |
| const targetCtrl = document.getElementById('targetSizeControl'); | |
| // Lossy formats support quality slider | |
| const isLossy = ['jpeg', 'webp', 'avif', 'pdf'].includes(this.options.targetFormat); | |
| // Quality slider is shown if lossy and target size is NOT enabled | |
| if (isLossy && !this.options.targetSizeLimit) { | |
| qualityCtrl.classList.remove('hidden'); | |
| } else { | |
| qualityCtrl.classList.add('hidden'); | |
| } | |
| // Target size input is shown only if target size option is toggled | |
| if (this.options.targetSizeLimit) { | |
| targetCtrl.classList.remove('hidden'); | |
| } else { | |
| targetCtrl.classList.add('hidden'); | |
| } | |
| }, | |
| // Handle files input selection or drop | |
| async handleFilesAdded(fileList) { | |
| let imagesCount = 0; | |
| const filesToProcess = Array.from(fileList); | |
| for (const file of filesToProcess) { | |
| const ext = file.name.split('.').pop().toLowerCase(); | |
| if (ext === 'pdf') { | |
| try { | |
| App.showToast(`Обработка PDF "${file.name}"...`, 'info'); | |
| const pages = await this.extractPagesFromPDF(file); | |
| for (const pageFile of pages) { | |
| this.addSingleFileToQueue(pageFile); | |
| imagesCount++; | |
| } | |
| } catch (err) { | |
| console.error(err); | |
| App.showToast(`Ошибка разбора PDF "${file.name}": ${err.message}`, 'error'); | |
| } | |
| } else { | |
| const isImg = file.type.startsWith('image/') || | |
| ['heic', 'heif', 'tiff', 'tif', 'avif', 'ico', 'bmp', 'svg'].includes(ext); | |
| if (!isImg) { | |
| App.showToast(`Файл "${file.name}" не является поддерживаемым изображением.`, 'warning'); | |
| continue; | |
| } | |
| this.addSingleFileToQueue(file); | |
| imagesCount++; | |
| } | |
| } | |
| if (imagesCount > 0) { | |
| App.showToast(`Добавлено файлов: ${imagesCount}`, 'info'); | |
| document.getElementById('imageQueueCard').classList.remove('hidden'); | |
| this.renderQueue(); | |
| } | |
| }, | |
| addSingleFileToQueue(file) { | |
| const id = this.nextId++; | |
| const srcUrl = URL.createObjectURL(file); | |
| this.files.push({ | |
| id, | |
| file, | |
| name: file.name, | |
| srcUrl, | |
| srcSize: file.size, | |
| srcDims: null, | |
| status: 'pending', | |
| out: null, | |
| error: null | |
| }); | |
| }, | |
| // Parse PDF and render pages as PNG image files offline | |
| async extractPagesFromPDF(file) { | |
| const pdfjsLib = window.pdfjsLib || window['pdfjs-dist/build/pdf']; | |
| if (!pdfjsLib) { | |
| throw new Error('Библиотека PDF.js не загружена. Проверьте lib/pdf.min.js'); | |
| } | |
| pdfjsLib.GlobalWorkerOptions.workerSrc = 'lib/pdf.worker.min.js'; | |
| const arrayBuffer = await file.arrayBuffer(); | |
| const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; | |
| const numPages = pdf.numPages; | |
| App.showToast(`Извлечение страниц из PDF: ${numPages}...`, 'info'); | |
| const pagesList = []; | |
| const baseName = file.name.replace(/\.[^.]+$/, ''); | |
| for (let pageNum = 1; pageNum <= numPages; pageNum++) { | |
| const page = await pdf.getPage(pageNum); | |
| // Render at double scale to ensure good image quality/readability | |
| const scale = 2.0; | |
| const viewport = page.getViewport({ scale }); | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = viewport.width; | |
| canvas.height = viewport.height; | |
| const ctx = canvas.getContext('2d'); | |
| await page.render({ | |
| canvasContext: ctx, | |
| viewport: viewport | |
| }).promise; | |
| const blob = await new Promise((resolve) => { | |
| canvas.toBlob((b) => resolve(b), 'image/png'); | |
| }); | |
| const pageFile = new File([blob], `${baseName}_page_${pageNum}.png`, { type: 'image/png' }); | |
| pagesList.push(pageFile); | |
| } | |
| return pagesList; | |
| }, | |
| // Clear everything in state | |
| clearQueue() { | |
| this.files.forEach(f => { | |
| if (f.srcUrl) URL.revokeObjectURL(f.srcUrl); | |
| if (f.out?.url) URL.revokeObjectURL(f.out.url); | |
| }); | |
| this.files = []; | |
| document.getElementById('imageQueueCard').classList.add('hidden'); | |
| document.getElementById('bulkDownloadBar').classList.add('hidden'); | |
| this.renderQueue(); | |
| App.showToast('Очередь очищена', 'info'); | |
| }, | |
| // Render HTML queue cards | |
| renderQueue() { | |
| const container = document.getElementById('imagesListContainer'); | |
| const queueCount = document.getElementById('queueCount'); | |
| container.innerHTML = ''; | |
| queueCount.textContent = this.files.length; | |
| if (this.files.length === 0) { | |
| return; | |
| } | |
| this.files.forEach(f => { | |
| const card = document.createElement('div'); | |
| card.className = 'file-card'; | |
| card.id = `img-card-${f.id}`; | |
| // Thumbnail check | |
| let thumbContent = ''; | |
| if (f.status === 'done' && f.out?.url && f.out.fmt !== 'pdf') { | |
| thumbContent = `<img src="${f.out.url}" alt="${f.name}">`; | |
| } else if (f.file.type.startsWith('image/svg') || f.name.endsWith('.svg')) { | |
| thumbContent = `<span class="file-thumb-icon">🎨</span>`; | |
| } else if (f.name.endsWith('.heic') || f.name.endsWith('.heif')) { | |
| thumbContent = `<span class="file-thumb-icon">🍏</span>`; | |
| } else { | |
| thumbContent = `<img src="${f.srcUrl}" alt="${f.name}" onerror="this.parentNode.innerHTML='<span class=\\'file-thumb-icon\\'>🖼️</span>'">`; | |
| } | |
| // Details row | |
| let metaDetails = `<span>${App.formatBytes(f.srcSize)}</span>`; | |
| if (f.srcDims) { | |
| metaDetails += `<span>•</span><span>${f.srcDims.w}x${f.srcDims.h}px</span>`; | |
| } | |
| if (f.out) { | |
| const delta = f.out.size - f.srcSize; | |
| const pct = ((delta / f.srcSize) * 100).toFixed(1); | |
| const sign = delta < 0 ? '' : '+'; | |
| const cssClass = delta < 0 ? 'positive' : 'negative'; | |
| metaDetails += ` | |
| <span class="meta-arrow">→</span> | |
| <span>${App.formatBytes(f.out.size)}</span> | |
| <span>•</span> | |
| <span class="meta-saving ${cssClass}">(${sign}${pct}%)</span> | |
| `; | |
| if (f.out.dims) { | |
| metaDetails += `<span>•</span><span>${f.out.dims.w}x${f.out.dims.h}px</span>`; | |
| } | |
| } | |
| // Status Badge | |
| let badgeHtml = ''; | |
| if (f.status === 'pending') badgeHtml = `<span class="file-status-badge pending">${I18n.t('badge_pending', 'ожидает')}</span>`; | |
| if (f.status === 'processing') badgeHtml = `<span class="file-status-badge processing"><span class="loading-spinner"></span> ${I18n.t('badge_processing', 'сжатие...')}</span>`; | |
| if (f.status === 'done') badgeHtml = `<span class="file-status-badge done">${I18n.t('badge_done', '✓ готово')}</span>`; | |
| if (f.status === 'error') badgeHtml = `<span class="file-status-badge error">${I18n.t('badge_error', '✗ ошибка')}</span>`; | |
| // Actions buttons | |
| const isDlDisabled = f.status !== 'done' ? 'disabled' : ''; | |
| const isCvDisabled = f.status === 'processing' ? 'disabled' : ''; | |
| card.innerHTML = ` | |
| <div class="file-thumb">${thumbContent}</div> | |
| <div class="file-details"> | |
| <div class="file-name" title="${f.out?.name || f.name}">${f.out?.name || f.name}</div> | |
| <div class="file-meta-row">${metaDetails}</div> | |
| <div style="margin-top: 4px; display:flex; gap: 8px; align-items:center;"> | |
| ${badgeHtml} | |
| ${f.error ? `<span style="color:var(--error); font-size:11px;">${f.error}</span>` : ''} | |
| </div> | |
| </div> | |
| <div class="file-actions"> | |
| <button class="icon-btn primary-btn" title="Скачать результат" ${isDlDisabled} id="dl-btn-${f.id}"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> | |
| </button> | |
| <button class="icon-btn" title="Запустить конвертацию" ${isCvDisabled} id="cv-btn-${f.id}"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> | |
| </button> | |
| <button class="icon-btn" title="Убрать из списка" id="rm-btn-${f.id}"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> | |
| </button> | |
| </div> | |
| `; | |
| // Event Listeners on buttons | |
| card.querySelector(`#dl-btn-${f.id}`).addEventListener('click', () => this.downloadSingle(f)); | |
| card.querySelector(`#cv-btn-${f.id}`).addEventListener('click', () => this.processFile(f)); | |
| card.querySelector(`#rm-btn-${f.id}`).addEventListener('click', () => this.removeFile(f.id)); | |
| container.appendChild(card); | |
| }); | |
| this.updateBulkDownloadBar(); | |
| }, | |
| // Remove single file | |
| removeFile(id) { | |
| const f = this.files.find(x => x.id === id); | |
| if (f) { | |
| if (f.srcUrl) URL.revokeObjectURL(f.srcUrl); | |
| if (f.out?.url) URL.revokeObjectURL(f.out.url); | |
| } | |
| this.files = this.files.filter(x => x.id !== id); | |
| if (this.files.length === 0) { | |
| document.getElementById('imageQueueCard').classList.add('hidden'); | |
| document.getElementById('bulkDownloadBar').classList.add('hidden'); | |
| } | |
| this.renderQueue(); | |
| }, | |
| // Trigger individual file download | |
| downloadSingle(f) { | |
| if (!f.out) return; | |
| const a = document.createElement('a'); | |
| a.href = f.out.url; | |
| a.download = f.out.name; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| }, | |
| // Update total savings count and ZIP button visibility | |
| updateBulkDownloadBar() { | |
| const bar = document.getElementById('bulkDownloadBar'); | |
| const text = document.getElementById('bulkSavingsText'); | |
| const finished = this.files.filter(f => f.status === 'done'); | |
| if (finished.length <= 1) { | |
| bar.classList.add('hidden'); | |
| return; | |
| } | |
| const totalIn = finished.reduce((s, f) => s + f.srcSize, 0); | |
| const totalOut = finished.reduce((s, f) => s + f.out.size, 0); | |
| const delta = totalIn - totalOut; | |
| if (delta > 0) { | |
| const savedPct = ((delta / totalIn) * 100).toFixed(1); | |
| text.textContent = `Готово: Сэкономлено всего ${App.formatBytes(delta)} (-${savedPct}%)`; | |
| } else { | |
| text.textContent = `Готово: Конвертировано ${finished.length} файлов`; | |
| } | |
| bar.classList.remove('hidden'); | |
| }, | |
| // Trigger conversion of ALL items | |
| async processAll() { | |
| const processBtn = document.getElementById('processAllImagesBtn'); | |
| processBtn.disabled = true; | |
| for (const f of this.files) { | |
| if (f.status !== 'done' && f.status !== 'processing') { | |
| await this.processFile(f); | |
| } | |
| } | |
| processBtn.disabled = false; | |
| App.showToast('Пакетная обработка завершена', 'success'); | |
| }, | |
| // core file processing engine | |
| async processFile(f) { | |
| f.status = 'processing'; | |
| f.error = null; | |
| this.renderQueue(); | |
| let tempObjectUrl = null; | |
| let imageSource = null; | |
| try { | |
| let targetFileBlob = f.file; | |
| // 1. Apple HEIC/HEIF Decoding Wrapper | |
| const ext = f.name.split('.').pop().toLowerCase(); | |
| if (['heic', 'heif'].includes(ext)) { | |
| if (typeof window.heic2any !== 'function') { | |
| throw new Error('Модуль HEIC-декодера не загружен.'); | |
| } | |
| // Convert HEIC blob to PNG | |
| const pngBlob = await window.heic2any({ | |
| blob: f.file, | |
| toType: 'image/png' | |
| }); | |
| targetFileBlob = Array.isArray(pngBlob) ? pngBlob[0] : pngBlob; | |
| } | |
| // 2. Load into HTML Image representation | |
| imageSource = await this.loadImageSource(targetFileBlob); | |
| f.srcDims = { w: imageSource.width, h: imageSource.height }; | |
| let finalBlob = null; | |
| let finalDims = { w: imageSource.width, h: imageSource.height }; | |
| // 3. Size constraints calculations & smart compression | |
| if (this.options.targetSizeLimit) { | |
| // Run Binary Search for target size limit | |
| const limitBytes = this.options.targetSizeValue * 1024; | |
| const searchResult = await this.compressToTargetSize(imageSource, this.options.targetFormat, limitBytes); | |
| finalBlob = searchResult.blob; | |
| finalDims = searchResult.dims; | |
| } else { | |
| // standard manual settings | |
| const canvas = this.drawCanvas(imageSource, this.options.maxDimension, this.options.bgWhite); | |
| finalDims = { w: canvas.width, h: canvas.height }; | |
| finalBlob = await this.canvasToBlobWrapper(canvas, this.options.targetFormat, this.options.quality); | |
| } | |
| // 4. Output assembly | |
| const outUrl = URL.createObjectURL(finalBlob); | |
| const rawName = f.name.replace(/\.[^.]+$/, ''); | |
| const outExt = this.getFormatExtension(this.options.targetFormat); | |
| const outName = `${rawName}_converted.${outExt}`; | |
| // Revoke old out if exists | |
| if (f.out?.url) URL.revokeObjectURL(f.out.url); | |
| f.out = { | |
| blob: finalBlob, | |
| url: outUrl, | |
| size: finalBlob.size, | |
| name: outName, | |
| fmt: this.options.targetFormat, | |
| dims: finalDims | |
| }; | |
| f.status = 'done'; | |
| if (this.options.autoDownload) { | |
| this.downloadSingle(f); | |
| } | |
| } catch (err) { | |
| console.error(err); | |
| f.status = 'error'; | |
| f.error = err.message || 'Ошибка обработки'; | |
| } finally { | |
| // Clean temporary references | |
| if (imageSource && imageSource.source && imageSource.source.close) { | |
| imageSource.source.close(); | |
| } | |
| this.renderQueue(); | |
| } | |
| }, | |
| // Image loader utility trying ImageBitmap first | |
| async loadImageSource(blob) { | |
| try { | |
| const bitmap = await createImageBitmap(blob); | |
| return { source: bitmap, width: bitmap.width, height: bitmap.height, type: 'bitmap' }; | |
| } catch (e) { | |
| return new Promise((resolve, reject) => { | |
| const url = URL.createObjectURL(blob); | |
| const img = new Image(); | |
| img.onload = () => { | |
| resolve({ source: img, width: img.naturalWidth, height: img.naturalHeight, type: 'img', url }); | |
| }; | |
| img.onerror = () => { | |
| URL.revokeObjectURL(url); | |
| reject(new Error('Не удалось прочитать структуру изображения')); | |
| }; | |
| img.src = url; | |
| }); | |
| } | |
| }, | |
| // Scale, background, and draw image to Canvas | |
| drawCanvas(imgInfo, maxSide, bgWhite) { | |
| let w = imgInfo.width; | |
| let h = imgInfo.height; | |
| // Apply max dimension scaling | |
| if (maxSide > 0 && Math.max(w, h) > maxSide) { | |
| const ratio = maxSide / Math.max(w, h); | |
| w = Math.round(w * ratio); | |
| h = Math.round(h * ratio); | |
| } | |
| // ICO limit dimension | |
| if (this.options.targetFormat === 'ico') { | |
| const target = Math.min(256, Math.max(w, h)); | |
| const ratio = target / Math.max(w, h); | |
| w = Math.round(w * ratio); | |
| h = Math.round(h * ratio); | |
| } | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = w; | |
| canvas.height = h; | |
| const ctx = canvas.getContext('2d'); | |
| // Fill background if format requires | |
| const needsBg = ['jpeg', 'bmp'].includes(this.options.targetFormat) && bgWhite; | |
| if (needsBg) { | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillRect(0, 0, w, h); | |
| } | |
| ctx.drawImage(imgInfo.source, 0, 0, w, h); | |
| return canvas; | |
| }, | |
| // Core formatting encoder router | |
| async canvasToBlobWrapper(canvas, format, quality) { | |
| const mimeTypes = { | |
| png: 'image/png', | |
| jpeg: 'image/jpeg', | |
| webp: 'image/webp', | |
| avif: 'image/avif' | |
| }; | |
| if (['png', 'jpeg', 'webp'].includes(format)) { | |
| return new Promise((resolve, reject) => { | |
| canvas.toBlob(blob => { | |
| if (blob) resolve(blob); | |
| else reject(new Error(`Браузер не скомпилировал ${format.toUpperCase()}`)); | |
| }, mimeTypes[format], quality); | |
| }); | |
| } | |
| if (format === 'avif') { | |
| return new Promise((resolve, reject) => { | |
| canvas.toBlob(blob => { | |
| // If browser doesn't support native AVIF encoding, it will fallback to PNG or return null | |
| if (blob && blob.type === 'image/avif') { | |
| resolve(blob); | |
| } else { | |
| // Graceful fallback to high-quality WebP with warning | |
| App.showToast('AVIF-кодирование не поддерживается браузером. Выполняю экспорт в WebP.', 'warning'); | |
| canvas.toBlob(webpBlob => { | |
| resolve(webpBlob); | |
| }, 'image/webp', quality); | |
| } | |
| }, 'image/avif', quality); | |
| }); | |
| } | |
| if (format === 'bmp') { | |
| return this.encodeBMP(canvas); | |
| } | |
| if (format === 'ico') { | |
| return this.encodeICO(canvas); | |
| } | |
| if (format === 'pdf') { | |
| return this.encodePDF(canvas); | |
| } | |
| throw new Error('Неподдерживаемый целевой формат'); | |
| }, | |
| // Binary search size optimizer | |
| async compressToTargetSize(imgInfo, format, limitBytes) { | |
| let scale = 1.0; | |
| // For BMP, since it is raw bytes, we can calculate size mathematically | |
| // size = 54 + rowSize * h | |
| if (format === 'bmp') { | |
| const getBmpSize = (w, h) => { | |
| const rowSize = Math.floor((24 * w + 31) / 32) * 4; | |
| return 54 + rowSize * h; | |
| }; | |
| let w = imgInfo.width; | |
| let h = imgInfo.height; | |
| // Scale down factor calculation directly | |
| if (getBmpSize(w, h) > limitBytes) { | |
| // size roughly scales quadratically with scale factor | |
| scale = Math.sqrt(limitBytes / getBmpSize(w, h)) * 0.95; | |
| } | |
| const canvas = this.drawCanvas(imgInfo, Math.max(w, h) * scale, this.options.bgWhite); | |
| const blob = await this.encodeBMP(canvas); | |
| return { blob, dims: { w: canvas.width, h: canvas.height } }; | |
| } | |
| // For PDF/PNG (lossless types): Binary search on canvas dimensions scale factor | |
| if (format === 'png' || format === 'pdf') { | |
| let lo = 0.05, hi = 1.0, bestBlob = null, bestDims = null; | |
| for (let i = 0; i < 10; i++) { | |
| const currentScale = (lo + hi) / 2; | |
| const maxSide = Math.max(imgInfo.width, imgInfo.height) * currentScale; | |
| const canvas = this.drawCanvas(imgInfo, maxSide, this.options.bgWhite); | |
| const blob = await this.canvasToBlobWrapper(canvas, format, 0.9); // high quality | |
| if (blob.size <= limitBytes) { | |
| bestBlob = blob; | |
| bestDims = { w: canvas.width, h: canvas.height }; | |
| lo = currentScale; // Try larger | |
| } else { | |
| hi = currentScale; // Try smaller | |
| } | |
| } | |
| if (bestBlob) return { blob: bestBlob, dims: bestDims }; | |
| // Minimal fail-safe | |
| const canvas = this.drawCanvas(imgInfo, Math.max(imgInfo.width, imgInfo.height) * 0.05, this.options.bgWhite); | |
| const blob = await this.canvasToBlobWrapper(canvas, format, 0.5); | |
| return { blob, dims: { w: canvas.width, h: canvas.height } }; | |
| } | |
| // For JPEG / WebP / AVIF (lossy formats): | |
| // First, binary search on QUALITY with full image resolution | |
| let canvas = this.drawCanvas(imgInfo, this.options.maxDimension, this.options.bgWhite); | |
| let lo = 0.01, hi = 0.99, bestBlob = null; | |
| for (let i = 0; i < 12; i++) { | |
| const q = (lo + hi) / 2; | |
| const blob = await this.canvasToBlobWrapper(canvas, format, q); | |
| if (blob.size <= limitBytes) { | |
| bestBlob = blob; | |
| lo = q; // Try higher quality | |
| } else { | |
| hi = q; // Try lower quality | |
| } | |
| } | |
| if (bestBlob) { | |
| return { blob: bestBlob, dims: { w: canvas.width, h: canvas.height } }; | |
| } | |
| // If even quality 0.01 is too large, scale down dimensions sequentially | |
| scale = 0.9; | |
| while (scale > 0.05) { | |
| const side = Math.max(imgInfo.width, imgInfo.height) * scale; | |
| canvas = this.drawCanvas(imgInfo, side, this.options.bgWhite); | |
| const blob = await this.canvasToBlobWrapper(canvas, format, 0.4); // moderate quality | |
| if (blob.size <= limitBytes) { | |
| return { blob, dims: { w: canvas.width, h: canvas.height } }; | |
| } | |
| scale -= 0.1; | |
| } | |
| // Worst-case escape fallback | |
| canvas = this.drawCanvas(imgInfo, Math.max(imgInfo.width, imgInfo.height) * 0.05, this.options.bgWhite); | |
| const blob = await this.canvasToBlobWrapper(canvas, format, 0.1); | |
| return { blob, dims: { w: canvas.width, h: canvas.height } }; | |
| }, | |
| // 24-bit uncompressed BMP encoder | |
| encodeBMP(canvas) { | |
| const w = canvas.width; | |
| const h = canvas.height; | |
| const ctx = canvas.getContext('2d'); | |
| const imgData = ctx.getImageData(0, 0, w, h); | |
| const pixels = imgData.data; | |
| const rowSize = Math.floor((24 * w + 31) / 32) * 4; | |
| const pixelArraySize = rowSize * h; | |
| const fileSize = 54 + pixelArraySize; | |
| const buf = new ArrayBuffer(fileSize); | |
| const view = new DataView(buf); | |
| // BMP File Header | |
| view.setUint16(0, 0x4D42, true); // Signature 'BM' | |
| view.setUint32(2, fileSize, true); // total file size | |
| view.setUint32(6, 0, true); // reserved | |
| view.setUint32(10, 54, true); // offset to image data | |
| // DIB Header | |
| view.setUint32(14, 40, true); // DIB header length | |
| view.setInt32(18, w, true); // image width | |
| view.setInt32(22, h, true); // image height (positive = bottom-to-top) | |
| view.setUint16(26, 1, true); // color planes | |
| view.setUint16(28, 24, true); // bits per pixel | |
| view.setUint32(30, 0, true); // compression: BI_RGB (none) | |
| view.setUint32(34, pixelArraySize, true); // byte size of pixel array | |
| view.setInt32(38, 2835, true); // x pixels/meter (72 dpi) | |
| view.setInt32(42, 2835, true); // y pixels/meter | |
| view.setUint32(46, 0, true); // palette colors | |
| view.setUint32(50, 0, true); // important colors | |
| // Pixel data loop (packed BGR format, flipped vertically, with 4-byte padding) | |
| const bytes = new Uint8Array(buf); | |
| let dst = 54; | |
| for (let y = h - 1; y >= 0; y--) { | |
| let src = y * w * 4; | |
| for (let x = 0; x < w; x++) { | |
| bytes[dst] = pixels[src + 2]; // Blue | |
| bytes[dst + 1] = pixels[src + 1]; // Green | |
| bytes[dst + 2] = pixels[src]; // Red | |
| dst += 3; | |
| src += 4; | |
| } | |
| // row padding to align to multiple of 4 bytes | |
| while ((dst - 54) % rowSize !== 0) { | |
| bytes[dst++] = 0; | |
| } | |
| } | |
| return new Blob([buf], { type: 'image/bmp' }); | |
| }, | |
| // ICO Encoder (packages standard PNG within ICO file structure) | |
| async encodeICO(canvas) { | |
| const pngBlob = await new Promise((resolve, reject) => { | |
| canvas.toBlob(b => b ? resolve(b) : reject(new Error('Сбой кодирования PNG для ICO')), 'image/png'); | |
| }); | |
| const pngBuf = await pngBlob.arrayBuffer(); | |
| const pngBytes = new Uint8Array(pngBuf); | |
| // ICO size restrictions | |
| const w = canvas.width >= 256 ? 0 : canvas.width; | |
| const h = canvas.height >= 256 ? 0 : canvas.height; | |
| const headerSize = 6 + 16; | |
| const totalSize = headerSize + pngBytes.length; | |
| const outBytes = new Uint8Array(totalSize); | |
| const view = new DataView(outBytes.buffer); | |
| // Icon Directory Header | |
| view.setUint16(0, 0, true); // reserved | |
| view.setUint16(2, 1, true); // image type: 1 = ICO | |
| view.setUint16(4, 1, true); // number of images in file (1) | |
| // Icon Directory Entry | |
| outBytes[6] = w; | |
| outBytes[7] = h; | |
| outBytes[8] = 0; // color palette size (0 for truecolor) | |
| outBytes[9] = 0; // reserved | |
| view.setUint16(10, 1, true); // color planes | |
| view.setUint16(12, 32, true); // bits per pixel | |
| view.setUint32(14, pngBytes.length, true); // size of image bytes | |
| view.setUint32(18, headerSize, true); // offset to image bytes | |
| // Paste actual PNG stream | |
| outBytes.set(pngBytes, headerSize); | |
| return new Blob([outBytes.buffer], { type: 'image/x-icon' }); | |
| }, | |
| // PDF Encoder using jsPDF from local lib | |
| async encodePDF(canvas) { | |
| if (!window.jspdf || !window.jspdf.jsPDF) { | |
| throw new Error('Библиотека jsPDF не загружена. Проверьте lib/jspdf.umd.min.js'); | |
| } | |
| const { jsPDF } = window.jspdf; | |
| // Setup document matching canvas aspect ratio | |
| const doc = new jsPDF({ | |
| orientation: canvas.width > canvas.height ? 'l' : 'p', | |
| unit: 'px', | |
| format: [canvas.width, canvas.height] | |
| }); | |
| const imgData = canvas.toDataURL('image/jpeg', 0.95); | |
| doc.addImage(imgData, 'JPEG', 0, 0, canvas.width, canvas.height); | |
| const pdfBlob = doc.output('blob'); | |
| return new Blob([pdfBlob], { type: 'application/pdf' }); | |
| }, | |
| // Get matching extension string | |
| getFormatExtension(fmt) { | |
| const mapping = { | |
| png: 'png', | |
| jpeg: 'jpg', | |
| webp: 'webp', | |
| avif: 'avif', | |
| bmp: 'bmp', | |
| ico: 'ico', | |
| pdf: 'pdf' | |
| }; | |
| return mapping[fmt] || 'bin'; | |
| }, | |
| // Package processed list to JSZip and trigger download | |
| async downloadAllAsZip() { | |
| const finished = this.files.filter(f => f.status === 'done'); | |
| if (finished.length === 0) { | |
| App.showToast('Нет готовых файлов для скачивания', 'warning'); | |
| return; | |
| } | |
| if (!window.JSZip) { | |
| App.showToast('Библиотека JSZip не загружена. Проверьте lib/jszip.min.js', 'error'); | |
| return; | |
| } | |
| App.showToast('Сборка архива ZIP...', 'info'); | |
| const zip = new window.JSZip(); | |
| finished.forEach(f => { | |
| zip.file(f.out.name, f.out.blob); | |
| }); | |
| try { | |
| const zipBlob = await zip.generateAsync({ type: 'blob' }); | |
| const url = URL.createObjectURL(zipBlob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `omniconvert_images_${Date.now()}.zip`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| setTimeout(() => URL.revokeObjectURL(url), 5000); | |
| App.showToast('Архив успешно скачан!', 'success'); | |
| } catch (e) { | |
| App.showToast('Ошибка создания архива: ' + e.message, 'error'); | |
| } | |
| } | |
| }; | |
| // Initialize module | |
| document.addEventListener('DOMContentLoaded', () => { | |
| ImageHub.init(); | |
| }); | |