Tool updates...

This was just supposed to be a new feature for the generic image tool (dithering support), but while working on that things kind of snowballed 😅

* Added dithering support to the generic image tool and the boot logo changer, when converting images to RGB565 format. This uses a Bayer 8x8 matrix, and the overall "strength" of the dither can be controlled - it defaults to what I feel is a sane value. Dithering can help reduce banding effects due to the low colour depth in RGB565.
* Made "fix" scaling mode more flexible in the generic image tool - now there's checkboxes beside the width and height dimensions - if you un-check one, the other dimension will be calculated automatically to keep the input's aspect ratio intact
* Improved downscaling quality in the generic image tool. While working on the dithering feature, I discovered the previous "gaussian resampling" downscaling method I was using introduced distortion in certain situations. I had a lot of fun playing around with possible replacements (I tried 10 new downscaling functions!), and finally settled on a hybrid function that mixes powers-of-two downscaling (with some custom mipmap style cross-blending) with Hermite interpolation. This new method is reasonably quick, gives clean results with no great distortion, and works well with alpha channels (doesn't introduce any dark fringing)
* Generic image tool now shows the dimensions of the output image
* Added a max width on the main tool page bodies, so that they don't get so wide on full-screen desktop browsers
* Fixed a few edge-case logic bugs here and there (e.g., when upscaling only a single dimensions with the generic image tool, or places where I thought I was copying objects, but was only creating references to them, etc.)
* Switched from using "var" declarations across all tool codebases to "let" or "const" instead
* Switched out the SVG alert icons to just use emoji instead
* Various other nips and tucks (fixed up my arbitrary 80-column comment wrapping on the tools that had previously just been eye-balled, fixed a few comment typos, etc.)
This commit is contained in:
vonmillhausen 2024-05-14 20:20:57 +01:00
parent 41e0ffabac
commit 806444d2a9
8 changed files with 1044 additions and 699 deletions

View File

@ -25,7 +25,7 @@
<script type="text/javascript">
// Global variables...
var bisrvData; // Used to store the binary data from the bisrv.asd file
let bisrvData; // Used to store the binary data from the bisrv.asd file
// Display our initial warning message
setMessage("warning", "initialWarning", "<strong><em>WARNING:</em></strong> This tool will just blindly modify bytes <code>0x18c</code> to <code>0x18f</code> of whatever input file you provide; correct usage of this tool is therefore up to you, not me! 🤣");
@ -47,7 +47,7 @@
// If we're here then we should be good - read in our file and store its
// contents in bisrvData, then patch the CRC32 bytes...
var frBisrv = new FileReader();
const frBisrv = new FileReader();
frBisrv.readAsArrayBuffer(file);
frBisrv.onload = function(event) {
@ -64,7 +64,7 @@
function stepTwo() {
// Start building our HTML...
var html = "<section id=\"downloadSection\"><h2>Step 2: Download Patched <code>bisrv.asd</code></h2><p>Click the download button for the patched <code>bisrv.asd</code> file; use it to replace the one in the <code>bios</code> folder on your SF2000's microSD card.</p><div id=\"downloadSectionMessages\"></div>";
let html = "<section id=\"downloadSection\"><h2>Step 2: Download Patched <code>bisrv.asd</code></h2><p>Click the download button for the patched <code>bisrv.asd</code> file; use it to replace the one in the <code>bios</code> folder on your SF2000's microSD card.</p><div id=\"downloadSectionMessages\"></div>";
// Add our download button...
html += "<div class=\"controlContainer\"><div class=\"control\"><input id=\"downloadButton\" type=\"button\" value=\"Download\"></div></div>";
@ -74,13 +74,13 @@
document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Attach our event handler to our download button...
var dButton = document.getElementById("downloadButton");
const dButton = document.getElementById("downloadButton");
dButton.addEventListener("click", function() {
downloadToBrowser(bisrvData, "application/octet-stream", "bisrv.asd");
});
}
</script>
<hr>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.1, 20230626.1</p>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.2, 20240514.1</p>
</body>
</html>

View File

@ -24,15 +24,17 @@
<script type="text/javascript">
// Global variables...
var bisrvData; // Used to store the binary data from the bisrv.asd file
var logoOffset; // Will contain the offset of the boot logo within the bisrv.asd file
var newLogoData; // Used to store the little-endian RGB565 binary data of the new boot logo
let bisrvData; // Used to store the binary data from the bisrv.asd file
let logoOffset; // Will contain the offset of the boot logo within the bisrv.asd file
let newLogoData; // Used to store raw ImageData of the new boot logo
let ditherEnabled = false; // Holds whether the user wants to enable dithering for RGB565 mode or not
let ditherStrength = 0.2; // Holds the selected dither strength value - 0.2 seems to be a good general balance
// This function is triggered when the person selects a file in Step 1...
function bisrvLoad(file) {
// Read in the contents of the selected file as array buffer...
var frBisrv = new FileReader();
const frBisrv = new FileReader();
frBisrv.readAsArrayBuffer(file);
frBisrv.onload = function(event) {
@ -48,13 +50,14 @@
bisrvData = new Uint8Array(event.target.result);
// We'll do a hash-check against it...
hashResult = getFirmwareHash(bisrvData);
const hashResult = getFirmwareHash(bisrvData);
// The result could be either a Promise if it had a bisrv.asd-like structure and we got
// a hash, or false otherwise... let's check!
// The result could be either a Promise if it had a bisrv.asd-like
// structure and we got a hash, or false otherwise... let's check!
if (hashResult instanceof Promise) {
// We got a Promise! Wait for it to finish so we get our bisrv.asd hash...
// We got a Promise! Wait for it to finish so we get our bisrv.asd
// hash...
hashResult.then(function(dataHash) {
// Check the hash against all the known good ones...
@ -103,14 +106,15 @@
break;
default:
// Huh... wasn't false so had bisrv.asd structure, but didn't return
// a known hash... a new BIOS version? Unknown anyway!
// Huh... wasn't false so had bisrv.asd structure, but didn't
// return a known hash... a new BIOS version? Unknown anyway!
console.log(dataHash);
setMessage("error", "bisrvMessages", "ERROR: While the file you've selected does appear to be generally structured like the SF2000's <code>bisrv.asd</code> BIOS file, the specifics of your file don't match any known SF2000 BIOS version. As such, this tool cannot safely modify the selected file.");
return;
}
// If we're here we've got a good file, so onwards to Step 2 (image selection)...
// If we're here we've got a good file, so onwards to Step 2
// (image selection)...
stepTwo();
});
}
@ -132,20 +136,27 @@
// file is an image, and that it has the correct dimensions of 256x100
// pixels; if it is, we convert the image data to an SF2000 format and
// move on to Step 3. First, let's start building our HTML...
var html = "<section id=\"imageSection\"><h2>Step 2: Select New Logo Image File</h2><p>Select the image file you want to use as the new boot logo. It must be 256 pixels wide and 100 pixels tall. Only PNG and JPEG image types are supported. For PNG files, transparency is ignored; also, you might want to make sure your PNG image has any embedded gamma or colour profile information removed first using a metadata scrubbing tool, otherwise the colour output from <i>this</i> tool might be incorrect. The image is displayed centred on a black background, so you may want to factor that into your design as well.</p><div id=\"imageMessages\"></div>";
let html = "<section id=\"imageSection\"><h2>Step 2: Select New Logo Image File</h2><p>Select the image file you want to use as the new boot logo. It must be 256 pixels wide and 100 pixels tall. Only PNG and JPEG image types are supported. For PNG files, transparency is handled by drawing the image on a black background; also, you might want to make sure your PNG image has any embedded gamma or colour profile information removed first using a metadata scrubbing tool, otherwise the colour output from <i>this</i> tool might be incorrect. On the SF2000, the boot logo is displayed centred on a black background, so you may want to factor that into your design as well.</p><div id=\"imageMessages\"></div>";
// Add our file chooser...
html += "<div class=\"controlContainer\"><label class=\"control\"><input id=\"inputImage\" type=\"file\"></label></div>";
// Add our file chooser and dithering controls...
html += "<form id=\"imageForm\" class=\"controlContainer\"><div class=\"control\">";
html += "<label class=\"control\"><input id=\"inputImage\" type=\"file\"></label>";
html += "<div class=\"control\"><label for=\"ditherEnabled\">Dithering: <input type=\"checkbox\" id=\"ditherEnabled\" name=\"ditherEnabled\"" + (ditherEnabled == true ? " checked" : "") + "></label> <label for=\"ditherStrength\">Strength: <select id=\"ditherStrength\" name=\"ditherStrength\"><option" + (ditherStrength == 0.1 ? " selected" : "") + ">0.1</option><option" + (ditherStrength == 0.2 ? " selected" : "") + ">0.2</option><option" + (ditherStrength == 0.3 ? " selected" : "") + ">0.3</option><option" + (ditherStrength == 0.5 ? " selected" : "") + ">0.5</option><option" + (ditherStrength == 0.75 ? " selected" : "") + ">0.75</option><option" + (ditherStrength == 1.0 ? " selected" : "") + ">1</option><option" + (ditherStrength == 2.0 ? " selected" : "") + ">2</option></select></label></div>";
html += "</div></form>";
// Next we'll add our image preview...
html += "<div id=\"previewContainer\" class=\"controlContainer\" style=\"display: none;\"><div class=\"control\"><canvas id=\"processFilePreview\" width=\"256\" height=\"100\"></canvas></div></div>";
// Close our section...
html += "</section>";
// Finally, add a <hr> separator after the last step, and append the new step...
// Finally, add a <hr> separator after the last step, and append the new
// step...
document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>");
document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Attach our event handler to our new file input control...
var userImageInput = document.getElementById("inputImage");
const userImageInput = document.getElementById("inputImage");
userImageInput.addEventListener("change", function() {
// The user has chosen a new file; it should be either a PNG or JPEG
@ -154,17 +165,24 @@
// First let's clear any old messages...
document.getElementById("imageMessages").innerHTML = "";
// ... and make sure our preview image is hidden...
document.getElementById("previewContainer").style.display = "none";
// ... and clear our newLogoData object ...
newLogoData = new ImageData(1, 1);
// And clear up any HTML already added after the current step...
while(document.getElementById("imageSection").nextSibling) {
document.getElementById("imageSection").nextSibling.remove();
}
// Now let's read in the file...
var frImage = new FileReader();
const frImage = new FileReader();
frImage.readAsDataURL(event.target.files[0]);
frImage.onload = function(event) {
// The file is loaded; let's check to make sure we got a PNG or JPEG...
// The file is loaded; let's check to make sure we got a PNG or
// JPEG...
if (!event.target.result.includes("data:image/png;") && !event.target.result.includes("data:image/jpeg;")) {
// Whoops! Doesn't look like the user provided a PNG or a JPEG!
@ -172,36 +190,60 @@
return;
}
// Now we're going to load the file's data into an Image object, so we can
// access the raw image data...
var img = new Image;
// Now we're going to load the file's data into an Image object, so
// we can access the raw image data...
const img = new Image;
img.src = event.target.result;
img.onload = function(event) {
// Check to make sure the image has the correct dimensions for a
// boot logo...
if (img.width != 256 || img.height != 100) {
setMessage("error", "imageMessages", "ERROR: The selected image does not have dimensions of 256x100px!");
setMessage("error", "imageMessages", "ERROR: The selected image does not have dimensions of 256 x 100!");
return;
}
// We need to scale the image to the internal resolution used by
// the SF2000 (512x200)...
var imageData = scaleImage(imageToImageData(img), 512, 200, "Nearest Neighbour");
// If we're here, the image is good - store its data in our
// global...
newLogoData = imageToImageData(img, false);
// Next we'll convert the image data to little-endian RGB565
// format for the SF2000...
newLogoData = imageDataToRgb565(imageData);
// And finally, let's show our preview image...
document.getElementById("previewContainer").style.display = "flex";
renderPreview();
// On to Step 3!
stepThree();
}
}
});
// And finally, attach an event handler to the overall form - we'll use
// this to get the dithering settings, and as the trigger for updating
// the preview image accordingly...
const imageForm = document.getElementById("imageForm");
imageForm.addEventListener("change", function() {
// Get the state of all of our form controls...
const formData = Object.fromEntries(new FormData(imageForm));
// Set our general globals from the current form state...
//if (formData.ditherEnabled !== undefined) {ditherEnabled = true;}
//else if (imageFormat == "RGB565") {ditherEnabled = false;}
ditherEnabled = formData.ditherEnabled ? true : false;
ditherStrength = formData.ditherStrength? parseFloat(formData.ditherStrength) : ditherStrength;
// Finally render an updated preview...
renderPreview();
});
}
// This function is where we display our download button, which patches
// the provided bisrv.asd with the updated logo data, and sends it to the
// user's browser as a download...
function stepThree() {
var html = "<section id=\"downloadSection\"><h2>Step 3: Download Updated <code>bisrv.asd</code></h2><p>Click the download button for the updated <code>bisrv.asd</code> file; use it to replace the one in the <code>bios</code> folder on your SF2000's microSD card.</p><div id=\"downloadMessages\"></div>";
// As usual, start building up our HTML...
let html = "<section id=\"downloadSection\"><h2>Step 3: Download Updated <code>bisrv.asd</code></h2><p>Click the download button for the updated <code>bisrv.asd</code> file; use it to replace the one in the <code>bios</code> folder on your SF2000's microSD card.</p><div id=\"downloadMessages\"></div>";
// Add our download button...
html += "<div class=\"controlContainer\"><div class=\"control\"><input id=\"userDownload\" type=\"button\" value=\"Download\"></div></div>";
@ -209,35 +251,59 @@
// Close our section...
html += "</section>";
// Finally, add a <hr> separator after the last step, and append the new step...
// Finally, add a <hr> separator after the last step, and append the new
// step...
document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>");
document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Let's add the event handler for our Download button...
var dButton = document.getElementById("userDownload");
const dButton = document.getElementById("userDownload");
dButton.addEventListener("click", function() {
// So, we should have the original bisrv.asd data in bisrvData; and we should have
// the offset to the original logo data in logoOffset; and finally, we should have
// our new logo's binary data in newLogoData. All we need to do is replace the old
// data bytes with the new bytes, re-calculate the CRC32 bytes for the modified
// file and set them in the data, and send the data to the user's browser. Easy!
// So, we should have the original bisrv.asd data in bisrvData; and we
// should have the offset to the original logo data in logoOffset; and
// finally, we should have our new logo's binary data in newLogoData.
// All we need to do is convert the new logo to RGB565 format (using
// whatever dithering options are set), and then replace the old data
// bytes with the new bytes, re-calculate the CRC32 bytes for the
// modified file and set them in the data, and send the data to the
// user's browser. Easy!
// First, replace the logo data...
for (var i = 0; i < newLogoData.length; i++) {
bisrvData[logoOffset + i] = newLogoData[i];
// First, get the RGB565 version of the logo; this is a *bit* messy,
// as we take the user's raw input image, convert it to binary RGB565
// data, then convert it back to an ImageData object so we can upscale
// it with a 2x nearest neighbour upscale, and then convert the result
// *back* to an RGB565 binary array... dumb, but it works!
const convertedImage = imageDataToRgb565(scaleImage(rgb565ToImageData(imageDataToRgb565(newLogoData, ditherEnabled, ditherStrength), 256, 100), 512, 200, "Nearest Neighbour"));
// Now replace the old logo data in the bisrv.asd file...
for (let i = 0; i < convertedImage.length; i++) {
bisrvData[logoOffset + i] = convertedImage[i];
}
// Next, we calculate a new CRC32 for the updated bisrv.asd and apply it...
// Next, patch the CRC32 value in the bisrv.asd to account for the new
// logo data...
patchCRC32(bisrvData);
// And finally, send the data to the user's browser as a file download...
// And finally, send the updated bisrv.asd data to the user's browser
// as a file download...
downloadToBrowser(bisrvData, "application/octet-stream", "bisrv.asd");
});
}
// This utility function renders a preview of the final image...
function renderPreview() {
if (newLogoData.data.length > 4) {
let convertedImageData = imageDataToRgb565(newLogoData, ditherEnabled, ditherStrength);
convertedImageData = rgb565ToImageData(convertedImageData, 256, 100);
const previewCanvas = document.getElementById("processFilePreview");
const previewContext = previewCanvas.getContext("2d");
previewContext.clearRect(0, 0, 256, 100);
previewContext.putImageData(convertedImageData, 0, 0);
}
}
</script>
<hr>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.5, 20231019.1</p>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.6, 20240514.1</p>
</body>
</html>

View File

@ -26,11 +26,11 @@
<script>
// Global variables...
var mappingTableOffset; // Will contain the offset of the button mappings within the data file
var mappingConsoles; // Will contain a list of the specific game consoles we'll be setting up mappings for
var firmwareVersion; // Will contain the firmware version if the user selects a bisrv.asd file
var mappingData; // Used to store the binary data that will eventually be written to the downloadable file
var fileName; // Will hold the name of the selected file, used for naming ROM .kmp files
let mappingTableOffset; // Will contain the offset of the button mappings within the data file
let mappingConsoles; // Will contain a list of the specific game consoles we'll be setting up mappings for
let firmwareVersion; // Will contain the firmware version if the user selects a bisrv.asd file
let mappingData; // Used to store the binary data that will eventually be written to the downloadable file
let fileName; // Will hold the name of the selected file, used for naming ROM .kmp files
// Utility function: getButtonMap(int index)
// =========================================
@ -52,9 +52,9 @@
// This function is called whenever a file is selected in Step 1...
function fileLoad(file) {
// Create a FileReader object, and read in the selected file's contents as
// an array buffer...
var fr = new FileReader();
// Create a FileReader object, and read in the selected file's contents
// as an array buffer...
const fr = new FileReader();
fr.readAsArrayBuffer(file);
fr.onload = function(event) {
@ -69,22 +69,25 @@
// We'll also reset the firmwareVersion global variable...
firmwareVersion = null;
// Read the provided file's data from the buffer array into an unsigned 8-bit int array...
var data = new Uint8Array(event.target.result);
// Read the provided file's data from the buffer array into an
// unsigned 8-bit int array...
const data = new Uint8Array(event.target.result);
// We'll do a hash-check against it, even if it's not a bisrv.asd...
hashResult = getFirmwareHash(data);
const hashResult = getFirmwareHash(data);
// The result could be either a Promise if it had a bisrv.asd-like structure and we got
// a hash, or false otherwise... let's check!
// The result could be either a Promise if it had a bisrv.asd-like
// structure and we got a hash, or false otherwise... let's check!
if (hashResult instanceof Promise) {
// We got a Promise! Wait for it to finish so we get our bisrv.asd hash...
// We got a Promise! Wait for it to finish so we get our bisrv.asd
// hash...
hashResult.then(function(dataHash) {
// If we have a newer bisrv.asd that stores button mappings in an external KeyMapInfo.kmp,
// we'll need a Step 1b to load that file as well...
var step1BRequired = false;
// If we have a newer bisrv.asd that stores button mappings in an
// external KeyMapInfo.kmp, we'll need a Step 1b to load that file
// as well...
let step1BRequired = false;
// Check the hash against all the known good ones...
firmwareVersion = knownHash(dataHash);
@ -144,22 +147,23 @@
break;
default:
// Huh... wasn't false so had bisrv.asd structure, but didn't return
// a known hash... a new BIOS version? Unknown anyway!
// Huh... wasn't false so had bisrv.asd structure, but didn't
// return a known hash... a new BIOS version? Unknown anyway!
console.log(dataHash);
setMessage("error", "fileMessages", "ERROR: While the file you've selected does appear to be generally structured like the SF2000's <code>bisrv.asd</code> BIOS file, the specifics of your file don't match any known SF2000 BIOS version. As such, this tool cannot safely modify the selected file.");
return;
break;
}
// If we're here, then we got some kind bisrv.asd file we're happy with; we'll set
// mappingData to it's full contents...
mappingData = data;
// If we're here, then we got some kind bisrv.asd file we're happy
// with; we'll set mappingData to it's full contents...
mappingData = data.slice();
// Keep a record of the input file's name as well...
fileName = file.name;
// Check if we need a KeyMapInfo.kmp file to be provided as well...
// Check if we need a KeyMapInfo.kmp file to be provided as
// well...
if (step1BRequired) {
// Yup, we're going to need a KeyMapInfo.kmp file as well...
stepOneB();
@ -172,55 +176,64 @@
});
}
else {
// We got false, so whatever it was, it wasn't a bisrv.asd... let's check some other
// possibilities...
// We got false, so whatever it was, it wasn't a bisrv.asd... let's
// check some other possibilities...
if (data.length == 288) {
// That's the correct length for a KeyMapInfo.kmp file, however we must know the host
// BIOS version before we can correctly process those files. Let the user know...
// That's the correct length for a KeyMapInfo.kmp file, however we
// must know the host BIOS version before we can correctly process
// those files. Let the user know...
setMessage("error", "fileMessages", "ERROR: The file you've provided may be a <code>KeyMapInfo.kmp</code> button map file; however as the internal data structure of these files varies depending on the version of the host BIOS, you must select your device's <code>bisrv.asd</code> file first. You can find this file in the <code>bios</code> folder on your device's microSD card.");
return;
}
// If we're still checking, next test the file extensions for the individual console's ROMs...
// If we're still checking, next test the file extensions for the
// individual console's ROMs...
else if (/\.(zfb|zip)$/i.exec(file.name)) {
// The file's name ends with .zfb or .zip - assume it's an arcade ROM!
// The file's name ends with .zfb or .zip - assume it's an arcade
// ROM!
mappingConsoles = ["Arcade"];
}
else if (/\.(zgb|gba|agb|gbz)$/i.exec(file.name)) {
// The file's name ends with .zgb, .gba, .agb or .gbz - assume it's a Game Boy Advance ROM!
// The file's name ends with .zgb, .gba, .agb or .gbz - assume
// it's a Game Boy Advance ROM!
mappingConsoles = ["Game Boy Advance"];
}
else if (/\.(gbc|gb|sgb)$/i.exec(file.name)) {
// The file's name ends with .gbc, .gb or .sgb - assume it's a Game Boy or Game Boy Color ROM!
// The file's name ends with .gbc, .gb or .sgb - assume it's a
// Game Boy or Game Boy Color ROM!
mappingConsoles = ["Game Boy, Game Boy Color"];
}
else if (/\.(zsf|smc|fig|sfc|gd3|gd7|dx2|bsx|swc)$/i.exec(file.name)) {
// The file's name ends with .zsf, .smc, .fig, .sfc, .gd3, .gd7, .dx2, .bsx or .swc - assume it's a SNES ROM!
// The file's name ends with .zsf, .smc, .fig, .sfc, .gd3, .gd7,
// .dx2, .bsx or .swc - assume it's a SNES ROM!
mappingConsoles = ["SNES"];
}
else if (/\.(zmd|bin|md|smd|gen|sms)$/i.exec(file.name)) {
// The file's name ends with .zmd, .bin, .md, .smd, .gen or .sms - assume it's a Genesis/Mega Drive or Master System ROM!
// The file's name ends with .zmd, .bin, .md, .smd, .gen or .sms,
// assume it's a Genesis/Mega Drive or Master System ROM!
mappingConsoles = ["Genesis/Mega Drive, Master System"];
}
else if (/\.(zfc|nes|nfc|fds|unf)$/i.exec(file.name)) {
// The file's name ends with .zfc, .nes, .nfc, .fds or .unf - assume it's a NES ROM!
// The file's name ends with .zfc, .nes, .nfc, .fds or .unf,
// assume it's a NES ROM!
mappingConsoles = ["NES"];
}
else {
// Oh dear, the provided file didn't match any of the above rules! Display an error
// to the user...
// Oh dear, the provided file didn't match any of the above rules!
// Display an error to the user...
setMessage("error", "fileMessages", "ERROR: The selected file does not appear to be a known <code>bisrv.asd</code> file, a <code>KeyMapInfo.kmp</code> file, or a game ROM with a known extension!");
return;
}
// If we're here, then we got some kind of non-bisrv.asd file we're happy with. If
// mappingConsoles only contains one entry, then it was a ROM file, and we'll want to
// initialise our mappingData array with 48 slots; otherwise, it was a KeyMapInfo.kmp
// and we'll set mappingData to it's full contents instead...
// If we're here, then we got some kind of non-bisrv.asd file we're
// happy with. If mappingConsoles only contains one entry, then it
// was a ROM file, and we'll want to initialise our mappingData
// array with 48 slots; otherwise, it was a KeyMapInfo.kmp and we'll
// set mappingData to it's full contents instead...
if (mappingConsoles.length == 1) {
mappingData = new Uint8Array(48);
}
else {
mappingData = data;
mappingData = data.slice();
}
// In both cases, the mapping data begins at the very start of our data stream...
@ -241,14 +254,14 @@
function stepOneB() {
// Build our HTML...
var html = "<section id=\"stepOneB\"><h2>Step 1b: Select <code>KeyMapInfo.kmp</code></h2><p>This version of the SF2000 BIOS reads its button mappings from an external file called <code>KeyMapInfo.kmp</code>, stored in the <code>Resources</code> folder on the microSD card. Please select your device's <code>KeyMapInfo.kmp</code> file now.</p><div id=\"stepOneBMessages\"></div><div class=\"controlContainer\"><label class=\"control\">Open <code>KeyMapInfo.kmp</code>: <input id=\"keyMapInfoSelector\" type=\"file\" accept=\".kmp\"></label></div></section>";
let html = "<section id=\"stepOneB\"><h2>Step 1b: Select <code>KeyMapInfo.kmp</code></h2><p>This version of the SF2000 BIOS reads its button mappings from an external file called <code>KeyMapInfo.kmp</code>, stored in the <code>Resources</code> folder on the microSD card. Please select your device's <code>KeyMapInfo.kmp</code> file now.</p><div id=\"stepOneBMessages\"></div><div class=\"controlContainer\"><label class=\"control\">Open <code>KeyMapInfo.kmp</code>: <input id=\"keyMapInfoSelector\" type=\"file\" accept=\".kmp\"></label></div></section>";
// Add a <hr> separator after the last step, and append the new step...
document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>");
document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Attach our event handler to our new file input control...
var keyMapInfoInput = document.getElementById("keyMapInfoSelector");
const keyMapInfoInput = document.getElementById("keyMapInfoSelector");
keyMapInfoInput.addEventListener("change", function() {
// The user has chosen a new file; it should be a KeyMapInfo.kmp file,
@ -263,29 +276,29 @@
document.getElementById("stepOneBMessages").innerHTML = "";
// Next, read in the contents of the user-provided file...
var frKMI = new FileReader();
const frKMI = new FileReader();
fileName = event.target.files[0].name;
frKMI.readAsDataURL(event.target.files[0]);
frKMI.onload = function(event) {
// Get the file's data and data type...
var fileData = event.target.result;
var dataType = fileData.substring(5, fileData.indexOf(";"));
const fileData = event.target.result;
const dataType = fileData.substring(5, fileData.indexOf(";"));
// Check to make sure the data type is binary...
if (dataType === "application/octet-stream") {
// The user selected a file that appears to contain binary data; it's
// a good candidate! Let's check its length next...
var base64Data = fileData.substring(fileData.indexOf(",") + 1);
var binaryData = atob(base64Data);
const base64Data = fileData.substring(fileData.indexOf(",") + 1);
const binaryData = atob(base64Data);
if (binaryData.length == 288) {
// It's the right length - let's assume it's a KeyMapInfo.kmp!
// We'll assign it to our global mappingData variable, and
// we'll proceed to Step 2...
mappingData = new Uint8Array(binaryData.length);
for (var i = 0; i < binaryData.length; i++) {
for (let i = 0; i < binaryData.length; i++) {
mappingData[i] = binaryData.charCodeAt(i);
}
stepTwo();
@ -321,7 +334,7 @@
// mapped, and for each a selection box of the target console's buttons
// for the mapping. There'll also be a checkbox per button, which can
// be checked to enable "autofire" on that button.
var html = "<section id=\"mappingSection\"><h2>Step 2: Choose your button mappings</h2>";
let html = "<section id=\"mappingSection\"><h2>Step 2: Choose your button mappings</h2>";
// First, we need to update Step 2's instructions, depending on whether
// or not the user supplied a bisrv.asd file or KeyMapInfo.kmp
@ -340,19 +353,19 @@
// Next we'll be looping through all of the consoles we'll be setting
// up mappings for...
var presentationButtonOrder = ['A', 'B' ,'X', 'Y', 'L', 'R'];
for (var currentConsole = 0; currentConsole < mappingConsoles.length; currentConsole++) {
const presentationButtonOrder = ['A', 'B' ,'X', 'Y', 'L', 'R'];
for (let currentConsole = 0; currentConsole < mappingConsoles.length; currentConsole++) {
// This console's bank of mapping controls will be stored in a <div>,
// and we'll add a <h3> header for good measure as well...
html += "<div class=\"control\"><h3>" + mappingConsoles[currentConsole] + "</h3>";
// Get the button mapping for this console...
var buttonMap = getButtonMap(currentConsole);
const buttonMap = getButtonMap(currentConsole);
// We'll add two tables of control mappings to the <div>, one each
// for Player 1 and Player 2...
for (var player = 0; player < 2; player++) {
for (let player = 0; player < 2; player++) {
// Start creating our table HTML...
html += "<table><caption>Player " + (player + 1) + "</caption>";
@ -361,21 +374,21 @@
// Loop through all the SF2000's buttons (well, the ones that can
// be mapped, anyway)...
for (var button = 0; button < 6; button++) {
for (let button = 0; button < 6; button++) {
// By default, the SF2000 stores its button maps in XYLABR
// order... except for GBA under newer firmware versions where
// the order is LRXABY for some reason. We specify the order the
// bytes are in here. If they do other weird stuff in the future,
// it'll probably be here that needs to change!
var buttonByteOrder = ['X', 'Y' ,'L', 'A', 'B', 'R'];
let buttonByteOrder = ['X', 'Y' ,'L', 'A', 'B', 'R'];
if (mappingConsoles[currentConsole] == "Game Boy Advance" && ["05.15", "05.22", "08.03", "10.07", "10.13"].includes(firmwareVersion)) {
buttonByteOrder = ['L', 'R', 'X', 'A', 'B', 'Y'];
}
// Calculate our offset within our mapping data for the current
// button...
var offset = mappingTableOffset + (currentConsole * 48) + (player * 24) + (buttonByteOrder.indexOf(presentationButtonOrder[button]) * 4);
const offset = mappingTableOffset + (currentConsole * 48) + (player * 24) + (buttonByteOrder.indexOf(presentationButtonOrder[button]) * 4);
// Start creating the HTML data for this row in the table...
html += "<tr>";
@ -386,7 +399,7 @@
// Console button selection list...
html += "<td class=\"alignC\">";
html += "<select id=\"sel" + offset.toString(16) + "\">";
for (var buttonTable in buttonMap) {
for (let buttonTable in buttonMap) {
html += "<option ";
if (mappingData[offset] == buttonMap[buttonTable]) {
html += "selected";
@ -437,7 +450,7 @@
}
// Now let's start building our HTML...
var html = "<section id=\"saveSection\"><h2>Step 3: Save your mapping changes</h2>";
let html = "<section id=\"saveSection\"><h2>Step 3: Save your mapping changes</h2>";
// First up, instructions! These will depend on whether they provided a
// bisrv.asd, a KeyMapInfo.kmp, or a game ROM...
@ -462,7 +475,7 @@
// They provided a ROM file! To make the instructions clearer, let's
// calculate the name of the keymap file we're generating...
var kmpFileName = fileName.replace(/($|\.[^.]*$)/, function(m, p1) {return p1.toUpperCase() + '.kmp';});
const kmpFileName = fileName.replace(/($|\.[^.]*$)/, function(m, p1) {return p1.toUpperCase() + '.kmp';});
// Now the instructions themselves...
html += "<p>Click the Download button below to download \"" + kmpFileName + "\", a game-specific keymap file for \"" + fileName + "\". Once downloaded, place it in the <code>save</code> subfolder of the folder where the ROM itself is stored. So for example, if \"" + fileName + "\" is in the <code>ROMS</code> folder on your SF2000's microSD card, place the \"" + kmpFileName + "\" file in <code>ROMS/save/</code>. If the <code>save</code> subfolder does not already exist, create it yourself first.</p>";
@ -480,7 +493,7 @@
document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Let's add the event handler for our Download button...
var dButton = document.getElementById("downloadButton");
const dButton = document.getElementById("downloadButton");
dButton.addEventListener("click", function() {
// Here, we'll construct the file for the user to download (a
@ -491,24 +504,24 @@
// settings, and use those settings to build the binary data of our
// button mapping. Loop through all of the consoles we're mapping
// for...
var presentationButtonOrder = ['A', 'B' ,'X', 'Y', 'L', 'R'];
for (var currentConsole = 0; currentConsole < mappingConsoles.length; currentConsole ++) {
const presentationButtonOrder = ['A', 'B' ,'X', 'Y', 'L', 'R'];
for (let currentConsole = 0; currentConsole < mappingConsoles.length; currentConsole ++) {
// Get the button mapping for this console...
var buttonMap = getButtonMap(currentConsole);
const buttonMap = getButtonMap(currentConsole);
// For each player...
for (var player = 0; player < 2; player++) {
for (let player = 0; player < 2; player++) {
// ... and for each button...
for (var button = 0; button < 6; button++) {
for (let button = 0; button < 6; button++) {
// By default, the SF2000 stores its button maps in XYLABR
// order... except for GBA under newer firmware versions where
// the order is LRXABY for some reason. We specify the order
// the bytes are in here. If they do other weird stuff in the
// future, it'll probably be here that needs to change!
var buttonByteOrder = ['X', 'Y' ,'L', 'A', 'B', 'R'];
let buttonByteOrder = ['X', 'Y' ,'L', 'A', 'B', 'R'];
if (mappingConsoles[currentConsole] == "Game Boy Advance" && ["05.15", "05.22", "08.03", "10.07", "10.13"].includes(firmwareVersion)) {
buttonByteOrder = ['L', 'R', 'X', 'A', 'B', 'Y'];
}
@ -516,7 +529,7 @@
// Calculate the offset in our mapping data for the current
// button, read the button settings from the HTML controls, and
// assign the appropriate values to our binary mappingData...
var offset = mappingTableOffset + (currentConsole * 48) + (player * 24) + (buttonByteOrder.indexOf(presentationButtonOrder[button]) * 4);
const offset = mappingTableOffset + (currentConsole * 48) + (player * 24) + (buttonByteOrder.indexOf(presentationButtonOrder[button]) * 4);
mappingData[offset] = buttonMap[document.getElementById("sel" + offset.toString(16)).value];
mappingData[offset + 2] = document.getElementById("cb" + offset.toString(16)).checked ? 1 : 0;
}
@ -534,7 +547,7 @@
// Next, let's determine the name of the file we're going to send to
// the user's browser...
var downloadFileName = fileName;
let downloadFileName = fileName;
if (mappingConsoles.length == 1) {
downloadFileName = downloadFileName.replace(/($|\.[^.]*$)/, function(m, p1) {return p1.toUpperCase() + '.kmp';});
}
@ -546,6 +559,6 @@
}
</script>
<hr>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.5, 20231019.1</p>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.6, 20240514.1</p>
</body>
</html>

View File

@ -24,13 +24,13 @@
<script type="text/javascript">
// Global variables...
var bisrvData; // Used to store the binary data from the bisrv.asd file
let bisrvData; // Used to store the binary data from the bisrv.asd file
// This function is triggered when the person selects a file...
function bisrvLoad(file) {
// Read in the contents of the selected file as array buffer...
var frBisrv = new FileReader();
const frBisrv = new FileReader();
frBisrv.readAsArrayBuffer(file);
frBisrv.onload = function(event) {
@ -41,7 +41,7 @@
bisrvData = new Uint8Array(event.target.result);
// We'll do a hash-check against it...
hashResult = getFirmwareHash(bisrvData);
const hashResult = getFirmwareHash(bisrvData);
// The result could be either a Promise if it had a bisrv.asd-like structure and we got
// a hash, or false otherwise... let's check!
@ -107,6 +107,6 @@
}
</script>
<hr>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.2, 20231019.1</p>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.3, 20240514.1</p>
</body>
</html>

View File

@ -15,8 +15,8 @@
<h2>Step 1: Select Operating Mode</h2>
<p>This tool can operate in two main modes - you can use it to convert <em>from</em> an SF2000 format <em>to</em> RGB888/RGBA, or you can use it to convert <em>to</em> an SF2000 format <em>from</em> RGB888/RGBA. Choose the mode you want, and follow the next instruction that appears.</p>
<div class="controlContainer">
<label class="control"><input type="radio" id="radioFrom" name="toolMode" value="fromSF2000" autocomplete="off"> Convert <em>from</em> SF2000</label><br>
<label class="control"><input type="radio" id="radioTo" name="toolMode" value="toSF2000" autocomplete="off"> Convert <em>to</em> SF2000</label>
<label for="radioFrom" class="control"><input type="radio" id="radioFrom" name="toolMode" value="fromSF2000" autocomplete="off"> Convert <em>from</em> SF2000</label><br>
<label for="radioTo" class="control"><input type="radio" id="radioTo" name="toolMode" value="toSF2000" autocomplete="off"> Convert <em>to</em> SF2000</label>
</div>
</section>
</div>
@ -25,24 +25,28 @@
// Global Variables
// ================
var sf2000ImageData; // Holds binary data for images coming from the SF2000
var userImageData; // Holds the original data of the PNG/JPEG coming from the user
var previewCanvasData; // Holds the binary data used for drawing to our preview canvases
var outputImageWidth = 320; // The width the user wants to interpret the image from the SF2000 as, default to 320px
var outputImageHeight = 240; // The height the user wants to interpret the image from the SF2000 as, default to 240px
var imageFormat = "RGB565"; // The format the user wants to interpret the image coming from the SF2000 as, default to RGB565
var fileName; // Holds the name of the user-selected file
var userScaleMode = "scale"; // The type of scaling the user wants for their image, default to preset scales
var userScaleFactor = "1x"; // Holds the currently selected scaling factor, defaults to 1x
var userFitWidth = 320; // Holds the current fit-to width, default to 320px
var userFitHeight = 240; // Holds the current fit-to height, default to 240px
var userFilterType = "Nearest Neighbour"; // Holds the currently selected filter type, defaulting to Nearest Neighbour
let sf2000ImageData; // Holds binary data for images coming from the SF2000
let userImageData; // Holds the original data of the PNG/JPEG coming from the user
let previewCanvasData; // Holds the binary data used for drawing to our preview canvases
let outputImageWidth = 320; // The width the user wants to interpret the image from the SF2000 as, default to 320px
let outputImageHeight = 240; // The height the user wants to interpret the image from the SF2000 as, default to 240px
let fileName; // Holds the name of the user-selected file
let imageFormat = "RGB565"; // The format the user wants to interpret the image coming from the SF2000 as, default to RGB565
let ditherEnabled = false; // Holds whether the user wants to enable dithering for RGB565 mode or not
let ditherStrength = 0.2; // Holds the selected dither strength value - 0.2 seems to be a good general balance
let userScaleMode = "scale"; // The type of scaling the user wants for their image, default to preset scales
let userScaleFactor = "1x"; // Holds the currently selected scaling factor, defaults to 1x
let fitToWidthEnabled = true; // Holds whether the "fit to width" checkbox is ticked or not
let userFitWidth = 320; // Holds the current fit-to width, default to 320px
let fitToHeightEnabled = true; // Holds whether the "fit to width" checkbox is ticked or not
let userFitHeight = 240; // Holds the current fit-to height, default to 240px
let userFilterType = "Nearest Neighbour"; // Holds the currently selected filter type, defaulting to Nearest Neighbour
// When the tool loads, add some event watchers for when the Step 1 radio
// buttons change; and depending on which mode the user selects, begin
// rewriting the rest of the page...
var modes = document.getElementsByName("toolMode");
for (var i = 0; i < modes.length; i++) {
const modes = document.getElementsByName("toolMode");
for (let i = 0; i < modes.length; i++) {
modes[i].addEventListener("change", function() {
// Clear out any HTML that might already exist after Step 1, just so
@ -69,10 +73,10 @@
// Create the new section, add our heading and our instruction
// paragraph...
var html = "<section id=\"selectSF2000File\"><h2>Step 2: Select SF2000 Image File</h2><p>Select the SF2000 image file you want to convert from. If you need help choosing the image you want to convert, you can <a href=\"https://vonmillhausen.github.io/sf2000/#images-used\" target=\"_blank\" rel=\"noreferrer noopener\">find a reference to all of the used UI images in my overview of the SF2000 here</a>.</p><div id=\"step2Messages\"></div>";
let html = "<section id=\"selectSF2000File\"><h2>Step 2: Select SF2000 Image File</h2><p>Select the SF2000 image file you want to convert from. If you need help choosing the image you want to convert, you can <a href=\"https://vonmillhausen.github.io/sf2000/#images-used\" target=\"_blank\" rel=\"noreferrer noopener\">find a reference to all of the used UI images in my overview of the SF2000 here</a>.</p><div id=\"step2Messages\"></div>";
// Add our file chooser...
html += "<div class=\"controlContainer\"><label class=\"control\"><input id=\"inputImage\" type=\"file\"></label></div>";
html += "<form class=\"controlContainer\"><label for=\"inputImage\" class=\"control\"><input id=\"inputImage\" name=\"inputImage\" type=\"file\"></label></form>";
// Close off our section...
html += "</section>";
@ -83,7 +87,7 @@
document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Attach our event handler to our new file input control...
var sf2000ImageInput = document.getElementById("inputImage");
const sf2000ImageInput = document.getElementById("inputImage");
sf2000ImageInput.addEventListener("change", function() {
// The user has chosen a new file; it should be a binary blob we can
@ -92,22 +96,22 @@
// Now let's check to make sure the data URI indicates
// "application/octet-stream"...
var frImage = new FileReader();
const frImage = new FileReader();
fileName = event.target.files[0].name;
frImage.readAsDataURL(event.target.files[0]);
frImage.onload = function(event) {
var fileData = event.target.result;
var dataType = fileData.substring(5, fileData.indexOf(";"));
const fileData = event.target.result;
const dataType = fileData.substring(5, fileData.indexOf(";"));
if (dataType === "application/octet-stream") {
// The user selected a file that appears to contain binary data;
// it's a good candidate for image interpretation so! Extract the
// binary data, store it in our sf2000ImageData global, and start
// setting up Step 3...
var base64Data = fileData.substring(fileData.indexOf(",") + 1);
var binaryData = atob(base64Data);
const base64Data = fileData.substring(fileData.indexOf(",") + 1);
const binaryData = atob(base64Data);
sf2000ImageData = new Uint8Array(binaryData.length);
for (var i = 0; i < binaryData.length; i++) {
for (let i = 0; i < binaryData.length; i++) {
sf2000ImageData[i] = binaryData.charCodeAt(i);
}
@ -137,10 +141,10 @@
function setupStepTwo_To() {
// Start our new section, add our header and our instructions...
var html = "<section id=\"selectImageFile\"><h2>Step 2: Select RGB888/RGBA Image File</h2><p>Select the image file you want to convert to an SF2000 image format. You can select either a PNG or JPEG format image. Transparency is respected for PNGs, but only if outputting to the BGRA output format.</p><div id=\"step2Messages\"></div>";
let html = "<section id=\"selectImageFile\"><h2>Step 2: Select RGB888/RGBA Image File</h2><p>Select the image file you want to convert to an SF2000 image format. You can select either a PNG or JPEG format image. Transparency is respected for PNGs, but only if outputting to the BGRA output format.</p><div id=\"step2Messages\"></div>";
// Add our file chooser...
html += "<div class=\"controlContainer\"><label class=\"control\"><input id=\"inputImage\" type=\"file\"></label></div>";
html += "<div class=\"controlContainer\"><label for=\"inputImage\" class=\"control\"><input id=\"inputImage\" type=\"file\" accept=\".jpg,.jpeg,.png\"></label></div>";
// Close our section...
html += "</section>";
@ -151,17 +155,16 @@
document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Attach our event handler to our new file input control...
var userImageInput = document.getElementById("inputImage");
const userImageInput = document.getElementById("inputImage");
userImageInput.addEventListener("change", function() {
// The user has chosen a new file; it should be either a PNG or JPEG
// image for converting to an SF2000 binary image format...
// First let's clear any old messages...
// image for converting to an SF2000 binary image format. First let's
// clear any old messages...
document.getElementById("step2Messages").innerHTML = "";
// Now let's read in the file...
var frImage = new FileReader();
const frImage = new FileReader();
fileName = event.target.files[0].name;
frImage.readAsDataURL(event.target.files[0]);
frImage.onload = function(event) {
@ -173,7 +176,6 @@
// Whoops! Doesn't look like the user provided a PNG or a JPEG!
setMessage("error", "step2Messages", "ERROR: The selected file does not appear to be either a PNG or JPEG image; please make sure you're selecting an appropriate image file.");
return;
}
// Now we're going to load the file's data into an Image object, so
@ -335,15 +337,15 @@
// Create the new section, add our heading and our instruction
// paragraph...
var html = "<section id=\"processSF2000File\"><h2>Step 3: Set Data Interpretation Options</h2><p>As the image files on the SF2000 are raw binary blobs, there's no information stored in them to say what their width or height is, what format the pixel information is stored in, etc.. Therefore, it is up to <em>you</em> to specify those details yourself, which you can do using the options below.</p><p>A preview of the image will appear below the options, reflecting the current options; the preview has a white dash outline so you can better judge where the bounds of transparent images are. Below the preview image is a \"Download\" button you can use to download the image as a PNG.</p><p>Depending on the size of the file you selected, this tool may recognise it as a \"known\" file from the SF2000, and if so the options below will have been set automatically for you. If not, or if the options are automatically set incorrectly, here's a brief description of what each option does:</p><dl><dt>Image Format:</dt><dd>Specifies the data format for each pixel in the image. There are two image formats currently used by the SF2000 - RGB565 (used for most of the \"basic\" UI elements, including main backgrounds, etc.), and BGRA (used for anything that requires transparency, such as the system logos on the main menu, etc.).</dd><dt>Width:</dt><dd>Specifies the width of the image in pixels.</dd><dt>Height:</dt><dd>Specifies the height of the image in pixels.</dd></dl>";
let html = "<section id=\"processSF2000File\"><h2>Step 3: Set Data Interpretation Options</h2><p>As the image files on the SF2000 are raw binary blobs, there's no information stored in them to say what their width or height is, what format the pixel information is stored in, etc.. Therefore, it is up to <em>you</em> to specify those details yourself, which you can do using the options below.</p><p>A preview of the image will appear below the options, reflecting the current options; the preview has a white dash outline so you can better judge where the bounds of transparent images are. Below the preview image is a \"Download\" button you can use to download the image as a PNG.</p><p>Depending on the size of the file you selected, this tool may recognise it as a \"known\" file from the SF2000, and if so the options below will have been set automatically for you. If not, or if the options are automatically set incorrectly, here's a brief description of what each option does:</p><dl><dt>Image Format:</dt><dd>Specifies the data format for each pixel in the image. There are two image formats currently used by the SF2000 - RGB565 (used for most of the \"basic\" UI elements, including main backgrounds, etc.), and BGRA (used for anything that requires transparency, such as the system logos on the main menu, etc.).</dd><dt>Width:</dt><dd>Specifies the width of the image in pixels.</dd><dt>Height:</dt><dd>Specifies the height of the image in pixels.</dd></dl>";
// Next, let's add our image controls; a select list for the image
// format, and number inputs for the interpreted width and height...
html += "<div class=\"controlContainer\">";
html += "<div class=\"control\"><label>Image Format: <select id=\"imageFormat\"><option" + (imageFormat == "RGB565" ? " selected" : "") + ">RGB565</option><option" + (imageFormat == "BGRA" ? " selected" : "") + ">BGRA</option></select></label></div>";
html += "<div class=\"control\"><label>Width: <input id=\"outputImageWidth\" type=\"number\" min=\"1\" step=\"1\" value=\"" + outputImageWidth + "\"></label></div>";
html += "<div class=\"control\"><label>Height: <input id=\"outputImageHeight\" type=\"number\" min=\"1\" step=\"1\" value=\"" + outputImageHeight + "\"></label></div>";
html += "</div>";
html += "<form id=\"fromSF2000ImageSettings\"class=\"controlContainer\">";
html += "<div class=\"control\"><label for=\"imageFormat\">Image Format: <select id=\"imageFormat\" name=\"imageFormat\"><option" + (imageFormat == "RGB565" ? " selected" : "") + ">RGB565</option><option" + (imageFormat == "BGRA" ? " selected" : "") + ">BGRA</option></select></label></div>";
html += "<div class=\"control\"><label for=\"outputImageWidth\">Width: <input id=\"outputImageWidth\" name=\"outputImageWidth\" type=\"number\" min=\"1\" step=\"1\" value=\"" + outputImageWidth + "\"></label></div>";
html += "<div class=\"control\"><label for=\"outputImageHeight\">Height: <input id=\"outputImageHeight\" name=\"outputImageHeight\" type=\"number\" min=\"1\" step=\"1\" value=\"" + outputImageHeight + "\"></label></div>";
html += "</form>";
// Next, we'll add the image preview...
html += "<div class=\"controlContainer\"><div class=\"control\"><canvas id=\"processFilePreview\"></canvas></div></div>";
@ -359,18 +361,12 @@
document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>");
document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Now, add our interactivity by attaching our event handlers; first up,
// our format selector...
var ifField = document.getElementById("imageFormat");
ifField.addEventListener("change", function() {
// Set our global imageFormat to the new value, and update our
// preview...
imageFormat = this.value;
convertFromSF2000AndRender();
});
// Next our width and height fields...
var whFields = [document.getElementById("outputImageWidth"), document.getElementById("outputImageHeight")];
// Now, attach some event handlers to our form controls; these will
// handle some of the logic for how I want the controls to behave. First
// up, our width and height fields - I have some limitations on the
// types of things I want typed in them, and how I want them to be
// changed...
const whFields = [document.getElementById("outputImageWidth"), document.getElementById("outputImageHeight")];
whFields.forEach(field => {
// When a key is pressed, only allow the events through under certain
@ -390,25 +386,39 @@
if (this.value === "" || this.value === "0") {
this.value = "1";
}
if (this.id == "outputImageWidth") {
outputImageWidth = this.value;
}
else {
outputImageHeight = this.value;
}
convertFromSF2000AndRender();
});
});
// And attach the event handler for the download button...
var downloadButton = document.getElementById("sf2000Download");
// Next, attach the event handler for the download button...
const downloadButton = document.getElementById("sf2000Download");
downloadButton.addEventListener("click", function() {
var canvas = document.getElementById("processFilePreview");
const canvas = document.getElementById("processFilePreview");
canvas.toBlob(function(blob) {
downloadToBrowser(blob, "image/png", fileName + ".png");
});
});
// That's the end of the specific UI event handlers; the final handler
// we'll set up is a general event handler for the entire form itself.
// Any time *any* of the controls in the form change, that event will
// bubble-up and trigger this handler, where we'll get the current state
// of the form's controls, and use it to re-convert and re-render the
// image using the current settings...
const fromSF2000Form = document.getElementById("fromSF2000ImageSettings");
fromSF2000Form.addEventListener("change", function() {
// Get the state of all of the form's controls...
const formData = Object.fromEntries(new FormData(fromSF2000Form));
// Set our general globals from the current form state...
imageFormat = formData.imageFormat;
outputImageWidth = formData.outputImageWidth;
outputImageHeight = formData.outputImageHeight;
// And re-convert and re-render based on those globals...
convertFromSF2000AndRender();
});
// We're nearly ready to wrap up Step 3; all that's left is to perform
// our initial conversion and rendering...
convertFromSF2000AndRender();
@ -423,7 +433,7 @@
// from a known image, we already have our width and height this time,
// and so we can just get on to rendering the HTML. Create our new
// section, add its heading and instruction paragraphs...
html = "<section id=\"processUserFile\"><h2>Step 3: Set Conversion Options</h2><p>The options below control how your image is converted for use on the SF2000. A preview of the image will appear below the options, reflecting their current settings; the preview has a white dash outline so you can better judge where the bounds of transparent images are. Below the preview image is a \"Download\" button you can use to download the image as binary blob for the SF2000. If you name your input image like <code>name.extension.extension</code> (e.g., <code>c1eac.pal.png</code>), then the download will automatically be named like <code>name.extension</code> (e.g., <code>c1eac.pal</code>); this may help to speed up your workflow.</p><p>Here's a brief description of what each option does:</p><dl><dt>Image Format:</dt><dd>Specifies the data format for the output image. There are two image formats currently used by the SF2000 - RGB565 (used for most of the \"basic\" UI elements, including main backgrounds, etc.), and BGRA (used for anything that requires transparency, such as the system logos on the main menu, etc.). If you're not sure which format to choose, <a href=\"https://vonmillhausen.github.io/sf2000/#images-used\" target=\"_blank\" rel=\"noreferrer noopener\">refer to my list of images used by the SF2000</a>.</dd><dt>Scaling:</dt><dd>One of two available scaling modes for your input image; the \"Scaling\" option lets you scale your image maintaining aspect ratio amongst several common sizes (e.g., 1x scaling to maintain the input image size, 2x scaling to double it, etc.)</dd><dt>Fit to:</dt><dd>The other available scaling mode; this will scale your image to the specified width and height, <em>without</em> maintaining aspect ratio. This will allow you to scale an image to any dimension you want, but it'll be up to you to do your own aspect ratio calculations.</dd><dt>Filter Type:</dt><dd>When scaling an image by anything other than 1x, specifies the type of image filtering to use. \"Nearest Neighbour\" will give sharp pixel scaling but only at integer upscales; any other scale will appear aliased. \"Bilinear\" will give a fuzzier scale, but it works better for non-integer scale factors and for downscaling.</dd></dl>";
let html = "<section id=\"processUserFile\"><h2>Step 3: Set Conversion Options</h2><p>The options below control how your image is converted for use on the SF2000. A preview of the image will appear below the options, reflecting their current settings; the preview has a white dash outline so you can better judge where the bounds of transparent images are. Below the preview image is a \"Download\" button you can use to download the image as binary blob for the SF2000. If you name your input image like <code>name.extension.extension</code> (e.g., <code>c1eac.pal.png</code>), then the download will automatically be named like <code>name.extension</code> (e.g., <code>c1eac.pal</code>); this may help to speed up your workflow.</p><p>Here's a brief description of what each option does:</p><dl><dt>Image Format:</dt><dd>Specifies the data format for the output image. There are two image formats currently used by the SF2000 - RGB565 (used for most of the \"basic\" UI elements, including main backgrounds, etc.), and BGRA (used for anything that requires transparency, such as the system logos on the main menu, etc.). If you're not sure which format to choose, <a href=\"https://vonmillhausen.github.io/sf2000/#images-used\" target=\"_blank\" rel=\"noreferrer noopener\">refer to my list of images used by the SF2000</a>.</dd><dt>Scaling:</dt><dd>One of two available scaling modes for your input image; the \"Scaling\" option lets you scale your image maintaining aspect ratio amongst several common sizes (e.g., 1x scaling to maintain the input image size, 2x scaling to double it, etc.)</dd><dt>Fit to:</dt><dd>The other available scaling mode; this will scale your image to the specified width and height, <em>without</em> maintaining aspect ratio. This will allow you to scale an image to any dimension you want, but it'll be up to you to do your own aspect ratio calculations.</dd><dt>Filter Type:</dt><dd>When scaling an image by anything other than 1x, specifies the type of image filtering to use. \"Nearest Neighbour\" will give sharp pixel scaling but only at integer upscales; any other scale will appear aliased. \"Bilinear\" will give a fuzzier scale, but it works better for non-integer scale factors and for downscaling.</dd></dl>";
// Now let's add our image controls; there's a few of them! We have a
// select box for choosing the image format, we have two different
@ -431,21 +441,24 @@
// scales, and one for user-defined output size), and we have a select
// box for choosing nearest-neighbour or hybrid gaussian/bilinear
// image filtering...
html += "<div class=\"controlContainer\">";
// Image format...
html += "<div class=\"control\"><label>Image Format: <select id=\"imageFormat\"><option" + (imageFormat == "RGB565" ? " selected" : "") + ">RGB565</option><option" + (imageFormat == "BGRA" ? " selected" : "") + ">BGRA</option></select></label></div>";
html += "<form id=\"toSF2000ImageSettings\" class=\"controlContainer\">";
// Image format and dithering options...
html += "<div class=\"control\">";
html += "<div class=\"control\"><label for=\"imageFormat\">Image Format: <select id=\"imageFormat\" name=\"imageFormat\"><option" + (imageFormat == "RGB565" ? " selected" : "") + ">RGB565</option><option" + (imageFormat == "BGRA" ? " selected" : "") + ">BGRA</option></select></label></div>";
html += "<div class=\"control\"><label for=\"ditherEnabled\">Dithering: <input type=\"checkbox\" id=\"ditherEnabled\" name=\"ditherEnabled\"" + (ditherEnabled == true ? " checked" : "") + (imageFormat == "BGRA" ? " disabled" : "") + "></label> <label for=\"ditherStrength\">Strength: <select id=\"ditherStrength\" name=\"ditherStrength\"" + (imageFormat == "BGRA" ? " disabled" : "") + "><option" + (ditherStrength == 0.1 ? " selected" : "") + ">0.1</option><option" + (ditherStrength == 0.2 ? " selected" : "") + ">0.2</option><option" + (ditherStrength == 0.3 ? " selected" : "") + ">0.3</option><option" + (ditherStrength == 0.5 ? " selected" : "") + ">0.5</option><option" + (ditherStrength == 0.75 ? " selected" : "") + ">0.75</option><option" + (ditherStrength == 1.0 ? " selected" : "") + ">1</option><option" + (ditherStrength == 2.0 ? " selected" : "") + ">2</option></select></label></div>";
html += "</div>";
// Scaling options...
html += "<div class=\"control\">";
html += "<div class=\"control\"><label><input type=\"radio\" name=\"scaleMode\" value=\"scale\"" + (userScaleMode == "scale" ? " checked" : "") + "> Scaling: </label><select id=\"scaleFactor\"" + (userScaleMode == "fit" ? " disabled" : "") + "><option" + (userScaleFactor == "0.5x" ? " selected" : "") + ">0.5x</option><option" + (userScaleFactor == "1x" ? " selected" : "") + ">1x</option><option" + (userScaleFactor == "2x" ? " selected" : "") + ">2x</option><option" + (userScaleFactor == "3x" ? " selected" : "") + ">3x</option><option" + (userScaleFactor == "4x" ? " selected" : "") + ">4x</option></select></div>";
html += "<div class=\"control\"><label for=\"scaleModeScale\"><input type=\"radio\" id=\"scaleModeScale\" name=\"scaleMode\" value=\"scale\"" + (userScaleMode == "scale" ? " checked" : "") + "> Scaling: </label><select id=\"scaleFactor\" name=\"scaleFactor\"" + (userScaleMode == "fit" ? " disabled" : "") + "><option" + (userScaleFactor == "0.5x" ? " selected" : "") + ">0.5x</option><option" + (userScaleFactor == "1x" ? " selected" : "") + ">1x</option><option" + (userScaleFactor == "2x" ? " selected" : "") + ">2x</option><option" + (userScaleFactor == "3x" ? " selected" : "") + ">3x</option><option" + (userScaleFactor == "4x" ? " selected" : "") + ">4x</option></select></div>";
html += " OR ";
html += "<div class=\"control\"><label><input type=\"radio\" name=\"scaleMode\" value=\"fit\"" + (userScaleMode == "fit" ? " checked" : "") + "> Fit to: </label><label>width <input id=\"userFitWidth\" type=\"number\" min=\"1\" step=\"1\" value=\""+ userFitWidth + "\"" + (userScaleMode == "scale" ? " disabled" : "") + "></label> and <label>height <input id=\"userFitHeight\" type=\"number\" min=\"1\" step=\"1\" value=\""+ userFitHeight + "\"" + (userScaleMode == "scale" ? " disabled" : "") + "></label></div>";
html += "<div class=\"control\"><label for=\"scaleModeFit\"><input type=\"radio\" id=\"scaleModeFit\" name=\"scaleMode\" value=\"fit\"" + (userScaleMode == "fit" ? " checked" : "") + "> Fit to: </label><label for=\"fitToWidthEnabled\"><input type=\"checkbox\" id=\"fitToWidthEnabled\" name=\"fitToWidthEnabled\"" + (fitToWidthEnabled == true ? " checked" : "") + (userScaleMode == "scale" ? " disabled" : "") + "> width </label><input id=\"userFitWidth\" name=\"userFitWidth\" type=\"number\" min=\"1\" step=\"1\" value=\""+ userFitWidth + "\"" + (userScaleMode == "scale" ? " disabled" : "") + "> and <label for=\"fitToHeightEnabled\"><input type=\"checkbox\" id=\"fitToHeightEnabled\" name=\"fitToHeightEnabled\"" + (fitToHeightEnabled == true ? " checked" : "") + (userScaleMode == "scale" ? " disabled" : "") + "> height </label><input id=\"userFitHeight\" name=\"userFitHeight\" type=\"number\" min=\"1\" step=\"1\" value=\""+ userFitHeight + "\"" + (userScaleMode == "scale" ? " disabled" : "") + "></div>";
html += "</div>";
// Filter type...
html += "<div class=\"control\"><label>Filter Type: <select id=\"userFilterType\"><option" + (userFilterType == "Nearest Neighbour" ? " selected" : "") + ">Nearest Neighbour</option><option" + (userFilterType == "Bilinear" ? " selected" : "") + ">Bilinear</option></select></label></div>";
html += "</div>";
html += "<div class=\"control\"><label for=\"userFilterType\">Filter Type: <select id=\"userFilterType\" name=\"userFilterType\"><option" + (userFilterType == "Nearest Neighbour" ? " selected" : "") + ">Nearest Neighbour</option><option" + (userFilterType == "Bilinear" ? " selected" : "") + ">Bilinear</option></select></label></div>";
html += "</form>";
// Next we'll add our image preview...
html += "<div class=\"controlContainer\"><div class=\"control\"><canvas id=\"processFilePreview\"></canvas></div></div>";
html += "<div class=\"controlContainer\"><div class=\"control alignC\"><span id=\"previewDimensions\"></span><br><canvas id=\"processFilePreview\"></canvas></div></div>";
// ... and our Download button...
html += "<div class=\"controlContainer\"><div class=\"control\"><input id=\"userDownload\" type=\"button\" value=\"Download\"></div></div>";
@ -458,76 +471,80 @@
document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>");
document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Now we'll attach event listeners for all of the interactive elements
// in this step. We'll start with our image format select box...
var ifField = document.getElementById("imageFormat");
ifField.addEventListener("change", function() {
// Now, attach some event handlers to our form controls; these will
// handle some of the logic for how I want the controls to behave.
// First, let's enable/disable dithering options depending on whether or
// not image format is set to RGB565...
const formatSelect = document.getElementById("imageFormat");
formatSelect.addEventListener("change", function() {
switch (this.value){
case "RGB565":
document.getElementById("ditherEnabled").removeAttribute("disabled");
document.getElementById("ditherStrength").removeAttribute("disabled");
break;
// Update our global variable with the new value, convert the user's
// image using the new format and render a preview...
imageFormat = this.value;
let newWH = calculateNewSize(userImageData);
convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType));
convertFromSF2000AndRender();
case "BGRA":
document.getElementById("ditherEnabled").setAttribute("disabled", "");
document.getElementById("ditherStrength").setAttribute("disabled", "");
break;
}
});
// The scaling mode radio buttons; these let the user choose between
// fixed-ratio scaling, or fixed width/height scaling...
var modes = document.getElementsByName("scaleMode");
for (var i = 0; i < modes.length; i++) {
// Now let's do the scaling mode radio buttons; these let the user
// choose between fixed-ratio scaling, or fixed width/height scaling...
const modes = document.getElementsByName("scaleMode");
for (let i = 0; i < modes.length; i++) {
modes[i].addEventListener("change", function() {
// Update our global variable with the new selection...
userScaleMode = this.value;
// Check what they chose...
if (userScaleMode == "scale") {
if (this.value == "scale") {
// They chose fixed-ratio scaling, so enable the ratio selector,
// and disable the fixed-width/height number inputs...
// and disable the fixed-width/height controls...
document.getElementById("scaleFactor").removeAttribute("disabled");
document.getElementById("fitToWidthEnabled").setAttribute("disabled", "");
document.getElementById("userFitWidth").setAttribute("disabled", "");
document.getElementById("fitToHeightEnabled").setAttribute("disabled", "");
document.getElementById("userFitHeight").setAttribute("disabled", "");
}
else if (userScaleMode == "fit") {
else if (this.value == "fit") {
// They chose fixed width/height scaling, so enable our
// width/height number inputs, and disable the ratio-scaling
// selection box...
document.getElementById("fitToWidthEnabled").removeAttribute("disabled");
document.getElementById("userFitWidth").removeAttribute("disabled");
document.getElementById("fitToHeightEnabled").removeAttribute("disabled");
document.getElementById("userFitHeight").removeAttribute("disabled");
document.getElementById("scaleFactor").setAttribute("disabled", "");
}
// We'll also want to re-convert the user's image data and render
// a new preview...
let newWH = calculateNewSize(userImageData);
convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType));
convertFromSF2000AndRender();
});
}
// The fixed-ratio scaling selector...
var sField = document.getElementById("scaleFactor");
sField.addEventListener("change", function() {
// Update our global variable with the newly chosen scaling factor,
// and then re-convert the user's image data and render a new
// preview...
userScaleFactor = this.value;
let newWH = calculateNewSize(userImageData);
convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType));
convertFromSF2000AndRender();
// Next, the fixed-width/height checkboxes; these have a little twist
// whereby we don't want to allow them both to be unticked at the same
// time - so we catch click events, and ignore them if they would cause
// both checkboxes to be unticked...
const widthCheckbox = document.getElementById("fitToWidthEnabled");
widthCheckbox.addEventListener("click", function() {
const heightCheckbox = document.getElementById("fitToHeightEnabled");
if (heightCheckbox.checked == false) {
event.preventDefault();
}
});
const heightCheckbox = document.getElementById("fitToHeightEnabled");
heightCheckbox.addEventListener("click", function() {
const widthCheckbox = document.getElementById("fitToWidthEnabled");
if (widthCheckbox.checked == false) {
event.preventDefault();
}
});
// And the fixed-width/height number inputs...
var whFields = [document.getElementById("userFitWidth"), document.getElementById("userFitHeight")];
// And now the fixed-width/height number inputs...
const whFields = [document.getElementById("userFitWidth"), document.getElementById("userFitHeight")];
whFields.forEach(field => {
// These get two event listeners; the first is designed to prevent the
// user from entering anything other than positive integers...
field.addEventListener("keydown", function(event) {
// Only allow integer entry and things like arrow keys, etc!
if (event.key.length > 1 || /\d/.test(event.key)) {
return;
@ -535,95 +552,129 @@
event.preventDefault();
});
// ... and this updates our global variables, re-converts the user's
// image data and renders a new preview image when the numbers are
// changed...
// ... and the second catches when if the value is blank or set to 0,
// and if so sets it to 1 instead...
field.addEventListener("change", function() {
if (this.value === "" || this.value === "0") {
this.value = "1";
}
if (this.id == "userFitWidth") {
userFitWidth = this.value;
}
else {
userFitHeight = this.value;
}
let newWH = calculateNewSize(userImageData);
convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType));
convertFromSF2000AndRender();
});
});
// Next, the event listener for our filter type selection...
var ftField = document.getElementById("userFilterType");
ftField.addEventListener("change", function() {
// Update our global variable with the new filter type, re-convert
// the user's image data, and update our preview...
userFilterType = this.value;
let newWH = calculateNewSize(userImageData);
convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType));
convertFromSF2000AndRender();
});
// And last but not least, our Download button...
var dButton = document.getElementById("userDownload");
const dButton = document.getElementById("userDownload");
dButton.addEventListener("click", function() {
// For the file-name we're going to do something semi-fancy; if the
// user's original image file name had *two* extensions (e.g.,
// c1eac.pal.png), then we'll strip off the second extension and
// just save the file with the remainder of the name (e.g.,
// c1eac.pal) - this will speed up their workflow if their source
// image came from a design tool that exports images named after
// layers/artboards. Otherwise, we'll just use the name of the
// file they provided with ".bin" appended to the end...
var downloadName = fileName + ".bin";
var match = fileName.match(/(.+)\.([^.]+)\.([^.]+)/);
// c1eac.pal.png), then we'll strip off the second extension and just
// save the file with the remainder of the name (e.g., c1eac.pal) -
// this will speed up their workflow if their source image came from a
// design tool that exports images named after layers/art-boards.
// Otherwise, we'll just use the name of the file they provided with
// ".bin" appended to the end...
let downloadName = fileName + ".bin";
const match = fileName.match(/(.+)\.([^.]+)\.([^.]+)/);
if (match) {
// It's got a double extension - so let's just strip off the
// second one...
// It's got a double extension - so let's just strip off the second
// one...
downloadName = match[1] + "." + match[2];
}
downloadToBrowser(sf2000ImageData, "application/octet-stream", downloadName);
});
// Great, that's all the UI handling done. The final event we'll set up
// is on the overall control strip itself, to watch for changes to any
// of the controls. When any of them change, this event will trigger,
// and we'll get the new control state and use their current values to
// render an updated image...
const toSF2000Form = document.getElementById("toSF2000ImageSettings");
toSF2000Form.addEventListener("change", function() {
// Get the state of all of our form controls...
const formData = Object.fromEntries(new FormData(toSF2000Form));
// Set our general globals from the current form state...
imageFormat = formData.imageFormat;
if (formData.ditherEnabled !== undefined) {ditherEnabled = true;}
else if (imageFormat == "RGB565") {ditherEnabled = false;}
ditherEnabled = formData.ditherEnabled ? true : ditherEnabled;
ditherStrength = formData.ditherStrength? parseFloat(formData.ditherStrength) : ditherStrength;
userScaleMode = formData.scaleMode;
userScaleFactor = formData.scaleFactor;
if (formData.fitToWidthEnabled !== undefined) {fitToWidthEnabled = true;}
else if (userScaleMode == "fit") {fitToWidthEnabled = false;}
if (formData.fitToHeightEnabled !== undefined) {fitToHeightEnabled = true;}
else if (userScaleMode == "fit") {fitToHeightEnabled = false;}
userFitWidth = formData.userFitWidth ? formData.userFitWidth : userFitWidth;
userFitHeight = formData.userFitHeight ? formData.userFitHeight : userFitHeight;
userFilterType = formData.userFilterType;
// Finally render an updated preview...
calculateOutputDimensions();
convertImageToSF2000(scaleImage(userImageData, outputImageWidth, outputImageHeight, userFilterType), ditherEnabled, ditherStrength);
convertFromSF2000AndRender();
});
// The last thing to do here in Step 3 is to perform our initial
// image data conversion and preview rendering... so let's do it!
let newWH = calculateNewSize(userImageData);
convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType));
calculateOutputDimensions();
convertImageToSF2000(scaleImage(userImageData, outputImageWidth, outputImageHeight, userFilterType), ditherEnabled, ditherStrength);
convertFromSF2000AndRender();
}
// This function takes in an ImageData object, and given our global
// variables for the image conversion options, returns an object
// containing the new width and height of the image based on those
// options...
function calculateNewSize(imageData) {
var newWidth = userFitWidth;
var newHeight = userFitHeight;
if (userScaleMode == "scale") {
newWidth = Math.round(imageData.width * Number(userScaleFactor.slice(0, -1)));
newHeight = Math.round(imageData.height * Number(userScaleFactor.slice(0, -1)));
// This function takes our userImageData global, and given our other
// global variables for the image conversion options, sets
// outputImageWidth and outputImageHeight based on calculated dimensions
// from the options...
function calculateOutputDimensions() {
// Default to just being the same size as the original image...
outputImageWidth = userImageData.width;
outputImageHeight = userImageData.height;
// Check to see which scaling mode the user has selected...
switch (userScaleMode){
case "scale":
// They've selected "scale" mode! So take the value from the
// selection box, strip off the "x" character at the end, and use it
// as a multiplier for the original image dimensions, rounding to
// the nearest whole number...
outputImageWidth = Math.round(outputImageWidth * Number(userScaleFactor.slice(0, -1)));
outputImageHeight = Math.round(outputImageHeight * Number(userScaleFactor.slice(0, -1)));
break;
case "fit":
// They've selected "fit to width/height" mode! This is a little
// more involved than "scale", as the final width/height will depend
// on which combination of the width/height fitting checkboxes are
// currently checked...
if (fitToWidthEnabled){
outputImageWidth = Number(userFitWidth);
if (fitToHeightEnabled) {
outputImageHeight = Number(userFitHeight);
}
else {
outputImageHeight = Math.round(userImageData.height * (outputImageWidth / userImageData.width));
}
}
else if (fitToHeightEnabled){
outputImageHeight = Number(userFitHeight);
outputImageWidth = Math.round(userImageData.width * (outputImageHeight / userImageData.height));
}
break;
}
// Update the global variables used for rendering SF2000 image data with
// our new output width and height...
outputImageWidth = newWidth;
outputImageHeight = newHeight;
return {width: newWidth, height: newHeight};
}
// This function takes in an ImageData object, and uses our standard
// library conversion functions to convert it to either RGB565 or BGRA
// format, and sets the sf2000ImageData global to the resulting
// Uint8Array object...
function convertImageToSF2000(sourceData) {
function convertImageToSF2000(sourceData, dither, ditherStrength) {
if (imageFormat == "RGB565") {
sf2000ImageData = imageDataToRgb565(sourceData);
sf2000ImageData = imageDataToRgb565(sourceData, dither, ditherStrength);
}
else if (imageFormat == "BGRA") {
sf2000ImageData = imageDataToBgra(sourceData);
@ -641,15 +692,20 @@
else if (imageFormat == "BGRA") {
previewCanvasData = bgraToImageData(sf2000ImageData, outputImageWidth, outputImageHeight);
}
var canvas = document.getElementById("processFilePreview");
var context = canvas.getContext("2d");
const canvas = document.getElementById("processFilePreview");
const context = canvas.getContext("2d");
canvas.width = previewCanvasData.width;
canvas.height = previewCanvasData.height;
context.clearRect(0, 0, canvas.width, canvas.height);
context.putImageData(previewCanvasData, 0, 0);
// Just as a utility, let's also show the output image's width and
// height; we'll also set this as a title on the canvas...
document.getElementById("previewDimensions").innerText = previewCanvasData.width + " x " + previewCanvasData.height;
canvas.setAttribute("title", previewCanvasData.width + " x " + previewCanvasData.height);
}
</script>
<hr>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.1, 20230626.1</p>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.2, 20240514.1</p>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -11,6 +11,10 @@
Just like the tools themselves, this file should be considered CC0 Public
Domain (https://creativecommons.org/publicdomain/zero/1.0/)
Version 1.1: Added a max-width to the body element, and auto margins to center
things again (just stops the page being too wide on full-screen desktop
browsers).
Version 1.0: Initial version
*/
@ -44,6 +48,8 @@ body {
background-color: var(--background);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif;
max-width: 80em;
margin: 0 auto;
}
a, a:visited, a:hover, a:active { color: inherit; }

View File

@ -12,6 +12,17 @@
Just like the tools themselves, this file should be considered CC0 Public
Domain (https://creativecommons.org/publicdomain/zero/1.0/)
Version 1.7: Added ordered dithering support to imageDataToRgb565(). Also
swapped out the Gaussian resampling function in scaleImage() for a higher
quality algorithm based on a semi-custom hybrid between a looped 50%
bilinear downsampling scheme and a Hermite interpolation resampler. Added a
"keepAlpha" argument to imageToImageData() (defaults to true) - if false,
the provided image is drawn on a black background, removing any alpha
channel; this was added as a utility for the boot logo changer. Also
generally tidied up the code here and there (changed vars to lets/consts,
replaced the SVG icons with simple emoji, fixed a few small logic edge
cases, etc.)
Version 1.6: Added support for the (hopefully not broken) October 13th BIOS in
getFirmwareHash() and knownHash()
@ -44,17 +55,17 @@
// 0x18f with an updated CRC32 calculated on bytes 512 to the end of the
// array. Credit to `bnister` for this code!
function patchCRC32(data) {
var c;
var tabCRC32 = new Int32Array(256);
for (var i = 0; i < 256; i++) {
let c;
const tabCRC32 = new Int32Array(256);
for (let i = 0; i < 256; i++) {
c = i << 24;
for (var j = 0; j < 8; j++) {
for (let j = 0; j < 8; j++) {
c = c & (1 << 31) ? c << 1 ^ 0x4c11db7 : c << 1;
}
tabCRC32[i] = c;
}
c = ~0;
for (var i = 512; i < data.length; i++) {
for (let i = 512; i < data.length; i++) {
c = c << 8 ^ tabCRC32[c >>> 24 ^ data[i]];
}
data[0x18c] = c & 255;
@ -71,7 +82,7 @@ function getFirmwareHash(data) {
// we're going to be manipulating that data before generating our hash, but we
// don't want to modify the original object at all... so we'll create a copy,
// and work only on the copy...
var dataCopy = data.slice();
const dataCopy = data.slice();
// Only really worthwhile doing this for big bisrv.asd files...
if (dataCopy.length > 12600000) {
@ -87,9 +98,9 @@ function getFirmwareHash(data) {
// level indicator. These unfortunately can't be searched for - they're just
// in specific known locations for specific firmware versions...
// Location: Approximately 0x35A8F8 (about 25% of the way through the file)
var prePowerCurve = findSequence([0x11, 0x05, 0x00, 0x02, 0x24], dataCopy);
const prePowerCurve = findSequence([0x11, 0x05, 0x00, 0x02, 0x24], dataCopy);
if (prePowerCurve > -1) {
var powerCurveFirstByteLocation = prePowerCurve + 5;
const powerCurveFirstByteLocation = prePowerCurve + 5;
switch (powerCurveFirstByteLocation) {
case 0x35A8F8:
// Seems to match mid-March layout...
@ -156,11 +167,11 @@ function getFirmwareHash(data) {
// Next identify the emulator button mappings (if they exist), and blank
// them out too...
// Location: Approximately 0x8D6200 (about 75% of the way through the file)
var preButtonMapOffset = findSequence([0x00, 0x00, 0x00, 0x71, 0xDB, 0x8E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], dataCopy);
const preButtonMapOffset = findSequence([0x00, 0x00, 0x00, 0x71, 0xDB, 0x8E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], dataCopy);
if (preButtonMapOffset > -1) {
var postButtonMapOffset = findSequence([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00], dataCopy, preButtonMapOffset);
const postButtonMapOffset = findSequence([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00], dataCopy, preButtonMapOffset);
if (postButtonMapOffset > -1) {
for (var i = preButtonMapOffset + 16; i < postButtonMapOffset; i++) {
for (let i = preButtonMapOffset + 16; i < postButtonMapOffset; i++) {
dataCopy[i] = 0x00;
}
}
@ -174,10 +185,10 @@ function getFirmwareHash(data) {
// Next identify the boot logo position, and blank it out too...
// Location: Approximately 0x9B3520 (about 80% of the way through the file)
var badExceptionOffset = findSequence([0x62, 0x61, 0x64, 0x5F, 0x65, 0x78, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6F, 0x6E, 0x00, 0x00, 0x00], dataCopy);
const badExceptionOffset = findSequence([0x62, 0x61, 0x64, 0x5F, 0x65, 0x78, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6F, 0x6E, 0x00, 0x00, 0x00], dataCopy);
if (badExceptionOffset > -1) {
var bootLogoStart = badExceptionOffset + 16;
for (var i = bootLogoStart; i < (bootLogoStart + 204800); i++) {
const bootLogoStart = badExceptionOffset + 16;
for (let i = bootLogoStart; i < (bootLogoStart + 204800); i++) {
dataCopy[i] = 0x00;
}
}
@ -189,10 +200,10 @@ function getFirmwareHash(data) {
// CPU cycles, in case folks want to patch those bytes to correct SNES
// first-launch issues on newer firmwares...
// Location: Approximately 0xC0A170 (about 99% of the way through the file)
var preSNESBytes = findSequence([0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80], dataCopy);
const preSNESBytes = findSequence([0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80], dataCopy);
if (preSNESBytes > -1) {
var snesAudioBitrateBytes = preSNESBytes + 8;
var snesCPUCyclesBytes = snesAudioBitrateBytes + 8;
const snesAudioBitrateBytes = preSNESBytes + 8;
const snesCPUCyclesBytes = snesAudioBitrateBytes + 8;
dataCopy[snesAudioBitrateBytes] = 0x00;
dataCopy[snesAudioBitrateBytes + 1] = 0x00;
dataCopy[snesCPUCyclesBytes] = 0x00;
@ -208,9 +219,8 @@ function getFirmwareHash(data) {
// against some known values...
return crypto.subtle.digest("SHA-256", dataCopy.buffer)
.then(function(digest) {
var array = Array.from(new Uint8Array(digest));
var hash = array.map(byte => ("00" + byte.toString(16)).slice(-2)).join("");
return hash;
const array = Array.from(new Uint8Array(digest));
return array.map(byte => ("00" + byte.toString(16)).slice(-2)).join("");
})
.catch(function(error) {
return false;
@ -224,26 +234,21 @@ function getFirmwareHash(data) {
// This function searches for array needle in array haystack starting at offset
// and returns the zero-based index of the first match found, or -1 if not
// found...
function findSequence(needle, haystack, offset) {
// If offset is not provided, default to 0...
offset = offset || 0;
function findSequence(needle, haystack, offset = 0) {
// Loop through the haystack array starting from the offset...
for (var i = offset; i < haystack.length - needle.length + 1; i++) {
for (let i = offset; i < haystack.length - needle.length + 1; i++) {
// Assume a match until proven otherwise...
var match = true;
let match = true;
// Loop through the needle array and compare each byte...
for (var j = 0; j < needle.length; j++) {
for (let j = 0; j < needle.length; j++) {
if (haystack[i + j] !== needle[j]) {
// Mismatch found, break the inner loop and continue the outer loop...
match = false;
break;
}
}
// If match is still true after the inner loop, we have found a match;
@ -261,7 +266,7 @@ function findSequence(needle, haystack, offset) {
// download...
function downloadToBrowser(data, type, name) {
// Send the data to the user's browser as a file download...
var link = document.createElement("a");
const link = document.createElement("a");
link.href = window.URL.createObjectURL(new Blob([data], {type: type}));
link.download = name;
link.style.display = "none";
@ -304,33 +309,103 @@ function knownHash(hash) {
}
// Takes in an ImageData object, and returns a Uint8Array object containing the
// data in little-endian RGB565 format...
function imageDataToRgb565(input) {
// data in little-endian RGB565 format. Optionally supports applying ordered
// dithering to the input...
function imageDataToRgb565(input, dither = false, ditherStrength = 0.2) {
// Pre-define a Bayer 8x8 matrix for ordered dithering (if enabled); also,
// this matrix was shamelessly yoinked from the Wikipedia example!
const bayerMatrix = [
[ 0, 32, 8, 40, 2, 34, 10, 42],
[48, 16, 56, 24, 50, 18, 58, 26],
[12, 44, 4, 36, 14, 46, 6, 38],
[60, 28, 52, 20, 62, 30, 54, 22],
[ 3, 35, 11, 43, 1, 33, 9, 41],
[51, 19, 59, 27, 49, 17, 57, 25],
[15, 47, 7, 39, 13, 45, 5, 37],
[63, 31, 55, 23, 61, 29, 53, 21]
];
// Loop through the image data, and convert it to little-endian RGB565. First,
// we'll store the raw RGB565-converted integers in an array, one entry per
// pixel...
var intArray = [];
var pixelCount = 0;
for (var i = 0; i < input.data.length; i += 4){
const intArray = [];
for (let i = 0; i < input.data.length; i += 4){
// Read in the raw source RGB colours from the image data stream...
var red = input.data[i];
var green = input.data[i+1];
var blue = input.data[i+2];
let red = input.data[i];
let green = input.data[i+1];
let blue = input.data[i+2];
// Use some shifting and masking to get a big-endian version of the RGB565
// colour and store it in our array before moving on...
intArray[pixelCount] = ((red & 248)<<8) + ((green & 252)<<3) + (blue>>3);
pixelCount++;
// Check if we're going to dither or not...
if (dither) {
// We are! The ordered dither algorithm is kinda messy, but essentially we
// just end up slightly brightening/darkening our source colour pixels,
// using the matrix defined above - this essentially adds a kind of
// "noise" to the image, which stops banding being as apparent when
// dropping down to RGB565. The first thing we need to do is calculate our
// X and Y coordinates within the Bayer matrix for the current source
// image pixel...
const bayerX = (i / 4) % input.width % 8;
const bayerY = (Math.floor(i / 4 / input.width)) % 8;
// The Wikipedia Bayer matrix was designed to work with colour values that
// range from 0 to 63... which is great for our RGB565 green colour
// channel, but not for red or blue - so we scale the matrix values to
// range from 0 to 31 for those two colour channels. Also, we want to both
// lighten *and* darken our source input values, so to do that we subtract
// roughly half the *possible* maximum from each value - 16 for red and
// blue (half of 31, rounded up), and 32 for green (half of 63, rounded
// up); note I'm using actual values of 18 and 36 instead of 16 and 32, as
// I found that the Wikipedia matrix tended towards lightening more than
// darkening, so I'm compensating by offsetting downwards a little more...
const bayerValueRedBlue = (bayerMatrix[bayerY][bayerX] / 63 * 31) - 18;
const bayerValueGreen = bayerMatrix[bayerY][bayerX] - 36;
// Now we apply the ordered dithering itself; basically this "adds" the
// Bayer matrix values to red, green and blue (which might lighten or
// darken the pixel, depending on the specific value), and then uses that
// value as a percentage of 255 (the highest possible value) to scale our
// *actual* RGB565 output values for the pixel (which is a maximum of 31
// for red and blue, and 63 for green). We also scale the whole effect by
// ditherStrength, so that we can adjust how strong or weak the overall
// dithering noise is (too strong and its distracting, too weak and it
// mightn't effectively cover up banding in the output image). We scale
// the green colour channel half as much as red and blue, as otherwise at
// high dither strengths the image would take on an increasingly green
// color cast...
red = Math.round(31 * (Math.min(255, Math.max(0, red + (bayerValueRedBlue * ditherStrength))) / 255));
green = Math.round(63 * (Math.min(255, Math.max(0, green + (bayerValueGreen * ditherStrength * 0.5))) / 255));
blue = Math.round(31 * (Math.min(255, Math.max(0, blue + (bayerValueRedBlue * ditherStrength))) / 255));
// As a result of the multiplying above, it's possible our red, green and
// blue values are now outside of the 0-31/63 ranges that are allowed for
// our RGB565 output - so we need to clamp the values, just in case...
red = Math.min(31, Math.max(0, red));
green = Math.min(63, Math.max(0, green));
blue = Math.min(31, Math.max(0, blue));
// And finally, we take our values and convert them to an int representing
// the RGB565 value, that will eventually be stuffed into our output
// Uint8Array object...
intArray[i / 4] = (red << 11) + (green << 5) + blue;
}
else {
// We're not dithering, so all we need to do is use some shifting and
// masking to get a big-endian version of the RGB565 colour and store it
// in our array before moving on...
intArray[i / 4] = ((red & 248)<<8) + ((green & 252)<<3) + (blue>>3);
}
}
// Create a data buffer and a data view; we'll use the view to convert our int
// array data to little-endian format (the "true" below) to be stored in the
// buffer...
var buffer = new ArrayBuffer(intArray.length * 2);
var dataView = new DataView(buffer);
for (var i = 0; i < intArray.length; i++) {
const buffer = new ArrayBuffer(intArray.length * 2);
const dataView = new DataView(buffer);
for (let i = 0; i < intArray.length; i++) {
dataView.setInt16(i * 2, intArray[i], true);
}
@ -344,8 +419,8 @@ function imageDataToBgra(input) {
// This is pretty simple - we just loop through the input data (which is in
// RGBA format), and swap the Red and Blue channels to output BGRA instead...
output = new Uint8Array(input.data.length);
for (var i = 0; i < input.data.length; i += 4) {
const output = new Uint8Array(input.data.length);
for (let i = 0; i < input.data.length; i += 4) {
output[i] = input.data[i + 2];
output[i + 1] = input.data[i + 1];
output[i + 2] = input.data[i];
@ -360,25 +435,25 @@ function rgb565ToImageData(input, width, height) {
// Create an output ImageData object of the specified dimensions; it'll
// default to transparent black, but we'll fill it with our input data...
output = new ImageData(width, height);
outputIndex = 0;
for (var i = 0; i < input.length; i += 2) {
const output = new ImageData(width, height);
let outputIndex = 0;
for (let i = 0; i < input.length; i += 2) {
// Check to make sure we haven't run out of space in our output buffer...
if (outputIndex < output.data.length) {
// Read in two bytes, representing one RGB565 pixel in little-endian
// format...
var byte1 = input[i];
var byte2 = input[i + 1];
const byte1 = input[i];
const byte2 = input[i + 1];
// Extract the red, green and blue components from them. The first five
// bits of byte2 are red, the first three bits of byte1 and the last
// three bits of byte 2 are green, and the last five bits of byte1 are
// blue...
var red = (byte2 & 0b11111000) >> 3;
var green = ((byte1 & 0b11100000) >> 5) | ((byte2 & 0b00000111) << 3);
var blue = byte1 & 0b00011111;
let red = (byte2 & 0b11111000) >> 3;
let green = ((byte1 & 0b11100000) >> 5) | ((byte2 & 0b00000111) << 3);
let blue = byte1 & 0b00011111;
// These values are in 5-bit/6-bit ranges; we need to scale them to 8-bit
// ranges for the colours to look right...
@ -422,9 +497,9 @@ function bgraToImageData(input, width, height) {
// Create an output ImageData object of the specified dimensions; it'll
// default to transparent black, but we'll fill it with our input data...
output = new ImageData(width, height);
outputIndex = 0;
for (var i = 0; i < input.length; i += 4) {
const output = new ImageData(width, height);
let outputIndex = 0;
for (let i = 0; i < input.length; i += 4) {
// Check to make sure we haven't run out of space in our output buffer...
if (outputIndex < output.data.length) {
@ -461,193 +536,302 @@ function bgraToImageData(input, width, height) {
// This function takes in a Javascript Image object, and outputs it as an
// ImageData object instead...
function imageToImageData(image) {
function imageToImageData(image, keepAlpha = true) {
// Create a virtual canvas, and load it up with our image file...
var canvas = document.createElement("canvas");
var context = canvas.getContext("2d");
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.width = image.width;
canvas.height = image.height;
// Draw our image to the canvas, which will allow us to get data about the
// image...
// image. If the user doesn't want to keep the alpha channel, draw the image
// on a black background...
if (!keepAlpha) {
context.fillStyle = "black";
context.fillRect(0, 0, image.width, image.height);
}
context.drawImage(image, 0, 0, image.width, image.height);
var imageData = context.getImageData(0, 0, image.width, image.height);
const imageData = context.getImageData(0, 0, image.width, image.height);
// Return the ImageData object...
return imageData;
}
// This function takes in an ImageData object, and returns a new ImageData
// object containing a scaled version of the original image. The new image's
// width, height and scaling method are also specified...
function scaleImage(input, newWidth, newHeight, method) {
// This function takes in an ImageData object "input", and returns a new
// ImageData object containing a scaled version of the original image resized to
// "newWidth" and "newHeight". Two different scaling methods are supported:
// "Nearest Neighbour", and "Bilinear", although specifically when downscaling,
// "Bilinear" is just a friendly name for two different scaling
// filters/techniques that are used in a hybrid approach - halve-to-target, and
// Hermite interpolation...
function scaleImage(input, newWidth, newHeight, method, downscaleFilter = "hermite") {
// Utility function which takes in an ImageData object, and returns
// a (partially) upscaled version using bilinear filtering...
// Utility function which takes in an ImageData object, and returns a scaled
// version using bilinear filtering...
function _bilinear(imageData, newWidth, newHeight) {
var canvas = document.createElement("canvas");
var context = canvas.getContext("2d");
canvas.width = imageData.width;
canvas.height = imageData.height;
context.putImageData(imageData, 0, 0);
var outCanvas = document.createElement("canvas");
var outContext = outCanvas.getContext("2d");
outContext.imageSmoothingEnabled = true;
outContext.imageSmoothingQuality = "high";
outCanvas.width = newWidth;
outCanvas.height = newHeight;
outContext.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, newWidth, newHeight);
return outContext.getImageData(0, 0, newWidth, newHeight);
}
// Utility function which takes in an ImageData object, and returns
// a (partially) downscaled version using gaussian resampling...
// [Side note: I asked Bing AI for this function; ain't technology grand!]
function _gaussian(imageData, newWidth, newHeight) {
// Get the original width and height of the image data
let oldWidth = imageData.width;
let oldHeight = imageData.height;
// Get the pixel data array of the image data
let oldData = imageData.data;
// Create a new pixel data array for the scaled image data
let newData = new Uint8ClampedArray(newWidth * newHeight * 4);
// Calculate the scaling factor along each axis
let scaleX = newWidth / oldWidth;
let scaleY = newHeight / oldHeight;
// Calculate the radius of the Gaussian kernel based on the scaling factor
let radiusX = Math.ceil(1 / scaleX);
let radiusY = Math.ceil(1 / scaleY);
// Calculate the size of the Gaussian kernel along each axis
let sizeX = radiusX * 2 + 1;
let sizeY = radiusY * 2 + 1;
// Create a Gaussian kernel array
let kernel = new Float32Array(sizeX * sizeY);
// Calculate the standard deviation of the Gaussian distribution based on
// the radius
let sigmaX = radiusX / 3;
let sigmaY = radiusY / 3;
// Calculate the inverse of the variance of the Gaussian distribution along
// each axis
let invVarX = 1 / (2 * sigmaX * sigmaX);
let invVarY = 1 / (2 * sigmaY * sigmaY);
// Calculate the normalization factor for the Gaussian kernel
let norm = Math.sqrt(2 * Math.PI * sigmaX * sigmaY);
// Loop through each element in the Gaussian kernel array
for (let ky = -radiusY; ky <= radiusY; ky++) {
for (let kx = -radiusX; kx <= radiusX; kx++) {
// Calculate the index of the element in the Gaussian kernel array
let k = (ky + radiusY) * sizeX + (kx + radiusX);
// Calculate the value of the element using the Gaussian formula
kernel[k] = Math.exp(-(kx * kx) * invVarX - (ky * ky) * invVarY) / norm;
}
// Just in case, let's check to see if imageData's dimensions are already at
// the target width and height - if they are, there's no need to do anything
// to it, and we can just return it as-is...
if (imageData.width == newWidth && imageData.height == newHeight) {
return imageData;
}
// Loop through each pixel in the new image data
for (let y = 0; y < newHeight; y++) {
for (let x = 0; x < newWidth; x++) {
// Calculate the corresponding coordinates in the old image data
let oldX = x / scaleX;
let oldY = y / scaleY;
// If we're here, then nope - we have some scaling to do! Create a canvas
// object and draw our input ImageData object to it...
const inputCanvas = document.createElement("canvas");
const inputContext = inputCanvas.getContext("2d");
inputCanvas.width = imageData.width;
inputCanvas.height = imageData.height;
inputContext.putImageData(imageData, 0, 0);
// Initialize the RGBA values of the pixel to zero
let r7 = 0;
let g7 = 0;
let b7 = 0;
let a7 = 0;
// Create another canvas object with the target dimensions, and draw the
// inputCanvas to it at target dimensions; this utilises the browser's
// native bilinear filtering...
const outputCanvas = document.createElement("canvas");
const outputContext = outputCanvas.getContext("2d");
outputContext.imageSmoothingEnabled = true;
outputContext.imageSmoothingQuality = "high";
outputCanvas.width = newWidth;
outputCanvas.height = newHeight;
outputContext.drawImage(inputCanvas, 0, 0, inputCanvas.width, inputCanvas.height, 0, 0, newWidth, newHeight);
// Initialize the sum of the kernel values to zero
let sum = 0;
// Return an ImageData object pulled from the output canvas...
return outputContext.getImageData(0, 0, newWidth, newHeight);
}
// Loop through each element in the Gaussian kernel array
for (let ky = -radiusY; ky <= radiusY; ky++) {
for (let kx = -radiusX; kx <= radiusX; kx++) {
// Calculate the index of the element in the Gaussian kernel array
let k = (ky + radiusY) * sizeX + (kx + radiusX);
// Utility function which takes in an ImageData object, and returns a
// downscaled version using a technique that involves downscaling the input's
// width and height repeatedly by half until doing so again would take the
// image below the target dimensions. The result is then blended with the next
// scale down based on how close the target dimensions are to the current or
// next scale - this essentially gives an effect like mip-mapping in 3D games.
// This method is fast, doesn't introduce image distortion, and works great
// with alpha channels... but can give "soft" images if the target image
// dimensions are just under native scale (or a 50% downscale threshold), and
// can give a "harsh" image if the target dimensions are just above a
// downscale threshold. I try to mitigate the latter by blending in an
// additional downscale when we get close to a downscale threshold. This
// function and sub-functions are based on the following JSFiddle code, but
// with my own modifications: https://jsfiddle.net/1b68eLdr/93089/
function _halveToTarget(imageData, newWidth, newHeight) {
// Get the value of the element in the Gaussian kernel array
let w = kernel[k];
// This sub-function takes two ImageData objects (assumed to be of identical
// dimensions) and blends their RGBA data together based on the passed-in
// amount (between 0 and 1 inclusive) of how much of imageDataOne should be
// in the blend. An amount of 1 means the result will be purely
// imageDataOne, while an amount of 0 means the result will be purely
// imageDataTwo. Numbers between 0 and 1 will give an appropriate mix of the
// two. The result of the blend is then returned as an ImageData object...
function blendImageDatas(imageDataOne, imageDataTwo, amountImageDataOne){
const blendedData = new ImageData(imageDataOne.width, imageDataOne.height);
const amountImageDataTwo = 1 - amountImageDataOne;
for (let i = 0; i < imageDataOne.data.length; i += 4) {
blendedData.data[i ] = (imageDataOne.data[i ] * amountImageDataOne) + (imageDataTwo.data[i ] * amountImageDataTwo);
blendedData.data[i + 1] = (imageDataOne.data[i + 1] * amountImageDataOne) + (imageDataTwo.data[i + 1] * amountImageDataTwo);
blendedData.data[i + 2] = (imageDataOne.data[i + 2] * amountImageDataOne) + (imageDataTwo.data[i + 2] * amountImageDataTwo);
blendedData.data[i + 3] = (imageDataOne.data[i + 3] * amountImageDataOne) + (imageDataTwo.data[i + 3] * amountImageDataTwo);
}
return blendedData;
}
// Calculate the coordinates of the pixel in the old image data that
// corresponds to this element
let x1 = Math.round(oldX + kx);
let y1 = Math.round(oldY + ky);
// First, let's just copy imageData, and work on the copy instead - just in
// case we don't want to modify the input directly (it's passed by reference
// because it's an object - thanks JavaScript!)
let inputData = new ImageData(imageData.width, imageData.height);
inputData.data.set(imageData.data);
// Clamp the coordinates to the valid range
x1 = Math.max(0, Math.min(x1, oldWidth - 1));
y1 = Math.max(0, Math.min(y1, oldHeight - 1));
// Now let's reduce the input's width and height (independently) until doing
// so again would take the input's dimensions below the target dimensions;
// we'll use plain-old browser bilinear filtering to do this...
while (newWidth <= Math.round(inputData.width * 0.5)) {
inputData = _bilinear(inputData, Math.round(inputData.width * 0.5), inputData.height);
}
while (newHeight <= Math.round(inputData.height * 0.5)) {
inputData = _bilinear(inputData, inputData.width, Math.round(inputData.height * 0.5));
}
// Get the index of the pixel in the old pixel data array
let i1 = (y1 * oldWidth + x1) * 4;
// Now, inputData either exactly matches the dimensions of newWidth AND
// newHeight (in which case we're done), OR one or more of inputData's
// dimensions is greater than newWidth/newHeight. In that case, we want to
// generate the next level down for the dimensions that't aren't equal, and
// then blend between inputData (which is too detailed) and the next level
// down (which is too soft) based on how far away from the next level down
// we are - this essentially gives an effect like mip-mapping, and will help
// to ensure that transitions across 50% downscale thresholds aren't too
// jarring...
if (newWidth < inputData.width || newHeight < inputData.height) {
// Get the RGBA values of the pixel in the old pixel data array
let r1 = oldData[i1];
let g1 = oldData[i1 + 1];
let b1 = oldData[i1 + 2];
let a1 = oldData[i1 + 3];
// OK, one or more of inputData's dimensions is greater than
// newWidth/newHeight, so we're going to be doing some blending. First we
// need to generate the next scale of image downwards - just like before,
// we'll do each dimension separately...
let blendData = new ImageData(inputData.width, inputData.height);
blendData.data.set(inputData.data);
if (newWidth < inputData.width) {
blendData = _bilinear(blendData, Math.round(blendData.width * 0.5), blendData.height);
}
if (newHeight < inputData.height) {
blendData = _bilinear(blendData, blendData.width, Math.round(blendData.height * 0.5));
}
// Multiply the RGBA values by the kernel value and add them to the
// pixel values
r7 += r1 * w;
g7 += g1 * w;
b7 += b1 * w;
a7 += a1 * w;
// Now we have the next level of downscale, we need to work out where
// our target width and height lie between inputData's width and height,
// and blendData's width and height - that'll tell us what proportion of
// each image should be in the final blend between the two. Once we've
// worked that out, we blend inputData with blendData accordingly
// (blendData gets upscaled to inputData's resolution first, to make the
// blending less complicated)...
let axisBeingBlended = 0;
let widthFactor = 0;
let heightFactor = 0;
if (inputData.width - blendData.width != 0) {
widthFactor = (newWidth - blendData.width) / (inputData.width - blendData.width);
axisBeingBlended += 1;
}
if (inputData.height - blendData.height != 0) {
heightFactor = (newHeight - blendData.height) / (inputData.height - blendData.height);
axisBeingBlended += 1;
}
inputData = blendImageDatas(inputData, _bilinear(blendData, inputData.width, inputData.height), (widthFactor + heightFactor) / axisBeingBlended);
}
// Add the kernel value to the sum
sum += w;
// Finally, return imageData as scaled to the final desired width and height
// (it may not currently exactly those dimensions)...
return _bilinear(inputData, newWidth, newHeight);
}
// Utility function that takes in an ImageData object, and returns a
// downscaled version using a technique called Hermite interpolation. This
// method is a little slow, produces very high-quality output and works great
// with alpha channels... but it can introduce a little image distortion with
// high-frequency inputs, as well as some light aliasing on very fine details.
// To mitigate this, I've modified it to use a hybrid approach - I scale the
// image image to twice the target width and height using _halveToTarget
// (defined above), even if this means upscaling the input image first, and
// *then* I downscale the result using Hermite interpolation. The result is a
// bit slower again (as the input gets rescaled several times), but produced
// excellent results with zero distortion across all of my test images, even
// those with huge downscale ratios and very fine details (starfields). The
// core of this function is based on the following JSFiddle code, but again
// with some modifications by myself: https://jsfiddle.net/9g9Nv/442/
function _hermite(imageData, newWidth, newHeight){
// So the very first thing we do is rescale the input to twice the desired
// width and height using _halveToTarget and bilinear filtering (which may
// involve upscaling the input), before we use Hermite interpolation to
// downscale to the final target resolution - this hybrid approach appears
// to give excellent results for a slight speed penalty. We just call our
// main scaleImage() function, and force it to use "halveToTarget" instead
// of the default "hermite" method (don't want to be stuck in an infinite
// loop!). We'll do all our work on a copy of the input, just in case
// we don't want to modify that in the calling code...
let inputData = new ImageData(imageData.width, imageData.height);
inputData.data.set(imageData.data);
inputData = scaleImage(inputData, newWidth * 2, newHeight * 2, "Bilinear", "halveToTarget");
// OK, now it's on to the main Hermite interpolation; thanks to the original
// author of the JSFiddle! I've just cleaned up their code a little to fit
// within my use-case here. I'm not a "maths" person, and so the
// underpinnings of Hermite interpolation go over my head (I'm talking about
// the Wikipedia article here)... but from reading the code below, what I
// believe is happening is that it loops through the input data, and breaks
// it up into little rectangular chunks, where each chunk will become one
// pixel in the output image. It goes through each pixel of the chunk, and
// accumulates a weighted version of its RGBA data into a buffer (the gr_X
// variables). Once it's finished going through the chunk, it takes the
// accumulation buffer and uses the total weight to calculate a final RGBA
// for the matching pixel in the output buffer and stores it. The bit that's
// over my head is why the weighting is calculated the way it is... I'll
// leave that as an exercise to the more mathematically inclined, and just
// say silent thanks to the JSFiddle author again! I've renamed their
// original variables to match my own understanding as best I can, in case
// it helps to parse it a bit better...
const ratioWidth = inputData.width / newWidth;
const ratioWidthHalf = Math.ceil(ratioWidth / 2);
const ratioHeight = inputData.height / newHeight;
const ratioHeightHalf = Math.ceil(ratioHeight / 2);
const output = new ImageData(newWidth, newHeight);
// Loop through our desired output (as we're calculating the final value
// of each output pixel directly)...
for (let outputY = 0; outputY < newHeight; outputY++) {
for (let outputX = 0; outputX < newWidth; outputX++) {
let currentWeight = 0;
let totalWeightRGB = 0;
let totalWeightAlpha = 0;
let accumulatorRed = 0;
let accumulatorGreen = 0;
let accumulatorBlue = 0;
let accumulatorAlpha = 0;
const center_y = (outputY + 0.5) * ratioHeight;
// Calculate the borders of the "chunk" of input that'll be weighted
// down to a single output pixel...
const inputChunkLeftEdge = Math.floor(outputX * ratioWidth);
const inputChunkRightEdge = Math.min(Math.ceil((outputX + 1) * ratioWidth), inputData.width);
const inputChunkTopEdge = Math.floor(outputY * ratioHeight);
const inputChunkBottomEdge = Math.min(Math.ceil((outputY + 1) * ratioHeight), inputData.height);
// Now loop through the input rows within that chunk...
for (let inputY = inputChunkTopEdge; inputY < inputChunkBottomEdge; inputY++) {
// These three lines I'm not sure about, to be honest...
const dy = Math.abs(center_y - (inputY + 0.5)) / ratioHeightHalf;
const center_x = (outputX + 0.5) * ratioWidth;
const w0 = dy * dy;
// And loop through the input columns within those rows...
for (let inputX = inputChunkLeftEdge; inputX < inputChunkRightEdge; inputX++) {
// Again, these lines are similar to the three above; I know "w" is
// used in the Hermite weighting calculation...
const dx = Math.abs(center_x - (inputX + 0.5)) / ratioWidthHalf;
const w = Math.sqrt(w0 + dx * dx);
if (w >= 1) {
continue;
}
// This line is where the Hermite weighting is calculated...
currentWeight = 2 * w * w * w - 3 * w * w + 1;
// Now we use the weighting to fractions of the source pixel RGBA
// data in our accumulators; we also add the weight of the current
// pixel to a weight accumulator...
const pos_x = 4 * (inputX + inputY * inputData.width);
accumulatorAlpha += currentWeight * inputData.data[pos_x + 3];
totalWeightAlpha += currentWeight;
if (inputData.data[pos_x + 3] < 255) {
currentWeight = currentWeight * inputData.data[pos_x + 3] / 250;
}
accumulatorRed += currentWeight * inputData.data[pos_x];
accumulatorGreen += currentWeight * inputData.data[pos_x + 1];
accumulatorBlue += currentWeight * inputData.data[pos_x + 2];
totalWeightRGB += currentWeight;
}
}
// Divide the RGBA values by the sum to get an average value
r7 /= sum;
g7 /= sum;
b7 /= sum;
a7 /= sum;
// Round the RGBA values to integers
r7 = Math.round(r7);
g7 = Math.round(g7);
b7 = Math.round(b7);
a7 = Math.round(a7);
// Get the index of the pixel in the new pixel data array
let j = (y * newWidth + x) * 4;
// Set the RGBA values of the pixel in the new pixel data array
newData[j] = r7;
newData[j + 1] = g7;
newData[j + 2] = b7;
newData[j + 3] = a7;
// Now that we've finished accumulating the weighted RGBA data for that
// entire source "chunk", we divide it by the total weight and store the
// result in our output ImageData object...
const pixelIndex = (outputX + outputY * newWidth) * 4;
output.data[pixelIndex ] = accumulatorRed / totalWeightRGB;
output.data[pixelIndex + 1] = accumulatorGreen / totalWeightRGB;
output.data[pixelIndex + 2] = accumulatorBlue / totalWeightRGB;
output.data[pixelIndex + 3] = accumulatorAlpha / totalWeightAlpha;
}
}
// Create and return a new ImageData object with the new pixel data array
// and dimensions
return new ImageData(newData, newWidth, newHeight);
// And that's it - output now contains the downscaled image, so return it!
return output;
}
// Get the original width and height...
var width = input.width;
var height = input.height;
// Before we consider doing *any* scaling, let's check to make sure the new
// dimensions are different from the old ones; if they're not, there's no
// point in doing any scaling!
if (width == newWidth && height == newHeight) {
if (input.width == newWidth && input.height == newHeight) {
return input;
}
@ -659,43 +843,33 @@ function scaleImage(input, newWidth, newHeight, method) {
// If the method is "Nearest Neighbour"...
case "Nearest Neighbour":
// Create a new canvas element to draw the scaled image (we'll use the
// canvas to get our output ImageData object)...
var canvas = document.createElement("canvas");
var context = canvas.getContext("2d");
// Set the canvas size to the new dimensions...
canvas.width = newWidth;
canvas.height = newHeight;
// Create a new image data object to store the scaled pixel data...
var scaledData = context.createImageData(newWidth, newHeight);
// Create a new ImageData object to store the scaled pixel data...
const outputData = new ImageData(newWidth, newHeight);
// Loop through each pixel of the new image...
for (var y = 0; y < newHeight; y++) {
for (var x = 0; x < newWidth; x++) {
for (let outputY = 0; outputY < newHeight; outputY++) {
for (let outputX = 0; outputX < newWidth; outputX++) {
// Calculate the index of the new pixel in the scaled data array...
var index = (y * newWidth + x) * 4;
// Calculate the index of the new pixel in the output data...
const outputIndex = (outputY * newWidth + outputX) * 4;
// Calculate the x and y coordinates of the corresponding pixel in
// the original image...
var x2 = Math.floor(x * width / newWidth);
var y2 = Math.floor(y * height / newHeight);
// the original image, and it's index within input's data...
const inputX = Math.floor(outputX * input.width / newWidth);
const inputY = Math.floor(outputY * input.height / newHeight);
const inputIndex = (inputY * input.width + inputX) * 4;
// Calculate the index of the original pixel in the data array...
var index2 = (y2 * width + x2) * 4;
// Copy the color values from the original pixel to the new pixel...
scaledData.data[index] = input.data[index2]; // Red
scaledData.data[index + 1] = input.data[index2 + 1]; // Green
scaledData.data[index + 2] = input.data[index2 + 2]; // Blue
scaledData.data[index + 3] = input.data[index2 + 3]; // Alpha
// Copy the color values from the input pixel to the output pixel...
outputData.data[outputIndex] = input.data[inputIndex]; // Red
outputData.data[outputIndex + 1] = input.data[inputIndex + 1]; // Green
outputData.data[outputIndex + 2] = input.data[inputIndex + 2]; // Blue
outputData.data[outputIndex + 3] = input.data[inputIndex + 3]; // Alpha
}
}
// Finally, return the scaled ImageData object...
return scaledData;
return outputData;
break;
// If the method is "Bilinear"...
case "Bilinear":
@ -704,66 +878,73 @@ function scaleImage(input, newWidth, newHeight, method) {
// you're *upscaling* an image, but if you're *downscaling* an image
// by more than half the original's width/height, then true bilinear
// filtering creates just as much of an aliased mess as nearest
// neighbour filtering. Most image editing apps therefore cheat and
// use a resampling algorithm when downscaling, and bilinear filtering
// when upscaling... so that's what we're going to do here too! We'll
// use gaussian resampling for any image axis that's being downscaled,
// and bilinear for any axis that's being upscaled; this should give
// the user a result that's much closer to what they'd expect to see...
// neighbour filtering once you get down to downscaling by more than half
// the dimensions of the original image. Most image editing apps therefore
// cheat and use a resampling algorithm when downscaling, and bilinear
// filtering when upscaling... so that's what we're going to do here too!
// We'll use a hybrid of bilinear and Hermite interpolation for any image
// axis that's being downscaled, and bilinear for any axis that's being
// upscaled; this should give the user a result that's much closer to what
// they'd expect to see from a graphics app like Photoshop or similar...
let upscaling = false, downscaling = false;
if (newWidth > input.width || newHeight > input.height) { upscaling = true; }
if (newWidth < input.width || newHeight < input.height) { downscaling = true; }
// Let's see which kind of scaling scenario we're in...
if (newWidth > width && newHeight > height) {
// All dimensions being upscaled, so we'll use bilinear filtering for
// everything...
// Now we'll process the image differently depending on whether or not
// we're only upscaling, only downscaling, or doing a mix of upscaling and
// downscaling. In the case of a mix, we do the upscaling part first, and
// the downscaling part second, as it'll give a slightly sharper result...
if (upscaling && !downscaling) {
// Upscale only...
return _bilinear(input, newWidth, newHeight);
}
else if (newWidth < width && newHeight < height) {
// All dimensions being downscaled, so we'll use gaussian resampling
// for everything...
return _gaussian(input, newWidth, newHeight);
else if (downscaling && !upscaling) {
// Downscale only - run the input through either our halve-to-target or
// Hermite filter...
switch (downscaleFilter){
case "hermite":
return _hermite(input, newWidth, newHeight);
break;
case "halveToTarget":
return _halveToTarget(input, newWidth, newHeight);
break;
}
}
else {
// It's a mix!
if (newWidth < width) {
// Gaussian for width, bilinear for height...
let partial = _gaussian(input, newWidth, height);
return _bilinear(partial, newWidth, newHeight);
// Both upscaling and downscaling... do the upscale first, then send the
// result back to this function again for downscaling...
let partiallyScaled;
if (newWidth > input.width) {
// Upscale width
partiallyScaled = _bilinear(input, newWidth, input.height);
}
else if (newHeight < height) {
// Gaussian for height, bilinear for width...
let partial = _gaussian(input, width, newHeight);
return _bilinear(partial, newWidth, newHeight);
else {
// Upscale height
partiallyScaled = _bilinear(input, input.width, newHeight);
}
// Downscale the rest...
return scaleImage(partiallyScaled, newWidth, newHeight, method, downscaleFilter);
}
break;
}
}
// This utility function is used for adding info, warning and error messages to
// the appropriate spots in my tools. It uses custom-modified versions of the
// SVG Famfamfam Silk icon set via https://github.com/frhun/silk-icon-scalable
// the appropriate spots in my tools...
function setMessage(type, divID, text) {
var icon;
let icon = "";
switch(type) {
case "info":
icon = '<img class="icon" alt="An icon of a blue circle with a white lowercase letter I, indicating an informative message" aria-hidden="true" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB2aWV3Qm94PScwIDAgNjQgNjQnPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0nYSc+PHN0b3Agb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjNDk5MWQyJyBzdG9wLW9wYWNpdHk9Jy45Jy8+PHN0b3Agb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjMmU0NjgxJyBzdG9wLW9wYWNpdHk9Jy45NicvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IHhsaW5rOmhyZWY9JyNhJyBpZD0nYycgeDE9JzEyJyB4Mj0nNTInIHkxPScxMicgeTI9JzUyJyBncmFkaWVudFVuaXRzPSd1c2VyU3BhY2VPblVzZScvPjxsaW5lYXJHcmFkaWVudCBpZD0nYic+PHN0b3Agb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjN2RhOWQ2Jy8+PHN0b3Agb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjNWU4NWIzJy8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgeGxpbms6aHJlZj0nI2InIGlkPSdkJyB4MT0nMTYnIHgyPSc0OCcgeTE9JzE2JyB5Mj0nNDgnIGdyYWRpZW50VW5pdHM9J3VzZXJTcGFjZU9uVXNlJy8+PC9kZWZzPjxnIHBhaW50LW9yZGVyPSdtYXJrZXJzIHN0cm9rZSBmaWxsJz48Y2lyY2xlIGN4PSczMicgY3k9JzMyJyByPScyOCcgZmlsbD0ndXJsKCNjKScvPjxjaXJjbGUgY3g9JzMyJyBjeT0nMzInIHI9JzI0JyBmaWxsPScjYWVjNWUzJy8+PGNpcmNsZSBjeD0nMzInIGN5PSczMicgcj0nMjAnIGZpbGw9J3VybCgjZCknLz48cGF0aCBmaWxsPScjZmZmJyBkPSdNMjYgNDB2NGgxMnYtNGgtM1YyOGgtOHY0aDJ2OHpNMzUgMTloLTZ2Nmg2eicvPjwvZz48L3N2Zz4=">';
break;
icon = "";
break;
case "warning":
icon = '<img class="icon" alt="An icon of a yellow triangle with a dark orange exclamation point, indicating a warning or notice" aria-hidden="true" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB2aWV3Qm94PScwIDAgNjQgNjQnPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0nYSc+PHN0b3Agb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjZTliYjNhJy8+PHN0b3Agb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjYzE2ODAzJy8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgeGxpbms6aHJlZj0nI2EnIGlkPSdiJyB4MT0nMjAnIHgyPSc1NicgeTE9JzEyJyB5Mj0nNjAnIGdyYWRpZW50VW5pdHM9J3VzZXJTcGFjZU9uVXNlJy8+PC9kZWZzPjxwYXRoIGZpbGw9JyNmZmYnIHN0cm9rZT0ndXJsKCNiKScgc3Ryb2tlLWxpbmVqb2luPSdyb3VuZCcgc3Ryb2tlLXdpZHRoPSc4JyBkPSdNNCA1Nmg1NkwzMiA4WicgcGFpbnQtb3JkZXI9J3N0cm9rZSBtYXJrZXJzIGZpbGwnLz48cGF0aCBmaWxsPScjZjVkODUyJyBkPSdNMTIgNTJoNDBMMzIgMThaJyBwYWludC1vcmRlcj0nbWFya2VycyBmaWxsIHN0cm9rZScvPjxwYXRoIGZpbGw9JyNjNTg3MTEnIGQ9J00yOSA0M3Y2aDZ2LTZ6TTI5IDQwaDZWMjZoLTZ6JyBwYWludC1vcmRlcj0nc3Ryb2tlIG1hcmtlcnMgZmlsbCcvPjwvc3ZnPg==">';
break;
icon = "⚠️";
break;
case "error":
icon = '<img class="icon" alt="An icon of a red circle with a white exclamation point, indicating an error has occurred" aria-hidden="true" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB2aWV3Qm94PScwIDAgNjQgNjQnPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0nYic+PHN0b3Agb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjZjU4MTYyJy8+PHN0b3Agb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjZTI1ZjUzJy8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9J2EnPjxzdG9wIG9mZnNldD0nMCcgc3RvcC1jb2xvcj0nI2VjODE2YycvPjxzdG9wIG9mZnNldD0nMScgc3RvcC1jb2xvcj0nI2JmNDMyOScvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IHhsaW5rOmhyZWY9JyNhJyBpZD0nYycgeDE9JzEyJyB4Mj0nNTInIHkxPScxMicgeTI9JzUyJyBncmFkaWVudFVuaXRzPSd1c2VyU3BhY2VPblVzZScvPjxsaW5lYXJHcmFkaWVudCB4bGluazpocmVmPScjYicgaWQ9J2QnIHgxPScyMCcgeDI9JzQ4JyB5MT0nMTYnIHkyPSc0NCcgZ3JhZGllbnRVbml0cz0ndXNlclNwYWNlT25Vc2UnLz48L2RlZnM+PGNpcmNsZSBjeD0nMzInIGN5PSczMicgcj0nMjgnIGZpbGw9J3VybCgjYyknIHBhaW50LW9yZGVyPSdtYXJrZXJzIHN0cm9rZSBmaWxsJy8+PGNpcmNsZSBjeD0nMzInIGN5PSczMicgcj0nMjQnIGZpbGw9JyNmOWQzY2MnIHBhaW50LW9yZGVyPSdtYXJrZXJzIHN0cm9rZSBmaWxsJy8+PGNpcmNsZSBjeD0nMzInIGN5PSczMicgcj0nMjAnIGZpbGw9J3VybCgjZCknIHBhaW50LW9yZGVyPSdtYXJrZXJzIHN0cm9rZSBmaWxsJy8+PHBhdGggZmlsbD0nI2ZmZicgZD0nTTI5IDE2djIwaDZWMTZaTTI5IDQwdjZoNnYtNnonIHBhaW50LW9yZGVyPSdmaWxsIG1hcmtlcnMgc3Ryb2tlJy8+PC9zdmc+">';
break;
icon = "🛑";
break;
}
document.getElementById(divID).innerHTML = "<p class=\"" + type + "\">" + icon + " " + text + "</p>";
return;
}