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
63 changes: 37 additions & 26 deletions js/utils/imageUtils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* eslint-disable no-bitwise */

import { runtime } from './miscUtils.js';

/**
* Loads an image from a given URL and sets it to a specified HTML element.
*
Expand Down Expand Up @@ -51,44 +53,53 @@ const detectImageFormat = (image) => {

/**
*
* @param {File|FileNode|ArrayBuffer} file
* @param {Blob|File|FileNode|ArrayBuffer} file
* @returns {Promise<string>}
*/
export const importImageFileToBase64 = async (file) => new Promise((resolve, reject) => {
export async function importImageFileToBase64(file) {
const isNode = runtime === 'node';

if (file instanceof ArrayBuffer) {
const imageUint8 = new Uint8Array(file);
const format = detectImageFormat(imageUint8);
const binary = String.fromCharCode(...imageUint8);
resolve(`data:image/${format};base64,${btoa(binary)}`);
return;
let b64 = '';
if (isNode) {
b64 = Buffer.from(imageUint8).toString('base64');
} else {
for (let i = 0; i < imageUint8.length; i += 8192) {
const slice = imageUint8.subarray(i, i + 8192);
let bin = '';
for (let j = 0; j < slice.length; j++) bin += String.fromCharCode(slice[j]);
b64 += btoa(bin);
}
}
return `data:image/${format};base64,${b64}`;
}

// The `typeof process` condition is necessary to avoid error in Node.js versions <20, where `File` is not defined.
if (typeof process === 'undefined' && file instanceof File) {
if (typeof FileReader !== 'undefined' && file instanceof Blob) {
const reader = new FileReader();

reader.onloadend = async () => {
resolve(/** @type {string} */(reader.result));
};

reader.onerror = (error) => {
reject(error);
};

reader.readAsDataURL(file);
return;
return await new Promise((resolve, reject) => {
reader.onload = () => resolve(/** @type {string} */ (reader.result));
reader.onerror = (err) => reject(err);
reader.readAsDataURL(/** @type {Blob} */(file));
});
}

if (typeof process !== 'undefined') {
if (!file?.name) reject(new Error('Invalid input. Must be a FileNode or ArrayBuffer.'));
const format = file.name.match(/jpe?g$/i) ? 'jpeg' : 'png';
// @ts-ignore
resolve(`data:image/${format};base64,${file.fileData.toString('base64')}`);
return;
if (isNode) {
const name = file && file.name;
const fileData = file && file.fileData;
if (!name || !fileData) {
throw new Error('Invalid input. Must be a Blob/File, FileNode, or ArrayBuffer.');
}
// Normalize to Uint8Array for format detection
const bytes = fileData instanceof Uint8Array ? fileData : new Uint8Array(fileData);
const format = detectImageFormat(bytes);
const b64 = Buffer.from(bytes).toString('base64');
return `data:image/${format};base64,${b64}`;
}

reject(new Error('Invalid input. Must be a File or ArrayBuffer.'));
});
throw new Error('Invalid input. Must be a Blob/File, FileNode, or ArrayBuffer.');
}

/**
* Converts a base64 encoded string to an array of bytes.
Expand Down
37 changes: 37 additions & 0 deletions js/utils/miscUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -655,3 +655,40 @@ export const cleanFamilyName = (family) => {

return familyClean;
};

/**
* Detect the JavaScript runtime environment.
*
* @returns {('node'|'deno'|'bun'|'browser'|'other')}
*/
function getRuntime() {
// Bun
// eslint-disable-next-line no-undef
if (typeof Bun !== 'undefined' && typeof Bun.version?.bun === 'string') {
return 'bun';
}

// Deno
// eslint-disable-next-line no-undef
if (typeof Deno !== 'undefined' && typeof Deno.version?.deno === 'string') {
return 'deno';
}

// Node.js
if (typeof process !== 'undefined' && typeof process.versions?.node === 'string') {
return 'node';
}

// Browser (or a web worker)
if (
typeof window !== 'undefined' // normal browser
// eslint-disable-next-line no-restricted-globals
|| typeof self !== 'undefined' // Web Workers / Service Workers
) {
return 'browser';
}

return 'other';
}

export const runtime = getRuntime();
84 changes: 84 additions & 0 deletions tests/module/imageUtils.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Relative imports are required to run in browser.
/* eslint-disable import/no-relative-packages */
import { assert, config } from '../../node_modules/chai/chai.js';

// Using arrow functions breaks references to `this`.
/* eslint-disable prefer-arrow-callback */
/* eslint-disable func-names */

import { importImageFileToBase64, base64ToBytes } from '../../js/utils/imageUtils.js';

config.truncateThreshold = 0; // Disable truncation for actual/expected values on assertion failure.

// Helper: create minimal byte arrays for testing format detection
function makeJpegBytes() {
/* eslint-disable-next-line max-len */
return new Uint8Array([0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x02, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x06, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08, 0x06, 0x06, 0x05, 0x06, 0x09, 0x08, 0x0A, 0x0A, 0x09, 0x08, 0x09, 0x09, 0x0A, 0x0C, 0x0F, 0x0C, 0x0A, 0x0B, 0x0E, 0x0B, 0x09, 0x09, 0x0D, 0x11, 0x0D, 0x0E, 0x0F, 0x10, 0x10, 0x11, 0x10, 0x0A, 0x0C, 0x12, 0x13, 0x12, 0x10, 0x13, 0x0F, 0x10, 0x10, 0x10, 0xFF, 0xC9, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xCC, 0x00, 0x06, 0x00, 0x10, 0x10, 0x05, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0xD2, 0xCF, 0x20, 0xFF, 0xD9]);
}

function makePngBytes() {
/* eslint-disable-next-line max-len */
return new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x37, 0x6E, 0xF9, 0x24, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x01, 0x63, 0x60, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x73, 0x75, 0x01, 0x18, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]);
}

describe('importImageFileToBase64', function () {
this.timeout(10000);

it('converts JPEG ArrayBuffer to a data URL with image/jpeg prefix', async () => {
const bytes = makeJpegBytes();
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const dataUrl = await importImageFileToBase64(ab);
assert.isTrue(dataUrl.startsWith('data:image/jpeg;base64,'), 'Should have jpeg data URL prefix');

// Verify base64 decodes back to original bytes
const decoded = base64ToBytes(dataUrl);
assert.strictEqual(decoded.length, bytes.length);
for (let i = 0; i < bytes.length; i++) {
assert.strictEqual(decoded[i], bytes[i], `Byte mismatch at index ${i}`);
}
}).timeout(5000);

it('converts PNG ArrayBuffer to a data URL with image/png prefix', async () => {
const bytes = makePngBytes();
const ab = bytes.buffer;
const dataUrl = await importImageFileToBase64(ab);
assert.isTrue(dataUrl.startsWith('data:image/png;base64,'), 'Should have png data URL prefix');

const decoded = base64ToBytes(dataUrl);
assert.strictEqual(decoded.length, bytes.length);
for (let i = 0; i < bytes.length; i++) {
assert.strictEqual(decoded[i], bytes[i], `Byte mismatch at index ${i}`);
}
}).timeout(5000);

it('reads browser File via FileReader and returns a data URL with correct mime', async function () {
// Skip if File API is not available (e.g., running in Node)
if (typeof FileReader === 'undefined') {
this.skip();
return;
}
const bytes = makePngBytes();
const blob = new Blob([bytes], { type: 'image/png' });
const file = new File([blob], 'sample.png', { type: 'image/png' });

const dataUrl = await importImageFileToBase64(file);
assert.isTrue(dataUrl.startsWith('data:image/png;base64,'), 'Should have png data URL prefix');

const decoded = base64ToBytes(dataUrl);
assert.strictEqual(decoded.length, bytes.length);
for (let i = 0; i < bytes.length; i++) {
assert.strictEqual(decoded[i], bytes[i], `Byte mismatch at index ${i}`);
}
}).timeout(5000);

it('rejects on invalid input with helpful error message', async () => {
let caught = null;
try {
await importImageFileToBase64({});
} catch (e) {
caught = e;
}
assert.isNotNull(caught, 'Error should be thrown');
assert.match(String(caught?.message || caught), /Invalid input/i);
}).timeout(5000);
});
Loading