1906 lines
66 KiB
PHP
1906 lines
66 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* 自动头像:为没有头像的访客按邮箱生成一张头像
|
|||
|
|
*
|
|||
|
|
* ── 为什么需要这个 ────────────────────────────────────────────────
|
|||
|
|
* WordPress 在访客没注册、没上传头像时,会退回 Gravatar 的默认图
|
|||
|
|
* (那个灰底「神秘人」)。观感很差,而且主题默认走 cn.gravatar.com,
|
|||
|
|
* 该域名在国内已超时 —— 不勾「Gravatar 加速」时头像根本加载不出来。
|
|||
|
|
*
|
|||
|
|
* 本模块按访客邮箱确定性地生成一张头像:同一个邮箱永远得到同一张脸,
|
|||
|
|
* 不同邮箱得到不同的脸。全程纯 PHP,不发起任何外部请求。
|
|||
|
|
*
|
|||
|
|
* ── 素材与算法来源 ────────────────────────────────────────────────
|
|||
|
|
* 算法与图形素材来自 DiceBear(https://www.dicebear.com)9.x。
|
|||
|
|
* 收录的 14 套风格全部是 CC0 1.0 或 MIT 许可,可自由使用与商用:
|
|||
|
|
* CC0 1.0:glass identicon initials lorelei lorelei-neutral
|
|||
|
|
* notionists notionists-neutral open-peeps pixel-art
|
|||
|
|
* pixel-art-neutral rings shapes thumbs
|
|||
|
|
* MIT :icons
|
|||
|
|
* 每套模板都是把 DiceBear 的风格包「编译」成纯数组后的产物
|
|||
|
|
* (inc/avatar-styles/*.php),文件里没有任何可执行 JS。
|
|||
|
|
*
|
|||
|
|
* ── 为什么不落盘 ──────────────────────────────────────────────────
|
|||
|
|
* 单次渲染实测 0.09~0.15 ms,比一次磁盘 stat 还便宜。
|
|||
|
|
* 落盘反而要额外处理目录权限、过期清理、并发写冲突、以及
|
|||
|
|
* 「用户换了风格之后旧图怎么办」。直接生成更简单也更可靠。
|
|||
|
|
*
|
|||
|
|
* ── PRNG 为什么要自己实现 ────────────────────────────────────────
|
|||
|
|
* 必须与 DiceBear 的输出逐字节一致,否则同一邮箱在官方预览页
|
|||
|
|
* 和本站会得到两张不同的脸。DiceBear 9.x 用的是 xorshift +
|
|||
|
|
* 自定义 hashSeed,且全程在 JS 的 32 位有符号整数域里运算。
|
|||
|
|
* PHP 的整数是 64 位,必须逐步截断到 int32,见 Yang_Avatar_Prng。
|
|||
|
|
*
|
|||
|
|
* @author Yongquan Yang <yangyq.net>
|
|||
|
|
* @license MIT License
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
if (!defined('ABSPATH')) {
|
|||
|
|
exit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ══════════════════════════════════════════════════════════════════
|
|||
|
|
* UTF-8 工具:**全部不依赖 mbstring**
|
|||
|
|
*
|
|||
|
|
* ── 为什么不能用 mb_* ────────────────────────────────────────────
|
|||
|
|
* mbstring 是 PHP 的**可选扩展**。线上这台机器装了,但主题是要发给
|
|||
|
|
* 别人装的 —— 谁也不能保证对方的虚拟主机开了 mbstring。一旦没开,
|
|||
|
|
* `mb_substr()` 就是"调用未定义函数",PHP 7 里直接 Fatal error,
|
|||
|
|
* **整个站点白屏**,而报错指向头像模块,跟"没装扩展"看起来毫无关系。
|
|||
|
|
*
|
|||
|
|
* 所以要用的三件事都自己实现:
|
|||
|
|
* · 按码点切开字符串(utf8_split)
|
|||
|
|
* · 取前 N 个字符(utf8_substr)
|
|||
|
|
* · 转大写(utf8_upper,只处理 Latin-1 + 常见希腊/西里尔)
|
|||
|
|
*
|
|||
|
|
* 转大写本来最麻烦(Unicode 有大写映射表),但这里只用在「首字母」
|
|||
|
|
* 上,输入是 `\p{L}` 单个字符。真实场景里 99% 是 ASCII 或中文:
|
|||
|
|
* 中文没有大小写,原样返回即可;ASCII 走 strtoupper。其余语种按
|
|||
|
|
* 原样返回 —— 首字母不大写不影响正确性,只是观感略差,
|
|||
|
|
* 远比"白屏"可接受。
|
|||
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 单个 UTF-8 字符 → 码点(纯字节运算)
|
|||
|
|
*
|
|||
|
|
* @param string $ch 一个完整 UTF-8 字符
|
|||
|
|
* @return int|null 非法序列返回 null
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_cp($ch)
|
|||
|
|
{
|
|||
|
|
$n = strlen($ch);
|
|||
|
|
if ($n === 0) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
$b0 = ord($ch[0]);
|
|||
|
|
if ($n === 1) {
|
|||
|
|
// 纯 ASCII
|
|||
|
|
if ($b0 < 0x80) {
|
|||
|
|
return $b0;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
if ($n === 2) {
|
|||
|
|
$b1 = ord($ch[1]);
|
|||
|
|
if (($b0 & 0xE0) !== 0xC0 || ($b1 & 0xC0) !== 0x80) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return (($b0 & 0x1F) << 6) | ($b1 & 0x3F);
|
|||
|
|
}
|
|||
|
|
if ($n === 3) {
|
|||
|
|
$b1 = ord($ch[1]);
|
|||
|
|
$b2 = ord($ch[2]);
|
|||
|
|
if (($b0 & 0xF0) !== 0xE0 || ($b1 & 0xC0) !== 0x80 || ($b2 & 0xC0) !== 0x80) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return (($b0 & 0x0F) << 12) | (($b1 & 0x3F) << 6) | ($b2 & 0x3F);
|
|||
|
|
}
|
|||
|
|
if ($n === 4) {
|
|||
|
|
$b1 = ord($ch[1]);
|
|||
|
|
$b2 = ord($ch[2]);
|
|||
|
|
$b3 = ord($ch[3]);
|
|||
|
|
if (($b0 & 0xF8) !== 0xF0 || ($b1 & 0xC0) !== 0x80
|
|||
|
|
|| ($b2 & 0xC0) !== 0x80 || ($b3 & 0xC0) !== 0x80) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return (($b0 & 0x07) << 18) | (($b1 & 0x3F) << 12)
|
|||
|
|
| (($b2 & 0x3F) << 6) | ($b3 & 0x3F);
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* UTF-8 字符串 → 字符数组(每个元素是一个完整字符)
|
|||
|
|
*
|
|||
|
|
* 遇到非法字节时**逐字节跳过并记一个 U+FFFD**,不整串放弃 ——
|
|||
|
|
* 邮箱理论上都是 ASCII,但注释作者名/昵称可能是乱码,
|
|||
|
|
* 那时应该画个替代符,而不是让整张头像渲染失败。
|
|||
|
|
*
|
|||
|
|
* @param string $s
|
|||
|
|
* @return string[]
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_utf8_split($s)
|
|||
|
|
{
|
|||
|
|
$out = array();
|
|||
|
|
$len = strlen($s);
|
|||
|
|
$i = 0;
|
|||
|
|
while ($i < $len) {
|
|||
|
|
$b = ord($s[$i]);
|
|||
|
|
if ($b < 0x80) {
|
|||
|
|
$w = 1;
|
|||
|
|
} elseif (($b & 0xE0) === 0xC0) {
|
|||
|
|
$w = 2;
|
|||
|
|
} elseif (($b & 0xF0) === 0xE0) {
|
|||
|
|
$w = 3;
|
|||
|
|
} elseif (($b & 0xF8) === 0xF0) {
|
|||
|
|
$w = 4;
|
|||
|
|
} else {
|
|||
|
|
// 非法起始字节
|
|||
|
|
$out[] = "\xEF\xBF\xBD";
|
|||
|
|
$i++;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if ($i + $w > $len) {
|
|||
|
|
// 结尾被截断
|
|||
|
|
$out[] = "\xEF\xBF\xBD";
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
$ch = substr($s, $i, $w);
|
|||
|
|
if (yang_avatar_cp($ch) === null) {
|
|||
|
|
$out[] = "\xEF\xBF\xBD";
|
|||
|
|
$i++;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
$out[] = $ch;
|
|||
|
|
$i += $w;
|
|||
|
|
}
|
|||
|
|
return $out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 取前 $n 个字符(替代 mb_substr($s, 0, $n, 'UTF-8'))
|
|||
|
|
*
|
|||
|
|
* @param string $s
|
|||
|
|
* @param int $n
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_utf8_sub($s, $n)
|
|||
|
|
{
|
|||
|
|
$n = (int) $n;
|
|||
|
|
if ($n <= 0) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
// ASCII 快路径:绝大多数种子(邮箱)都走这里
|
|||
|
|
if (!preg_match('/[\x80-\xFF]/', $s)) {
|
|||
|
|
return strlen($s) <= $n ? $s : substr($s, 0, $n);
|
|||
|
|
}
|
|||
|
|
$chars = yang_avatar_utf8_split($s);
|
|||
|
|
if (count($chars) <= $n) {
|
|||
|
|
return $s;
|
|||
|
|
}
|
|||
|
|
return implode('', array_slice($chars, 0, $n));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 转大写(只保证 ASCII 与 Latin-1 补集,其余原样返回)
|
|||
|
|
*
|
|||
|
|
* @param string $s
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_utf8_upper($s)
|
|||
|
|
{
|
|||
|
|
if ($s === '') {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
if (!preg_match('/[\x80-\xFF]/', $s)) {
|
|||
|
|
return strtoupper($s);
|
|||
|
|
}
|
|||
|
|
// 带重音的拉丁字母:逐个码点处理,非 ASCII 的原样保留
|
|||
|
|
$chars = yang_avatar_utf8_split($s);
|
|||
|
|
$out = '';
|
|||
|
|
foreach ($chars as $ch) {
|
|||
|
|
$cp = yang_avatar_cp($ch);
|
|||
|
|
if ($cp === null || $cp > 0x7F) {
|
|||
|
|
$out .= $ch;
|
|||
|
|
} else {
|
|||
|
|
$out .= strtoupper($ch);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return $out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* UTF-8 字符串 → UTF-16 码元数组(JS charCodeAt 的语义)
|
|||
|
|
*
|
|||
|
|
* @param string $s
|
|||
|
|
* @return int[]
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_utf16_units($s)
|
|||
|
|
{
|
|||
|
|
$out = array();
|
|||
|
|
foreach (yang_avatar_utf8_split($s) as $ch) {
|
|||
|
|
$cp = yang_avatar_cp($ch);
|
|||
|
|
if ($cp === null) {
|
|||
|
|
$out[] = 0xFFFD;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if ($cp <= 0xFFFF) {
|
|||
|
|
// 单个"代理区"码元直接照搬(含 U+D800..U+DFFF 这种非法单值)
|
|||
|
|
$out[] = $cp;
|
|||
|
|
} else {
|
|||
|
|
$cp -= 0x10000;
|
|||
|
|
$out[] = 0xD800 + ($cp >> 10);
|
|||
|
|
$out[] = 0xDC00 + ($cp & 0x3FF);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return $out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 与 DiceBear 9.x 完全一致的 PRNG
|
|||
|
|
*
|
|||
|
|
* 对照 @dicebear/core/lib/utils/prng.js:
|
|||
|
|
* const MIN = -2147483648, MAX = 2147483647;
|
|||
|
|
* function xorshift(value) {
|
|||
|
|
* value ^= value << 13; value ^= value >> 17; value ^= value << 5;
|
|||
|
|
* return value;
|
|||
|
|
* }
|
|||
|
|
* function hashSeed(seed) {
|
|||
|
|
* let hash = 0;
|
|||
|
|
* for (let i = 0; i < seed.length; i++) {
|
|||
|
|
* hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
|
|||
|
|
* hash = xorshift(hash);
|
|||
|
|
* }
|
|||
|
|
* return hash;
|
|||
|
|
* }
|
|||
|
|
*
|
|||
|
|
* 三处必须小心的地方:
|
|||
|
|
* ① `<<` `>>` `| 0` 都是 32 位有符号运算,PHP 里要逐步 i32() 归一;
|
|||
|
|
* ② `>>` 是算术右移(保住符号位),PHP 的 >> 对负数行为一致,
|
|||
|
|
* 但前提是先把值收紧到 int32;
|
|||
|
|
* ③ charCodeAt 取的是 **UTF-16 码元**,不是 UTF-8 字节也不是码点。
|
|||
|
|
* emoji 这类增补平面字符在 JS 里占两个码元(代理对),
|
|||
|
|
* 必须真的拆分,否则哈希完全不同。
|
|||
|
|
*/
|
|||
|
|
class Yang_Avatar_Prng
|
|||
|
|
{
|
|||
|
|
const MIN = -2147483648;
|
|||
|
|
const MAX = 2147483647;
|
|||
|
|
|
|||
|
|
/** @var int 当前状态 */
|
|||
|
|
private $value;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 截断成 JS 的 int32(有符号)
|
|||
|
|
*
|
|||
|
|
* @param int $v
|
|||
|
|
* @return int
|
|||
|
|
*/
|
|||
|
|
private static function i32($v)
|
|||
|
|
{
|
|||
|
|
$v = $v & 0xFFFFFFFF;
|
|||
|
|
if ($v >= 0x80000000) {
|
|||
|
|
$v -= 0x100000000;
|
|||
|
|
}
|
|||
|
|
return $v;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* JS 的 `value << n`(32 位有符号左移)
|
|||
|
|
*
|
|||
|
|
* @param int $v
|
|||
|
|
* @param int $n
|
|||
|
|
* @return int
|
|||
|
|
*/
|
|||
|
|
private static function shl($v, $n)
|
|||
|
|
{
|
|||
|
|
return self::i32(self::i32($v) << $n);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* JS 的 `value >> n`(32 位有符号算术右移)
|
|||
|
|
*
|
|||
|
|
* @param int $v
|
|||
|
|
* @param int $n
|
|||
|
|
* @return int
|
|||
|
|
*/
|
|||
|
|
private static function shr($v, $n)
|
|||
|
|
{
|
|||
|
|
return self::i32(self::i32($v) >> $n);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 与 JS 逐位一致的 xorshift
|
|||
|
|
*
|
|||
|
|
* @param int $value
|
|||
|
|
* @return int
|
|||
|
|
*/
|
|||
|
|
private static function xorshift($value)
|
|||
|
|
{
|
|||
|
|
$value = self::i32($value);
|
|||
|
|
$value = self::i32($value ^ self::shl($value, 13));
|
|||
|
|
$value = self::i32($value ^ self::shr($value, 17));
|
|||
|
|
$value = self::i32($value ^ self::shl($value, 5));
|
|||
|
|
return $value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* UTF-8 字符串 → UTF-16 码元数组
|
|||
|
|
*
|
|||
|
|
* 不能直接用码点:JS 的 charCodeAt 按 UTF-16 码元取值,
|
|||
|
|
* U+1F389 这种在 JS 里是 0xD83C 0xDF89 两个码元。
|
|||
|
|
*
|
|||
|
|
* 直接走字节,不用 mb_* —— 见文件头的「为什么不用 mbstring」。
|
|||
|
|
*
|
|||
|
|
* @param string $s
|
|||
|
|
* @return int[]
|
|||
|
|
*/
|
|||
|
|
private static function utf16_code_units($s)
|
|||
|
|
{
|
|||
|
|
return yang_avatar_utf16_units($s);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 单个 UTF-8 字符 → Unicode 码点
|
|||
|
|
*
|
|||
|
|
* 不用 mb_ord():它是 PHP 7.2 才有的函数,而主题基线是 7.0。
|
|||
|
|
* 实现见 yang_avatar_cp(),那里带静态缓存。
|
|||
|
|
*
|
|||
|
|
* @param string $ch
|
|||
|
|
* @return int|null
|
|||
|
|
*/
|
|||
|
|
private static function code_point($ch)
|
|||
|
|
{
|
|||
|
|
static $cache = array();
|
|||
|
|
if (array_key_exists($ch, $cache)) {
|
|||
|
|
return $cache[$ch];
|
|||
|
|
}
|
|||
|
|
$v = yang_avatar_cp($ch);
|
|||
|
|
$cache[$ch] = $v;
|
|||
|
|
return $v;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 与 JS 逐位一致的 hashSeed
|
|||
|
|
*
|
|||
|
|
* @param string $seed
|
|||
|
|
* @return int
|
|||
|
|
*/
|
|||
|
|
private static function hash_seed($seed)
|
|||
|
|
{
|
|||
|
|
$hash = 0;
|
|||
|
|
$units = self::utf16_code_units($seed);
|
|||
|
|
$n = count($units);
|
|||
|
|
for ($i = 0; $i < $n; $i++) {
|
|||
|
|
$hash = self::i32(self::shl($hash, 5) - $hash + $units[$i]);
|
|||
|
|
$hash = self::xorshift($hash);
|
|||
|
|
}
|
|||
|
|
return $hash;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* @param string $seed
|
|||
|
|
*/
|
|||
|
|
public function __construct($seed = '')
|
|||
|
|
{
|
|||
|
|
// JS: seed.toString().slice(0, 1024) —— 防止超长种子拖垮 CPU
|
|||
|
|
$seed = yang_avatar_utf8_sub((string) $seed, 1024);
|
|||
|
|
$h = self::hash_seed($seed);
|
|||
|
|
// hashSeed 可能返回 0,此时退化成 1(否则 xorshift 永远停在 0)
|
|||
|
|
$this->value = $h !== 0 ? $h : 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* @return int
|
|||
|
|
*/
|
|||
|
|
public function next()
|
|||
|
|
{
|
|||
|
|
$this->value = self::xorshift($this->value);
|
|||
|
|
return $this->value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 闭区间 [min, max] 取整
|
|||
|
|
*
|
|||
|
|
* @param int $min
|
|||
|
|
* @param int $max
|
|||
|
|
* @return int
|
|||
|
|
*/
|
|||
|
|
public function integer($min, $max)
|
|||
|
|
{
|
|||
|
|
$v = $this->next();
|
|||
|
|
$ratio = ($v - self::MIN) / (self::MAX - self::MIN);
|
|||
|
|
return (int) floor($ratio * ($max + 1 - $min) + $min);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* @param int $likelihood 百分比
|
|||
|
|
* @return bool
|
|||
|
|
*/
|
|||
|
|
public function bool($likelihood = 50)
|
|||
|
|
{
|
|||
|
|
return $this->integer(1, 100) <= $likelihood;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 从数组里取一个;空数组时**仍然消耗一次** next(),保证调用次数一致
|
|||
|
|
*
|
|||
|
|
* @param array $arr
|
|||
|
|
* @param mixed $fallback
|
|||
|
|
* @return mixed
|
|||
|
|
*/
|
|||
|
|
public function pick($arr, $fallback = null)
|
|||
|
|
{
|
|||
|
|
if (!is_array($arr) || count($arr) === 0) {
|
|||
|
|
$this->next();
|
|||
|
|
return $fallback;
|
|||
|
|
}
|
|||
|
|
return $arr[$this->integer(0, count($arr) - 1)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 与 DiceBear 一致的 Fisher-Yates 洗牌
|
|||
|
|
*
|
|||
|
|
* 关键:**只消耗本实例一次 next()**,其余随机数来自一个子 PRNG
|
|||
|
|
* (种子 = 那次 next() 结果的十进制字符串)。若这里多消耗或少消耗,
|
|||
|
|
* 后面 backgroundRotation 取到的值就歪了,整张图的背景色也会错。
|
|||
|
|
*
|
|||
|
|
* @param array $arr
|
|||
|
|
* @return array
|
|||
|
|
*/
|
|||
|
|
public function shuffle($arr)
|
|||
|
|
{
|
|||
|
|
$child = new self((string) $this->next());
|
|||
|
|
$n = count($arr);
|
|||
|
|
$work = array_values($arr);
|
|||
|
|
for ($i = $n - 1; $i > 0; $i--) {
|
|||
|
|
$j = $child->integer(0, $i);
|
|||
|
|
$tmp = $work[$i];
|
|||
|
|
$work[$i] = $work[$j];
|
|||
|
|
$work[$j] = $tmp;
|
|||
|
|
}
|
|||
|
|
return $work;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 收录的风格清单
|
|||
|
|
*
|
|||
|
|
* 只收 CC0 1.0 与 MIT。CC BY 4.0 那些(adventurer / avataaars / bottts /
|
|||
|
|
* croodles / micah / toon-head 等)虽然更好看,但要求保留署名 ——
|
|||
|
|
* 主题是给用户装的,用户不会去维护署名,等于把合规义务转嫁给用户。
|
|||
|
|
*
|
|||
|
|
* 'label' 用中文,因为后台是给中文站长看的。
|
|||
|
|
* 'size' 是模板文件体积,用于后台提示「这套会占多少」。
|
|||
|
|
*
|
|||
|
|
* @return array
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_styles()
|
|||
|
|
{
|
|||
|
|
return array(
|
|||
|
|
'lorelei' => array(
|
|||
|
|
'label' => __('柔和卡通', 'kratos'),
|
|||
|
|
'desc' => __('五官柔和,男女都有,头发变化多。最接近"真人头像"的一套。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'notionists' => array(
|
|||
|
|
'label' => __('简笔线稿', 'kratos'),
|
|||
|
|
'desc' => __('Notion 那种极简线稿。纯黑白,不挑主题配色。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'notionists-neutral' => array(
|
|||
|
|
'label' => __('简笔线稿(带底色)', 'kratos'),
|
|||
|
|
'desc' => __('跟上一套同一画风,但带一块背景色,更醒目一点。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'open-peeps' => array(
|
|||
|
|
'label' => __('手绘速写', 'kratos'),
|
|||
|
|
'desc' => __('手绘感强,表情夸张,很有个性。适合偏文艺的博客。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'lorelei-neutral' => array(
|
|||
|
|
'label' => __('柔和卡通(带底色)', 'kratos'),
|
|||
|
|
'desc' => __('同一套画风,带背景色块。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'shapes' => array(
|
|||
|
|
'label' => __('几何色块', 'kratos'),
|
|||
|
|
'desc' => __('随机几何图形叠加,不涉及人脸,永远不会"崩脸"。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'glass' => array(
|
|||
|
|
'label' => __('毛玻璃', 'kratos'),
|
|||
|
|
'desc' => __('模糊叠加的字母玻璃效果,设计感强。颜色比较鲜艳。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'rings' => array(
|
|||
|
|
'label' => __('同心圆环', 'kratos'),
|
|||
|
|
'desc' => __('极简同心圆,最不打扰视觉的一套。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'identicon' => array(
|
|||
|
|
'label' => __('对称方块', 'kratos'),
|
|||
|
|
'desc' => __('GitHub 那种 5x5 对称色块,程序员风格。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'thumbs' => array(
|
|||
|
|
'label' => __('拇指', 'kratos'),
|
|||
|
|
'desc' => __('只有"拇指"一个形象,靠表情与配色区分。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'pixel-art' => array(
|
|||
|
|
'label' => __('像素风', 'kratos'),
|
|||
|
|
'desc' => __('8-bit 像素人像。风格强烈,适合游戏/技术类博客。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'pixel-art-neutral' => array(
|
|||
|
|
'label' => __('像素风(带底色)', 'kratos'),
|
|||
|
|
'desc' => __('同一套像素画风,带背景色块。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'icons' => array(
|
|||
|
|
'label' => __('图标', 'kratos'),
|
|||
|
|
'desc' => __('抽象图标剪影,很干净。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
'initials' => array(
|
|||
|
|
'label' => __('首字母', 'kratos'),
|
|||
|
|
'desc' => __('显示邮箱对应的首字母文字。若访客用中文邮箱会显示拼音首字母。', 'kratos'),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 当前是否启用了自动头像
|
|||
|
|
*
|
|||
|
|
* @return bool
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_active()
|
|||
|
|
{
|
|||
|
|
$on = kratos_option('a_auto_enable', '0');
|
|||
|
|
return $on === '1' || $on === 1 || $on === true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 当前选中的风格名(已校验,非法值回落到默认风格)
|
|||
|
|
*
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_style()
|
|||
|
|
{
|
|||
|
|
static $cached = null;
|
|||
|
|
if ($cached !== null) {
|
|||
|
|
return $cached;
|
|||
|
|
}
|
|||
|
|
$all = yang_avatar_styles();
|
|||
|
|
$want = kratos_option('a_auto_style', 'lorelei');
|
|||
|
|
if (is_string($want) && isset($all[$want])) {
|
|||
|
|
$cached = $want;
|
|||
|
|
} else {
|
|||
|
|
$cached = 'lorelei';
|
|||
|
|
}
|
|||
|
|
return $cached;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 加载某套风格的编译后模板
|
|||
|
|
*
|
|||
|
|
* 只 require 用到的那一份,其余不解析 —— 14 套全量 1.1MB,
|
|||
|
|
* 每次请求都加载是不必要的开销。
|
|||
|
|
*
|
|||
|
|
* @param string $style
|
|||
|
|
* @return array|null
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_load_style($style)
|
|||
|
|
{
|
|||
|
|
static $loaded = array();
|
|||
|
|
if (isset($loaded[$style])) {
|
|||
|
|
return $loaded[$style];
|
|||
|
|
}
|
|||
|
|
$all = yang_avatar_styles();
|
|||
|
|
if (!isset($all[$style])) {
|
|||
|
|
$loaded[$style] = null;
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
// 风格名 → 文件名:- 换成 _
|
|||
|
|
$file = str_replace('-', '_', $style);
|
|||
|
|
$path = get_template_directory() . '/inc/avatar-styles/' . $file . '.php';
|
|||
|
|
if (!file_exists($path)) {
|
|||
|
|
$loaded[$style] = null;
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
$data = require $path;
|
|||
|
|
$loaded[$style] = is_array($data) ? $data : null;
|
|||
|
|
return $loaded[$style];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 人类调色板
|
|||
|
|
*
|
|||
|
|
* ── 为什么必须自带 ────────────────────────────────────────────────
|
|||
|
|
* DiceBear 的默认调色板几乎全是单值:hairColor / eyesColor / mouthColor
|
|||
|
|
* 都只有 '000000',skinColor 只有 'ffffff'。实测不换调色板的话,
|
|||
|
|
* 30 个不同邮箱只会产生 2 种配色 —— 这才是"默认头像丑"的真正原因,
|
|||
|
|
* 光换风格解决不了。
|
|||
|
|
*
|
|||
|
|
* 这里的键名对应各风格 schema 里的颜色字段;风格没有的键会被忽略。
|
|||
|
|
*
|
|||
|
|
* @return array
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_palette()
|
|||
|
|
{
|
|||
|
|
return array(
|
|||
|
|
// 背景色:低饱和,跟大多数主题都能搭
|
|||
|
|
'backgroundColor' => array(
|
|||
|
|
'8ecae6', '219ebc', 'ffb703', 'fb8500', '90be6d', '43aa8b',
|
|||
|
|
'f8961e', 'f9c74f', '4d908e', '577590', 'f94144', 'f3722c',
|
|||
|
|
'a8dadc', '457b9d',
|
|||
|
|
),
|
|||
|
|
// 肤色:从浅到深,覆盖常见人种
|
|||
|
|
'skinColor' => array(
|
|||
|
|
'f2d3b8', 'e8b98f', 'd9a066', 'c68642', '8d5524', 'ffdbac', 'f1c27d',
|
|||
|
|
),
|
|||
|
|
// 发色
|
|||
|
|
'hairColor' => array(
|
|||
|
|
'2c1b18', '4a312c', '6a4e42', 'a55728', 'b89778', 'd6b370',
|
|||
|
|
'c93305', '1a1a1a', '5a3825', 'e6cea8',
|
|||
|
|
),
|
|||
|
|
// 衣服色
|
|||
|
|
'clothesColor' => array(
|
|||
|
|
'2f4858', '33658a', '86bbd8', 'f6ae2d', 'f26419', '758e4f',
|
|||
|
|
'5d2e46', '1b4079', '8f2d56', '3d315b',
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 把调色板映射到某套风格实际拥有的颜色字段
|
|||
|
|
*
|
|||
|
|
* 不同风格用的是不同的键名(skinColor / baseColor、clothesColor /
|
|||
|
|
* shirtColor / bodyColor …)。这里只给风格**真实存在**的字段赋值,
|
|||
|
|
* 不存在的键不能塞进 options,否则会打乱 pick 的取值。
|
|||
|
|
*
|
|||
|
|
* @param array $style 模板数据
|
|||
|
|
* @return array 要叠加到 options 上的键值
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_style_palette($style)
|
|||
|
|
{
|
|||
|
|
$pal = yang_avatar_palette();
|
|||
|
|
$props = isset($style['defaults']) && is_array($style['defaults'])
|
|||
|
|
? $style['defaults'] : array();
|
|||
|
|
|
|||
|
|
// 字段名 → 调色板类别
|
|||
|
|
$map = array(
|
|||
|
|
'backgroundColor' => 'backgroundColor',
|
|||
|
|
'skinColor' => 'skinColor',
|
|||
|
|
'baseColor' => 'skinColor',
|
|||
|
|
'hairColor' => 'hairColor',
|
|||
|
|
'topColor' => 'hairColor',
|
|||
|
|
'beardColor' => 'hairColor',
|
|||
|
|
'facialHairColor' => 'hairColor',
|
|||
|
|
'eyebrowsColor' => 'hairColor',
|
|||
|
|
'clothesColor' => 'clothesColor',
|
|||
|
|
'shirtColor' => 'clothesColor',
|
|||
|
|
'bodyColor' => 'clothesColor',
|
|||
|
|
'clothingColor' => 'clothesColor',
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
$out = array();
|
|||
|
|
foreach ($map as $field => $kind) {
|
|||
|
|
// 只处理风格真实声明过的字段
|
|||
|
|
if (array_key_exists($field, $props) && isset($pal[$kind])) {
|
|||
|
|
$out[$field] = $pal[$kind];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return $out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 渲染一张头像
|
|||
|
|
*
|
|||
|
|
* @param string $seed 种子(用邮箱即可;空则回落到站点地址)
|
|||
|
|
* @param string $style 风格名,空则用后台选中的
|
|||
|
|
* @param int $size 像素边长
|
|||
|
|
* @return string SVG 源码;失败时返回空串
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_svg($seed, $style = '', $size = 96)
|
|||
|
|
{
|
|||
|
|
if ($style === '') {
|
|||
|
|
$style = yang_avatar_style();
|
|||
|
|
}
|
|||
|
|
$data = yang_avatar_load_style($style);
|
|||
|
|
if ($data === null) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$seed = trim((string) $seed);
|
|||
|
|
if ($seed === '') {
|
|||
|
|
// 没有邮箱时不能返回空 —— 那就所有匿名访客共用一张脸,
|
|||
|
|
// 用站点地址兜底,至少是稳定的一张。
|
|||
|
|
$seed = home_url();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$size = (int) $size;
|
|||
|
|
if ($size <= 0) {
|
|||
|
|
$size = 96;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$palette = yang_avatar_style_palette($data);
|
|||
|
|
|
|||
|
|
return yang_avatar_render($data, $seed, $size, $palette);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 供后台预览用的 data URI
|
|||
|
|
*
|
|||
|
|
* @param string $seed
|
|||
|
|
* @param string $style
|
|||
|
|
* @param int $size
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_data_uri($seed, $style = '', $size = 96)
|
|||
|
|
{
|
|||
|
|
$svg = yang_avatar_svg($seed, $style, $size);
|
|||
|
|
if ($svg === '') {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
return 'data:image/svg+xml;base64,' . base64_encode($svg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 接 get_avatar 过滤器
|
|||
|
|
*
|
|||
|
|
* ── 判据:只动「原本指向 Gravatar 默认图」的标签 ──────────────────
|
|||
|
|
* WP 的 get_avatar() 从不去查 Gravatar 到底有没有这张图 —— 它只是
|
|||
|
|
* 拼一个 `https://<host>/avatar/<md5>?s=48&d=mm&r=g` 交给浏览器,
|
|||
|
|
* 由 Gravatar 服务端在加载时决定返回真人头像还是默认图。
|
|||
|
|
*
|
|||
|
|
* 也就是说:**服务端离线状态下,无法区分「这个邮箱有 Gravatar」和
|
|||
|
|
* 「没有、会退回默认图」**。任何声称能精确判断的写法(比如去看
|
|||
|
|
* 响应状态码)都要发一次外网请求,而 gravatar.com 在国内全部超时,
|
|||
|
|
* 这条路根本走不通。
|
|||
|
|
*
|
|||
|
|
* 所以这里退一步,用「src 指向谁」做判据:
|
|||
|
|
* · src 是 data: → 说明别的插件/主题已经给过头像了,放行;
|
|||
|
|
* · src 主机属于 Gravatar 家族 → 替换成生成图;
|
|||
|
|
* · 其它(媒体库图片、外链头像)→ 放行。
|
|||
|
|
*
|
|||
|
|
* 代价:如果站长开了「Gravatar 加速」镜像、并且访客确实有 Gravatar
|
|||
|
|
* 头像,这里也会替换掉。这是有意的取舍 —— 相比"偶尔盖掉一个真实
|
|||
|
|
* 头像","多数访客是一张破图/灰头像"更伤。选项说明里写清了这一点。
|
|||
|
|
*
|
|||
|
|
* ── 为什么不再重新拼整个 <img> ────────────────────────────────────
|
|||
|
|
* 早先的实现直接用 $args 里的 class / extra_attr 重新拼一个标签。
|
|||
|
|
* 这有两个坑:
|
|||
|
|
* ① get_avatar() 输出了 `srcset`(2x/3x)。只改 src、留着 srcset,
|
|||
|
|
* 浏览器会**优先用 srcset**,我们生成的图根本不会被加载 ——
|
|||
|
|
* 现象是"代码明明换了,页面上还是老样子"。
|
|||
|
|
* ② `decoding="async"`、`loading="lazy"`、`fetchpriority` 这类
|
|||
|
|
* 较新 WP 版本才输出的属性,$args 里并不完整。
|
|||
|
|
* 改成在原字符串上做定点替换:删掉 srcset/sizes,只换 src,
|
|||
|
|
* 其余属性一律原样保留。
|
|||
|
|
*
|
|||
|
|
* @param string $avatar 已经拼好的 <img> 标签
|
|||
|
|
* @param mixed $id_or_email
|
|||
|
|
* @param int $size
|
|||
|
|
* @param string $default
|
|||
|
|
* @param string $alt
|
|||
|
|
* @param array $args
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_filter($avatar, $id_or_email, $size, $default, $alt, $args = array())
|
|||
|
|
{
|
|||
|
|
if (!yang_avatar_active()) {
|
|||
|
|
return $avatar;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 不是 <img> 就没有可换的地方(get_avatar 也可能是评论里的一段 HTML)
|
|||
|
|
if (!is_string($avatar) || stripos($avatar, '<img') === false) {
|
|||
|
|
return $avatar;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 先取出原 src 判断归属
|
|||
|
|
if (!preg_match('/\ssrc\s*=\s*(["\'])([^"\']*)\1/i', $avatar, $m)) {
|
|||
|
|
return $avatar;
|
|||
|
|
}
|
|||
|
|
$old_src = $m[2];
|
|||
|
|
|
|||
|
|
// data: 说明别人已经处理过了,别覆盖
|
|||
|
|
if (stripos($old_src, 'data:') === 0) {
|
|||
|
|
return $avatar;
|
|||
|
|
}
|
|||
|
|
if (!yang_avatar_is_gravatar_url($old_src)) {
|
|||
|
|
return $avatar;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 没有邮箱就无从生成 —— 直接放行
|
|||
|
|
$email = yang_avatar_email_of($id_or_email);
|
|||
|
|
if ($email === '') {
|
|||
|
|
return $avatar;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 尺寸取实际要显示的那个,别用 96 蒙一把
|
|||
|
|
$px = (int) $size;
|
|||
|
|
if ($px <= 0) {
|
|||
|
|
$px = 48;
|
|||
|
|
}
|
|||
|
|
// 上限 256:再大也没意义,而且模板会被撑得很大
|
|||
|
|
if ($px > 256) {
|
|||
|
|
$px = 256;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$svg = yang_avatar_svg($email, '', $px);
|
|||
|
|
if ($svg === '') {
|
|||
|
|
return $avatar;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$uri = 'data:image/svg+xml;base64,' . base64_encode($svg);
|
|||
|
|
|
|||
|
|
// ① 删 srcset:不删的话浏览器优先用 srcset,替换等于没做
|
|||
|
|
$out = preg_replace('/\s+srcset\s*=\s*(["\']).*?\1/isu', '', $avatar, 1);
|
|||
|
|
if (!is_string($out)) {
|
|||
|
|
$out = $avatar;
|
|||
|
|
}
|
|||
|
|
// ② 删 sizes:srcset 去了之后 sizes 已无意义,留着是脏属性
|
|||
|
|
$out = preg_replace('/\s+sizes\s*=\s*(["\']).*?\1/isu', '', $out, 1);
|
|||
|
|
if (!is_string($out)) {
|
|||
|
|
$out = $avatar;
|
|||
|
|
}
|
|||
|
|
// ③ 只换 src
|
|||
|
|
$out = preg_replace(
|
|||
|
|
'/\ssrc\s*=\s*(["\'])[^"\']*\1/isu',
|
|||
|
|
' src="' . esc_attr($uri) . '"',
|
|||
|
|
$out,
|
|||
|
|
1
|
|||
|
|
);
|
|||
|
|
if (!is_string($out) || $out === '') {
|
|||
|
|
return $avatar;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return $out;
|
|||
|
|
}
|
|||
|
|
add_filter('get_avatar', 'yang_avatar_filter', 20, 6);
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 判断一个头像 URL 是不是 Gravatar 家族的
|
|||
|
|
*
|
|||
|
|
* 除了 gravatar.com 自身,还要认这些:
|
|||
|
|
* · gravatar.loli.net —— 本主题「Gravatar 加速」用的镜像
|
|||
|
|
* · secure.gravatar.com / cn.gravatar.com / 0.gravatar.com 等子域
|
|||
|
|
* · i0.wp.com / i1.wp.com / i2.wp.com —— Jetpack 的头像 CDN 代理
|
|||
|
|
*
|
|||
|
|
* 一律按主机名后缀判断,不做整串 contains:
|
|||
|
|
* 否则 `https://evil.test/?x=gravatar.com` 这种也会被当成 Gravatar。
|
|||
|
|
*
|
|||
|
|
* @param string $url
|
|||
|
|
* @return bool
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_is_gravatar_url($url)
|
|||
|
|
{
|
|||
|
|
$host = parse_url($url, PHP_URL_HOST);
|
|||
|
|
if (!is_string($host) || $host === '') {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
$host = strtolower($host);
|
|||
|
|
|
|||
|
|
$suffixes = array(
|
|||
|
|
'.gravatar.com',
|
|||
|
|
'gravatar.com',
|
|||
|
|
'.loli.net',
|
|||
|
|
'loli.net',
|
|||
|
|
'.wp.com',
|
|||
|
|
'.gravatar.com.cn',
|
|||
|
|
'.geekzu.org',
|
|||
|
|
'.233v4.com',
|
|||
|
|
'.nihao.eu.org',
|
|||
|
|
);
|
|||
|
|
foreach ($suffixes as $sfx) {
|
|||
|
|
$len = strlen($sfx);
|
|||
|
|
if (strlen($host) >= $len && substr($host, -$len) === $sfx) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 从 get_avatar 的各种入参里取出邮箱
|
|||
|
|
*
|
|||
|
|
* $id_or_email 可能是:用户 ID、WP_User、WP_Comment、WP_Post、邮箱字符串。
|
|||
|
|
*
|
|||
|
|
* @param mixed $id_or_email
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_email_of($id_or_email)
|
|||
|
|
{
|
|||
|
|
if (is_string($id_or_email)) {
|
|||
|
|
// 已经是邮箱
|
|||
|
|
if (strpos($id_or_email, '@') !== false) {
|
|||
|
|
return trim($id_or_email);
|
|||
|
|
}
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
if (is_object($id_or_email)) {
|
|||
|
|
// WP_Comment / WP_User / WP_Post
|
|||
|
|
if (isset($id_or_email->comment_author_email)) {
|
|||
|
|
return trim((string) $id_or_email->comment_author_email);
|
|||
|
|
}
|
|||
|
|
if (isset($id_or_email->user_email)) {
|
|||
|
|
return trim((string) $id_or_email->user_email);
|
|||
|
|
}
|
|||
|
|
if (isset($id_or_email->post_author)) {
|
|||
|
|
$u = get_userdata((int) $id_or_email->post_author);
|
|||
|
|
if ($u && !empty($u->user_email)) {
|
|||
|
|
return trim((string) $u->user_email);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (isset($id_or_email->ID)) {
|
|||
|
|
$u = get_userdata((int) $id_or_email->ID);
|
|||
|
|
if ($u && !empty($u->user_email)) {
|
|||
|
|
return trim((string) $u->user_email);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
if (is_int($id_or_email)) {
|
|||
|
|
$u = get_userdata($id_or_email);
|
|||
|
|
if ($u && !empty($u->user_email)) {
|
|||
|
|
return trim((string) $u->user_email);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ══════════════════════════════════════════════════════════════════
|
|||
|
|
* 渲染器本体
|
|||
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 渲染一张 SVG
|
|||
|
|
*
|
|||
|
|
* 必须严格复刻 @dicebear/core 里 createAvatar() 的**执行顺序**,
|
|||
|
|
* 因为 PRNG 是有状态的:
|
|||
|
|
* 1. style.create() 内部:
|
|||
|
|
* onPreCreate() → getComponents() → getColors() → onPostCreate()
|
|||
|
|
* 2. body = create() 返回的 body
|
|||
|
|
* 3. 然后 core 才依次做:
|
|||
|
|
* backgroundType = prng.pick(options.backgroundType, 'solid')
|
|||
|
|
* primary/secondary = getBackgroundColors(prng, options.backgroundColor, bgType)
|
|||
|
|
* backgroundRotation = prng.integer(min, max)
|
|||
|
|
* background 层 → clip mask
|
|||
|
|
* 顺序错一个,后面全歪。
|
|||
|
|
*
|
|||
|
|
* @param array $style 模板数据
|
|||
|
|
* @param string $seed
|
|||
|
|
* @param int $size
|
|||
|
|
* @param array $palette 叠加到 options 上的颜色
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_render($style, $seed, $size = 96, $palette = array())
|
|||
|
|
{
|
|||
|
|
$d = $style['defaults'];
|
|||
|
|
foreach ($palette as $k => $v) {
|
|||
|
|
$d[$k] = $v;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$p = new Yang_Avatar_Prng($seed);
|
|||
|
|
|
|||
|
|
/* ── 0. onPreCreate(会动态改 options,必须在一切之前) ── */
|
|||
|
|
if (!empty($style['has_pre_create'])) {
|
|||
|
|
$d = yang_avatar_pre_create($style['name'], $p, $d);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 1. getComponents ──
|
|||
|
|
两种语义并存:
|
|||
|
|
a) 普通 prng.pick(values) —— 只消耗 1 次
|
|||
|
|
b) pickComponent(...) —— 额外取 rotation/offsetX/offsetY 三次整数
|
|||
|
|
但**只有** glass / rings / shapes / thumbs 的 pickComponent 会取整数;
|
|||
|
|
其余风格虽然也调 pickComponent,却是简化版(只 pick)。
|
|||
|
|
若对它们多取三次整数,PRNG 序列会多走 3N 步、整张图全变
|
|||
|
|
(实测 31 种里 27 种会挂)。编译期已用 pc_has_ints 标好。 */
|
|||
|
|
$pcMap = array();
|
|||
|
|
if (!empty($style['pc_meta']) && is_array($style['pc_meta'])) {
|
|||
|
|
foreach ($style['pc_meta'] as $m) {
|
|||
|
|
if ($m['group'] !== '') {
|
|||
|
|
$pcMap[$m['group']] = $m;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
$useInts = !empty($style['pc_has_ints']);
|
|||
|
|
|
|||
|
|
$picked = array();
|
|||
|
|
$meta = array();
|
|||
|
|
foreach ($style['pick_order'] as $g) {
|
|||
|
|
$vkey = (isset($pcMap[$g]) && $pcMap[$g]['values_opt'] !== '')
|
|||
|
|
? $pcMap[$g]['values_opt'] : $g;
|
|||
|
|
$vals = (isset($d[$vkey]) && is_array($d[$vkey])) ? $d[$vkey] : array();
|
|||
|
|
$picked[$g] = $p->pick($vals);
|
|||
|
|
|
|||
|
|
if ($useInts && isset($pcMap[$g])) {
|
|||
|
|
$m = $pcMap[$g];
|
|||
|
|
$meta[$g] = array(
|
|||
|
|
'rotation' => yang_avatar_int_range($p, $m['rotation'], $d),
|
|||
|
|
'offsetX' => yang_avatar_int_range($p, $m['offsetX'], $d),
|
|||
|
|
'offsetY' => yang_avatar_int_range($p, $m['offsetY'], $d),
|
|||
|
|
'width' => $m['width'],
|
|||
|
|
'height' => $m['height'],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$on = array();
|
|||
|
|
foreach ($style['bool_order'] as $g) {
|
|||
|
|
$pk = $g . 'Probability';
|
|||
|
|
$prob = isset($d[$pk]) ? $d[$pk] : 50;
|
|||
|
|
$on[$g] = $p->bool($prob);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 2. getColors ── */
|
|||
|
|
$colors = array();
|
|||
|
|
foreach ($style['color_order'] as $ck => $sk) {
|
|||
|
|
$vals = (isset($d[$sk]) && is_array($d[$sk])) ? $d[$sk] : array();
|
|||
|
|
$colors[$ck] = yang_avatar_color($p->pick($vals, 'transparent'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 3. onPostCreate(会改 colors / options.backgroundColor) ── */
|
|||
|
|
if (!empty($style['has_post_create'])) {
|
|||
|
|
$res = yang_avatar_post_create($style['name'], $d, $colors, $on);
|
|||
|
|
$d = $res[0];
|
|||
|
|
$colors = $res[1];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 4. 展开 body ── */
|
|||
|
|
$subs = array();
|
|||
|
|
foreach ($style['groups'] as $g => $bag) {
|
|||
|
|
if (in_array($g, $style['bool_order'], true) && empty($on[$g])) {
|
|||
|
|
$subs[$g] = null;
|
|||
|
|
} else {
|
|||
|
|
$subs[$g] = isset($picked[$g]) ? $picked[$g] : null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!empty($style['is_initials'])) {
|
|||
|
|
$body = yang_avatar_initials_body($d, $seed, $p);
|
|||
|
|
} else {
|
|||
|
|
$body = yang_avatar_expand($style, $subs, $colors, $meta);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 5. core 的背景(顺序不能提前!) ── */
|
|||
|
|
$bgTypeArr = (isset($d['backgroundType']) && is_array($d['backgroundType']))
|
|||
|
|
? $d['backgroundType'] : $style['bg_type'];
|
|||
|
|
$bgType = $p->pick($bgTypeArr, 'solid');
|
|||
|
|
|
|||
|
|
$bgColors = (isset($d['backgroundColor']) && is_array($d['backgroundColor']))
|
|||
|
|
? $d['backgroundColor'] : $style['bg_colors'];
|
|||
|
|
$bg = yang_avatar_bg_colors($p, $bgColors, $bgType);
|
|||
|
|
|
|||
|
|
$rotArr = (isset($d['backgroundRotation']) && is_array($d['backgroundRotation']))
|
|||
|
|
? $d['backgroundRotation'] : $style['bg_rotation'];
|
|||
|
|
$rotMin = count($rotArr) ? min($rotArr) : 0;
|
|||
|
|
$rotMax = count($rotArr) ? max($rotArr) : 0;
|
|||
|
|
$rotation = $p->integer($rotMin, $rotMax);
|
|||
|
|
|
|||
|
|
/* ── 6. attributes(逐字保留,按编译期顺序) ──
|
|||
|
|
initials 完全没有 fill / shape-rendering;
|
|||
|
|
identicon / pixel-art / rings 是 crispEdges 而不是 auto。 */
|
|||
|
|
$attrs = array();
|
|||
|
|
foreach ($style['attr_pairs'] as $pair) {
|
|||
|
|
$attrs[$pair[0]] = $pair[1];
|
|||
|
|
}
|
|||
|
|
if ($size > 0) {
|
|||
|
|
$attrs['width'] = (string) $size;
|
|||
|
|
$attrs['height'] = (string) $size;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 7. 背景层 ── */
|
|||
|
|
if ($bg['primary'] !== 'transparent' && $bg['secondary'] !== 'transparent') {
|
|||
|
|
$body = yang_avatar_add_bg($body, $attrs, $bg['primary'], $bg['secondary'], $bgType, $rotation);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 8. clip(core schema 默认 true)⇒ 恒定包 mask ── */
|
|||
|
|
if (!empty($style['clip_default'])) {
|
|||
|
|
$vb = explode(' ', $attrs['viewBox']);
|
|||
|
|
$body = '<mask id="viewboxMask">'
|
|||
|
|
. '<rect width="' . $vb[2] . '" height="' . $vb[3] . '"'
|
|||
|
|
. ' rx="0" ry="0" x="' . $vb[0] . '" y="' . $vb[1] . '" fill="#fff" />'
|
|||
|
|
. '</mask>'
|
|||
|
|
. '<g mask="url(#viewboxMask)">' . $body . '</g>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 9. 拼 SVG ── */
|
|||
|
|
$attrStr = 'xmlns="http://www.w3.org/2000/svg"';
|
|||
|
|
foreach ($attrs as $k => $v) {
|
|||
|
|
$attrStr .= ' ' . yang_avatar_esc($k) . '="' . yang_avatar_esc($v) . '"';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return '<svg ' . $attrStr . '>' . yang_avatar_meta($style['meta']) . $body . '</svg>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* pickComponent 里的一次 integer 取值
|
|||
|
|
*
|
|||
|
|
* 语义:options[$spec['opt']] 存在且非空 ⇒ 用它;否则用 $spec['fallback']。
|
|||
|
|
*
|
|||
|
|
* @param Yang_Avatar_Prng $p
|
|||
|
|
* @param array $spec
|
|||
|
|
* @param array $d
|
|||
|
|
* @return int
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_int_range($p, $spec, $d)
|
|||
|
|
{
|
|||
|
|
$arr = null;
|
|||
|
|
if (!empty($spec['opt']) && isset($d[$spec['opt']]) && is_array($d[$spec['opt']])
|
|||
|
|
&& count($d[$spec['opt']]) > 0) {
|
|||
|
|
$arr = $d[$spec['opt']];
|
|||
|
|
} else {
|
|||
|
|
$arr = $spec['fallback'];
|
|||
|
|
}
|
|||
|
|
if (!is_array($arr) || count($arr) === 0) {
|
|||
|
|
$arr = array(0);
|
|||
|
|
}
|
|||
|
|
$nums = array();
|
|||
|
|
foreach ($arr as $v) {
|
|||
|
|
$nums[] = (int) $v;
|
|||
|
|
}
|
|||
|
|
return $p->integer(min($nums), max($nums));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* onPreCreate:按风格名精确复刻
|
|||
|
|
*
|
|||
|
|
* 这些是**代码**(会动态改 options),不能当数据编译。
|
|||
|
|
* 收录的 14 套里只有 shapes 有,且它会消耗 3 次 pick。
|
|||
|
|
*
|
|||
|
|
* @param string $name
|
|||
|
|
* @param Yang_Avatar_Prng $p
|
|||
|
|
* @param array $d
|
|||
|
|
* @return array
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_pre_create($name, $p, $d)
|
|||
|
|
{
|
|||
|
|
if ($name === 'shapes') {
|
|||
|
|
// 见 @dicebear/shapes/lib/hooks/onPreCreate.js:
|
|||
|
|
// 三种形状各取一个"还没用过的颜色",并把用过的颜色从背景里剔掉。
|
|||
|
|
$used = array();
|
|||
|
|
foreach (array('shape1Color', 'shape2Color', 'shape3Color') as $key) {
|
|||
|
|
$colors = isset($d[$key]) && is_array($d[$key]) ? $d[$key] : array();
|
|||
|
|
$avail = array();
|
|||
|
|
foreach ($colors as $c) {
|
|||
|
|
if (!in_array($c, $used, true)) {
|
|||
|
|
$avail[] = $c;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (count($avail) === 0) {
|
|||
|
|
$avail = $colors;
|
|||
|
|
}
|
|||
|
|
$c = $p->pick($avail, 'transparent');
|
|||
|
|
$used[] = $c;
|
|||
|
|
$d[$key] = array($c);
|
|||
|
|
}
|
|||
|
|
$bg = isset($d['backgroundColor']) && is_array($d['backgroundColor'])
|
|||
|
|
? $d['backgroundColor'] : array();
|
|||
|
|
$avail = array();
|
|||
|
|
foreach ($bg as $c) {
|
|||
|
|
if (!in_array($c, $used, true)) {
|
|||
|
|
$avail[] = $c;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
$d['backgroundColor'] = count($avail) > 0 ? $avail : $bg;
|
|||
|
|
}
|
|||
|
|
return $d;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* onPostCreate:按风格名精确复刻
|
|||
|
|
*
|
|||
|
|
* @param string $name
|
|||
|
|
* @param array $d
|
|||
|
|
* @param array $colors
|
|||
|
|
* @param array $on
|
|||
|
|
* @return array array($d, $colors)
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_post_create($name, $d, $colors, $on)
|
|||
|
|
{
|
|||
|
|
if ($name === 'lorelei' || $name === 'lorelei-neutral') {
|
|||
|
|
// 有胡子且胡子与嘴同色时,把嘴改成白色,否则看不出嘴
|
|||
|
|
if (!empty($on['beard']) && isset($colors['hair'], $colors['mouth'])
|
|||
|
|
&& $colors['hair'] === $colors['mouth']) {
|
|||
|
|
$colors['mouth'] = '#ffffff';
|
|||
|
|
}
|
|||
|
|
} elseif ($name === 'thumbs') {
|
|||
|
|
// ① 背景色里去掉跟主体同色的那一项
|
|||
|
|
$shape = isset($colors['shape']) ? $colors['shape'] : '';
|
|||
|
|
$bg = isset($d['backgroundColor']) && is_array($d['backgroundColor'])
|
|||
|
|
? $d['backgroundColor'] : array();
|
|||
|
|
$filtered = array();
|
|||
|
|
foreach ($bg as $v) {
|
|||
|
|
if ($v !== str_replace('#', '', $shape)) {
|
|||
|
|
$filtered[] = $v;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (count($filtered) > 0) {
|
|||
|
|
$d['backgroundColor'] = $filtered;
|
|||
|
|
}
|
|||
|
|
// ② 眼睛/嘴按主体亮度取黑白对比色
|
|||
|
|
$contrast = null;
|
|||
|
|
if (substr($shape, 0, 1) === '#') {
|
|||
|
|
$r = hexdec(substr($shape, 1, 2));
|
|||
|
|
$g2 = hexdec(substr($shape, 3, 2));
|
|||
|
|
$b = hexdec(substr($shape, 5, 6));
|
|||
|
|
$yiq = ($r * 299 + $g2 * 587 + $b * 114) / 1000;
|
|||
|
|
$contrast = $yiq >= 200 ? '#000000' : '#ffffff';
|
|||
|
|
}
|
|||
|
|
if ($contrast !== null) {
|
|||
|
|
foreach (array('eyes', 'mouth') as $ck) {
|
|||
|
|
$opt = (isset($d[$ck . 'Color']) && is_array($d[$ck . 'Color']))
|
|||
|
|
? $d[$ck . 'Color'] : array();
|
|||
|
|
if (count($opt) === 2
|
|||
|
|
&& in_array('000000', $opt, true)
|
|||
|
|
&& in_array('ffffff', $opt, true)) {
|
|||
|
|
$colors[$ck] = $contrast;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return array($d, $colors);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* initials 风格的 body 是代码拼出来的 <text>,单独复刻
|
|||
|
|
*
|
|||
|
|
* @param array $d
|
|||
|
|
* @param string $seed
|
|||
|
|
* @param Yang_Avatar_Prng $p
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_initials_body($d, $seed, $p)
|
|||
|
|
{
|
|||
|
|
// 顺序要紧:textColor 的 pick 在首字母计算**之前**
|
|||
|
|
$tc = (isset($d['textColor']) && is_array($d['textColor'])) ? $d['textColor'] : array();
|
|||
|
|
$raw = $p->pick($tc);
|
|||
|
|
if ($raw === null) {
|
|||
|
|
$raw = 'ffffff';
|
|||
|
|
}
|
|||
|
|
$textColor = yang_avatar_color($raw);
|
|||
|
|
|
|||
|
|
$fontFamily = (isset($d['fontFamily']) && is_array($d['fontFamily']))
|
|||
|
|
? implode(', ', $d['fontFamily']) : 'Arial, sans-serif';
|
|||
|
|
$fontSize = isset($d['fontSize']) ? $d['fontSize'] : 50;
|
|||
|
|
$fontWeight = isset($d['fontWeight']) ? $d['fontWeight'] : 400;
|
|||
|
|
$chars = isset($d['chars']) ? (int) $d['chars'] : 2;
|
|||
|
|
|
|||
|
|
// JS 是 getInitials(prng.seed.trim()) —— 必须先 trim
|
|||
|
|
$initials = yang_avatar_utf8_sub(yang_avatar_initials(trim($seed)), $chars);
|
|||
|
|
|
|||
|
|
return '<text x="50%" y="50%" font-family="' . yang_avatar_esc($fontFamily)
|
|||
|
|
. '" font-size="' . $fontSize . '" font-weight="' . $fontWeight
|
|||
|
|
. '" fill="' . yang_avatar_esc($textColor) . '" text-anchor="middle"'
|
|||
|
|
. ' dy="' . number_format($fontSize * 0.356, 3, '.', '') . '">'
|
|||
|
|
. yang_avatar_esc($initials) . '</text>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 复刻 @dicebear/initials 的 getInitials()
|
|||
|
|
*
|
|||
|
|
* 规则:先砍掉 @ 后面的部分,取首个与末个"词"的首字母。
|
|||
|
|
* 中文没有空格时整串算一个词,取前 2 个字符。
|
|||
|
|
*
|
|||
|
|
* @param string $seed
|
|||
|
|
* @param bool $discard_at
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_initials($seed, $discard_at = true)
|
|||
|
|
{
|
|||
|
|
$input = $seed;
|
|||
|
|
if ($discard_at) {
|
|||
|
|
$input = preg_replace('/@.*/s', '', $input);
|
|||
|
|
}
|
|||
|
|
// 去掉各种撇号(与 DiceBear 的字符集一致)
|
|||
|
|
$input = preg_replace("/[`´‘’'ʼ]/u", '', $input);
|
|||
|
|
|
|||
|
|
if (!preg_match_all('/(\p{L}[\p{L}\p{M}]*)/u', $input, $mm)) {
|
|||
|
|
// 一个字母都没匹配到 ⇒ 去掉 @ 限制再试一次
|
|||
|
|
return $discard_at ? yang_avatar_initials($seed, false) : '';
|
|||
|
|
}
|
|||
|
|
$matches = $mm[0];
|
|||
|
|
if (count($matches) === 1) {
|
|||
|
|
if (preg_match('/^(?:\p{L}\p{M}*){1,2}/u', $matches[0], $s)) {
|
|||
|
|
return yang_avatar_utf8_upper($s[0]);
|
|||
|
|
}
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
$first = '';
|
|||
|
|
$last = '';
|
|||
|
|
if (preg_match('/^(?:\p{L}\p{M}*)/u', $matches[0], $s1)) {
|
|||
|
|
$first = $s1[0];
|
|||
|
|
}
|
|||
|
|
if (preg_match('/^(?:\p{L}\p{M}*)/u', $matches[count($matches) - 1], $s2)) {
|
|||
|
|
$last = $s2[0];
|
|||
|
|
}
|
|||
|
|
return yang_avatar_utf8_upper($first . $last);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 复刻 core/utils/color.js 的 getBackgroundColors()
|
|||
|
|
*
|
|||
|
|
* 关键:**调用次数必须一致**。某个分支不 shuffle 时,也必须空 next()
|
|||
|
|
* 一次,否则后面 backgroundRotation 取到的值就歪了。
|
|||
|
|
*
|
|||
|
|
* @param Yang_Avatar_Prng $p
|
|||
|
|
* @param array $list
|
|||
|
|
* @param string $bgType
|
|||
|
|
* @return array
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_bg_colors($p, $list, $bgType)
|
|||
|
|
{
|
|||
|
|
$shuffled = $p->shuffle($list);
|
|||
|
|
if (count($shuffled) <= 1) {
|
|||
|
|
$shuffled = $list;
|
|||
|
|
$p->next();
|
|||
|
|
} elseif (count($list) === 2 && $bgType === 'gradientLinear') {
|
|||
|
|
$shuffled = $list;
|
|||
|
|
$p->next();
|
|||
|
|
} else {
|
|||
|
|
$shuffled = $p->shuffle($list);
|
|||
|
|
}
|
|||
|
|
if (count($shuffled) === 0) {
|
|||
|
|
$shuffled = array('transparent');
|
|||
|
|
}
|
|||
|
|
$primary = $shuffled[0];
|
|||
|
|
$secondary = isset($shuffled[1]) ? $shuffled[1] : $shuffled[0];
|
|||
|
|
return array(
|
|||
|
|
'primary' => yang_avatar_color($primary),
|
|||
|
|
'secondary' => yang_avatar_color($secondary),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* @param string $c
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_color($c)
|
|||
|
|
{
|
|||
|
|
return ($c === 'transparent') ? $c : '#' . $c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 复刻 core/utils/svg.js 的 addBackground()
|
|||
|
|
*
|
|||
|
|
* @param string $body
|
|||
|
|
* @param array $attrs
|
|||
|
|
* @param string $primary
|
|||
|
|
* @param string $secondary
|
|||
|
|
* @param string $type
|
|||
|
|
* @param int $rotation
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_add_bg($body, $attrs, $primary, $secondary, $type, $rotation)
|
|||
|
|
{
|
|||
|
|
$vb = explode(' ', $attrs['viewBox']);
|
|||
|
|
$x = $vb[0];
|
|||
|
|
$y = $vb[1];
|
|||
|
|
$w = $vb[2];
|
|||
|
|
$h = $vb[3];
|
|||
|
|
if ($type === 'gradientLinear') {
|
|||
|
|
return '<rect fill="url(#backgroundLinear)" width="' . $w . '"'
|
|||
|
|
. ' height="' . $h . '" x="' . $x . '" y="' . $y . '" />'
|
|||
|
|
. '<defs>'
|
|||
|
|
. '<linearGradient id="backgroundLinear" gradientTransform="rotate('
|
|||
|
|
. $rotation . ' 0.5 0.5)">'
|
|||
|
|
. '<stop stop-color="' . yang_avatar_esc($primary) . '"/>'
|
|||
|
|
. '<stop offset="1" stop-color="' . yang_avatar_esc($secondary) . '"/>'
|
|||
|
|
. '</linearGradient>'
|
|||
|
|
. '</defs>'
|
|||
|
|
. $body;
|
|||
|
|
}
|
|||
|
|
return '<rect fill="' . yang_avatar_esc($primary) . '" width="' . $w . '"'
|
|||
|
|
. ' height="' . $h . '" x="' . $x . '" y="' . $y . '" />' . $body;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 把 body 模板里的 @@k:group@@ / @@c:key@@ 展开
|
|||
|
|
*
|
|||
|
|
* 循环 8 轮是因为存在「聚合组件」:rings 的 ring.container 里会
|
|||
|
|
* 再调用 ringOne..ringFive,要展开两三层才干净。
|
|||
|
|
*
|
|||
|
|
* @param array $style
|
|||
|
|
* @param array $subs 组名 → 选中的变体名
|
|||
|
|
* @param array $colors
|
|||
|
|
* @param array $meta pickComponent 的旋转/偏移
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_expand($style, $subs, $colors, $meta)
|
|||
|
|
{
|
|||
|
|
$svg = $style['body_template'];
|
|||
|
|
for ($round = 0; $round < 8; $round++) {
|
|||
|
|
if (strpos($svg, '@@k:') === false) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
$changed = false;
|
|||
|
|
foreach ($subs as $g => $variant) {
|
|||
|
|
$token = '@@k:' . $g . '@@';
|
|||
|
|
if (strpos($svg, $token) === false) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
$rep = '';
|
|||
|
|
if ($variant !== null && isset($style['groups'][$g][$variant])) {
|
|||
|
|
$rep = $style['groups'][$g][$variant];
|
|||
|
|
}
|
|||
|
|
// pickComponent 的 value() 会包一层 transform(任一非零才包)
|
|||
|
|
if ($rep !== '' && isset($meta[$g])) {
|
|||
|
|
$m = $meta[$g];
|
|||
|
|
if ($m['rotation'] != 0 || $m['offsetX'] != 0 || $m['offsetY'] != 0) {
|
|||
|
|
$rep = '<g transform="translate(' . $m['offsetX'] . ', ' . $m['offsetY']
|
|||
|
|
. ') rotate(' . $m['rotation'] . ' '
|
|||
|
|
. yang_avatar_num($m['width'] / 2) . ' '
|
|||
|
|
. yang_avatar_num($m['height'] / 2) . ')">' . $rep . '</g>';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
$svg = str_replace($token, $rep, $svg);
|
|||
|
|
$changed = true;
|
|||
|
|
}
|
|||
|
|
if (!$changed) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
foreach ($colors as $k => $v) {
|
|||
|
|
$svg = str_replace('@@c:' . $k . '@@', $v, $svg);
|
|||
|
|
}
|
|||
|
|
return $svg;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* JS 里 150/2 输出 75、100/2 输出 50 —— 都是整数,统一去掉 .0
|
|||
|
|
*
|
|||
|
|
* @param float $v
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_num($v)
|
|||
|
|
{
|
|||
|
|
if (abs($v - round($v)) < 1e-9) {
|
|||
|
|
return (string) (int) round($v);
|
|||
|
|
}
|
|||
|
|
return (string) $v;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* XML 转义(顺序要紧:& 必须第一个换)
|
|||
|
|
*
|
|||
|
|
* @param string $s
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_esc($s)
|
|||
|
|
{
|
|||
|
|
return str_replace(
|
|||
|
|
array('&', "'", '"', '<', '>'),
|
|||
|
|
array('&', ''', '"', '<', '>'),
|
|||
|
|
$s
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 版权元数据块(DiceBear 的 license.xml())
|
|||
|
|
*
|
|||
|
|
* 这段必须保留:CC0 虽然不要求署名,但保留来源信息是良好实践,
|
|||
|
|
* 也方便以后追溯。体积约 500 字节,压缩后几乎为零。
|
|||
|
|
*
|
|||
|
|
* @param array $m
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_meta($m)
|
|||
|
|
{
|
|||
|
|
$rights = yang_avatar_rights($m);
|
|||
|
|
return '<metadata'
|
|||
|
|
. ' xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"'
|
|||
|
|
. ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
|||
|
|
. ' xmlns:dc="http://purl.org/dc/elements/1.1/"'
|
|||
|
|
. ' xmlns:dcterms="http://purl.org/dc/terms/">'
|
|||
|
|
. '<rdf:RDF><rdf:Description>'
|
|||
|
|
. '<dc:title>' . yang_avatar_esc($m['title']) . '</dc:title>'
|
|||
|
|
. '<dc:creator>' . yang_avatar_esc($m['creator']) . '</dc:creator>'
|
|||
|
|
. '<dc:source xsi:type="dcterms:URI">' . yang_avatar_esc($m['source']) . '</dc:source>'
|
|||
|
|
. '<dcterms:license xsi:type="dcterms:URI">' . yang_avatar_esc($m['license_url']) . '</dcterms:license>'
|
|||
|
|
. '<dc:rights>' . yang_avatar_esc($rights) . '</dc:rights>'
|
|||
|
|
. '</rdf:Description></rdf:RDF></metadata>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 拼接版权声明文字(与 DiceBear 的 license.xml() 逐字一致)
|
|||
|
|
*
|
|||
|
|
* @param array $m
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_rights($m)
|
|||
|
|
{
|
|||
|
|
$title = (isset($m['title']) && $m['title'] !== '') ? '„' . $m['title'] . '”' : 'Design';
|
|||
|
|
$creator = '„' . ((isset($m['creator']) && $m['creator'] !== '') ? $m['creator'] : 'Unknown') . '”';
|
|||
|
|
if (!empty($m['source'])) {
|
|||
|
|
$title .= ' (' . $m['source'] . ')';
|
|||
|
|
}
|
|||
|
|
$r = '';
|
|||
|
|
if ($m['license_name'] !== 'MIT' && $m['creator'] !== 'DiceBear' && !empty($m['title'])) {
|
|||
|
|
$r .= 'Remix of ';
|
|||
|
|
}
|
|||
|
|
$r .= $title . ' by ' . $creator;
|
|||
|
|
if (!empty($m['license_name'])) {
|
|||
|
|
$r .= ', licensed under „' . $m['license_name'] . '”';
|
|||
|
|
if (!empty($m['license_url'])) {
|
|||
|
|
$r .= ' (' . $m['license_url'] . ')';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return $r;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ══════════════════════════════════════════════════════════════════
|
|||
|
|
* 后台:风格选择器控件
|
|||
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 渲染「头像风格」这个自定义选项类型
|
|||
|
|
*
|
|||
|
|
* 挂 optionsframework_avatar_style 过滤器 —— 选项框架在
|
|||
|
|
* class-options-interface.php 里对未知 type 会先查
|
|||
|
|
* has_filter('optionsframework_' . $type),命中就交给这里。
|
|||
|
|
*
|
|||
|
|
* ── 为什么不用框架自带 images 类型 ────────────────────────────
|
|||
|
|
* images 类型要一组静态图片 URL,而这里的预览图是按邮箱实时生成的
|
|||
|
|
* data URI。用自定义类型才能同时做到「点一下换预览」和「16 个种子
|
|||
|
|
* 并排展示不同邮箱的效果」。
|
|||
|
|
*
|
|||
|
|
* ── 为什么预览要 16 个种子 ────────────────────────────────────
|
|||
|
|
* 用户选风格时最容易踩的坑是「看一张觉得好看,装上发现都长一样」。
|
|||
|
|
* 单张预览看不出多样性,所以每个风格给 16 个不同邮箱的并排效果。
|
|||
|
|
*
|
|||
|
|
* @param string $option_name 选项组名(这里是 'kratos')
|
|||
|
|
* @param array $value 选项定义
|
|||
|
|
* @param mixed $val 当前值
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_option_html($option_name, $value, $val)
|
|||
|
|
{
|
|||
|
|
$styles = yang_avatar_styles();
|
|||
|
|
$current = is_string($val) && isset($styles[$val]) ? $val : 'lorelei';
|
|||
|
|
|
|||
|
|
// 预览用的 16 个邮箱:集中定义,AJAX 与首屏渲染共用同一份
|
|||
|
|
$seeds = yang_avatar_preview_seeds();
|
|||
|
|
|
|||
|
|
$name = $option_name . '[' . $value['id'] . ']';
|
|||
|
|
$id = $value['id'];
|
|||
|
|
|
|||
|
|
$o = array();
|
|||
|
|
$o[] = '<fieldset class="of-avatar-styles" id="' . esc_attr($id) . '">';
|
|||
|
|
|
|||
|
|
foreach ($styles as $key => $meta) {
|
|||
|
|
$checked = ($key === $current);
|
|||
|
|
$rid = $id . '_' . $key;
|
|||
|
|
|
|||
|
|
// 每个风格只生成 1 张 40px 预览(16 张全生成会让页面重 ~800KB)。
|
|||
|
|
// 单选后由 JS 把下方大预览换成该风格的 16 张。
|
|||
|
|
$thumb = yang_avatar_data_uri($seeds[0], $key, 80);
|
|||
|
|
|
|||
|
|
$o[] = '<label class="of-avatar-style' . ($checked ? ' is-selected' : '') . '"'
|
|||
|
|
. ' for="' . esc_attr($rid) . '" data-style="' . esc_attr($key) . '">';
|
|||
|
|
$o[] = '<input type="radio" class="of-avatar-style-radio"'
|
|||
|
|
. ' id="' . esc_attr($rid) . '"'
|
|||
|
|
. ' name="' . esc_attr($name) . '"'
|
|||
|
|
. ' value="' . esc_attr($key) . '"'
|
|||
|
|
. ($checked ? ' checked="checked"' : '') . ' />';
|
|||
|
|
$o[] = '<span class="of-avatar-style-thumb">';
|
|||
|
|
if ($thumb !== '') {
|
|||
|
|
$o[] = '<img src="' . esc_attr($thumb) . '" alt="' . esc_attr($meta['label']) . '" width="56" height="56" />';
|
|||
|
|
}
|
|||
|
|
$o[] = '</span>';
|
|||
|
|
$o[] = '<span class="of-avatar-style-text">';
|
|||
|
|
$o[] = '<span class="of-avatar-style-label">' . esc_html($meta['label']) . '</span>';
|
|||
|
|
$o[] = '<span class="of-avatar-style-desc">' . esc_html($meta['desc']) . '</span>';
|
|||
|
|
$o[] = '</span>';
|
|||
|
|
$o[] = '</label>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$o[] = '</fieldset>';
|
|||
|
|
|
|||
|
|
/* 大预览区:16 个邮箱的并排效果。
|
|||
|
|
初始内容由 PHP 直接渲染,不依赖 JS —— 万一 JS 没跑起来,
|
|||
|
|
用户至少能看到当前选中风格的真实效果。 */
|
|||
|
|
$o[] = '<div class="of-avatar-preview" data-preview="' . esc_attr($current) . '">';
|
|||
|
|
$o[] = '<p class="of-avatar-preview-title">'
|
|||
|
|
. esc_html__('效果预览(16 个不同邮箱)', 'kratos') . '</p>';
|
|||
|
|
$o[] = '<div class="of-avatar-preview-grid">';
|
|||
|
|
foreach ($seeds as $seed) {
|
|||
|
|
$uri = yang_avatar_data_uri($seed, $current, 96);
|
|||
|
|
if ($uri === '') {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
$o[] = '<figure class="of-avatar-preview-cell">'
|
|||
|
|
. '<img src="' . esc_attr($uri) . '" alt="" width="64" height="64" />'
|
|||
|
|
. '<figcaption>' . esc_html(preg_replace('/@.*/', '', $seed)) . '</figcaption>'
|
|||
|
|
. '</figure>';
|
|||
|
|
}
|
|||
|
|
$o[] = '</div>';
|
|||
|
|
$o[] = '<p class="of-avatar-preview-note">'
|
|||
|
|
. esc_html__('同一个邮箱永远得到同一张脸;换邮箱就换一张。', 'kratos')
|
|||
|
|
. '</p>';
|
|||
|
|
$o[] = '</div>';
|
|||
|
|
|
|||
|
|
return implode("\n", $o);
|
|||
|
|
}
|
|||
|
|
add_filter('optionsframework_avatar_style', 'yang_avatar_option_html', 10, 3);
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 保存校验:只接受收录清单里真实存在的风格名
|
|||
|
|
*
|
|||
|
|
* 框架自带的 of_sanitize_enum 要读 $option['options'],而这里没设
|
|||
|
|
* 那个键(风格清单是运行时算的),所以单独写一个。
|
|||
|
|
*
|
|||
|
|
* 非法值一律回落到默认风格,**不是**存空 —— 存空会导致下次
|
|||
|
|
* kratos_option() 拿到 ''、yang_avatar_style() 又要兜一次底,
|
|||
|
|
* 多一层不必要的间接。
|
|||
|
|
*
|
|||
|
|
* @param mixed $input
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_sanitize_style($input)
|
|||
|
|
{
|
|||
|
|
$styles = yang_avatar_styles();
|
|||
|
|
if (is_string($input) && isset($styles[$input])) {
|
|||
|
|
return $input;
|
|||
|
|
}
|
|||
|
|
return 'lorelei';
|
|||
|
|
}
|
|||
|
|
add_filter('of_sanitize_avatar_style', 'yang_avatar_sanitize_style');
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 后台:给 <body> 打上开关状态 class
|
|||
|
|
*
|
|||
|
|
* ── 为什么必须在服务端打 ──────────────────────────────────────────
|
|||
|
|
* 风格列表的显隐靠 `body.yang-avatar-off` 控制。如果这个 class 只在
|
|||
|
|
* 底部 JS 里加,浏览器解析 HTML 时是「先显示全部内容」,等脚本跑到
|
|||
|
|
* 才藏起来 —— 每次进设置页、每次保存后重载,都会看到风格列表闪一下
|
|||
|
|
* 再消失。放在 PHP 里输出,首帧就是对的。
|
|||
|
|
*
|
|||
|
|
* JS 里仍保留同一段 class 切换逻辑,负责「勾选/取消时立刻变化」。
|
|||
|
|
*
|
|||
|
|
* @param string $classes
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_body_class($classes)
|
|||
|
|
{
|
|||
|
|
if (yang_avatar_active()) {
|
|||
|
|
return $classes;
|
|||
|
|
}
|
|||
|
|
return trim($classes . ' yang-avatar-off');
|
|||
|
|
}
|
|||
|
|
add_filter('admin_body_class', 'yang_avatar_body_class');
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 后台预览用的 16 个邮箱种子
|
|||
|
|
*
|
|||
|
|
* 覆盖常见的中文博客访客形态:QQ / 163 / 126 / gmail / 企业邮,
|
|||
|
|
* 以及几个极短、极长的边界值(`a@b.co`、`admin@example.org`)。
|
|||
|
|
* 短种子的哈希分布和长的不一样,混着放才能看出这套风格在极端
|
|||
|
|
* 输入下会不会崩。
|
|||
|
|
*
|
|||
|
|
* @return array
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_preview_seeds()
|
|||
|
|
{
|
|||
|
|
return array(
|
|||
|
|
'yangyq@example.com', 'zhang.wei@qq.com', 'li.na@163.com', 'wang@126.com',
|
|||
|
|
'chen.jing@gmail.com', 'liu.yang@outlook.com', 'zhao.min@foxmail.com', 'sun.lei@sina.com',
|
|||
|
|
'admin@example.org', 'test@test.com', 'hello@world.net', 'a@b.co',
|
|||
|
|
'zhou.jie@139.com', 'xu@sohu.com', 'ma.chao@yeah.net', 'huang@vip.qq.com',
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* AJAX:换风格时重绘 16 格大预览
|
|||
|
|
*
|
|||
|
|
* ── 为什么走 AJAX 而不是前端算 ────────────────────────────────────
|
|||
|
|
* 渲染器是 PHP 的(跟前台头像共用同一份代码)。前端要「即时换预览」
|
|||
|
|
* 只能要么重跑一遍 JS 版渲染器(等于维护两份实现,且极易不一致),
|
|||
|
|
* 要么就请求后端。后者显然更稳。
|
|||
|
|
*
|
|||
|
|
* 一次请求只生成一套风格的 16 张(约 20~40 ms、几十 KB),
|
|||
|
|
* 只在用户点选时触发,不会拖慢设置页首屏。
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_ajax_preview()
|
|||
|
|
{
|
|||
|
|
// 两道门:nonce + 权限。少一道都可能被低权限账号拿来刷 CPU
|
|||
|
|
check_ajax_referer('yang_avatar_preview', 'nonce');
|
|||
|
|
if (!current_user_can('manage_options')) {
|
|||
|
|
wp_send_json_error(array('message' => __('权限不足', 'kratos')), 403);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$style = isset($_POST['style']) ? sanitize_text_field(wp_unslash($_POST['style'])) : '';
|
|||
|
|
$styles = yang_avatar_styles();
|
|||
|
|
// 白名单校验,别把用户传的值直接拼进文件路径
|
|||
|
|
if (!isset($styles[$style])) {
|
|||
|
|
wp_send_json_error(array('message' => __('未知的风格', 'kratos')), 400);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$cells = array();
|
|||
|
|
foreach (yang_avatar_preview_seeds() as $seed) {
|
|||
|
|
$uri = yang_avatar_data_uri($seed, $style, 96);
|
|||
|
|
if ($uri === '') {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
$cells[] = array(
|
|||
|
|
'uri' => $uri,
|
|||
|
|
'name' => preg_replace('/@.*/', '', $seed),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (empty($cells)) {
|
|||
|
|
wp_send_json_error(array('message' => __('这套风格的模板文件缺失', 'kratos')), 500);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
wp_send_json_success(array('style' => $style, 'cells' => $cells));
|
|||
|
|
}
|
|||
|
|
add_action('wp_ajax_yang_avatar_preview', 'yang_avatar_ajax_preview');
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 后台样式与脚本
|
|||
|
|
*
|
|||
|
|
* ── 为什么用 $_GET['page'] 判断而不是 $hook ──────────────────────
|
|||
|
|
* 框架把真实的 hook 后缀存在 $this->options_screen 里(私有,
|
|||
|
|
* 外面拿不到),对外的 add_menu_page 用的是 slug `kratos_options`。
|
|||
|
|
* 想靠 $hook 猜就得写 `strpos($hook, 'options')` 这种模糊匹配——
|
|||
|
|
* 一旦 WP 改了 hook 命名规则,匹配不上时 CSS/JS 会**静默不加载**,
|
|||
|
|
* 表现就是「开关勾了没反应」,而且报错都没有。
|
|||
|
|
*
|
|||
|
|
* 直接比 page 参数:菜单 slug 是我们自己注册的常量,永远对得上。
|
|||
|
|
*
|
|||
|
|
* @param string $hook
|
|||
|
|
*/
|
|||
|
|
function yang_avatar_admin_assets($hook)
|
|||
|
|
{
|
|||
|
|
if (!isset($_GET['page']) || $_GET['page'] !== 'kratos_options') {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$css = '
|
|||
|
|
.of-avatar-styles{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px;margin:0 0 16px;padding:0}
|
|||
|
|
.of-avatar-style{display:flex;gap:11px;align-items:flex-start;padding:11px 12px;border:1px solid #dcdcde;border-radius:8px;background:#fff;cursor:pointer;transition:border-color .12s,box-shadow .12s}
|
|||
|
|
.of-avatar-style:hover{border-color:#8c8f94}
|
|||
|
|
.of-avatar-style.is-selected{border-color:#2271b1;box-shadow:0 0 0 1px #2271b1}
|
|||
|
|
.of-avatar-style-radio{position:absolute;opacity:0;pointer-events:none}
|
|||
|
|
.of-avatar-style-thumb{flex:0 0 56px;line-height:0}
|
|||
|
|
.of-avatar-style-thumb img{border-radius:8px;display:block;background:#f6f7f7}
|
|||
|
|
.of-avatar-style-text{display:flex;flex-direction:column;gap:3px;min-width:0}
|
|||
|
|
.of-avatar-style-label{font-weight:600;font-size:13px;color:#1d2327}
|
|||
|
|
.of-avatar-style-desc{font-size:12px;line-height:1.5;color:#646970}
|
|||
|
|
.of-avatar-preview{margin-top:4px;padding:14px;border:1px solid #dcdcde;border-radius:8px;background:#f6f7f7}
|
|||
|
|
.of-avatar-preview-title{margin:0 0 10px;font-size:13px;font-weight:600;color:#1d2327}
|
|||
|
|
.of-avatar-preview-grid{display:flex;flex-wrap:wrap;gap:10px}
|
|||
|
|
.of-avatar-preview-cell{margin:0;text-align:center;width:64px}
|
|||
|
|
.of-avatar-preview-cell img{display:block;border-radius:8px;background:#fff}
|
|||
|
|
.of-avatar-preview-cell figcaption{font-size:10px;color:#646970;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|||
|
|
.of-avatar-preview-note{margin:10px 0 0;font-size:12px;color:#646970}
|
|||
|
|
/* 开关没打开时把风格那一行藏起来 —— 用 body class 而不是 JS 内联样式,
|
|||
|
|
这样保存后页面重载也不会闪一下 */
|
|||
|
|
body.yang-avatar-off #section-a_auto_style{display:none}
|
|||
|
|
';
|
|||
|
|
wp_register_style('yang-avatar-admin', false, array(), null);
|
|||
|
|
wp_enqueue_style('yang-avatar-admin');
|
|||
|
|
wp_add_inline_style('yang-avatar-admin', $css);
|
|||
|
|
|
|||
|
|
$js = <<<'JS'
|
|||
|
|
(function(){
|
|||
|
|
var box = document.getElementById('a_auto_enable');
|
|||
|
|
var section = document.getElementById('section-a_auto_style');
|
|||
|
|
var prev = document.querySelector('.of-avatar-preview');
|
|||
|
|
var grid = prev ? prev.querySelector('.of-avatar-preview-grid') : null;
|
|||
|
|
|
|||
|
|
// ── 开关控制风格列表显隐 ────────────────────────────────────
|
|||
|
|
// 首帧的显隐由 PHP 打在 body class 上(不闪),这里只管"点了之后"。
|
|||
|
|
function syncToggle(){
|
|||
|
|
if (!box) return;
|
|||
|
|
if (box.checked) {
|
|||
|
|
document.body.classList.remove('yang-avatar-off');
|
|||
|
|
} else {
|
|||
|
|
document.body.classList.add('yang-avatar-off');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (box) { box.addEventListener('change', syncToggle); syncToggle(); }
|
|||
|
|
|
|||
|
|
// ── 点选风格:切换高亮 + 拉取该风格的 16 格预览 ──────────────
|
|||
|
|
var CFG = window.YANG_AVATAR_CFG || {};
|
|||
|
|
var inflight = 0; // 请求序号,用来丢弃过期响应
|
|||
|
|
var cache = {}; // 已取过的风格,回点不再打后端
|
|||
|
|
var cur = prev ? prev.getAttribute('data-preview') : '';
|
|||
|
|
|
|||
|
|
// 首屏那份 PHP 渲染的格子直接进缓存。
|
|||
|
|
// 不这么做的话,用户点了一圈回到默认风格,会白白再请求一次
|
|||
|
|
// ——而屏幕上已经有一份一模一样的内容。
|
|||
|
|
function seedCache(){
|
|||
|
|
if (!prev || !grid || !cur || cache[cur]) return;
|
|||
|
|
var cells = [];
|
|||
|
|
Array.prototype.forEach.call(
|
|||
|
|
grid.querySelectorAll('.of-avatar-preview-cell'),
|
|||
|
|
function(fig){
|
|||
|
|
var im = fig.querySelector('img');
|
|||
|
|
var cp = fig.querySelector('figcaption');
|
|||
|
|
if (!im) return;
|
|||
|
|
cells.push({uri: im.getAttribute('src'), name: cp ? cp.textContent : ''});
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
if (cells.length) { cache[cur] = cells; }
|
|||
|
|
}
|
|||
|
|
seedCache();
|
|||
|
|
|
|||
|
|
function paint(cells){
|
|||
|
|
if (!grid) return;
|
|||
|
|
while (grid.firstChild) { grid.removeChild(grid.firstChild); }
|
|||
|
|
cells.forEach(function(c){
|
|||
|
|
var fig = document.createElement('figure');
|
|||
|
|
fig.className = 'of-avatar-preview-cell';
|
|||
|
|
var img = document.createElement('img');
|
|||
|
|
img.src = c.uri;
|
|||
|
|
img.alt = '';
|
|||
|
|
img.width = 64;
|
|||
|
|
img.height = 64;
|
|||
|
|
var cap = document.createElement('figcaption');
|
|||
|
|
cap.textContent = c.name;
|
|||
|
|
fig.appendChild(img);
|
|||
|
|
fig.appendChild(cap);
|
|||
|
|
grid.appendChild(fig);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function load(style){
|
|||
|
|
if (!grid || !CFG.ajax || !style) return;
|
|||
|
|
if (cache[style]) { paint(cache[style]); return; }
|
|||
|
|
|
|||
|
|
// 连点时只认最后一次:先自增序号,回来时对不上就丢弃
|
|||
|
|
var seq = ++inflight;
|
|||
|
|
if (prev) { prev.classList.add('is-loading'); }
|
|||
|
|
|
|||
|
|
var body = new URLSearchParams();
|
|||
|
|
body.append('action', 'yang_avatar_preview');
|
|||
|
|
body.append('nonce', CFG.nonce);
|
|||
|
|
body.append('style', style);
|
|||
|
|
|
|||
|
|
fetch(CFG.ajax, {
|
|||
|
|
method: 'POST',
|
|||
|
|
credentials: 'same-origin',
|
|||
|
|
headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
|
|||
|
|
body: body.toString()
|
|||
|
|
}).then(function(r){ return r.json(); }).then(function(res){
|
|||
|
|
if (!res || !res.success || !res.data || !res.data.cells) return;
|
|||
|
|
// 先入缓存:即使这次响应已过期(用户又点了别的),
|
|||
|
|
// 下次点回来也能直接用,不必再打一次后端
|
|||
|
|
cache[style] = res.data.cells;
|
|||
|
|
if (seq !== inflight) return; // 过期响应,不重绘
|
|||
|
|
if (prev) { prev.classList.remove('is-loading'); }
|
|||
|
|
paint(res.data.cells);
|
|||
|
|
}).catch(function(){
|
|||
|
|
if (seq !== inflight) return;
|
|||
|
|
if (prev) { prev.classList.remove('is-loading'); }
|
|||
|
|
// 失败就保持原样 —— 首屏已经有 PHP 渲染的一份,不必报错打扰用户
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function select(style){
|
|||
|
|
document.querySelectorAll('.of-avatar-style').forEach(function(x){
|
|||
|
|
x.classList.toggle('is-selected', x.getAttribute('data-style') === style);
|
|||
|
|
});
|
|||
|
|
if (prev) { prev.setAttribute('data-preview', style); }
|
|||
|
|
cur = style;
|
|||
|
|
load(style);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ⚠️ 必须监听 radio 的 change,**不能**监听 label 的 click。
|
|||
|
|
// label 的默认行为是"把点击转发给关联的 input",而在 input 内部
|
|||
|
|
// 触发的那次 click 会继续冒泡回 label ⇒ label 的处理器被调用两次
|
|||
|
|
// ⇒ 同一风格发两次请求(实测 hits 2→4)。
|
|||
|
|
// change 只在选中状态真正变化时触发一次,天然去重;
|
|||
|
|
// 点已选中的那张也不会触发(值没变),正好省掉一次无意义的重绘。
|
|||
|
|
document.querySelectorAll('.of-avatar-style-radio').forEach(function(r){
|
|||
|
|
r.addEventListener('change', function(){
|
|||
|
|
if (r.checked) { select(r.value); }
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 键盘可达性:radio 被视觉隐藏,但焦点仍在它上面。
|
|||
|
|
// 方向键切换时浏览器自己会触发 change,上面已覆盖。
|
|||
|
|
window.yangAvatarSelect = select;
|
|||
|
|
})();
|
|||
|
|
JS;
|
|||
|
|
wp_register_script('yang-avatar-admin', '', array(), null, true);
|
|||
|
|
wp_enqueue_script('yang-avatar-admin');
|
|||
|
|
wp_add_inline_script('yang-avatar-admin', $js);
|
|||
|
|
|
|||
|
|
// 把 ajax 地址与 nonce 交给上面的脚本(不能内联进 heredoc,会破坏 <<<'JS')
|
|||
|
|
wp_localize_script('yang-avatar-admin', 'YANG_AVATAR_CFG', array(
|
|||
|
|
'ajax' => admin_url('admin-ajax.php'),
|
|||
|
|
'nonce' => wp_create_nonce('yang_avatar_preview'),
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
add_action('admin_enqueue_scripts', 'yang_avatar_admin_assets');
|