Skip to content

Instantly share code, notes, and snippets.

@rsalgado
Last active June 5, 2023 17:22
Show Gist options
  • Save rsalgado/4a09e888cdaddd97d029a1dc0631ffc9 to your computer and use it in GitHub Desktop.
Save rsalgado/4a09e888cdaddd97d029a1dc0631ffc9 to your computer and use it in GitHub Desktop.
ASCII Art Image Converter
<div id="contents">
<h1>ASCII Art Image Converter</h1>
<main id="main">
<div id="left-section" class="section">
<h2>Input</h2>
<div>
<h3>Input File (Select an Image):</h3>
<p>If the input is not a square image, a square section of it will be selected. More precisely, the image will be cropped to be square by using its smallest side.</p>
<input type="file" id="file-input">
</div>
<div>
<h3>Preview:</h3>
<canvas id="preview-canvas"></canvas>
<p>This is the preview of (the part of) the image that will be rendered.</p>
</div>
<div>
<h3>Reduced Grayscale Image:</h3>
<canvas id="reduced-canvas"></canvas>
<p>
This is the grayscale reduced version of the image. It will be used to render
the ASCII output. Each pixel from this image will be mapped to a character from the
ramp, based on its value (luminosity).
</p>
</div>
<div>
<h3>Character Ramp:</h3>
<input type="text" id="ramp-input" value="8@S#0I?J=!;:-,. " />
<button id="generate-ascii-button">Generate ASCII Art</button>
<p>
This is the characters ("colors") ramp for the ASCII output.
It can contain any number of characters. The order should be from "darker"
characters to "lighter" ones, usually with a whitespace as the last character.
</p>
</div>
</div>
<div id="right-section" class="section">
<h2>Output</h2>
<div id="output-container">
<pre id="ascii-output"></pre>
</div>
</div>
</main>
</div>
// Constants
const MAX_PREVIEW_SIZE = 400;
const OUTPUT_SIZE = 128;
// Canvas contexts
const previewContext = document.querySelector("#preview-canvas").getContext("2d");
const reducedContext = document.querySelector("#reduced-canvas").getContext("2d");
// Functions
const getLuminosity = (r, g, b) => 0.21*r + 0.72*g + 0.07*b;
const toGrayScaleInPlace = (context, size) => {
let imageData = context.getImageData(0, 0, size, size);
for (let i = 0; i < imageData.data.length; i += 4) {
const r = imageData.data[i];
const g = imageData.data[i + 1];
const b = imageData.data[i + 2];
const luminosity = getLuminosity(r, g, b);
imageData.data[i] = imageData.data[i + 1] = imageData.data[i + 2] = luminosity;
};
context.putImageData(imageData, 0, 0);
};
const getGrayScaleValues = (context) => {
const size = context.canvas.clientWidth;
const imageData = context.getImageData(0, 0, size, size);
const grayScaleValues = [];
let value = null;
for (let i = 0; i < imageData.data.length; i+= 4) {
// Pick the first channel's (red) color. Any other works are they are equal.
value = imageData.data[i];
grayScaleValues.push(value);
}
return grayScaleValues;
}
const getCharacter = (value, ramp) => {
const L = ramp.length;
const quantizedValue = Math.trunc((value / 256) * L);
return ramp[quantizedValue];
}
const drawASCII = (grayScaleValues, width, ramp, outputElement) => {
let textOutput = "";
for (let i = 0; i < grayScaleValues.length; i++) {
let character = getCharacter(grayScaleValues[i], ramp);
textOutput += character;
if ((i + 1) % width === 0)
textOutput += "\n";
}
outputElement.textContent = textOutput;
};
const initialize = () => {
const reducedCanvas = document.querySelector("#reduced-canvas");
const ramp = document.querySelector("#ramp-input").value;
const asciiOutput = document.querySelector("#ascii-output");
reducedCanvas.width = OUTPUT_SIZE;
reducedCanvas.height = OUTPUT_SIZE;
reducedContext.rect(0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
reducedContext.fill();
const grayScaleValues = getGrayScaleValues(reducedContext);
drawASCII(grayScaleValues, OUTPUT_SIZE, ramp, asciiOutput);
};
// DOM Manipulation and Event Handlers.
document.querySelector("#file-input").addEventListener("change", (e) => {
const imageFile = e.target.files[0];
const fileReader = new FileReader();
fileReader.addEventListener("load", (loadEvent) => {
const image = new Image();
image.addEventListener("load", () => {
const previewCanvas = document.querySelector("#preview-canvas");
const side = Math.min(image.width, image.height);
const previewSide = Math.min(side, MAX_PREVIEW_SIZE);
previewCanvas.width = previewSide;
previewCanvas.height = previewSide;
previewContext.drawImage(image, 0, 0, side, side, 0, 0, previewSide, previewSide);
reducedContext.drawImage(image, 0, 0, side, side, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
toGrayScaleInPlace(reducedContext, OUTPUT_SIZE);
const ramp = document.querySelector("#ramp-input").value;
const asciiOutput = document.querySelector("#ascii-output");
const grayScaleValues = getGrayScaleValues(reducedContext);
drawASCII(grayScaleValues, OUTPUT_SIZE, ramp, asciiOutput);
});
image.src = loadEvent.target.result;
});
fileReader.readAsDataURL(imageFile);
});
document.querySelector("#generate-ascii-button").addEventListener("click", () => {
const ramp = document.querySelector("#ramp-input").value;
const asciiOutput = document.querySelector("#ascii-output");
const grayScaleValues = getGrayScaleValues(reducedContext);
drawASCII(grayScaleValues, OUTPUT_SIZE, ramp, asciiOutput);
});
// Initialize the preview and output, just to have something visible.
initialize();
/* NOTE:
I chose some of the values manually, after getting tired of
fiddling with CSS and trying to find an optimal responsive desing. I think this will do for now.
*/
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap');
* {
box-sizing: border-box;
}
#contents {
padding: 1rem;
background: #eee;
min-height: 100vh;
}
#main {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.section {
background: #fff;
box-shadow: 0px 0px 4px 2px #ddd;
}
#left-section {
width: 480px;
padding: 1rem;
}
#left-section canvas {
border: 1px solid gray;
}
#right-section {
flex-grow: 1;
flex-shrink: 0;
padding: 0 1rem;
/*min-width: 800px; */
}
#output-container {
display: flex;
justify-content: center;
}
#ascii-output {
margin: 1rem 0;
/* border: 1px solid gray; */
display: inline-block;
font-size: 6px;
font-family: "Space Mono", monospace;
line-height: 100%;
font-weight: bold;
letter-spacing: 2.75px;
}
#ramp-input {
font-family: "Space Mono", monospace;
font-weight: bold;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment