omniconvert / image-processor.js
smmisha's picture
feat: complete omni-converter app with trilingual support (UA/RU/EN), offline libraries, image/data/audio tools, pdf tools, crypto & qr/svg optimizer
befbf55
Raw History Blame Contribute Delete
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();
});