Last active
September 14, 2021 07:47
-
-
Save FelixRilling/82a6c6efad4be263ae43ea9d8e2f23a3 to your computer and use it in GitHub Desktop.
box-shadow-image encoder
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<meta content="width=device-width, initial-scale=1" name="viewport" /> | |
<title>Box Shadow Image</title> | |
<style> | |
*, | |
::before, | |
::after { | |
box-sizing: border-box; | |
} | |
body { | |
display: grid; | |
height: 100vh; | |
margin: 0; | |
grid-template-columns: 1fr 1.5fr; | |
grid-template-rows: 1fr 1fr; | |
grid-template-areas: | |
"input demo" | |
"output demo"; | |
} | |
.section { | |
padding: 1rem; | |
} | |
.input { | |
grid-area: input; | |
} | |
.output { | |
grid-area: output; | |
} | |
.demo { | |
margin: 1rem; | |
border: 1px solid #999999; | |
grid-area: demo; | |
} | |
#demo { | |
width: 1px; | |
height: 1px; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="section input"> | |
<h1>Input</h1> | |
<label for="fileUpload" | |
>Upload File (Warning: Large images may freeze browser!):</label | |
> | |
<input accept="image/*" id="fileUpload" type="file" /> | |
<p>Example images:</p> | |
<ul> | |
<li> | |
<a | |
href="https://commons.wikimedia.org/wiki/File:AdditiveColor.svg" | |
rel="noopener" | |
target="_blank" | |
>Image with primitive shapes and few different | |
colors.</a | |
> | |
</li> | |
<li> | |
<a | |
href="https://commons.wikimedia.org/wiki/File:PNG_transparency_demonstration_1.png" | |
rel="noopener" | |
target="_blank" | |
>Image with full and partial transparency.</a | |
> | |
</li> | |
<li> | |
<a | |
href="https://commons.wikimedia.org/wiki/File:Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg" | |
rel="noopener" | |
target="_blank" | |
>Image with a lot of different colors and complex | |
shapes.</a | |
> | |
</li> | |
</ul> | |
<canvas hidden id="canvas"></canvas> | |
</div> | |
<div class="section output"> | |
<h2>Output</h2> | |
<p> | |
<label for="output" | |
>Output <code>box-shadow</code> value:</label | |
> | |
</p> | |
<textarea cols="70" id="output" readonly rows="20"></textarea> | |
</div> | |
<div class="section demo"> | |
<div id="demo"></div> | |
</div> | |
<script type="module"> | |
import { initBoxShadowImage } from "./main.js"; | |
initBoxShadowImage(); | |
</script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const readFileAsDataUrl = (file: File): Promise<string> => | |
new Promise((resolve, reject) => { | |
const fileReader = new FileReader(); | |
fileReader.onload = () => resolve(fileReader.result as string); | |
fileReader.onerror = () => reject(fileReader.error); | |
fileReader.readAsDataURL(file); | |
}); | |
const prepareCanvas = ( | |
canvas: HTMLCanvasElement, | |
image: CanvasImageSource | |
): void => { | |
const { height, width } = image; | |
if ( | |
height instanceof SVGAnimatedLength || | |
width instanceof SVGAnimatedLength | |
) { | |
throw new TypeError("Unsupported source."); | |
} | |
canvas.width = width; | |
canvas.height = height; | |
const ctx = canvas.getContext("2d")!; | |
ctx.clearRect(0, 0, width, height); | |
ctx.drawImage(image, 0, 0); | |
}; | |
/** | |
* Serializes px value as CSS size. | |
* | |
* @param number Pixel value. | |
*/ | |
const pxAsString = (number: number): string => { | |
// Optimization: omit 'px' if 0. | |
if (number == 0) { | |
return "0"; | |
} | |
return `${number}px`; | |
}; | |
// Used for documentation purposes, not enforced by type checks. | |
// eslint-disable-next-line @typescript-eslint/naming-convention | |
type u8 = number; | |
// Encode u8 number as hexadecimal, padding to two digits if needed. | |
const encodeAsHexPair = (val: u8): string => { | |
let str = val.toString(16); | |
if (str.length === 1) { | |
str = "0" + str; // Pad single digits | |
} | |
return str; | |
}; | |
// Channels where both hex digits are the same (like 'FF') can be shorthanded. | |
const canShorthandChannel = (hexPair: string): boolean => | |
hexPair[0] == hexPair[1]; | |
// See canShorthandChannel | |
const shorthandChannel = (hexPair: string): string => hexPair[0]; | |
/** | |
* Serializes RGB channel values as CSS color. | |
* | |
* @param r Red channel integer from 0 to 255 | |
* @param g Green channel integer from 0 to 255 | |
* @param b Blue channel integer from 0 to 255 | |
* @param a Alpha channel integer from 0 to 255 | |
*/ | |
// Note: floating point numbers do not need handling as ImageData only contains u8 numbers. | |
const rgbAsString = (r: u8, g: u8, b: u8, a: u8): string => { | |
let channelValues = [r, g, b, a].map(encodeAsHexPair); | |
// Optimization: Use shorthand notation where fitting. | |
if (channelValues.every(canShorthandChannel)) { | |
channelValues = channelValues.map(shorthandChannel); | |
} | |
const [rHex, gHex, bHex, aHex] = channelValues; | |
let color = `#${rHex}${gHex}${bHex}`; | |
// Optimization: Is color fully opaque? If yes, we can omit the alpha channel | |
if (a != 255) { | |
color += aHex; | |
} | |
return color; | |
}; | |
/* | |
* Possible optimisations: | |
* - Only use one shadow for adjacent pixels in the same color. | |
* - Stop using this and use images like normal people. | |
*/ | |
const createBoxShadowImageFromCanvas = ( | |
preparedCanvas: HTMLCanvasElement | |
): string => { | |
const { height, width } = preparedCanvas; | |
const ctx = preparedCanvas.getContext("2d")!; | |
const boxShadowRows: string[] = []; | |
for (let y = 0; y < height; y++) { | |
for (let x = 0; x < width; x++) { | |
const [r, g, b, a] = ctx.getImageData(x, y, 1, 1).data; | |
// Optimization: Omit current if fully transparent. | |
if (a == 0) { | |
continue; | |
} | |
boxShadowRows.push( | |
`${pxAsString(x)} ${pxAsString(y)} ${rgbAsString(r, g, b, a)}` | |
); | |
} | |
} | |
return boxShadowRows.join(","); | |
}; | |
export const initBoxShadowImage = (): void => { | |
const fileUpload: HTMLInputElement = document.getElementById( | |
"fileUpload" | |
)! as HTMLInputElement; | |
const canvas: HTMLCanvasElement = document.getElementById( | |
"canvas" | |
) as HTMLCanvasElement; | |
const output: HTMLTextAreaElement = document.getElementById( | |
"output" | |
) as HTMLTextAreaElement; | |
const demo: HTMLDivElement = document.getElementById( | |
"demo" | |
) as HTMLDivElement; | |
const doConversion = (image: HTMLImageElement): void => { | |
prepareCanvas(canvas, image); | |
const boxShadowImage = createBoxShadowImageFromCanvas(canvas); | |
output.textContent = boxShadowImage; | |
console.time("DRAW"); | |
demo.style.boxShadow = boxShadowImage; | |
console.timeEnd("DRAW"); | |
}; | |
fileUpload.addEventListener("input", () => { | |
const imageFile = fileUpload.files?.item(0); | |
if (imageFile == null) { | |
throw new TypeError("Could not read image."); | |
} | |
let image: HTMLImageElement; | |
readFileAsDataUrl(imageFile) | |
.then((imageDataUrl) => { | |
image = new Image(); | |
image.src = imageDataUrl; | |
return image.decode(); | |
}) | |
.then(() => { | |
document.body.appendChild(image); | |
doConversion(image); | |
image.remove(); | |
}) | |
.catch(console.error); | |
}); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
For details, see https://rilling.dev/blog/highly-inefficient-ways-to-store-images-in-css/.