Files
wordpress-kratos-yang/inc/theme-mail-template.php
T
laoyang 9c48ba56eb 版本号 3.2.7 -> 3.2.8(邮件配色整封同色系化 + 对比度修复)
邮件模板原来只有品牌条一个色块,其余大面积底色全是中性灰,
主色稍朴素(主题默认 #24292e)整封信看起来就是黑白的。

- 新增 yang_mail_palette():底色/描边/强调文字全部从主色同色系派生
- 新增 yang_mail_chroma()/hsl()/hsl_hex()/ensure_chroma()/has_hue()
- 修复深浅判定:白字对比度 = 黑字对比度的交叉点在 L≈0.179,不是 0.5。
  用 0.5 会把亮蓝 #00a2ff(L=0.331)判成深色配白字,只有 2.76:1
- 新增 yang_mail_fg_on():在白与深之间取对比度更高者,
  中灰 #808080 上从 4.41 提到 5.32(AA 达标)
- 修复:与白混合是同比压缩色度,0.94 档压 16 倍 ⇒ 近中性主色
  派生出的浅底只剩 0.002,物理不可见。加彩度兜底,按各自亮度补足
- 修复:页脚改浅底后 12px 说明文字降到 4.26:1,muted 改为按背景推导
- 品牌条/按钮仍严格用用户填的原色,补彩度只作用于派生色
2026-10-23 16:13:48 +08:00

757 lines
34 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
/**
* 邮件模板渲染
*
* 设计约束(写这套模板的全部理由):
*
* 1. **必须是表格布局**。Outlook 2007-2019 用 Word 的排版引擎,完全不支持
* flex / grid,连 `max-width` 都不稳。只有 `<table>` + `cellpadding` 在老
* Outlook 里可靠。现在的 web 布局写法在邮件里必崩。
*
* 2. **样式必须内联**。Gmail 会把 `<head>` 里的 `<style>` 全部剥掉(含 media query),
* QQ 邮箱、网易邮箱同样。所以每个 `td` 都要带 `style="..."`,不能依赖 class。
*
* 3. **宽度 600px**。这是邮件客户端预览窗格的通用安全宽度。用
* `width="600"` 属性 + `style="width:600px;max-width:600px"` 双保险。
*
* 4. **不用外部图片做装饰**。邮件里的外链图默认被 Gmail / QQ 邮箱屏蔽,
* 用户看到的是一堆灰框。所有视觉元素用纯色块 + 圆角 + 文字实现。
*
* 5. **不用 CSS 变量**。邮件客户端不支持,写了就是丢失样式。
*
* 6. 深色模式:Android Gmail / Apple Mail 会反色。这里显式声明
* `color-scheme` 与 `supported-color-schemes`,并避免纯黑纯白。
*
* @author Yongquan Yang <yangyq.net>
* @license MIT License
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* 站点主色(可被选项覆盖)
*
* 取主题的主色选项;取不到就用一个稳妥的深蓝灰。
* 不用亮色,因为邮件背景是浅灰,亮色正文对比度不够。
*/
function yang_mail_brand_color()
{
$c = kratos_option('top_color', '#24292e');
if (!preg_match('/^#[0-9a-fA-F]{6}$/', (string) $c)) {
$c = '#24292e';
}
return $c;
}
/**
* 把主色调暗一档,用作渐变末端 / 描边
*
* 邮件里不能用 `filter: brightness()`(不支持),只能算。
*/
function yang_mail_shade($hex, $ratio = 0.82)
{
$hex = ltrim($hex, '#');
if (strlen($hex) !== 6) {
return '#24292e';
}
$out = '#';
foreach (str_split($hex, 2) as $pair) {
$v = max(0, min(255, (int) round(hexdec($pair) * $ratio)));
$out .= str_pad(dechex($v), 2, '0', STR_PAD_LEFT);
}
return $out;
}
/**
* 判断一个颜色是深是浅,决定叠在上面的文字用白还是深色
*
* 用相对亮度公式(WCAG),不是简单取平均 —— 人眼对绿最敏感、蓝最不敏感,
* 直接平均会把 #00ff00 判成中等亮度。
*
* ── 阈值为什么是 0.179 而不是 0.5 ────────────────────────────
* 「白字对比度 = 黑字对比度」的那个点,可以解出来:
* 白字对比度 = 1.05 / (L + 0.05)
* 黑字对比度 = (L + 0.05) / 0.05
* 两者相等 ⇒ (L + 0.05)² = 0.0525 ⇒ L ≈ 0.179
* 所以在 L = 0.179 处,白字与黑字一样清楚;低于它白字更好,高于它黑字更好。
* 之前写 0.5,把一整段"其实该用深色字"的中间亮度全划成了"深色",
* 典型受害者就是亮蓝 #00a2ff(L=0.331):被判成深色 ⇒ 压白字 ⇒ 只有 2.76:1,
* 远低于 AA 的 4.5,品牌条上的站名其实很难看清。按 0.179 判定后走深色字,6.31:1。
*
* ⚠️ 这个函数只回答"深还是浅",**不负责保证对比度**。中灰 #808080 落在
* 交叉点附近,白字 3.95、深字 4.41 —— 两个都不到 4.5,但必须选一个。
* 所以配字时不要直接用它,用 yang_mail_fg_on():那里是"黑白里取更优",
* 顺带在都不达标时退回纯黑/纯白,能多榨出一两档。
*/
function yang_mail_is_dark($hex)
{
return yang_mail_lum($hex) < 0.179;
}
/**
* 相对亮度(WCAG,0=纯黑 1=纯白)
*/
function yang_mail_lum($hex)
{
$hex = ltrim((string) $hex, '#');
if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) {
return 0.0;
}
$lin = function ($c) {
$c /= 255;
return $c <= 0.03928 ? $c / 12.92 : pow(($c + 0.055) / 1.055, 2.4);
};
return 0.2126 * $lin(hexdec(substr($hex, 0, 2)))
+ 0.7152 * $lin(hexdec(substr($hex, 2, 2)))
+ 0.0722 * $lin(hexdec(substr($hex, 4, 2)));
}
/**
* 给某个底色配文字色:在白与深之间取**对比度更高**的那个
*
* 为什么不是"深色就配白、浅色就配深":中灰区(如 #808080)两边都不达标
* (白字 3.95、深字 4.41),二元判定的答案取决于阈值落在哪一侧,
* 而阈值本身对两个都不达标的情况毫无意义。取更优的那个至少是当前最好的选择,
* 而且这个判据跟"阈值该定多少"完全解耦 —— 换阈值也不会选错。
*/
function yang_mail_fg_on($bg)
{
$white = yang_mail_contrast('#ffffff', $bg);
$dark = yang_mail_contrast('#1a1a1a', $bg);
if ($white >= $dark) {
return '#ffffff';
}
// 深字够不着 AA 时,纯黑还能再挤一点(#808080 上 4.41 → 5.32)
return yang_mail_contrast('#000000', $bg) > $dark ? '#000000' : '#1a1a1a';
}
/**
* 取一个颜色的「色度」(chroma)—— 三个通道的最大差
*
* ── 为什么不用 HSL 饱和度判断"有没有颜色" ────────────────────
* HSL 的 S 在高亮度区会虚高。实测派生底 #f0f9ff(几乎纯白带一点点蓝)
* 的 HSL 饱和度是 **1.00**,而 #e5e5e6(纯灰)是 0.005 —— 看起来判据很灵。
* 但 #e9eaec 这种"肉眼就是灰"的颜色,HSL 饱和度也有 0.12,
* 会被判成"有颜色"。真正决定人眼能否看出颜色的,是通道极差(色度):
* #f0f9ff → 0.059(看得出是极浅蓝)
* #e9eaec → 0.012(看不出,就是灰)
* 两者 HSL 饱和度一个是 1.00、一个 0.12,顺序跟人的感受**正好相反**。
* 所以这里一律用色度,不用 S。
*/
function yang_mail_chroma($hex)
{
$hex = ltrim((string) $hex, '#');
if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) {
return 0.0;
}
$v = array(
hexdec(substr($hex, 0, 2)),
hexdec(substr($hex, 2, 2)),
hexdec(substr($hex, 4, 2)),
);
return (max($v) - min($v)) / 255;
}
/**
* 把「有色相但太淡」的颜色沿**它自己的色相**加饱和,直到色度达到下限
*
* ── 为什么需要这一步 ────────────────────────────────────────
* 主题默认主色是 #24292e,色度只有 0.039(几乎是中性深灰)。
* 往白里兑出浅底后色度只剩 0.002 —— 物理上不可见,整封邮件仍旧是黑白的,
* 用户看到的还是「模板没做好」。但「从灰里调出彩色」在数学上不可能,
* 而主题默认值就是它,所以必须有一个补饱和的环节。
*
* 只动饱和度,**色相与亮度一律不动** —— 派生色仍属同一色系,品牌识别没丢。
*/
function yang_mail_ensure_chroma($hex, $minChroma)
{
if (yang_mail_chroma($hex) >= $minChroma) {
return $hex;
}
list($hue, $l) = yang_mail_hsl($hex);
$den = 1 - abs(2 * $l - 1);
if ($den < 1e-6) {
// 纯白或纯黑:没有色相可言
return $hex;
}
$s0 = yang_mail_chroma($hex) / $den;
/* 逐档搜索而不是解析求解:色度是 max-min,跟 S 的关系在 l>0.5 与
* l<0.5 两侧公式不同,硬推容易在边界写错;这里步数只有几十。 */
for ($s = $s0; $s <= 0.95; $s += 0.02) {
$out = yang_mail_hsl_hex($hue, $s, $l);
if (yang_mail_chroma($out) >= $minChroma) {
return $out;
}
}
return yang_mail_hsl_hex($hue, 0.95, $l);
}
/**
* 这个颜色有没有「真实色相」(可以放心拿它的色相去染色)
*
* ── 为什么判据放在源头,而不是放在每个派生色上 ──────────────────
* 与白色混合是**同比压缩**色度:0.94 那一档压 16 倍。
* 所以"派生出的浅底色度很低"是**必然现象**,不是"这个颜色没有色相"。
* 早先的实现在 yang_mail_ensure_chroma() 里对入参做 0.02 死区判断,
* 于是极浅底(混合后 0.008)被当成"没有色相"直接跳过,
* 兜底等于没做 —— 恰恰是最需要兜底的那一档失效了。
*
* 真正该判断的是**种子**:种子的色度 ≥ 0.02 说明它有一个真实可辨的色相
* (#24292e 是 0.039,属"有一点点真实的蓝",可以放大);
* 低于 0.02 的(#808080 这种纯中性灰)色相是浮点噪声,按噪声染色
* 等于随机配色,还会让「主色本来就没有色相」变得不可观测 —— 那就不染。
*/
function yang_mail_has_hue($hex)
{
return yang_mail_chroma($hex) >= 0.02;
}
/**
* #rrggbb → array(色相 0~1, 亮度 0~1)
*/
function yang_mail_hsl($hex)
{
$hex = ltrim((string) $hex, '#');
if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) {
return array(0.0, 0.0);
}
$r = hexdec(substr($hex, 0, 2)) / 255;
$g = hexdec(substr($hex, 2, 2)) / 255;
$b = hexdec(substr($hex, 4, 2)) / 255;
$mx = max($r, $g, $b);
$mn = min($r, $g, $b);
$l = ($mx + $mn) / 2;
$d = $mx - $mn;
if ($d < 1e-9) {
return array(0.0, $l);
}
if ($mx === $r) {
$h = (($g - $b) / $d + ($g < $b ? 6 : 0)) / 6;
} elseif ($mx === $g) {
$h = (($b - $r) / $d + 2) / 6;
} else {
$h = (($r - $g) / $d + 4) / 6;
}
return array($h, $l);
}
/**
* HSL → #rrggbb(h 为 0~1)
*/
function yang_mail_hsl_hex($h, $s, $l)
{
$hue2rgb = function ($p, $q, $t) {
if ($t < 0) {
$t += 1;
}
if ($t > 1) {
$t -= 1;
}
if ($t < 1 / 6) {
return $p + ($q - $p) * 6 * $t;
}
if ($t < 1 / 2) {
return $q;
}
if ($t < 2 / 3) {
return $p + ($q - $p) * (2 / 3 - $t) * 6;
}
return $p;
};
if ($s <= 0) {
$v = (int) round($l * 255);
return sprintf('#%02x%02x%02x', $v, $v, $v);
}
$q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s;
$p = 2 * $l - $q;
return sprintf(
'#%02x%02x%02x',
(int) round(255 * $hue2rgb($p, $q, $h + 1 / 3)),
(int) round(255 * $hue2rgb($p, $q, $h)),
(int) round(255 * $hue2rgb($p, $q, $h - 1 / 3))
);
}
/**
* 生成整封邮件用的配色表(全部从主色派生,同色系)
*
* ── 为什么整封邮件不能是灰的 ──────────────────────────────────
* 早先模板里只有品牌条、徽标、按钮、最后一块引用块的竖线用主色,
* 其余 90% 面积(标题、正文、引用块底色、信息表分隔线、页脚)全是中性灰
* (#1a2028 / #5f6b78 / #c2cbd4 / #edf0f3)。主色若本身不够艳
* (比如主题默认的 #24292e 是接近中性的深灰蓝),整封信看起来就是黑白的。
* 「彩色」不是靠一个大色块,而是让**底色、描边、强调文字**都在同一色系里。
*
* ── 为什么要算而不是写死 ──────────────────────────────────────
* 主色是用户在主题选项里改的,写死一套蓝就没有意义了。
* 这里从主色派生出:极浅底(区块背景)、浅底(页脚背景)、
* 中档描边、深档文字(保证对浅底 ≥4.5:1)。
*
* ── 派生为什么不能只是「与白混合」────────────────────────────
* 混合是同比压缩色度:0.94 那一档把色度压 16 倍。主色是 #24292e 这种
* 近中性深灰(色度 0.039)时,兑完只剩 0.002,肉眼与纯灰无异 ——
* 这正是「换成默认主色后邮件又变黑白」的机制。
* 所以浅底在混合之后还要过一道 yang_mail_ensure_chroma(),按**它自己所在
* 亮度**补一个可见的色度下限(浅底用 MIN_CHROMA_TINT,中档用 MIN_CHROMA_MID)。
* 这一步只动饱和度、不动色相和亮度,颜色仍是主色的同一色系。
*
* ── 品牌条为什么仍用原色 ────────────────────────────────────
* 用户直接看到主色的地方(品牌条、按钮、徽标)一律用他填的原色。
* 他选了深灰蓝,就不该在标题栏上看到它被换成亮蓝。补饱和只用于
* 「主色的稀释/加深」,那是他自己没指定过的颜色。
*
* 全部输出**实色**,不用 rgba:Outlook(Word 引擎)不认 rgba,会整条丢掉背景。
*/
function yang_mail_palette($brand = null)
{
if ($brand === null) {
$brand = yang_mail_brand_color();
}
$brand = ltrim((string) $brand, '#');
if (!preg_match('/^[0-9a-fA-F]{6}$/', $brand)) {
$brand = '24292e';
}
$brand = '#' . $brand;
/* 派生用的种子:中性一点的先提饱和,否则这一整套派生色都将是灰的。
* 只在主色有**真实色相**时才放大(见 yang_mail_has_hue);
* 零色相的纯灰宁可保持灰,也不要凭空编一个色相。 */
$seed = yang_mail_has_hue($brand) ? yang_mail_ensure_chroma($brand, 0.30) : $brand;
$onBrand = yang_mail_fg_on($brand);
/* 浅底:混合后按自身亮度再补一次色度。
* 极浅底(ratio 0.94)本身亮度很高,能容纳的色度有限,
* 所以下限分两档 —— 用同一个值会让极浅底被迫变深、不再是"浅底"。
* 主色零色相时不补:那套灰是事实,硬染就是编造。 */
$keep = yang_mail_has_hue($brand);
$tintXX = $keep ? yang_mail_ensure_chroma(yang_mail_tint($seed, 0.94), 0.045)
: yang_mail_tint($seed, 0.94);
$tintX = $keep ? yang_mail_ensure_chroma(yang_mail_tint($seed, 0.88), 0.075)
: yang_mail_tint($seed, 0.88);
$tintLine = $keep ? yang_mail_ensure_chroma(yang_mail_tint($seed, 0.80), 0.110)
: yang_mail_tint($seed, 0.80);
$tintMid = $keep ? yang_mail_ensure_chroma(yang_mail_tint($seed, 0.55), 0.220)
: yang_mail_tint($seed, 0.55);
$pageBg = $keep ? yang_mail_ensure_chroma(yang_mail_tint($seed, 0.90), 0.065)
: yang_mail_tint($seed, 0.90);
/* 强调文字色:从种子一路压暗,取第一个"在白底和极浅底上都 ≥4.5"的档位。
* 不能直接拿主色当文字色 —— 亮蓝 #00a2ff 在白底只有 2.76:1。
* 也不能无脑取很深,那样就看不出是"主色的深版"了,所以要取**最亮的达标档**。 */
$textOnTint = $seed;
for ($s = 0.20; $s <= 0.90; $s += 0.02) {
$c = yang_mail_shade($seed, $s);
if (yang_mail_contrast($c, '#ffffff') >= 4.5
&& yang_mail_contrast($c, $tintXX) >= 4.5) {
$textOnTint = $c;
break;
}
}
/* 次要文字色:原来是固定的 #5c6b7a,但页脚底色改成了主色浅底之后,
* 这个固定值在浅蓝上只有 4.26:1 —— 12px 小字不达 AA。
* 「文字色写死、背景色随主色变」这个组合本身就是错的:背景一变浅,
* 固定灰就压不住。所以改成从基准灰往下压,压到在**它实际会遇到的
* 两种背景**(页脚浅底、邮件最外层)上都达标为止。 */
$muted = '#5c6b7a';
for ($t = 1.0; $t >= 0.55; $t -= 0.02) {
$cand = $t >= 0.999 ? $muted : yang_mail_shade($muted, $t);
if (yang_mail_contrast($cand, $tintX) >= 4.5
&& yang_mail_contrast($cand, $pageBg) >= 4.5) {
$muted = $cand;
break;
}
}
return array(
'brand' => $brand, // 主色(品牌条、按钮)
'brand_dark' => yang_mail_shade($brand, 0.78), // 品牌条下沿深色带
'on_brand' => $onBrand, // 压在品牌色上的字
'text' => $textOnTint, // 同色系的强调文字(标题/标签)
'tint_xx' => $tintXX, // 极浅底:引用块、信息表
'tint_x' => $tintX, // 稍深一点的浅底:页脚
'tint_line' => $tintLine, // 浅底上的描边/分隔线
'tint_mid' => $tintMid, // 中档:弱化竖线、次要描边
'page_bg' => $pageBg, // 邮件最外层背景
'body' => '#243040', // 正文(保持深灰蓝,长文好读)
'muted' => $muted, // 次要文字(时间、页脚),按背景调过
);
}
/**
* 计算两色的 WCAG 对比度
*
* 给配色表自检用:派生颜色是否达标,靠算而不是靠眼睛看。
*/
function yang_mail_contrast($fg, $bg)
{
$lumOf = function ($hex) {
$hex = ltrim((string) $hex, '#');
if (strlen($hex) !== 6) {
return 0;
}
$lin = function ($c) {
$c /= 255;
return $c <= 0.03928 ? $c / 12.92 : pow(($c + 0.055) / 1.055, 2.4);
};
return 0.2126 * $lin(hexdec(substr($hex, 0, 2)))
+ 0.7152 * $lin(hexdec(substr($hex, 2, 2)))
+ 0.0722 * $lin(hexdec(substr($hex, 4, 2)));
};
$a = $lumOf($fg);
$b = $lumOf($bg);
return (max($a, $b) + 0.05) / (min($a, $b) + 0.05);
}
/**
* 把可能含 HTML 的片段转成邮件安全的纯文本(保留换行)
*
* 邮件正文里塞用户提交的评论内容,必须防注入:邮件客户端也会执行
* `<script>`(部分 webmail 会),而且一旦渲染出畸形 HTML,整封邮件布局就崩。
*
* ── 换行为什么要专门处理 ─────────────────────────────────────
* 两个坑都会把整条评论拍成一行:
* 1. `wp_strip_all_tags($x, true)` 的第二个参数是 remove_breaks,
* 真实实现里会 `preg_replace('/[\r\n\t ]+/', ' ', $x)` —— 换行直接没了。
* 所以这里必须传 false。
* 2. `strip_tags()` 遇到 `<br>` 是**直接删掉、不补换行**的,`</p>` 同理。
* 评论里手写的换行和段落因此在去标签阶段就无痕消失。
* 结论:先把块级边界换成 `\n`,再去标签,最后才收拾空白。
* 这样模板里的 `nl2br()` 才有换行可转,不是一段死代码。
*/
function yang_mail_text($html, $limit = 0)
{
$t = (string) $html;
// 1. 先把"视觉上的换行"降级成真正的 \n
$t = preg_replace('@<br\s*/?>@i', "\n", $t);
$t = preg_replace('@</(p|div|blockquote|pre|h[1-6])\s*>@i', "\n", $t);
// <li> 自己带前缀,就不要</li>再加换行,否则列表每项之间会多一个空行
$t = preg_replace('@<li[^>]*>@i', "\n- ", $t);
// 2. 去标签,但不要 remove_breaks(那是换行消失的真正原因)
$t = wp_strip_all_tags($t, false);
// 3. 收拾空白:行内空白收成一个空格,换行保留,最多一个空行
$t = html_entity_decode($t, ENT_QUOTES, 'UTF-8');
$t = str_replace(array("\r\n", "\r"), "\n", $t);
/* 用 [^\S\n]("非空白"取反 + 排除换行 = 除换行外的空白)而不是
[ \t\f\v]:PCRE 里 \v 是**垂直空白类**,把 \n 也算进去,
写成 [ \t\f\v] 会连换行一起替换成空格 —— 而且只在带 /u 时表现如此,
极易漏看。语义直接写出来,别依赖转义字母的记忆。 */
$t = preg_replace('/[^\S\n]+/u', ' ', $t);
$t = preg_replace('/ *\n */u', "\n", $t);
$t = preg_replace('/\n{3,}/u', "\n\n", $t);
$t = trim($t);
if ($limit > 0 && mb_strlen($t, 'UTF-8') > $limit) {
$t = mb_substr($t, 0, $limit, 'UTF-8') . '…';
}
return $t;
}
/**
* 把主色与白色按比例混合,得到一档浅色
*
* 为什么不用 rgba:Outlook(Word 引擎)完全不支持 rgba(),
* 写了会当成非法值丢弃 ⇒ 半透明背景变成完全透明,badge 直接"消失"。
* 所以这里预先算出实色。$ratio 是白色的占比(0=纯主色,1=纯白)。
*/
function yang_mail_tint($hex, $ratio = 0.18, $onto = '#ffffff')
{
$hex = ltrim((string) $hex, '#');
$onto = ltrim((string) $onto, '#');
if (strlen($hex) !== 6 || strlen($onto) !== 6) {
return '#ffffff';
}
$out = '#';
for ($i = 0; $i < 3; $i++) {
$a = hexdec(substr($hex, $i * 2, 2));
$b = hexdec(substr($onto, $i * 2, 2));
$v = (int) round($a + ($b - $a) * $ratio);
$out .= str_pad(dechex(max(0, min(255, $v))), 2, '0', STR_PAD_LEFT);
}
return $out;
}
/**
* 生成一个按钮(邮件里的按钮只能用表格单元格做,不能用 <button>)
*
* Outlook 不认 `<a>` 的 `padding`,必须把 padding 放在 `<td>` 上。
*/
function yang_mail_button($text, $url, $bg = null)
{
if ($bg === null) {
$bg = yang_mail_brand_color();
}
$fg = yang_mail_is_dark($bg) ? '#ffffff' : '#1a1a1a';
$url = esc_url($url);
$text = esc_html($text);
return '<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto;">'
. '<tr><td align="center" bgcolor="' . esc_attr($bg) . '" style="border-radius:6px;padding:13px 34px;">'
. '<a href="' . $url . '" target="_blank" rel="noopener" '
. 'style="display:inline-block;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,\'Helvetica Neue\',Arial,\'PingFang SC\',\'Microsoft YaHei\',sans-serif;'
. 'font-size:15px;font-weight:600;line-height:1;color:' . $fg . ';text-decoration:none;letter-spacing:0.3px;">'
. $text . '</a></td></tr></table>';
}
/**
* 渲染整封邮件
*
* @param array $args {
* @type string $title 邮件主标题(收件箱预览也会看到)
* @type string $preheader 预读文字,显示在收件箱标题后面,不影响正文
* @type string $heading 正文第一行大字
* @type string $intro 正文引导段落(纯文本)
* @type array $rows 表格数据,每项 array('label'=>, 'value'=>, 'strong'=>bool)
* @type array $quote 引用块 array('label'=>, 'content'=>, 'meta'=>)
* @type string $cta_text 按钮文字
* @type string $cta_url 按钮链接
* @type string $note 底部提示(纯文本)
* @type string $badge 顶部小标签,如「新评论」
* }
* @return string 完整 HTML
*/
function yang_mail_render($args)
{
$d = wp_parse_args($args, array(
'title' => get_bloginfo('name'),
'preheader' => '',
'heading' => '',
'intro' => '',
'rows' => array(),
'quote' => array(), // 单个引用块(旧接口,保留兼容)
'quotes' => array(), // 多个引用块,按顺序渲染
'cta_text' => '',
'cta_url' => '',
'note' => '',
'badge' => '',
));
/* 统一成 $quoteList:
* 调用方可能只给一个 quote,也可能给 quotes 数组(评论回复场景要同时放
* 「被回复的那条」和「新的回复」,两段各自有作者和时间,必须分开渲染)。
* 两者都给时以 quotes 为准,避免重复出现。 */
$quoteList = array();
if (!empty($d['quotes']) && is_array($d['quotes'])) {
$quoteList = $d['quotes'];
} elseif (!empty($d['quote'])) {
$quoteList = array($d['quote']);
}
/* 整套配色从主色派生一次,后面所有颜色都从这里取。
* 不在这里散落写死 #xxxxxx —— 那样主色一改,「彩色」就断了,
* 又退回成"一个色块 + 一堆灰」。 */
$pal = yang_mail_palette();
$brand = $pal['brand'];
$brandD = $pal['brand_dark'];
$onBrand = $pal['on_brand'];
$cText = $pal['text'];
$cBody = $pal['body'];
$cMuted = $pal['muted'];
$cTintXX = $pal['tint_xx'];
$cTintX = $pal['tint_x'];
$cTintLine = $pal['tint_line'];
$cTintMid = $pal['tint_mid'];
$cPageBg = $pal['page_bg'];
// badge 的背景:把主色往白色方向提亮一档,得到比主色浅、但同色相的实色。
// 主色本身是深色时提亮 22%,浅色时压暗 —— 否则浅主色上叠浅 badge 会看不见。
$badgeBg = yang_mail_is_dark($brand)
? yang_mail_tint($brand, 0.22)
: yang_mail_shade($brand, 0.88);
$badgeBd = yang_mail_is_dark($brand)
? yang_mail_tint($brand, 0.34)
: yang_mail_shade($brand, 0.72);
$site = get_bloginfo('name');
$home = home_url('/');
$year = date('Y');
// 字体栈:中文优先 PingFang / 微软雅黑,英文走系统默认。
// 邮件里不能用 @font-face(Gmail 剥掉),只能靠系统字体。
$sans = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif";
ob_start();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="x-apple-disable-message-reformatting">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title><?php echo esc_html($d['title']); ?></title>
<!--[if mso]>
<style type="text/css">
body,table,td,p,a{font-family:Arial,'Microsoft YaHei',sans-serif !important;}
</style>
<![endif]-->
</head>
<body style="margin:0;padding:0;width:100%;background-color:<?php echo esc_attr($cPageBg); ?>;">
<?php /* 预读文字:显示在收件箱列表里标题的后面。藏起来但必须真实存在,且要足够长,
否则客户端会把正文第一句抓上去当摘要。 */ ?>
<div style="display:none;font-size:1px;color:<?php echo esc_attr($cPageBg); ?>;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">
<?php echo esc_html($d['preheader'] ? $d['preheader'] : $d['heading']); ?>
</div>
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation" style="background-color:<?php echo esc_attr($cPageBg); ?>;">
<tr><td align="center" style="padding:32px 12px;">
<?php /* 宽度写法是「混合式」:
· Outlook(Word 引擎)只认 HTML 属性 width="600",CSS 的 max-width 它当没看见;
· 其它客户端认 CSS,于是 width:100% + max-width:600px 让手机窄屏自动收窄。
两套并存,各取所需。只写死 600px 会让手机横向滚动——实测过。 */ ?>
<table border="0" cellpadding="0" cellspacing="0" width="600" role="presentation" style="width:100%;max-width:600px;background-color:#ffffff;border-radius:12px;overflow:hidden;border:1px solid <?php echo esc_attr($cTintLine); ?>;">
<?php /* ── 顶部品牌条 ── */ ?>
<tr>
<td bgcolor="<?php echo esc_attr($brand); ?>" style="background-color:<?php echo esc_attr($brand); ?>;padding:0;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation">
<tr>
<td style="padding:26px 32px;">
<div style="font-family:<?php echo $sans; ?>;font-size:19px;font-weight:600;color:<?php echo esc_attr($onBrand); ?>;letter-spacing:0.2px;line-height:1.3;">
<?php echo esc_html($site); ?>
</div>
<?php if ($d['badge'] !== ''): ?>
<?php /* badge 用预算好的实色,不用 rgba —— Outlook 不认 rgba,会直接丢掉背景 */ ?>
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin-top:10px;">
<tr>
<td bgcolor="<?php echo esc_attr($badgeBg); ?>" style="background-color:<?php echo esc_attr($badgeBg); ?>;border:1px solid <?php echo esc_attr($badgeBd); ?>;border-radius:20px;padding:4px 12px;font-family:<?php echo $sans; ?>;font-size:12px;font-weight:500;line-height:1.4;">
<span style="color:<?php echo esc_attr($onBrand); ?>;"><?php echo esc_html($d['badge']); ?></span>
</td>
</tr>
</table>
<?php endif; ?>
</td>
</tr>
</table>
</td>
</tr>
<?php /* 主色条下沿压一条深色,给扁平色块一点层次(渐变在 Outlook 里不生效,用纯色)。
height 必须**同时**写成 HTML 属性和 CSS:Outlook 只认属性,
只写 CSS 高度时这条 3px 带会撑成一行文字高(约 18px),比例就毁了。 */ ?>
<tr>
<td bgcolor="<?php echo esc_attr($brandD); ?>" height="3" style="background-color:<?php echo esc_attr($brandD); ?>;height:3px;line-height:3px;font-size:0;mso-line-height-rule:exactly;">&nbsp;</td>
</tr>
<?php /* ── 正文 ── */ ?>
<tr>
<td style="padding:34px 32px 8px 32px;">
<?php if ($d['heading'] !== ''): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:21px;font-weight:600;color:<?php echo esc_attr($cText); ?>;line-height:1.45;margin:0 0 12px 0;">
<?php echo esc_html($d['heading']); ?>
</div>
<?php endif; ?>
<?php if ($d['intro'] !== ''): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:15px;color:<?php echo esc_attr($cBody); ?>;line-height:1.7;margin:0 0 22px 0;">
<?php echo esc_html($d['intro']); ?>
</div>
<?php endif; ?>
<?php foreach ($quoteList as $qi => $q): ?>
<?php
/* 引用块:左边一条竖线,用 border-left 而不是伪元素(邮件不支持 ::before)。
*
* 配色规则:**最后一块用主色高亮,前面的用弱化灰**。
* 为什么是「最后」而不是「第一」:评论回复场景里顺序是
* 「被回复的原评论」→「新的回复」,语义重点在新的回复上,所以让它高亮。
* 只有一个块时它既是第一也是最后 ⇒ 拿到主色,与旧版行为一致。
*/
$isLast = ($qi === count($quoteList) - 1);
$barColor = $isLast ? $brand : $cTintMid;
$barBg = $isLast ? $cTintXX : $cTintX;
$barMargin = $isLast ? '22px' : '14px';
?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation" style="margin:0 0 <?php echo $barMargin; ?> 0;">
<tr>
<td style="border-left:3px solid <?php echo esc_attr($barColor); ?>;background-color:<?php echo esc_attr($barBg); ?>;padding:16px 18px;border-radius:0 8px 8px 0;">
<?php if (!empty($q['label'])): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:12px;font-weight:600;color:<?php echo esc_attr($cText); ?>;letter-spacing:0.6px;text-transform:uppercase;margin:0 0 8px 0;">
<?php echo esc_html($q['label']); ?>
</div>
<?php endif; ?>
<div style="font-family:<?php echo $sans; ?>;font-size:15px;color:<?php echo esc_attr($cBody); ?>;line-height:1.7;word-break:break-word;">
<?php echo nl2br(esc_html(isset($q['content']) ? $q['content'] : '')); ?>
</div>
<?php if (!empty($q['meta'])): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:13px;color:<?php echo esc_attr($cMuted); ?>;line-height:1.6;margin:9px 0 0 0;">
<?php echo esc_html($q['meta']); ?>
</div>
<?php endif; ?>
</td>
</tr>
</table>
<?php endforeach; ?>
<?php if (!empty($d['rows'])): ?>
<?php /* 信息表:两列。label 列定宽 88px 并右对齐,value 左对齐,视觉上最稳。
不用 table-layout:fixed 之外的花样,Outlook 只认这个。 */ ?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation" style="margin:0 0 24px 0;border-top:1px solid <?php echo esc_attr($cTintLine); ?>;">
<?php foreach ($d['rows'] as $row): ?>
<tr>
<td width="88" valign="top" style="width:88px;padding:11px 0;border-bottom:1px solid <?php echo esc_attr($cTintLine); ?>;font-family:<?php echo $sans; ?>;font-size:13px;color:<?php echo esc_attr($cMuted); ?>;line-height:1.6;white-space:nowrap;">
<?php echo esc_html($row['label']); ?>
</td>
<td valign="top" style="padding:11px 0 11px 16px;border-bottom:1px solid <?php echo esc_attr($cTintLine); ?>;font-family:<?php echo $sans; ?>;font-size:14px;color:<?php echo esc_attr($cBody); ?>;line-height:1.6;word-break:break-word;">
<?php
$v = isset($row['value']) ? $row['value'] : '';
if (!empty($row['strong'])) {
echo '<strong style="font-weight:600;">' . esc_html($v) . '</strong>';
} else {
echo esc_html($v);
}
?>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<?php if ($d['cta_text'] !== '' && $d['cta_url'] !== ''): ?>
<div style="margin:0 0 28px 0;">
<?php echo yang_mail_button($d['cta_text'], $d['cta_url']); ?>
</div>
<?php endif; ?>
<?php if ($d['note'] !== ''): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:13px;color:<?php echo esc_attr($cMuted); ?>;line-height:1.7;padding:16px 0 0 0;border-top:1px solid <?php echo esc_attr($cTintLine); ?>;">
<?php echo esc_html($d['note']); ?>
</div>
<?php endif; ?>
</td>
</tr>
<?php /* ── 页脚 ── */ ?>
<tr>
<td bgcolor="<?php echo esc_attr($cTintX); ?>" style="background-color:<?php echo esc_attr($cTintX); ?>;padding:24px 32px 30px 32px;">
<div style="border-top:1px solid <?php echo esc_attr($cTintLine); ?>;padding-top:20px;font-family:<?php echo $sans; ?>;font-size:12px;color:<?php echo esc_attr($cMuted); ?>;line-height:1.9;">
<div style="margin:0 0 6px 0;">
<a href="<?php echo esc_url($home); ?>" target="_blank" rel="noopener" style="color:<?php echo esc_attr($cText); ?>;text-decoration:none;font-weight:600;"><?php echo esc_html($site); ?></a>
</div>
<div style="margin:0;">此邮件由系统自动发出,请勿直接回复。</div>
<div style="margin:6px 0 0 0;color:<?php echo esc_attr($cMuted); ?>;">&copy; <?php echo esc_html($year); ?> <?php echo esc_html($site); ?></div>
</div>
</td>
</tr>
</table>
<?php /* 底部说明:为什么收到这封邮件。放在卡片外面,视觉上属于系统信息 */ ?>
<div style="font-family:<?php echo $sans; ?>;font-size:12px;color:<?php echo esc_attr($cMuted); ?>;line-height:1.7;padding:18px 8px 0 8px;max-width:600px;">
您收到这封邮件,是因为本站记录了与您邮箱相关的订阅或互动。
</div>
</td></tr>
</table>
</body>
</html>
<?php
return ob_get_clean();
}