Files
wordpress-kratos-yang/inc/theme-avatar.php
T
laoyang 203721b1fe fix(avatar): 注册用户保留原头像;人脸风格肤色只留黄种人区间
#8 邮箱能在本站用户表查到的,一律用他自己的头像,不再替换
   · 新增 yang_avatar_is_registered_email():get_user_by('email') 查表,
     邮箱先 trim + strtolower 规范化(导入用户/插件写库/老数据都有大小写混用)
   · 加请求内 static $memo 缓存:评论列表一页 20 条、同一个人常有 2~3 条,
     不缓存就是同一个邮箱反复打库(实测 20 次 → 1 次)
   · 新增 yang_avatar_keep_member() 读开关,运行时默认 '1'
   · 过滤器在拼 data: URI 之前拦截,命中即原样 return $avatar
   · 后台新增「注册用户保留原头像」复选框
   · 关于「注册用户自己也没有真 Gravatar」:WP 的 get_avatar() 从不检查图上
     是否存在,只拼 URL 交给浏览器;要分辨必须向 gravatar.com 发请求,
     而本功能坚持零外网调用(发布脚本会断言)。因此做成开关、默认保留,
     这类用户保持灰人像、不猜。

#9 肤色从 7 档收窄到 4 档,只留最浅到偏黄
   · 移除 d9a066(偏深)、c68642(深棕)、8d5524(最深)
   · 安全性依据:Prng::pick() 用 integer(0, count-1) 取索引,只消耗一次
     next(),与区间跨度无关 ⇒ 缩短数组不改变 PRNG 消耗次数,发型/衣着/
     表情/背景抽到的结果一点不变,只有肤色跟着变
     (已用 _v39_isolate.php 逐字节证明:7 色与 4 色渲染除肤色外完全相同)

顺带修掉一个老 bug:baseColor 错当肤色
   · bottts 的 baseColor 是机器人外壳色(19 种彩色),映射成肤色后外壳被
     涂成肉色 e8b98f / f1c27d。从 $map 里去掉 'baseColor' => 'skinColor'
   · 「base」这名字像是底色,但各家风格语义并不统一,不能按名字猜

开关位置(踩过的坑)
   · 新开关必须排在「头像风格」之前。风格列表是 18 张带缩略图的卡片、
     实测高约 2100px,排在它后面的控件会落到 y≈2327px,而设置页视口
     约 1000px —— 用户不滚三屏根本看不到。3.2.36 就是栽在这里。
     挪到之前后实测 y=216px、首屏可见。

验证
   _v39_probe.php              5 / 0   肤色收窄 + bottts 修复
   _v39_isolate.php           50 / 0   「只改肤色、其它不变」逐字节隔离证明
   _v39_member.php            19 / 0   注册用户判据(含缓存次数精确断言)
   _v39_falsify.py              8 / 8   上述判据反向
   _guard337_layout.py        25 / 0   布局守卫(新增开关位置三项)
   _guard337_layout_falsify.py  5 / 5
   _guard336_avatar.py        55 / 0   源码守卫
   _guard336_falsify.py       17 / 17
   _geo337_avatar_reach.py    34 / 0   真实几何 + 显隐交互
   _smoke18.py                18 / 0   18 套风格全量冒烟
   _verify4_prod.php          80 / 0   逐字节对官方(对配色无感,见 docblock)
   _pkgcheck339.py            52 / 0   包内内容契约
   _pkgcheck339_falsify.py    20 / 20  包内断言反向
   回归 88/0、56/0、77/0、13/0;93 个 php -l 全通过
   PHP 8.4.25 与 8.5.12 严格错误级别均 0 警告

Refs #8 #9
2026-10-25 20:19:27 +08:00

2070 lines
75 KiB
PHP
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.
<?php
/**
* 自动头像:为没有头像的访客按邮箱生成一张头像
*
* ── 为什么需要这个 ────────────────────────────────────────────────
* WordPress 在访客没注册、没上传头像时,会退回 Gravatar 的默认图
* (那个灰底「神秘人」)。观感很差,而且主题默认走 cn.gravatar.com,
* 该域名在国内已超时 —— 不勾「Gravatar 加速」时头像根本加载不出来。
*
* 本模块按访客邮箱确定性地生成一张头像:同一个邮箱永远得到同一张脸,
* 不同邮箱得到不同的脸。全程纯 PHP,不发起任何外部请求。
*
* ── 素材与算法来源 ────────────────────────────────────────────────
* 算法与图形素材来自 DiceBear(https://www.dicebear.com)9.x。
* 收录的 18 套风格全部可自由使用与商用,且**都不要求署名**:
* CC0 1.0:glass identicon initials lorelei lorelei-neutral
* notionists notionists-neutral open-peeps pixel-art
* pixel-art-neutral rings shapes thumbs
* MIT :icons
* 作者自定义许可(Free for personal and commercial use,不要求署名):
* avataaars avataaars-neutral bottts bottts-neutral
* 设计者 Pablo Stanley,来源 avataaars.com / bottts.com。
* 这四套在 npm 包里写的是「See LICENSE file」,必须点进去看才知道
* 条款 —— 别把「措辞不明」当成「不能商用」,实际比 CC BY 还宽松。
* 每套模板都是把 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;
}
}
/**
* 收录的风格清单
*
* 收录的 18 套全部可自由使用与商用,且都不要求署名(许可证明细见文件头)。
* CC BY 4.0 那 13 套(adventurer / big-ears / 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'),
),
'avataaars' => array(
'label' => __('美式卡通', 'kratos'),
'desc' => __('美式插画风,表情与发型最丰富的一套。不画背景,头像直接压在页面上。', 'kratos'),
),
'avataaars-neutral' => array(
'label' => __('美式卡通(带底色)', 'kratos'),
'desc' => __('同一套画风,带一块背景色,更醒目一点。', 'kratos'),
),
'bottts' => array(
'label' => __('机器人', 'kratos'),
'desc' => __('机器脑袋,靠零件与配色区分,没有人脸所以不会"崩脸"。不画背景。', 'kratos'),
),
'bottts-neutral' => 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 用到的那一份,其余不解析 —— 18 套全量 1.2MB,
* 每次请求都加载是不必要的开销。
*
* @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',
),
// 肤色:只留偏黄/偏浅的区间,不出现深棕与黑色
// ⚠️ 缩短这个数组是**安全的**:Prng::pick() 用
// integer(0, count-1) 取索引,消耗的随机数与长度无关,
// 所以其它组件(发型、衣着、表情)抽到的结果一点不变,
// 只有肤色这一项跟着变。别在这里加逻辑,改长度就够了。
'skinColor' => array(
'ffdbac', 'f2d3b8', 'f1c27d', 'e8b98f',
),
// 发色
'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();
// 字段名 → 调色板类别
// ⚠️ 这里只放**真的是皮肤**的字段。`baseColor` 不算 ——
// 它在 bottts 里是机器人的外壳色(19 种彩色),实测把它映射成
// 肤色后,机器人外壳会被涂成肉色 e8b98f / f1c27d,非常难看。
// 「base」这个名字像是底色,但各家风格语义并不统一,别按名字猜。
$map = array(
'backgroundColor' => 'backgroundColor',
'skinColor' => '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;
}
/* ── 本站注册用户:原样返回,一个字都不改 ──────────────────────
用户诉求:邮箱能在用户表里查到,说明这个人是本站的人,用他自己的
头像;查不到的(匿名评论者)才给他生成一张。
这一步必须放在**拼 data: URI 之前**,否则白白算一遍 SVG 才丢弃。
⚠️ 这里有个服务端无法回避的限制:WP 的 get_avatar() 从不检查
Gravatar 上到底有没有这张图,它只拼 URL 交给浏览器。所以
「注册用户自己也没上传头像、Gravatar 上其实是默认灰人像」
这种情况,**不联网就分辨不出来**。本功能坚持零外网调用
(发布检查会断言不含 curl/wp_remote_get),因此这里选择
「只要是注册用户就不动」—— 宁可留一张灰人像,也不去猜。 */
if (yang_avatar_keep_member() && yang_avatar_is_registered_email($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 '';
}
/**
* 这个邮箱是不是本站**注册用户**的邮箱
*
* ── 为什么要有这一层 ──────────────────────────────────────────────
* 早先的实现只判断「src 是不是 Gravatar 家族」,只要是就换成本地生成的
* 头像。结果是**连本站注册用户自己的头像也被盖掉** —— 站长看后台、
* 作者看文章列表,全是同一套卡通脸,很别扭。
*
* 用户的判据是:邮箱能在用户表里查到 ⇒ 这个人属于本站,用他自己的;
* 查不到(匿名评论者)⇒ 才给他生成一张。
*
* ── 查表方式 ────────────────────────────────────────────────────
* 优先 get_user_by('email'):WP 内部会把结果缓存到 object cache,
* 同一请求内多次调用同一邮箱不会重复打库。评论列表一页 20 条也只
* 打有限几次。
*
* ⚠️ 邮箱比较必须**先规范化**(trim + 小写)。WP 建用户时邮箱理论上
* 已小写,但导入用户、插件写库、老数据都可能出现大小写混用;
* 不规范化会漏判,表现成"明明是注册用户却还是被换了头像"。
*
* @param string $email
* @return bool
*/
function yang_avatar_is_registered_email($email)
{
$email = trim((string) $email);
if ($email === '') {
return false;
}
// 大小写不敏感:WP 的 user_email 列在大多数排序规则下也是不敏感的,
// 但函数调用前先规范化,避免依赖数据库排序规则
$email = strtolower($email);
/* 请求内缓存
────────────────────────────────────────────────────────────
评论列表一页 20 条,同一个人常有 2~3 条;不缓存就是同一个邮箱
反复打库。WP 的 get_user_by 内部虽有 object cache,但那是**按
user ID 缓存**的,第一次仍要走一次邮箱查询。
这里再压一层,保证「同一请求内同一邮箱只查一次」。
静态变量在请求结束即销毁,不会跨请求污染。 */
static $memo = array();
if (array_key_exists($email, $memo)) {
return $memo[$email];
}
$hit = false;
if (function_exists('get_user_by')) {
$u = get_user_by('email', $email);
if ($u && isset($u->ID) && (int) $u->ID > 0) {
$hit = true;
}
}
$memo[$email] = $hit;
return $hit;
}
/**
* 是否「只有匿名访客才换头像」
*
* 开关存库;默认 '1'(开)—— 这正是用户提需求时的原话:
* 「邮箱在我们博客里面属于这个博客的用户的,就用他自己的头像」。
*
* 关掉这个开关等于回到 3.2.38 的行为:所有 Gravatar 家族的 src 都换。
*
* @return bool
*/
function yang_avatar_keep_member()
{
if (!yang_avatar_active()) {
return false;
}
if (function_exists('kratos_option')) {
return kratos_option('a_auto_keep_member', '1') !== '0';
}
return true;
}
/* ══════════════════════════════════════════════════════════════════
* 渲染器本体
* ══════════════════════════════════════════════════════════════════ */
/**
* 渲染一张 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 / 组件开关) ──
必须把 $picked 传进去:官方这里读的是 components.style.name 与 components.top.name。 */
if (!empty($style['has_post_create'])) {
$res = yang_avatar_post_create($style['name'], $d, $colors, $on, $picked);
$d = $res[0];
$colors = $res[1];
$on = $res[2];
}
/* ── 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),不能当数据编译。
* 收录的 18 套里只有 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
* @param array $picked getComponents 已抽到的组件名(官方 onPostCreate 读的是
* components.style.name / components.top.name,必须传进来)
* @return array array($d, $colors, $on)
*/
function yang_avatar_post_create($name, $d, $colors, $on, $picked = array())
{
if ($name === 'avataaars') {
/* 官方 onPostCreate 的三条,逐条复刻(顺序也要一样):
① style 组取到 'circle' ⇒ 背景整个清空;
② style 不是 'circle'、但背景**恰好只有 65c9ff 这一项** ⇒ 也清空。
65c9ff 是这款的默认背景色,意思是「用户没主动选过背景」,
留着会在脸后面糊一块天蓝,看着像没去底。
⚠️ 注意这里判的是「只有一个、且等于 65c9ff」,不是「包含 65c9ff」:
若用户给了多个颜色,就说明是主动选的,要留着。
③ 戴头巾(top = hijab)且没强制 100% 胡子 ⇒ 去掉胡子
(头巾配胡子现实中不存在)。
清空 backgroundColor 不是「不画背景」那么简单:下游
yang_avatar_bg_colors() 对空数组会走 shuffle + next() 消耗 PRNG,
与官方 getBackgroundColors() 的空数组分支步数一致 —— 两边都恰好消耗一次。 */
$styleName = isset($picked['style']) ? $picked['style'] : '';
if ($styleName === 'circle') {
$d['backgroundColor'] = array();
} elseif (isset($d['backgroundColor']) && is_array($d['backgroundColor'])
&& count($d['backgroundColor']) === 1
&& $d['backgroundColor'][0] === '65c9ff') {
$d['backgroundColor'] = array();
}
// ③ 头巾与胡子互斥
if (!empty($on['top'])) {
$topName = isset($picked['top']) ? $picked['top'] : '';
$prob = isset($d['facialHairProbability']) ? $d['facialHairProbability'] : 10;
if ((int) $prob !== 100 && $topName === 'hijab') {
// 官方置 undefined ⇒ 展开时该组不出现;这里同步关掉 bool,避免取到残留值
$d['facialHair'] = array();
$on['facialHair'] = false;
}
}
} elseif ($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, $on);
}
/**
* 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('&amp;', '&apos;', '&quot;', '&lt;', '&gt;'),
$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,
body.yang-avatar-off #section-a_auto_keep_member{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');