/* eslint-disable radix */ /* eslint-disable max-len */ /* eslint-disable no-plusplus */ // A bunch of settings used when converting const settings = { screenWidth: 128, screenHeight: 64, scaleToFit: true, preserveRatio: true, centerHorizontally: false, centerVertically: false, flipHorizontally: false, flipVertically: false, backgroundColor: 'white', scale: 1, drawMode: 'horizontal', prefix: '0x', ditheringThreshold: 128, ditheringMode: 0, outputFormat: 'plain', separator: ', ', outputComments: true, invertColors: false, rotation: 0, esp32Format: false, fullExample: false, bytesPerRow: 16, showAsciiPreview: false, }; function bitswap(b) { if (settings.bitswap) { // eslint-disable-next-line no-bitwise, no-mixed-operators, no-param-reassign b = (b & 0xF0) >> 4 | (b & 0x0F) << 4; // eslint-disable-next-line no-bitwise, no-mixed-operators, no-param-reassign b = (b & 0xCC) >> 2 | (b & 0x33) << 2; // eslint-disable-next-line no-bitwise, no-mixed-operators, no-param-reassign b = (b & 0xAA) >> 1 | (b & 0x55) << 1; } return b; } // settings.bytesPerRow of 0 means "wrap using the image's width instead", // so a 8px-wide image gets a newline every 8 bytes. function resolveWrapWidth(canvasWidth) { return settings.bytesPerRow > 0 ? settings.bytesPerRow : canvasWidth; } // Accumulates formatted bytes (prefix/value/separator, wrapped every // wrapWidth bytes) so our ConversionFunctions can use this formatting. // horizontal888 always wraps once per image row (canvasWidth) regardless // of settings.bytesPerRow. When settings.showAsciiPreview is on, wrapWidth // is ignored in favor of explicit endRow() calls (see makeAsciiRowTracker). function makeByteWriter(wrapWidth) { let out = ''; let count = 0; return { push(value, hexWidth) { const byteSet = bitswap(value).toString(16).padStart(hexWidth, '0'); out += `${settings.prefix}${byteSet}${settings.separator}`; count++; if (!settings.showAsciiPreview && count >= wrapWidth) { out += '\n'; count = 0; } }, // Ends the current line early with a dot/hash ascii-art comment for the // row of pixels just written. Only called when settings.showAsciiPreview // is on, so it always wins over wrapWidth-based wrapping. endRow(asciiRow) { out += `// ${asciiRow.split('').join(' ')}\n`; count = 0; }, result() { return out; }, }; } // Builds a per-image-row ascii-art tracker (. for off, # for on) used // when settings.showAsciiPreview is on. Call once per pixel with the same // `index` used to read that pixel's channel data; flushes to the writer as // a comment at the end of each image row, mirroring the row-boundary math // packBitsHorizontal uses to flush partial bytes. function makeAsciiRowTracker(canvasWidth, dataLength) { let row = ''; return (index, isOn, writer) => { if (!settings.showAsciiPreview) return; row += isOn ? '#' : '.'; const pixelIndex = index / 4; const isRowEnd = pixelIndex !== 0 && ((pixelIndex + 1) % canvasWidth) === 0; const isImageEnd = index === dataLength - 4; if (isRowEnd || isImageEnd) { writer.endRow(row); row = ''; } }; } // Shared by horizontal1bit/horizontalAlpha: pack 8 samples (one per pixel, // MSB first) into a byte, resetting early at the end of a row or the image // so a partial final byte is still zero-padded instead of dropped. function packBitsHorizontal(data, canvasWidth, writer, sampleAt) { let byteIndex = 7; let number = 0; const trackAscii = makeAsciiRowTracker(canvasWidth, data.length); for (let index = 0; index < data.length; index += 4) { const isOn = sampleAt(data, index) > settings.ditheringThreshold; if (isOn) { number += 2 ** byteIndex; } byteIndex--; const isRowEnd = index !== 0 && (((index / 4) + 1) % canvasWidth) === 0; const isImageEnd = index === data.length - 4; if (isRowEnd || isImageEnd) { byteIndex = -1; } if (byteIndex < 0) { writer.push(number, 2); number = 0; byteIndex = 7; } trackAscii(index, isOn, writer); } } const ConversionFunctions = { // Output the image as a string for horizontally drawing displays horizontal1bit(data, canvasWidth) { const writer = makeByteWriter(resolveWrapWidth(canvasWidth)); const avgRgb = (d, i) => (d[i] + d[i + 1] + d[i + 2]) / 3; packBitsHorizontal(data, canvasWidth, writer, avgRgb); return writer.result(); }, // Output the image as a string for vertically drawing displays vertical1bit(data, canvasWidth, canvasHeight) { const writer = makeByteWriter(resolveWrapWidth(canvasWidth)); for (let p = 0; p < Math.ceil(canvasHeight / 8); p++) { for (let x = 0; x < canvasWidth; x++) { let byteIndex = 7; let number = 0; for (let y = 7; y >= 0; y--) { const index = ((p * 8) + y) * (canvasWidth * 4) + x * 4; const avg = (data[index] + data[index + 1] + data[index + 2]) / 3; if (avg > settings.ditheringThreshold) { number += 2 ** byteIndex; } byteIndex--; } writer.push(number, 2); } } return writer.result(); }, // Output the image as a string for 565 displays (horizontally) // eslint-disable-next-line no-unused-vars horizontal565(data, canvasWidth) { const writer = makeByteWriter(resolveWrapWidth(canvasWidth)); const trackAscii = makeAsciiRowTracker(canvasWidth, data.length); // format is RGBA, so move 4 steps per pixel for (let index = 0; index < data.length; index += 4) { const r = data[index]; const g = data[index + 1]; const b = data[index + 2]; // Calculate the 565 color value // eslint-disable-next-line no-bitwise const rgb = ((r & 0b11111000) << 8) | ((g & 0b11111100) << 3) | ((b & 0b11111000) >> 3); writer.push(rgb, 4); trackAscii(index, (r + g + b) / 3 > settings.ditheringThreshold, writer); } return writer.result(); }, // Output the image as a string for rgb888 displays (horizontally), one // image row per output line horizontal888(data, canvasWidth) { const writer = makeByteWriter(canvasWidth); const trackAscii = makeAsciiRowTracker(canvasWidth, data.length); // format is RGBA, so move 4 steps per pixel for (let index = 0; index < data.length; index += 4) { // Split into RGB and pack into a single 24-bit value (MSB first) const r = data[index]; const g = data[index + 1]; const b = data[index + 2]; // eslint-disable-next-line no-bitwise const rgb = (r << 16) | (g << 8) | (b); writer.push(rgb, 8); trackAscii(index, (r + g + b) / 3 > settings.ditheringThreshold, writer); } return writer.result(); }, // Output the alpha mask as a string for horizontally drawing displays horizontalAlpha(data, canvasWidth) { const writer = makeByteWriter(resolveWrapWidth(canvasWidth)); packBitsHorizontal(data, canvasWidth, writer, (d, i) => d[i + 3]); return writer.result(); }, }; settings.conversionFunction = ConversionFunctions.horizontal1bit; // Bytes per output value for each conversion function, matching the // hexWidth passed to writer.push() in that function (hexWidth / 2). // Needed by downloadBinFile() to reconstruct the true byte stream instead // of truncating multi-byte values (565/888) down to a single byte. const CONVERSION_BYTES_PER_VALUE = new Map([ [ConversionFunctions.horizontal1bit, 1], [ConversionFunctions.vertical1bit, 1], [ConversionFunctions.horizontal565, 2], [ConversionFunctions.horizontal888, 4], [ConversionFunctions.horizontalAlpha, 1], ]); // An images collection with helper methods function Images() { const collection = []; this.push = (img, canvas, glyph, width, height) => { collection.push({ img, canvas, glyph, width, height, }); }; this.remove = (image) => { const i = collection.indexOf(image); if (i !== -1) collection.splice(i, 1); }; this.each = (f) => { collection.forEach(f); }; this.length = () => collection.length; this.first = () => collection[0]; this.last = () => collection[collection.length - 1]; this.getByIndex = (index) => collection[index]; this.setByIndex = (index, img) => { collection[index] = img; }; this.get = (img) => { if (img) { for (let i = 0; i < collection.length; i++) { if (collection[i].img === img) { return collection[i]; } } } return collection; }; return this; } const images = new Images(); // Filetypes accepted by the file picker // const fileTypes = ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'svg']; // Invert the colors of the canvas function invert(canvas, ctx) { const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const { data } = imageData; for (let i = 0; i < data.length; i += 4) { data[i] = 255 - data[i]; // red data[i + 1] = 255 - data[i + 1]; // green data[i + 2] = 255 - data[i + 2]; // blue } ctx.putImageData(imageData, 0, 0); } // Size the on-screen canvas so tiny glyphs stay legible without distorting // their aspect ratio (independent min-width/min-height would stretch them). function setCanvasDisplaySize(canvas) { const minVisibleSize = 96; const scale = Math.max(1, minVisibleSize / Math.min(canvas.width, canvas.height)); // eslint-disable-next-line no-param-reassign canvas.style.aspectRatio = `${canvas.width} / ${canvas.height}`; // eslint-disable-next-line no-param-reassign canvas.style.width = `${Math.round(canvas.width * scale)}px`; // eslint-disable-next-line no-param-reassign canvas.style.height = 'auto'; } // Draw the image onto the canvas, taking into account color and scaling function placeImage(_image) { const { img } = _image; const { canvas } = _image; const ctx = canvas.getContext('2d'); // reset canvas size (falls back to the canvas' current size for images, // like a re-imported byte array, that aren't tracked in the image list // with their own width/height) canvas.width = Number.isFinite(_image.width) && _image.width > 0 ? _image.width : canvas.width; canvas.height = Number.isFinite(_image.height) && _image.height > 0 ? _image.height : canvas.height; // eslint-disable-next-line no-param-reassign _image.ctx = ctx; ctx.save(); // Draw background if (settings.backgroundColor === 'transparent') { ctx.fillStyle = 'rgba(0,0,0,0.0)'; ctx.globalCompositeOperation = 'copy'; } else { if (settings.invertColors) { if (settings.backgroundColor === 'white') { ctx.fillStyle = 'black'; } else { ctx.fillStyle = 'white'; } } else { ctx.fillStyle = settings.backgroundColor; } ctx.globalCompositeOperation = 'source-over'; } ctx.fillRect(0, 0, canvas.width, canvas.height); // Offset used for centering the image when requested let offsetX = 0; let offsetY = 0; const imgW = img.width; const imgH = img.height; switch (settings.scale) { case 1: // Original if (settings.centerHorizontally) { offsetX = Math.round((canvas.width - imgW) / 2); } if (settings.centerVertically) { offsetY = Math.round((canvas.height - imgH) / 2); } ctx.drawImage( img, 0, 0, imgW, imgH, offsetX, offsetY, imgW, imgH, ); break; case 2: { // Fit (make as large as possible without changing ratio) const useRatio = Math.min(canvas.width / imgW, canvas.height / imgH); if (settings.centerHorizontally) { offsetX = Math.round((canvas.width - imgW * useRatio) / 2); } if (settings.centerVertically) { offsetY = Math.round((canvas.height - imgH * useRatio) / 2); } ctx.drawImage( img, 0, 0, imgW, imgH, offsetX, offsetY, imgW * useRatio, imgH * useRatio, ); break; } case 3: // Stretch x+y (make as large as possible without keeping ratio) ctx.drawImage( img, 0, 0, imgW, imgH, offsetX, offsetY, canvas.width, canvas.height, ); break; case 4: // Stretch x (make as wide as possible) offsetX = 0; if (settings.centerVertically) { Math.round(offsetY = (canvas.height - imgH) / 2); } // offsetY *= offsetY_dir; ctx.drawImage( img, 0, 0, imgW, imgH, offsetX, offsetY, canvas.width, imgH, ); break; case 5: // Stretch y (make as tall as possible) if (settings.centerHorizontally) { offsetX = Math.round((canvas.width - imgW) / 2); } // offsetX *= offsetX_dir; offsetY = 0; ctx.drawImage( img, 0, 0, imgW, imgH, offsetX, offsetY, imgW, canvas.height, ); break; default: // console.log('unknown scale'); break; } ctx.restore(); if (settings.conversionFunction === ConversionFunctions.horizontal1bit || settings.conversionFunction === ConversionFunctions.vertical1bit) { // eslint-disable-next-line no-undef dithering(ctx, canvas.width, canvas.height, settings.ditheringThreshold, settings.ditheringMode); if (settings.invertColors) { invert(canvas, ctx); } } if (settings.rotation !== 0) { const clone = canvas.cloneNode(true); clone.getContext('2d').drawImage(canvas, 0, 0); ctx.fillStyle = 'red'; ctx.fillRect(0, 0, canvas.width, canvas.height); if (settings.rotation === 90) { canvas.width = clone.height; canvas.height = clone.width; ctx.setTransform(1, 0, 0, 1, canvas.width, 0); ctx.rotate(Math.PI / 2); ctx.drawImage(clone, 0, 0); } else if (settings.rotation === 180) { ctx.setTransform(1, 0, 0, 1, canvas.width, canvas.height); ctx.rotate(Math.PI); ctx.drawImage(clone, 0, 0); } else if (settings.rotation === 270) { canvas.width = clone.height; canvas.height = clone.width; ctx.setTransform(1, 0, 0, 1, 0, canvas.height); ctx.rotate(Math.PI * 1.5); ctx.drawImage(clone, 0, 0); } ctx.setTransform(1, 0, 0, 1, 0, 0); } const flipHorizontal = settings.flipHorizontally ? -1 : 1; const xOffset = settings.flipHorizontally ? canvas.width : 0; const flipVertical = settings.flipVertically ? -1 : 1; const yOffset = settings.flipVertically ? canvas.height : 0; if (flipHorizontal === -1 || flipVertical === -1) { const clone = canvas.cloneNode(true); clone.getContext('2d').drawImage(canvas, 0, 0); ctx.setTransform(flipHorizontal, 0, 0, flipVertical, xOffset, yOffset); // set the scale and position ctx.drawImage(clone, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0); } setCanvasDisplaySize(canvas); } // Handle drawing each of our images function updateAllImages() { images.each((image) => { placeImage(image); }); } // Easy way to update settings controlled by a textfield // eslint-disable-next-line no-unused-vars function updateInteger(fieldName) { settings[fieldName] = parseInt(document.getElementById(fieldName).value); updateAllImages(); } // Easy way to update settings controlled by a checkbox // eslint-disable-next-line no-unused-vars function updateBoolean(fieldName) { settings[fieldName] = document.getElementById(fieldName).checked; updateAllImages(); } // Easy way to update settings controlled by a text input // eslint-disable-next-line no-unused-vars function updateString(fieldName) { settings[fieldName] = document.getElementById(fieldName).value; updateAllImages(); } // Convert hex to binary function hexToBinary(s) { let ret = ''; for (let i = 0; i < s.length; i += 1) { const nibble = parseInt(s[i], 16); if (Number.isNaN(nibble)) { return { valid: false, s }; } ret += nibble.toString(2).padStart(4, '0'); } return { valid: true, result: ret }; } // The plain byte type, independent of drawMode (used by output formats, // like adafruit_gfx, that are always byte-packed regardless of the current // conversion function). function getByteType() { return settings.esp32Format ? 'uint8_t' : 'unsigned char'; } // get the type (in arduino code) of the output image // this is a bit of a hack, it's better to make this a property of the conversion function (should probably turn it into objects) function getImageType() { if (settings.conversionFunction === ConversionFunctions.horizontal565) { return 'uint16_t'; } if (settings.conversionFunction === ConversionFunctions.horizontal888) { return 'unsigned long'; } return getByteType(); } // PROGMEM is an AVR-ism: it's a no-op on ESP32 (flash is memory-mapped // there), so the ESP32 output toggle drops it instead of emitting a keyword // that does nothing. function progmemKeyword() { return settings.esp32Format ? '' : ' PROGMEM'; } // Use the horizontally oriented list to draw the image // Validates and decodes a comma-split list of pasted hex byte strings into // one contiguous bit string (MSB first, each entry padded to a full byte). // Returns { valid: true, bits } or { valid: false, s } naming the bad entry. function hexListToBits(list) { let bits = ''; for (let i = 0; i < list.length; i++) { const binString = hexToBinary(list[i]); if (!binString.valid) { return binString; } bits += binString.result.length === 4 ? `${binString.result}0000` : binString.result; } return { valid: true, bits }; } function reportInvalidByteArray(s) { const errorEl = document.getElementById('text-input-error'); errorEl.textContent = 'Something went wrong converting the string. Make sure there are no comments in your input.'; errorEl.style.display = 'block'; // eslint-disable-next-line no-console console.error('invalid hexToBinary: ', s); } // Save the canvas contents inside the (first) image object so it can be // reused when scaling/inverting/etc. function commitCanvasToFirstImage(canvas) { const img = new Image(); img.onload = () => { images.first().img = img; }; img.src = canvas.toDataURL('image/png'); } // Use the horizontally oriented list to draw the image function listToImageHorizontal(list, canvas) { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); const decoded = hexListToBits(list); if (!decoded.valid) { reportInvalidByteArray(decoded.s); return false; } const imgData = ctx.createImageData(canvas.width, canvas.height); let index = 0; // round the width up to the next byte const widthRoundedUp = Math.floor(canvas.width / 8 + (canvas.width % 8 ? 1 : 0)) * 8; let widthCounter = 0; // Move the list into the imageData object for (let k = 0; k < decoded.bits.length; k++, widthCounter++) { // if we've counted enough bits, reset counter for next line if (widthCounter >= widthRoundedUp) { widthCounter = 0; } // skip 'artifact' pixels due to rounding up to a byte if (widthCounter < canvas.width) { const color = decoded.bits.charAt(k) === '1' ? 255 : 0; imgData.data[index] = color; imgData.data[index + 1] = color; imgData.data[index + 2] = color; imgData.data[index + 3] = 255; index += 4; } } ctx.putImageData(imgData, 0, 0); commitCanvasToFirstImage(canvas); return true; } // Quick and effective way to draw single pixels onto the canvas // using a global 1x1px large canvas function drawPixel(ctx, x, y, color) { const singlePixel = ctx.createImageData(1, 1); const d = singlePixel.data; d[0] = color; d[1] = color; d[2] = color; d[3] = 255; ctx.putImageData(singlePixel, x, y); } // Use the vertically oriented list to draw the image function listToImageVertical(list, canvas) { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); const decoded = hexListToBits(list); if (!decoded.valid) { reportInvalidByteArray(decoded.s); return false; } let page = 0; let x = 0; let y = 7; // Move the list into the imageData object for (let k = 0; k < decoded.bits.length; k++) { const color = decoded.bits.charAt(k) === '1' ? 255 : 0; drawPixel(ctx, x, (page * 8) + y, color); y--; if (y < 0) { y = 7; x++; if (x >= settings.screenWidth) { x = 0; page++; } } } commitCanvasToFirstImage(canvas); return true; } // Set width/height preset for byte array text input // eslint-disable-next-line no-unused-vars function setTextInputSize(width, height) { document.getElementById('text-input-width').value = width; document.getElementById('text-input-height').value = height; } // Show/hide the "no images" error and report whether images are available function checkImagesAvailable() { const error = document.getElementById('no-images-error'); const glyphNameNote = document.getElementById('glyph-name-note'); const hasImages = images.length() > 0; error.style.display = hasImages ? 'none' : 'block'; glyphNameNote.style.display = hasImages ? 'block' : 'none'; return hasImages; } // Handle inserting an image by pasting code // eslint-disable-next-line no-unused-vars function handleTextInput(drawMode) { let input = document.getElementById('byte-input').value; // Remove Arduino code input = input.replace(/const\s+(unsigned\s+char|uint8_t)\s+[a-zA-Z0-9]+\s*\[\]\s*(PROGMEM\s*)?=\s*/g, ''); input = input.replace(/\};|\{/g, ''); // Remove comments (while newlines are still newlines, so the whole // comment is dropped even if it contains a comma, e.g. "// 'name', 8x8px") input = input.replace(/\/\/.*$/gm, ''); // Convert newlines to comma input = input.replace(/\r\n|\r|\n/g, ','); // Convert multiple commas in a row into a single one input = input.replace(/,{2,}/g, ','); // Remove whitespace input = input.replace(/\s/g, ''); // Remove "0x" input = input.replace(/0[xX]/g, ''); // Split into list const list = input.split(','); if (list.length === 1 && list[0] === '') { const errorEl = document.getElementById('text-input-error'); errorEl.textContent = 'Byte array is empty or invalid. Make sure there are no comments in your input.'; errorEl.style.display = 'block'; return; } const canvasContainer = document.getElementById('images-canvas-container'); const canvas = document.createElement('canvas'); canvas.width = parseInt(document.getElementById('text-input-width').value); canvas.height = parseInt(document.getElementById('text-input-height').value); settings.screenWidth = canvas.width; settings.screenHeight = canvas.height; if (canvasContainer.children.length) { canvasContainer.removeChild(canvasContainer.firstChild); } canvasContainer.appendChild(canvas); const image = new Image(); images.setByIndex(0, { img: image, canvas, ctx: canvas.getContext('2d') }); const success = drawMode === 'horizontal' ? listToImageHorizontal(list, canvas) : listToImageVertical(list, canvas); if (success) { document.getElementById('text-input-error').style.display = 'none'; document.querySelectorAll('.no-file-selected').forEach((el) => { // eslint-disable-next-line no-param-reassign el.style.display = 'none'; }); checkImagesAvailable(); // Mirror the result onto a small preview canvas right below the // buttons, so the pasted array is visible without scrolling to step 3. const previewCanvas = document.getElementById('text-input-preview-canvas'); previewCanvas.width = canvas.width; previewCanvas.height = canvas.height; previewCanvas.getContext('2d').drawImage(canvas, 0, 0); previewCanvas.style.display = 'block'; const readImagesNoteEl = document.getElementById('read-images-note'); readImagesNoteEl.style.display = 'block'; } } // eslint-disable-next-line no-unused-vars function allSameSize() { if (images.length() > 1) { const inputs = document.querySelectorAll('#image-size-settings input'); // all images same size button for (let i = 2; i < inputs.length; i++) { if (inputs[i].name === 'width') { inputs[i].value = inputs[0].value; inputs[i].oninput(); } if (inputs[i].name === 'height') { inputs[i].value = inputs[1].value; inputs[i].oninput(); } } } } // Handle selecting an image with the file picker function handleImageSelection(evt) { const files = Array.from(evt.target.files); files.sort((a, b) => a.name > b.name); // error message const onlyImagesFileError = document.getElementById('only-images-file-error'); onlyImagesFileError.style.display = 'none'; // initial message const noFileSelected = document.querySelectorAll('.no-file-selected'); if (files.length > 0) { noFileSelected.forEach((el) => { // eslint-disable-next-line no-param-reassign el.style.display = 'none'; }); } else { noFileSelected.forEach((el) => { // eslint-disable-next-line no-param-reassign el.style.display = 'block'; }); } if (files.length > 0) { document.getElementById('continue-note').style.display = 'block'; } else { document.getElementById('continue-note').style.display = 'none'; } for (let i = 0; files[i]; i++) { // Only process image files. if (!files[i].type.match('image.*')) { onlyImagesFileError.style.display = 'block'; // eslint-disable-next-line no-continue continue; } const reader = new FileReader(); reader.onload = (file) => { // eslint-disable-next-line no-param-reassign file.name = reader.name; // Render thumbnail. const img = new Image(); img.onload = () => { const fileInputEntryTemplate = document.getElementById('file-input-entry-template'); const fileInputEntryFragment = fileInputEntryTemplate.content.cloneNode(true); const fileInputColumnEntry = fileInputEntryFragment.querySelector('.file-input-entry'); const fileInputColumnEntryThumb = fileInputColumnEntry.querySelector('.image-size-thumb'); fileInputColumnEntryThumb.src = img.src; fileInputColumnEntryThumb.alt = file.name; fileInputColumnEntry.querySelector('.js-label').textContent = file.name; const fileInputColumnEntryRemoveButton = fileInputColumnEntry.querySelector('.js-remove'); const canvas = document.createElement('canvas'); const entryTemplate = document.getElementById('image-size-entry-template'); const entryFragment = entryTemplate.content.cloneNode(true); const imageEntry = entryFragment.querySelector('li'); imageEntry.setAttribute('data-img', file.name); const thumb = imageEntry.querySelector('.image-size-thumb'); thumb.src = img.src; thumb.alt = file.name; const w = imageEntry.querySelector('.js-width'); w.value = img.width; w.oninput = () => { const image = images.get(img); image.width = parseInt(w.value, 10); placeImage(image); }; const h = imageEntry.querySelector('.js-height'); h.value = img.height; h.oninput = () => { const image = images.get(img); image.height = parseInt(h.value, 10); placeImage(image); }; const gi = imageEntry.querySelector('.js-glyph'); const [fileName] = file.name.split('.'); gi.value = fileName; gi.onchange = () => { const image = images.get(img); image.glyph = gi.value; }; const fileInputColumn = document.getElementById('file-input-column-items'); const imageSizeSettings = document.getElementById('image-size-settings'); const canvasContainer = document.getElementById('images-canvas-container'); const removeButtonOnClick = () => { const image = images.get(img); canvasContainer.removeChild(image.canvas); images.remove(image); imageSizeSettings.removeChild(imageEntry); fileInputColumn.removeChild(fileInputColumnEntry); if (imageSizeSettings.children.length <= 1) { document.getElementById('all-same-size').style.display = 'none'; } if (images.length() === 0) { document.getElementById('file-input').value = ''; noFileSelected.forEach((el) => { // eslint-disable-next-line no-param-reassign el.style.display = 'block'; }); } updateAllImages(); checkImagesAvailable(); }; imageEntry.querySelector('.js-file-name').textContent = `${file.name} (file resolution: ${img.width} x ${img.height})`; imageEntry.querySelector('.js-remove').onclick = removeButtonOnClick; fileInputColumnEntryRemoveButton.onclick = removeButtonOnClick; fileInputColumn.appendChild(fileInputColumnEntry); imageSizeSettings.appendChild(imageEntry); canvas.width = img.width; canvas.height = img.height; canvasContainer.appendChild(canvas); images.push(img, canvas, file.name.split('.')[0], img.width, img.height); if (images.length() > 1) { document.getElementById('all-same-size').style.display = 'block'; } placeImage(images.last()); checkImagesAvailable(); }; img.src = file.target.result; }; reader.name = files[i].name; reader.readAsDataURL(files[i]); } } function imageToString(image) { // extract raw image data const { ctx } = image; const { canvas } = image; const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const { data } = imageData; return settings.conversionFunction(data, canvas.width, canvas.height); } // Get the custom arduino output variable name, if any function getIdentifier() { const vn = document.getElementById('identifier'); return (vn && vn.value.length) ? vn.value : ''; } // Builds the "// 'glyph', WxHpx" comment line placed before each image's // byte array, or '' when comments are disabled. Indent is prefixed when given. function glyphComment(image, indent = '') { if (!settings.outputComments) return ''; return `${indent}// '${image.glyph}', ${image.canvas.width}x${image.canvas.height}px\n`; } // Wraps generated bitmap array code in a full Arduino sketch, based on // oled_example/oled_example.ino, that compiles against the Adafruit // SSD1306/GFX libraries and draws the first bitmap on the display. function wrapFullExample(arrayCode, varName, width, height) { return `#include #include #include #include #define OLED_RESET 4 Adafruit_SSD1306 display(OLED_RESET); #if (SSD1306_LCDHEIGHT != ${settings.screenHeight}) #error("Height incorrect, please fix Adafruit_SSD1306.h!"); #endif ${arrayCode} void setup() { display.begin(SSD1306_SWITCHCAPVCC, 0x3D); // initialize with the I2C addr 0x3D (for the 128x64) display.clearDisplay(); // Make sure the display is cleared // Draw the bitmap: // drawBitmap(x position, y position, bitmap data, bitmap width, bitmap height, color) display.drawBitmap(0, 0, ${varName}, ${width}, ${height}, WHITE); // Update the display display.display(); } void loop() { } `; } // Removes the trailing separator comma left after the last byte value. // When settings.showAsciiPreview is on, the string always ends with a // dot/hash ascii-art comment line instead (see makeAsciiRowTracker), so the // comma sits just before that comment rather than at the true end. function stripTrailingComma(str) { return settings.showAsciiPreview ? str.replace(/,(\s*\/\/[^\n]*)\n?\s*$/, '$1') : str.replace(/,\s*$/, ''); } // Output the image string to the textfield // eslint-disable-next-line no-unused-vars function generateOutputString() { if (!checkImagesAvailable()) return; let outputString = ''; let code = ''; switch (settings.outputFormat) { case 'arduino': { const varQuickArray = []; let bytesUsed = 0; // -- images.each((image) => { code = imageToString(image); // Trim whitespace from end and remove trailing comma code = stripTrailingComma(code); code = `\t${code.split('\n').join('\n\t')}\n`; // const variableCount = images.length() > 1 ? count++ : ''; const comment = glyphComment(image); bytesUsed += code.split('\n').length * resolveWrapWidth(image.canvas.width); const varname = getIdentifier() + (image.glyph ? `${image.glyph.replace(/[^a-zA-Z0-9]/g, '_')}` : ''); varQuickArray.push(varname); code = `${comment}const ${getImageType()} ${varname} []${progmemKeyword()} = {\n${code}};\n`; outputString += code; }); varQuickArray.sort(); if (outputString.length > 0) { outputString += '\n'; } if (settings.outputComments) { const storageNote = settings.esp32Format ? '' : ' in PROGMEM'; outputString += `// Array of all bitmaps for convenience. (Total bytes used to store images${storageNote} = ${bytesUsed})\n`; } outputString += `const int ${getIdentifier()}allArray_LEN = ${varQuickArray.length};\n`; outputString += `const ${getImageType()}* ${getIdentifier()}allArray[${varQuickArray.length}] = {\n\t${varQuickArray.join(',\n\t')}\n};\n`; if (settings.fullExample) { const first = images.first(); const firstVarName = getIdentifier() + (first.glyph ? `${first.glyph.replace(/[^a-zA-Z0-9]/g, '_')}` : ''); outputString = wrapFullExample(outputString, firstVarName, first.canvas.width, first.canvas.height); } break; } case 'arduino_single': { let comment = ''; images.each((image) => { code = imageToString(image); code = `\t${code.split('\n').join('\n\t')}\n`; comment = glyphComment(image, '\t'); outputString += comment + code; }); outputString = stripTrailingComma(outputString); outputString = `const ${getImageType()} ${ getIdentifier() } []${progmemKeyword()} = {` + `\n${outputString}\n};`; if (settings.fullExample) { const first = images.first(); outputString = wrapFullExample(outputString, getIdentifier(), first.canvas.width, first.canvas.height); } break; } case 'adafruit_gfx': { // bitmap let comment = ''; let useGlyphs = 0; images.each((image) => { code = imageToString(image); code = `\t${code.split('\n').join('\n\t')}\n`; comment = glyphComment(image, '\t'); outputString += comment + code; if (image.glyph.length === 1) { useGlyphs++; } }); outputString = stripTrailingComma(outputString); outputString = `const ${getByteType()} ${ getIdentifier() }Bitmap` + ` []${progmemKeyword()} = {` + `\n${outputString}\n};\n\n` + `const GFXbitmapGlyph ${ getIdentifier() }Glyphs []${progmemKeyword()} = {\n`; let firstAschiiChar = document.getElementById('first-ascii-char').value; const xAdvance = parseInt(document.getElementById('x-advance').value); let offset = 0; code = ''; // GFXbitmapGlyph images.each((image) => { code += `\t{ ${ offset}, ${ image.canvas.width}, ${ image.canvas.height}, ${ xAdvance}, ` + `'${images.length() === useGlyphs ? image.glyph : String.fromCharCode(firstAschiiChar++)}'` + ' }'; if (image !== images.last()) { code += ','; } code += settings.outputComments ? `// '${image.glyph}'\n` : '\n'; offset += image.canvas.width; }); code += '};\n'; outputString += code; // GFXbitmapFont outputString += `\nconst GFXbitmapFont ${ getIdentifier() }Font${progmemKeyword()} = {\n` + `\t(uint8_t *)${ getIdentifier()}Bitmap,\n` + `\t(GFXbitmapGlyph *)${ getIdentifier() }Glyphs,\n` + `\t${images.length() }\n};\n`; break; } default: { // plain images.each((image) => { code = imageToString(image); let comment = image.glyph ? glyphComment(image) : ''; if (image.img !== images.first().img) { comment = `\n${comment}`; } code = comment + code; outputString += code; }); // Trim whitespace from end and remove trailing comma outputString = stripTrailingComma(outputString); } } document.getElementById('code-output').value = outputString; } // Copy the final output to the clipboard // eslint-disable-next-line no-unused-vars function copyOutput() { if (!checkImagesAvailable()) return; const output = document.getElementById('code-output'); if (!output.value) { generateOutputString(); } navigator.clipboard.writeText(output.value); } // Rebuilds the true byte stream for the current images/conversion mode by // re-splitting each formatted output value (which may be 1, 2 or 4 bytes // wide, depending on drawMode) back into its individual bytes, most- // significant first. Exposed standalone so it can be exercised directly in // tests without going through a real browser file download. function computeBinData() { const bytesPerValue = CONVERSION_BYTES_PER_VALUE.get(settings.conversionFunction) || 1; const raw = []; images.each((image) => { const values = imageToString(image) .split(',') .map((s) => s.trim()) .filter(Boolean) .map((token) => parseInt(token, 16)); values.forEach((value) => { // Split back into individual bytes, most-significant first, matching // the left-to-right digit order produced by toString(16).padStart(). for (let shift = (bytesPerValue - 1) * 8; shift >= 0; shift -= 8) { // eslint-disable-next-line no-bitwise raw.push((value >> shift) & 0xff); } }); }); return new Uint8Array(raw); } // eslint-disable-next-line no-unused-vars function downloadBinFile() { if (!checkImagesAvailable()) return; const data = computeBinData(); const a = document.createElement('a'); a.style = 'display: none'; document.body.appendChild(a); const blob = new Blob([data], { type: 'octet/stream' }); const url = window.URL.createObjectURL(blob); a.href = url; a.download = `${getIdentifier()}.bin`; a.click(); window.URL.revokeObjectURL(url); } // eslint-disable-next-line no-unused-vars function updateDrawMode(elm) { const conversionFunction = ConversionFunctions[elm.value]; if (conversionFunction) { settings.conversionFunction = conversionFunction; } updateAllImages(); } // Updates Arduino code check-box // eslint-disable-next-line no-unused-vars function updateOutputFormat(elm) { let caption = document.getElementById('format-caption-container'); const adafruitGfx = document.getElementById('adafruit-gfx-settings'); const arduino = document.getElementById('arduino-identifier'); document.getElementById('code-output').value = ''; for (let i = 0; i < caption.children.length; i++) { caption.children[i].style.display = 'none'; } caption = document.querySelector(`div[data-caption='${elm.value}']`); if (caption) caption.style.display = 'block'; arduino.style.display = elm.value !== 'plain' ? 'block' : 'none'; settings.prefix = '0x'; document.getElementById('prefix').value = '0x'; settings.separator = ', '; document.getElementById('separator').value = ', '; if (elm.value === 'adafruit_gfx') { adafruitGfx.style.display = 'block'; } else { adafruitGfx.style.display = 'none'; } settings.outputFormat = elm.value; } // Easy way to update settings controlled by a radiobutton // eslint-disable-next-line no-unused-vars function updateRadio(fieldName) { const radioGroup = document.getElementsByName(fieldName); for (let i = 0; i < radioGroup.length; i++) { if (radioGroup[i].checked) { settings[fieldName] = radioGroup[i].value; } } updateAllImages(); } window.onload = () => { // Add events to the file input button const fileInput = document.getElementById('file-input'); fileInput.addEventListener('click', () => { this.value = null; }, false); fileInput.addEventListener('change', handleImageSelection, false); const outputFormat = document.getElementById('outputFormat'); outputFormat.value = 'arduino'; updateOutputFormat(outputFormat); };