From bcb7dde79b4a02a3502b22722d099d5c41442b6b Mon Sep 17 00:00:00 2001 From: timstone Date: Mon, 22 Sep 2025 14:14:49 +0100 Subject: [PATCH 1/3] Change ArrayBuffer handling in importImageFileToBase64 to avoid 'maximum call stack exceeded' error. --- js/utils/imageUtils.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/js/utils/imageUtils.js b/js/utils/imageUtils.js index 4f6fdb7..38f00f7 100644 --- a/js/utils/imageUtils.js +++ b/js/utils/imageUtils.js @@ -56,9 +56,8 @@ const detectImageFormat = (image) => { */ export const importImageFileToBase64 = async (file) => new Promise((resolve, reject) => { if (file instanceof ArrayBuffer) { - const imageUint8 = new Uint8Array(file); - const format = detectImageFormat(imageUint8); - const binary = String.fromCharCode(...imageUint8); + const format = detectImageFormat(file.subarray(0, 64)); + const binary = file.toString('latin1'); resolve(`data:image/${format};base64,${btoa(binary)}`); return; } From fef4414ff1174b91a5d4e54125e79e06f4d7645a Mon Sep 17 00:00:00 2001 From: timstone Date: Mon, 22 Sep 2025 14:36:56 +0100 Subject: [PATCH 2/3] Change subarray --- js/utils/imageUtils.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/utils/imageUtils.js b/js/utils/imageUtils.js index 38f00f7..49c34d2 100644 --- a/js/utils/imageUtils.js +++ b/js/utils/imageUtils.js @@ -56,7 +56,8 @@ const detectImageFormat = (image) => { */ export const importImageFileToBase64 = async (file) => new Promise((resolve, reject) => { if (file instanceof ArrayBuffer) { - const format = detectImageFormat(file.subarray(0, 64)); + const imageUint8 = new Uint8Array(file); + const format = detectImageFormat(imageUint8); const binary = file.toString('latin1'); resolve(`data:image/${format};base64,${btoa(binary)}`); return; From fbb57396ee44b8bd8226d0553563e0cd66d936dc Mon Sep 17 00:00:00 2001 From: timstone Date: Wed, 24 Sep 2025 11:46:32 +0100 Subject: [PATCH 3/3] =?UTF-8?q?Add=20runtime=E2=80=91aware=20`importImageF?= =?UTF-8?q?ileToBase64`=20handling=20for=20`Blob`/`File`=20and=20Node=20`F?= =?UTF-8?q?ileNode`,=20with=20added=20tests=20and=20`runtime`=20export.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/utils/imageUtils.js | 63 +++++++++++++++---------- js/utils/miscUtils.js | 37 +++++++++++++++ tests/module/imageUtils.spec.js | 84 +++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 26 deletions(-) create mode 100644 tests/module/imageUtils.spec.js diff --git a/js/utils/imageUtils.js b/js/utils/imageUtils.js index 49c34d2..350bab4 100644 --- a/js/utils/imageUtils.js +++ b/js/utils/imageUtils.js @@ -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. * @@ -51,44 +53,53 @@ const detectImageFormat = (image) => { /** * - * @param {File|FileNode|ArrayBuffer} file + * @param {Blob|File|FileNode|ArrayBuffer} file * @returns {Promise} */ -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 = file.toString('latin1'); - 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. diff --git a/js/utils/miscUtils.js b/js/utils/miscUtils.js index 432f4df..8409888 100644 --- a/js/utils/miscUtils.js +++ b/js/utils/miscUtils.js @@ -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(); diff --git a/tests/module/imageUtils.spec.js b/tests/module/imageUtils.spec.js new file mode 100644 index 0000000..10e60ab --- /dev/null +++ b/tests/module/imageUtils.spec.js @@ -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); +});