UNPKG

gun-avatar

Version:

Avatar generator for GUN public keys

548 lines (480 loc) 15.9 kB
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 };