Files
drawlogo/js/core/dicebear.js
T

285 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 画个标 · DiceBear 头像风格引擎(M4)
* 29 种 DiceBear v10 风格定义内置(js/data/sources/dicebear-*.js,由构建脚本生成),
* 本模块:按"风格 × seed"确定性组合出无限头像 SVG(不占图标配额)。
*
* 风格定义结构(v10 单文件 JSON):
* canvas 画布模板(elements 树,component 节点按 name 引用 components)
* components { name: { width, height, rotate/scale/translate 随机范围, variants: { 名: { elements, weight } } } }
* colors { 色名: { values: [...] } }(element 属性 fill/stroke 以 {type:'color',name} 引用)
* attributes 根 svg 属性(fill、shape-rendering 等)
*
* 渲染:seed → 确定性 PRNG → 每 component 加权选 variant + 随机变换 → color 引用取色 → 组装 SVG
*/
const HGB_Dicebear = (function () {
'use strict';
const VERSION = '20260822e';
const STYLES = [
'adventurer', 'adventurer-neutral', 'avataaars', 'avataaars-neutral',
'big-ears', 'big-ears-neutral', 'big-smile', 'blobs', 'bottts', 'bottts-neutral',
'clay', 'constellation', 'critters', 'croodles', 'croodles-neutral',
'cutouts', 'disco', 'dylan', 'fun-emoji', 'glass', 'glyphs', 'icons',
'identicon', 'initial-face', 'initials', 'landscape', 'line-face', 'loops',
'lorelei', 'lorelei-neutral', 'micah', 'miniavs', 'moods', 'notionists',
'notionists-neutral', 'open-peeps', 'patchwork', 'personas', 'pixel-art',
'pixel-art-neutral', 'pixelbot', 'planets', 'rings', 'shape-grid', 'shapes',
'sprouts', 'squircles', 'stripes', 'thumbs', 'toon-head', 'triangles',
'voxel-art', 'voxel-bot', 'waves', 'weave'
];
const CN = {
adventurer: '冒险家', 'adventurer-neutral': '冒险家中性', avataaars: '阿凡达',
'avataaars-neutral': '阿凡达中性', 'big-ears': '大耳朵', 'big-ears-neutral': '大耳朵中性',
'big-smile': '大笑脸', blobs: '斑点', bottts: '机器人', 'bottts-neutral': '机器人中性',
clay: '黏土', constellation: '星座', critters: '小动物',
croodles: '卡通线条', 'croodles-neutral': '卡通线条中性', cutouts: '剪贴画',
disco: '迪斯科', dylan: '迪伦', 'fun-emoji': '趣味表情', glass: '玻璃拟态',
glyphs: '字形符', icons: '图标风', identicon: '指纹几何', 'initial-face': '首字脸',
initials: '首字母', landscape: '风景', 'line-face': '线条脸', loops: '回环',
lorelei: '洛雷莱', 'lorelei-neutral': '洛雷莱中性', micah: '米迦',
miniavs: '迷你头像', moods: '心情', notionists: 'Notion 风格', 'notionists-neutral': 'Notion 中性',
'open-peeps': '开放小人', patchwork: '拼布', personas: '人物剪影', 'pixel-art': '像素风',
'pixel-art-neutral': '像素风中性', pixelbot: '像素机器人', planets: '星球',
rings: '圆环几何', 'shape-grid': '形状网格', shapes: '几何形状',
sprouts: '萌芽', squircles: '圆角方', stripes: '条纹', thumbs: '拇指人',
'toon-head': '卡通头', triangles: '三角形', 'voxel-art': '体素艺术',
'voxel-bot': '体素机器人', waves: '波浪', weave: '编织'
};
/* ---------- 确定性 PRNG(seed 字符串 → mulberry32) ---------- */
function hash(str) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function rng(seedStr) {
let s = hash(seedStr) || 1;
return function () {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function pick(r, arr) {
return arr[Math.floor(r() * arr.length)];
}
function weightedPick(r, items) {
let total = 0;
for (const i of items) total += (i.weight != null ? i.weight : 0.01);
if (total <= 0) return items[0].key;
let x = r() * total;
for (const i of items) {
x -= (i.weight != null ? i.weight : 0.01);
if (x <= 0) return i.key;
}
return items[items.length - 1].key;
}
function rn(v) { return Math.round(v * 100) / 100; }
/** 变量引用解析(initials 风格:initial/fontFamily/fontWeight 等) */
function resolveVariable(name, seed, opts) {
switch (name) {
case 'initial':
case 'initials': {
const s = String(opts.initial || seed || '').trim();
if (s) return s[0].toUpperCase();
return opts.defaultInitial || 'D';
}
case 'fontFamily': return opts.fontFamily || 'Arial';
case 'fontWeight': return String(opts.fontWeight != null ? opts.fontWeight : 600);
default: return '';
}
}
/** 属性值:color 引用 → 从色板取色;variable 引用 → 变量解析;其他原样 */
function resolveAttr(v, r, colors, opts) {
if (v && typeof v === 'object') {
if (v.type === 'color' && v.name) {
const c = colors[v.name];
if (typeof c === 'string') return c; // 已解析的单一颜色
if (c && Array.isArray(c.values) && c.values.length) return pick(r, c.values);
return '#000000';
}
if (v.type === 'variable' && v.name) {
return resolveVariable(v.name, opts.seed, opts);
}
return '';
}
return v;
}
/* ---------- 递归渲染 element / component / text 节点 ---------- */
function renderComponent(comp, r, colors, components, opts) {
const names = Object.keys(comp.variants || {});
if (!names.length) return '';
const items = names.map(n => ({ key: n, weight: comp.variants[n].weight }));
const variant = comp.variants[weightedPick(r, items)] || {};
// 随机变换(rotate/scale/translate 范围;个别风格 translate 只有 x 或 y,缺字段视为 0)
const t = [];
if (comp.translate) {
const tx = comp.translate.x;
const ty = comp.translate.y;
const x = tx ? tx.min + r() * (tx.max - tx.min) : 0;
const y = ty ? ty.min + r() * (ty.max - ty.min) : 0;
if (x || y) t.push('translate(' + rn(x) + ' ' + rn(y) + ')');
}
if (comp.rotate) {
const deg = comp.rotate.min + r() * (comp.rotate.max - comp.rotate.min);
if (deg) t.push('rotate(' + rn(deg) + ')');
}
if (comp.scale) {
const s = comp.scale.min + r() * (comp.scale.max - comp.scale.min);
if (s !== 1) t.push('scale(' + rn(s) + ')');
}
const inner = (variant.elements || []).map(e => renderNode(e, r, colors, components, opts)).join('');
return t.length ? '<g transform="' + t.join(' ') + '">' + inner + '</g>' : inner;
}
/**
* 解析组件继承(DiceBear schema 的 extends 字段):
* - identicon 的 row01~row05 → row;glass 的 shape01/shape02 → shape
* 继承语义 = 复用父组件定义(无自身 variants 时完全继承;带环保护)
*/
function resolveComp(name, components) {
const seen = new Set();
let cur = name;
while (cur && !seen.has(cur)) {
seen.add(cur);
const c = (components || {})[cur];
if (!c) return (components || {})[name] || null;
if (!c.extends) return c;
if (c.variants && Object.keys(c.variants).length) return c;
cur = c.extends;
}
return (components || {})[name] || null;
}
/**
* 统一渲染节点:支持三种类型
* - component 节点(任意层级出现,如 shapes 的 <g><component/></g>)→ 渲染其变体
* - text 节点 {type:'text', value} → 渲染为纯文本(initials 风格文字,value 可为变量引用)
* - element 节点 → 拼属性 + 递归 children
* components 仅在 canvas 层传入(renderComponent 内部复用自身 variants 而非 components 表)
*/
function renderNode(el, r, colors, components, opts) {
if (!el) return '';
if (el.type === 'component') {
const comp = resolveComp(el.name, components);
if (!comp) return '';
const inner = renderComponent(comp, r, colors, components, opts);
// component 节点自带 transform 等属性(如 shapes 的 matrix 定位)
const attrs = [];
for (const k of Object.keys(el.attributes || {})) {
const v = el.attributes[k];
if (v == null) continue;
attrs.push(k + '="' + String(v).replace(/"/g, '&quot;') + '"');
}
return '<g' + (attrs.length ? ' ' + attrs.join(' ') : '') + '>' + inner + '</g>';
}
if (el.type === 'text') {
const v = resolveAttr(el.value, r, colors, opts);
return String(v != null ? v : '');
}
// element:拼属性(color 引用取色,数字取整)
const attrs = [];
for (const k of Object.keys(el.attributes || {})) {
const v = resolveAttr(el.attributes[k], r, colors, opts);
if (v === '' || v == null) continue;
const sv = typeof v === 'number' ? rn(v) : String(v);
attrs.push(k + '="' + sv.replace(/"/g, '&quot;') + '"');
}
const children = (el.children || []).map(c => renderNode(c, r, colors, components, opts)).join('');
const name = (el.name || 'g').replace(/[^a-zA-Z0-9-]/g, '') || 'g';
return '<' + name + (attrs.length ? ' ' + attrs.join(' ') : '') +
(children ? '>' + children + '</' + name + '>' : '/>');
}
/* ---------- 画布组装 ---------- */
function renderCanvas(canvas, r, colors, components, opts) {
return (canvas.elements || []).map(n => renderNode(n, r, colors, components, opts)).join('');
}
/** 画布是否已含显式 background 色元素(避免重复垫底) */
function hasBgElement(canvas) {
function scan(el) {
if (!el || typeof el !== 'object') return false;
const a = el.attributes || {};
for (const k of Object.keys(a)) {
const v = a[k];
if (v && typeof v === 'object' && v.type === 'color' && v.name === 'background') return true;
if (v === 'background') return true;
}
return (el.children || []).some(scan);
}
return (canvas && canvas.elements || []).some(scan);
}
/**
* 渲染单个风格头像
* @param {string} styleId 风格 id(如 adventurer / pixel-art)
* @param {string} seed 确定性种子(同 seed 同输出)
* @returns {{svg:string, styleId:string}} svg 为完整 <svg> 文档
*/
function renderAvatar(styleId, seed, opts) {
const def = window['HGB_Dicebear_' + styleId.replace(/-/g, '_')];
if (!def) return { svg: '', styleId };
opts = opts || {};
const r = rng(String(seed || 'dicebear'));
const colors = def.colors || {};
// 每色名先定一个颜色(保持跨 component 一致)
const colorMap = {};
for (const name of Object.keys(colors)) {
colorMap[name] = pick(r, colors[name].values || ['#000000']);
}
let inner = renderCanvas(def.canvas, r, colorMap, def.components || {}, { seed, ...opts });
const w = (def.canvas && def.canvas.width) || 100;
const h = (def.canvas && def.canvas.height) || 100;
// 背景填充:dicebear 官方渲染器默认按 backgroundColor 垫底。neutral 系列(pixel-art-neutral 等)
// 脸部肤色依赖此背景,画布无显式背景元素且色板含 background 时先垫同尺寸 rect;
// 无 background 色板的风格(adventurer/pixel-art 等)零影响
if (colorMap.background && !hasBgElement(def.canvas)) {
inner = '<rect width="' + w + '" height="' + h + '" fill="' + colorMap.background + '"/>' + inner;
}
const rootAttrs = [];
for (const k of Object.keys(def.attributes || {})) {
rootAttrs.push(k + '="' + String(def.attributes[k]).replace(/"/g, '&quot;') + '"');
}
const svg = '<?xml version="1.0" encoding="UTF-8"?>\n' +
'<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h +
'" viewBox="0 0 ' + w + ' ' + h + '"' +
(rootAttrs.length ? ' ' + rootAttrs.join(' ') : '') + '>' + inner + '</svg>';
return { svg, styleId };
}
/** 懒加载单个风格定义(缓存 + 并发去重) */
const loaded = {};
const pending = {};
function loadStyle(styleId) {
if (loaded[styleId]) return Promise.resolve(loaded[styleId]);
if (pending[styleId]) return pending[styleId];
pending[styleId] = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'js/data/sources/dicebear-' + styleId + '.js?v=' + VERSION;
s.onload = () => {
const def = window['HGB_Dicebear_' + styleId.replace(/-/g, '_')];
if (!def) { reject(new Error('风格定义缺失: ' + styleId)); return; }
loaded[styleId] = def;
delete pending[styleId];
resolve(def);
};
s.onerror = () => { delete pending[styleId]; reject(new Error('风格加载失败: ' + styleId)); };
document.head.appendChild(s);
});
return pending[styleId];
}
/** 加载全部风格(预载) */
function loadAll() {
return Promise.allSettled(STYLES.map(loadStyle));
}
return { VERSION, STYLES, CN, renderAvatar, loadStyle, loadAll };
})();