gun-avatar
Version:
Avatar generator for GUN public keys
548 lines (480 loc) • 15.9 kB
TypeScript
import { ref } from 'vue';
import extract from 'png-chunks-extract';
import encode from 'png-chunks-encode';
import text from 'png-chunk-text';
/**
* MIT License - Copyright (c) 2021 Kaiido
*
* A monkey-patch for Safari's drawImage.
*
* This browser doesn't handle well using the cropping abilities of drawImage
* with out-of-bounds values.
* (see https://stackoverflow.com/questions/35500999/cropping-with-drawimage-not-working-in-safari)
* This script takes care of detecting when the monkey-patch is needed,
* and does redefine the cropping parameters so they fall inside the source's boundaries.
*
**/
var patchSafari = () => {
if (typeof window == 'undefined' || typeof window.CanvasRenderingContext2D == 'undefined') return
if (!needPoly()) return
const proto = CanvasRenderingContext2D.prototype;
const original = proto.drawImage;
if (!original) {
console.error('This script requires a basic implementation of drawImage');
return
}
proto.drawImage = function drawImage(source, x, y) {
// length: 3
const will_crop = arguments.length === 9;
if (!will_crop) {
return original.apply(this, [...arguments])
}
const safe_rect = getSafeRect(...arguments);
if (isEmptyRect(safe_rect)) {
return
}
return original.apply(this, safe_rect)
};
function needPoly() {
const ctx = document.createElement('canvas').getContext('2d');
ctx.fillRect(0, 0, 40, 40);
ctx.drawImage(ctx.canvas, -40, -40, 80, 80, 50, 50, 20, 20);
const img = ctx.getImageData(50, 50, 30, 30); // 10px around expected square
const data = new Uint32Array(img.data.buffer);
const colorAt = (x, y) => data[y * img.width + x];
const transparents = [
[9, 9],
[20, 9],
[9, 20],
[20, 20],
];
const blacks = [
[10, 10],
[19, 10],
[10, 19],
[19, 19],
];
return (
transparents.some(([x, y]) => colorAt(x, y) !== 0x00000000) ||
blacks.some(([x, y]) => colorAt(x, y) === 0x00000000)
)
}
function getSafeRect(image, sx, sy, sw, sh, dx, dy, dw, dh) {
const { width, height } = getSourceDimensions(image);
if (sw < 0) {
sx += sw;
sw = Math.abs(sw);
}
if (sh < 0) {
sy += sh;
sh = Math.abs(sh);
}
if (dw < 0) {
dx += dw;
dw = Math.abs(dw);
}
if (dh < 0) {
dy += dh;
dh = Math.abs(dh);
}
const x1 = Math.max(sx, 0);
const x2 = Math.min(sx + sw, width);
const y1 = Math.max(sy, 0);
const y2 = Math.min(sy + sh, height);
const w_ratio = dw / sw;
const h_ratio = dh / sh;
return [
image,
x1,
y1,
x2 - x1,
y2 - y1,
sx < 0 ? dx - sx * w_ratio : dx,
sy < 0 ? dy - sy * h_ratio : dy,
(x2 - x1) * w_ratio,
(y2 - y1) * h_ratio,
]
}
function isEmptyRect(args) {
// sw, sh, dw, dh
return [3, 4, 7, 8].some((index) => !args[index])
}
function getSourceDimensions(source) {
const sourceIs = (type) => {
const constructor = globalThis[type];
return constructor && source instanceof constructor
};
if (sourceIs('HTMLImageElement')) {
return { width: source.naturalWidth, height: source.naturalHeight }
} else if (sourceIs('HTMLVideoElement')) {
return { width: source.videoWidth, height: source.videoHeight }
} else if (sourceIs('SVGImageElement')) {
throw new TypeError(
"SVGImageElement isn't yet supported as source image.",
'UnsupportedError',
)
} else if (sourceIs('HTMLCanvasElement') || sourceIs('ImageBitmap')) {
return source
}
}
};
const error = ref(null);
function canvasToBuffer(canvas) {
try {
const base64 = canvas.toDataURL('image/png').split(',')[1];
const bytes = atob(base64);
const buffer = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) {
buffer[i] = bytes.charCodeAt(i);
}
return buffer
} catch (e) {
error.value = 'Failed to convert canvas to buffer: ' + e.message;
return null
}
}
function embedInSvg(svgString, data) {
try {
const metadata = `<metadata>
<gun-data>${JSON.stringify(data)}</gun-data>
</metadata>`;
return svgString.replace('</svg>', `${metadata}</svg>`)
} catch (e) {
error.value = 'Failed to embed data in SVG: ' + e.message;
return null
}
}
function extractFromSvg(svgString) {
try {
const match = svgString.match(/<gun-data>(.*?)<\/gun-data>/s);
return match ? JSON.parse(match[1]) : null
} catch (e) {
error.value = 'Failed to extract data from SVG: ' + e.message;
return null
}
}
function embedInImage(canvas, data, format = 'png') {
if (format === 'svg') {
const svgString = canvas.outerHTML || canvas;
return embedInSvg(svgString, data)
}
// Default PNG handling
try {
const buffer = canvasToBuffer(canvas);
if (!buffer) return null
const chunks = extract(buffer);
chunks.splice(-1, 0, text.encode('message', JSON.stringify(data)));
return encode(chunks)
} catch (e) {
error.value = 'Failed to embed data: ' + e.message;
return null
}
}
function extractFromBuffer(buffer, type = 'image/png') {
if (type === 'image/svg+xml') {
const text = new TextDecoder().decode(buffer);
return extractFromSvg(text)
}
// Default PNG handling
try {
const chunks = extract(buffer);
const textChunks = chunks
.filter(chunk => chunk.name === 'tEXt')
.map(chunk => text.decode(chunk.data));
const messageChunk = textChunks.find(chunk => chunk.keyword === 'message');
return messageChunk ? JSON.parse(messageChunk.text) : null
} catch (e) {
error.value = e.message;
return null
}
}
// Keep this async function for File inputs only
async function extractFromFile(file) {
try {
if (!(file instanceof File)) {
throw new Error('Input must be a File object')
}
const arrayBuffer = await file.arrayBuffer();
const buffer = new Uint8Array(arrayBuffer);
return await extractFromBuffer(buffer)
} catch (e) {
error.value = e.message;
return null
}
}
// https://datatracker.ietf.org/doc/html/rfc4648#section-5
const symbols =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
function fromB64(x) { return x.split("").reduce((s, v) => s * 64 + symbols.indexOf(v), 0) }
function decodeUrlSafeBase64(st) {
const symbolArray = symbols.split("");
let arr = [];
let i = 0;
for (let letter of st) {
arr[i++] = symbolArray.indexOf(letter) / 64;
}
return arr;
}
const cache = {};
/**
* Generate avatar from public key
*/
function gunAvatar({
pub,
size = 200,
dark = false,
draw = "circles",
reflect = true,
round = true,
embed = true,
}) {
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
if (!validatePub(pub)) return '';
if (!isBrowser) return createFallbackSVG({ pub, size, dark, embed });
const key = JSON.stringify(arguments[0]);
if (cache?.[key]) return cache[key]
const canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
const ctx = canvas.getContext("2d");
const { decoded, finals } = parsePub(pub);
drawGradient({ ctx, top: finals[0], bottom: finals[1], size, dark });
if (draw == "squares") {
ctx.filter = "blur(20px)";
drawSquares(decoded[0], ctx, size);
ctx.filter = "blur(0px)";
ctx.globalCompositeOperation = "color-burn";
drawSquares(decoded[1], ctx, size);
} else {
drawCircles(decoded[0], ctx, size, 0.42 * size);
ctx.globalCompositeOperation = "multiply";
drawCircles(decoded[1], ctx, size, 0.125 * size);
}
if (reflect) {
ctx.globalCompositeOperation = "source-over";
ctx.scale(-1, 1);
ctx.translate(-size / 2, 0);
ctx.drawImage(canvas, size / 2, 0, size, size, 0, 0, size, size);
// Reset transformation matrix after reflection
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
if (round) {
// Store the current canvas content
const imageData = ctx.getImageData(0, 0, size, size);
ctx.clearRect(0, 0, size, size);
// Fill with background color first
ctx.fillStyle = dark ? '#cccccc' : '#ffffff';
ctx.fillRect(0, 0, size, size);
// Draw original image back
ctx.putImageData(imageData, 0, 0);
// Create circular mask
ctx.globalCompositeOperation = 'destination-in';
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.fill();
}
let image = canvas.toDataURL("image/png");
if (embed) {
const embedData = {
pub,
content: embed
};
const embedBuffer = embedInImage(canvas, embedData);
if (embedBuffer) {
const blob = new Blob([embedBuffer], { type: 'image/png' });
image = URL.createObjectURL(blob);
}
}
cache[key] = image;
return image;
}
function validatePub(pub) {
return pub && typeof pub === 'string' && pub.length === 87 && pub.split('.').length === 2;
}
function parsePub(pub) {
const split = pub.split(".");
const decoded = split.map(single => decodeUrlSafeBase64(single));
const finals = decoded.map(d => d[42]);
const averages = decoded.map(e => e.reduce((acc, d) => acc + d) / e.length);
const angles = split.map(part => fromB64(part) % 360);
const colors = split.map((s, i) => `hsl(${angles[i]} ${finals[i] * 100}% ${averages[i] * 100}%)`);
return { finals, decoded, angles, averages, colors }
}
function chunkIt(list, chunkSize = 3) {
return [...Array(Math.ceil(list.length / chunkSize))].map(() =>
list.splice(0, chunkSize)
);
}
function drawGradient({ ctx, top = 0, bottom = 150, size = 200, dark = false }) {
const gradient = ctx.createLinearGradient(0, 0, 0, size);
const offset = dark ? 0 : 70;
gradient.addColorStop(0, `hsla(0,0%,${offset + top * 30}%)`);
gradient.addColorStop(1, `hsla(0,0%,${offset + bottom * 30}%)`);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
}
function drawSquares(data, ctx, size) {
chunkIt(data, 14).forEach(chunk => {
if (chunk.length === 14) {
let [x, y, rRaw, h1, s1, l1, a1, x1, h2, s2, l2, a2, x2, angle] = chunk;
let r = size / 8 + rRaw * size * (7 / 8);
const gradient = ctx.createLinearGradient(
x * size + r * x1, 0,
x * size + r * x2, size
);
gradient.addColorStop(0, `hsla(${h1 * 360},${s1 * 100}%,${l1 * 100}%,${a1})`);
gradient.addColorStop(1, `hsla(${h2 * 360},${s2 * 100}%,${l2 * 100}%,${a2})`);
ctx.fillStyle = gradient;
ctx.translate(x * size, y * size);
ctx.rotate(angle * Math.PI);
ctx.fillRect(-r / 2, -r / 2, r, r);
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
});
}
function drawCircles(data, ctx, size, radius) {
chunkIt(data, 7).forEach(chunk => {
if (chunk.length === 7) {
let [x, y, r, h, s, l, a] = chunk;
ctx.beginPath();
ctx.arc(
size / 2 + (x * size) / 2,
y * size,
r * radius,
0,
2 * Math.PI
);
ctx.fillStyle = `hsla(${h * 360},${s * 100}%,${l * 100}%,${a})`;
ctx.closePath();
ctx.fill();
}
});
}
// ====
// Fallback SVG for SSR
// ====
function createFallbackSVG({ pub, size = 200, dark = false, embed = true } = {}) {
const { decoded, finals } = parsePub(pub);
// Create gradient background
const bgGradient = `
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="hsla(0,0%,${(dark ? 0 : 70) + finals[0] * 30}%)"/>
<stop offset="100%" stop-color="hsla(0,0%,${(dark ? 0 : 70) + finals[1] * 30}%)"/>
</linearGradient>
`;
// Generate circles for both layers
const createCircles = (data, radius, isSecond = false) => {
return chunkIt(data, 7).map(chunk => {
if (chunk.length !== 7) return '';
const [x, y, r, h, s, l, a] = chunk;
const cx = size / 2 + (x * size) / 2;
const cy = y * size;
const rad = r * radius;
return `
<circle
cx="${cx}" cy="${cy}" r="${rad}"
fill="hsla(${h * 360},${s * 100}%,${l * 100}%,${a})"
style="${isSecond ? 'mix-blend-mode:multiply;' : ''}"
/>
<circle
cx="${size - cx}" cy="${cy}" r="${rad}"
fill="hsla(${h * 360},${s * 100}%,${l * 100}%,${a})"
style="${isSecond ? 'mix-blend-mode:multiply;' : ''}"
/>
`;
}).join('');
};
let svg = `
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
<defs>${bgGradient}</defs>
<rect width="${size}" height="${size}" fill="url(#bg)"/>
${createCircles(decoded[0], 0.42 * size)}
${createCircles(decoded[1], 0.125 * size, true)}
</svg>
`;
if (embed) {
const embedData = { pub, };
if (embed && embed == true) { embedData.content = embed; }
svg = embedInImage(svg, embedData, 'svg');
}
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
}
function mountClass(elClass = "gun-avatar") {
document.addEventListener("DOMContentLoaded", () => {
let avatars = document.getElementsByClassName(elClass);
for (let i in avatars) {
const img = avatars[i];
if (img.dataset.round !== "false") {
img.style.borderRadius = "100%";
}
let embed = img.dataset.embed;
if (img.dataset.embed !== 'true') {
try {
embed = JSON.parse(img.dataset.embed);
} catch (e) {
console.warn('Invalid content JSON in data-embed attribute');
}
}
img.src = gunAvatar({
pub: img.dataset.pub,
size: Number(img.dataset.size),
dark: Boolean(img.dataset.dark),
draw: img.dataset.draw,
reflect: img.dataset.reflect !== "false",
embed: embed === "false" ? false : embed || true
});
}
});
}
function mountElement(elName = "gun-avatar") {
let initiated = false;
if (initiated) return;
class Avatar extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
/** @type {HTMLImageElement} */
this.img = document.createElement("img");
this.shadowRoot.append(this.img);
}
render() {
this.pub = this.getAttribute("pub") || "1234123455Ute2tFhdjDQgzR-1234lfSlZxgEZKuquI.2F-j1234434U1234Asj-5lxnECG5TDyuPD8gEiuI123";
this.size = this.hasAttribute("size") ? Number(this.getAttribute("size")) : 400;
this.draw = this.getAttribute("draw") || "circles";
this.reflect = this.hasAttribute("reflect") ? this.getAttribute("reflect") !== "false" : true;
this.round = this.hasAttribute("round") || this.getAttribute("round") === "";
this.dark = this.hasAttribute("dark") || this.getAttribute("dark") === "";
this.embed = this.hasAttribute("embed") ? this.getAttribute("embed") !== "false" : true;
let embed = this.getAttribute("embed");
if (this.getAttribute("embed")) {
try {
embed = JSON.parse(this.getAttribute("embed"));
} catch (e) {
console.warn('Invalid content JSON in embed attribute');
}
}
this.img.style.borderRadius = this.round ? "100%" : "0%";
this.img.src = gunAvatar({
pub: this.pub,
size: this.size,
dark: this.dark,
draw: this.draw,
reflect: this.reflect,
embed: embed === "false" ? false : embed || true
});
}
connectedCallback() {
this.render();
}
static get observedAttributes() {
return ["pub", "round", "size", "dark", "draw", "reflect", "embed"];
}
attributeChangedCallback() {
this.render();
}
}
customElements.define(elName, Avatar);
initiated = true;
}
patchSafari();
export { embedInImage, error, extractFromFile, gunAvatar, mountClass, mountElement, parsePub };