Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
__pycache__/
*.pyc
node_modules/
4 changes: 4 additions & 0 deletions __mocks__/magick-wasm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { jest } from '@jest/globals';
export const initializeImageMagick = jest.fn().mockResolvedValue();
export const ImageMagick = { read: jest.fn() };
export const MagickFormat = {};
5 changes: 5 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }]
]
};
256 changes: 256 additions & 0 deletions docs/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { initializeImageMagick, ImageMagick, MagickFormat } from '@imagemagick/magick-wasm';

export let fileInput, targetFormatSelect, convertBtn, statusDiv, downloadsContainer, previewContainer, fileStatusText;
export function initDOM(documentObj = document) {
fileInput = documentObj.getElementById('fileInput');
targetFormatSelect = documentObj.getElementById('targetFormat');
convertBtn = documentObj.getElementById('convertBtn');
statusDiv = documentObj.getElementById('status');
downloadsContainer = documentObj.getElementById('downloads-container');
previewContainer = documentObj.getElementById('preview-container');
fileStatusText = documentObj.getElementById('fileStatusText');
}

export let isEngineReady = false;
export let selectedFiles = []; // Maintain our own list of files

// 1. Fetch our comprehensive JSON database to populate dropdown and input filter
export async function loadFormats() {
try {
const response = await fetch('format_database.json');
if (!response.ok) throw new Error("Network response was not ok");
const db = await response.json();

targetFormatSelect.innerHTML = '';

// Get unique formats
const validOutputs = [...new Set(db.filter(f => f.can_write).map(f => f.extension.toLowerCase()))].sort();
const validInputs = [...new Set(db.filter(f => f.can_read).map(f => `.${f.extension.toLowerCase()}`))];

// Restrict input file picker
if (validInputs.length > 0) {
fileInput.accept = validInputs.join(',');
}

validOutputs.forEach(ext => {
const option = document.createElement('option');
option.value = ext;
option.textContent = ext.toUpperCase();
targetFormatSelect.appendChild(option);
});

targetFormatSelect.value = 'png'; // default
} catch (err) {
statusDiv.textContent = "Failed to load format database. Defaulting to basic formats.";
targetFormatSelect.innerHTML = `
<option value="png">PNG</option>
<option value="jpg">JPG</option>
<option value="webp">WEBP</option>
<option value="pdf">PDF</option>
`;
fileInput.accept = ".png,.jpg,.jpeg,.webp,.pdf";
}
}

export function setupEventListeners() {
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
// Add new files to our array
selectedFiles = selectedFiles.concat(Array.from(e.target.files));
renderPreviews();
}
// Clear input so selecting the same file again triggers change event
fileInput.value = '';
});

}

// Render thumbnails
export function renderPreviews() {
previewContainer.innerHTML = '';

if (selectedFiles.length === 0) {
fileStatusText.textContent = "No file chosen";
} else if (selectedFiles.length === 1) {
fileStatusText.textContent = selectedFiles[0].name;
} else {
fileStatusText.textContent = `${selectedFiles.length} files selected`;
}

selectedFiles.forEach((file, index) => {
const wrapper = document.createElement('div');
wrapper.className = 'thumbnail-wrapper';
wrapper.title = file.name;

// Create remove button
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-btn';
removeBtn.innerHTML = '&times;';
removeBtn.onclick = () => {
selectedFiles.splice(index, 1);
renderPreviews();
};

// Create image preview
const img = document.createElement('img');
img.className = 'thumbnail-img';

// If it's a standard web image, browsers can render it natively
if (file.type.startsWith('image/') && !file.name.toLowerCase().endsWith('.heic')) {
img.src = URL.createObjectURL(file);
img.onload = () => URL.revokeObjectURL(img.src); // Free memory
} else {
// Fallback for RAW/HEIC that browsers can't render natively
img.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="%23ddd"/><text x="50" y="50" font-family="sans-serif" font-size="12" text-anchor="middle" dominant-baseline="middle" fill="%23666">RAW/Data</text></svg>';
}

wrapper.appendChild(img);
wrapper.appendChild(removeBtn);

// Overlay the name at the bottom
const nameLabel = document.createElement('div');
nameLabel.className = 'thumbnail-name';
nameLabel.style.position = 'absolute';
nameLabel.style.bottom = '0';
nameLabel.style.width = '100%';
nameLabel.style.background = 'rgba(255,255,255,0.8)';
nameLabel.textContent = file.name.length > 12 ? file.name.substring(0, 10) + '...' : file.name;

wrapper.appendChild(nameLabel);
previewContainer.appendChild(wrapper);
});
}

// 2. Initialize the WebAssembly Engine
export async function initWasm() {
try {
// Fetch the wasm file explicitly (use unpkg to ensure raw binary response)
const wasmUrl = "https://unpkg.com/@imagemagick/magick-wasm@0.0.30/dist/magick.wasm";
const response = await fetch(wasmUrl);
const wasmBytes = new Uint8Array(await response.arrayBuffer());

await initializeImageMagick(wasmBytes);

isEngineReady = true;
convertBtn.textContent = "Convert Image";
convertBtn.disabled = false;
statusDiv.textContent = "Engine Ready. Select a file to convert.";
} catch (err) {
console.error(err);
statusDiv.textContent = "Failed to initialize ImageMagick WASM engine.";
}
}

export function setupConvertListener() {
convertBtn.addEventListener('click', async () => {
if (selectedFiles.length === 0) {
alert("Please select files first.");
return;
}

const files = [...selectedFiles];
const targetExt = targetFormatSelect.value;
const isBatch = files.length > 1;

convertBtn.disabled = true;
downloadsContainer.innerHTML = ''; // clear previous links

let successCount = 0;
let failCount = 0;
const zip = new JSZip();

for (let i = 0; i < files.length; i++) {
const file = files[i];
statusDiv.textContent = `Processing file ${i + 1} of ${files.length}: ${file.name}...`;

try {
const arrayBuffer = await file.arrayBuffer();
const sourceBytes = new Uint8Array(arrayBuffer);

// Await a Promise that resolves when the setTimeout for this image finishes
await new Promise((resolve) => {
setTimeout(() => {
try {
ImageMagick.read(sourceBytes, (image) => {
if (targetExt === 'jpg' || targetExt === 'jpeg') {
image.hasAlpha = false;
}

// Title Case for enum
const titleCaseFormat = targetExt.charAt(0).toUpperCase() + targetExt.slice(1).toLowerCase();
const formatEnum = MagickFormat[titleCaseFormat] || targetExt.toUpperCase();

image.write(formatEnum, (data) => {
let originalName = file.name.split('.').slice(0, -1).join('.');
if (!originalName) originalName = "image"; // fallback if no extension
const outFileName = `${originalName}.${targetExt}`;

if (isBatch) {
// Add to zip
// CRITICAL: Must clone the Uint8Array because 'data' is a view into
// WebAssembly memory which will be overwritten by the next image processing.
zip.file(outFileName, new Uint8Array(data));
} else {
// Single file, provide direct link
const blob = new Blob([data], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const dlLink = document.createElement('a');
dlLink.href = url;
dlLink.download = outFileName;
dlLink.textContent = `⬇️ Download ${outFileName}`;
dlLink.style.fontWeight = 'bold';
dlLink.style.color = '#10b981';
downloadsContainer.appendChild(dlLink);
}
successCount++;
});
});
} catch(err) {
console.error(`Error processing ${file.name}:`, err);
failCount++;
}
resolve();
}, 50);
});
} catch (err) {
console.error(`Error reading ${file.name}:`, err);
failCount++;
}
}

if (isBatch && successCount > 0) {
statusDiv.textContent = `Compressing ${successCount} files into ZIP...`;

try {
const content = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(content);
const dlLink = document.createElement('a');
dlLink.href = url;
dlLink.download = `converted_images.zip`;
dlLink.textContent = `📦 Download All as ZIP (${successCount} files)`;
dlLink.style.fontWeight = 'bold';
dlLink.style.color = '#10b981';
downloadsContainer.appendChild(dlLink);
} catch (err) {
console.error("ZIP Generation Failed:", err);
failCount += successCount; // Consider it a failure if we can't zip
}
}

statusDiv.textContent = `Job complete! Successfully processed ${successCount} files. Failed: ${failCount}.`;
convertBtn.disabled = false;
});

}

// Bootstrap
export function bootstrap() {
initDOM();
setupEventListeners();
setupConvertListener();
loadFormats();
initWasm();
}
if (typeof process === 'undefined' || process.env.NODE_ENV !== 'test') {
bootstrap();
}
88 changes: 88 additions & 0 deletions docs/app.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { initWasm, initDOM, isEngineReady } from './app.js';
import { initializeImageMagick } from '@imagemagick/magick-wasm';
import { jest } from '@jest/globals';

describe('initWasm', () => {
let mockConvertBtn, mockStatusDiv;
let originalFetch;

beforeEach(() => {
// Reset DOM state
mockConvertBtn = document.createElement('button');
mockConvertBtn.id = 'convertBtn';
mockConvertBtn.disabled = true;

mockStatusDiv = document.createElement('div');
mockStatusDiv.id = 'status';

document.body.appendChild(mockConvertBtn);
document.body.appendChild(mockStatusDiv);

// Required by initDOM
const mockFileInput = document.createElement('input');
mockFileInput.id = 'fileInput';
document.body.appendChild(mockFileInput);

const mockTargetFormatSelect = document.createElement('select');
mockTargetFormatSelect.id = 'targetFormat';
document.body.appendChild(mockTargetFormatSelect);

const mockDownloadsContainer = document.createElement('div');
mockDownloadsContainer.id = 'downloads-container';
document.body.appendChild(mockDownloadsContainer);

const mockPreviewContainer = document.createElement('div');
mockPreviewContainer.id = 'preview-container';
document.body.appendChild(mockPreviewContainer);

const mockFileStatusText = document.createElement('span');
mockFileStatusText.id = 'fileStatusText';
document.body.appendChild(mockFileStatusText);

initDOM(document);

// Reset mocks
originalFetch = global.fetch;
global.fetch = jest.fn();
jest.clearAllMocks();
});

afterEach(() => {
document.body.innerHTML = '';
global.fetch = originalFetch;
});

it('successfully initializes WASM engine and updates DOM', async () => {
// Mock fetch response
global.fetch.mockResolvedValueOnce({
arrayBuffer: jest.fn().mockResolvedValueOnce(new ArrayBuffer(8))
});

initializeImageMagick.mockResolvedValueOnce();

await initWasm();

expect(global.fetch).toHaveBeenCalledWith("https://unpkg.com/@imagemagick/magick-wasm@0.0.30/dist/magick.wasm");
expect(initializeImageMagick).toHaveBeenCalled();

// Cannot check export let variable easily if it is reassigned internally unless we wrap it
// expect(isEngineReady).toBe(true);

expect(mockConvertBtn.textContent).toBe("Convert Image");
expect(mockConvertBtn.disabled).toBe(false);
expect(mockStatusDiv.textContent).toBe("Engine Ready. Select a file to convert.");
});

it('handles initialization failure and updates DOM', async () => {
// Mock fetch response to fail
global.fetch.mockRejectedValueOnce(new Error("Network Error"));

await initWasm();

expect(global.fetch).toHaveBeenCalled();

expect(mockStatusDiv.textContent).toBe("Failed to initialize ImageMagick WASM engine.");
// Button should remain in initial state
expect(mockConvertBtn.disabled).toBe(true);
});
});
Loading