// email-svg.js (function () { // ============== 可配置项 ============== const FONT_URL = "/_upload/tpl/04/62/1122/template1122/extends/roboto.woff"; // 使用 .ttf/.otf/.woff(不要 woff2) const FALLBACK_FONT_URL = "https://unpkg.com/@fontsource/roboto/files/roboto-latin-400-normal.ttf"; const DEFAULT_DOMAIN = "ntu.edu.cn"; // [de] 前面没写域名时使用 const ROOT_SELECTOR = "p, span, li, div"; // 扫描这些容器里的文本节点 const DPR_BONUS = (window.devicePixelRatio || 1) >= 2 ? 1.32 : 1.25; // HiDPI 轻微放大 const AUTO_LOAD_OPENTYPE = false; // 若页面未引入 opentype.js,可设为 true 自动从 CDN 加载 // 允许省略域名: (domain)? [de] user const PATTERN = /(?:([A-Za-z0-9.-]+\.[A-Za-z]{2,}))?\s*\[\s*de\s*\]\s*([A-Za-z0-9._%+-]+)/g; // ====== 全页面   清理(U+00A0 与字面量  )====== // 跳过不应处理的区域 function shouldSkip(textNode) { let p = textNode && textNode.parentNode; while (p && p.nodeType === 1) { const tn = p.tagName; if (tn === "SCRIPT" || tn === "STYLE" || tn === "NOSCRIPT" || tn === "TEXTAREA") return true; p = p.parentNode; } return false; } // 清理单个文本节点 function cleanNbspNode(node) { let v = node.nodeValue; if (!v) return; const before = v; // 去除不间断空格 U+00A0 v = v.replace(/\u00A0+/g, ""); // 去除字面量   或  (容错无分号) v = v.replace(/ ?/gi, ""); if (v !== before) node.nodeValue = v; } // 扫描并清理整棵子树 function cleanNbspTree(root) { const tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); for (let n = tw.nextNode(); n; n = tw.nextNode()) { if (!shouldSkip(n)) cleanNbspNode(n); } } // 监听后续变更并持续清理 function observeNbsp() { const mo = new MutationObserver((muts) => { for (const m of muts) { if (m.type === "characterData") { if (!shouldSkip(m.target)) cleanNbspNode(m.target); } else if (m.type === "childList") { m.addedNodes.forEach((nd) => { if (nd.nodeType === 3) { if (!shouldSkip(nd)) cleanNbspNode(nd); } else if (nd.nodeType === 1) { cleanNbspTree(nd); } }); } } }); mo.observe(document.body, { subtree: true, childList: true, characterData: true }); } // ============== 启动 ============== if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); } async function boot() { try { // ① 先全页面清理   cleanNbspTree(document.body); observeNbsp(); // ② 确保 opentype.js if (!window.opentype) { if (AUTO_LOAD_OPENTYPE) { await loadScript("https://unpkg.com/opentype.js@1.3.4/dist/opentype.min.js"); } else { console.error("opentype.js 未加载:请先在 HTML 里引入 opentype.min.js,或将 AUTO_LOAD_OPENTYPE 设为 true。"); return; } } // ③ 加载字体 const font = await loadFont(FONT_URL).catch(async () => loadFont(FALLBACK_FONT_URL)); if (!font) { console.error("字体加载/解析失败:请确认使用 .ttf/.otf/.woff 且可访问"); return; } // ④ 遍历文本节点并替换邮箱 const roots = document.querySelectorAll(ROOT_SELECTOR); roots.forEach((el) => { const cs = getComputedStyle(el); const fontSizePx = parseFloat(cs.fontSize) || 16; const color = cs.color || "#333"; const getStyle = () => ({ fontSize: fontSizePx, color }); const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); const texts = []; let n; while ((n = walker.nextNode())) texts.push(n); texts.forEach((t) => replaceInNodeText(t, getStyle, font)); }); } catch (e) { console.error("email-svg 渲染失败:", e); } } // ============== 工具函数 ============== function loadScript(src) { return new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = src; s.onload = () => resolve(); s.onerror = () => reject(new Error("加载脚本失败: " + src)); document.head.appendChild(s); }); } async function loadFont(url) { const res = await fetch(url); if (!res.ok) throw new Error("字体 HTTP " + res.status); const buf = await res.arrayBuffer(); // 拒绝 woff2:opentype.js 不支持 const sig = new TextDecoder("ascii").decode(new Uint8Array(buf, 0, 4)); if (sig === "wOF2") throw new Error("WOFF2 不被 opentype.js 支持,请换 .ttf/.otf/.woff"); return opentype.parse(buf); } // 把文本渲染成仅含 的 SVG dataURI;返回真实字形高度/下降部 function textToSVGPathDataURI(text, font, opts) { const fontSize = (opts && opts.fontSize) || 16; const color = (opts && opts.color) || "#333"; const scale = fontSize / font.unitsPerEm; const ascentPx = font.ascender * scale; // 基线到上界 const descentPx = -font.descender * scale; // 基线到下界(正值) const intrinsicH = ascentPx + descentPx; // 真实字形盒高度 let x = 0, d = "", prev = null; for (const ch of text) { const g = font.charToGlyph(ch); if (prev) x += font.getKerningValue(prev, g) * scale; const p = g.getPath(x, ascentPx, fontSize); // 基线放在 y=ascentPx d += (p.toPathData ? p.toPathData(2) : p.toSVG()); x += g.advanceWidth * scale; prev = g; } const width = Math.ceil(x); const height = Math.ceil(intrinsicH); const svg = ` `; return { uri: "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg), intrinsicH, descentPx, }; } // ===== 固定高度配置 ===== const EMAIL_SVG_HEIGHT_MODE = "px"; // "em" 或 "px" const EMAIL_SVG_HEIGHT = 20; // 若为 "em" 模式,这里是倍数;若为 "px" 模式,这里是像素值 const EMAIL_SVG_SUPERSAMPLE = 1.5; // 内部超采样倍数(>1提高清晰度),外部再缩回固定高度 function replaceInNodeText(node, getStyle, font) { const text = node.nodeValue; if (!PATTERN.test(text)) { PATTERN.lastIndex = 0; return; } PATTERN.lastIndex = 0; const frag = document.createDocumentFragment(); let last = 0, m; while ((m = PATTERN.exec(text))) { const [whole, domainRaw, user] = m; const start = m.index; const end = start + whole.length; if (start > last) frag.appendChild(document.createTextNode(text.slice(last, start))); const domain = domainRaw && domainRaw.trim() ? domainRaw.trim() : DEFAULT_DOMAIN; const email = `${user}@${domain}`; const { fontSize, color } = getStyle(); // 先按字号生成路径,得到真实字形盒与下降部 const { uri, intrinsicH, descentPx } = textToSVGPathDataURI(email, font, { fontSize: fontSize * EMAIL_SVG_SUPERSAMPLE, color }); // —— 目标外显高度(恒定)—— const targetPx = EMAIL_SVG_HEIGHT_MODE === "px" ? EMAIL_SVG_HEIGHT : Math.max(1, EMAIL_SVG_HEIGHT * fontSize); // "em" 模式 -> 倍数 * 当前字号 // 内部按超采样生成,外部按固定高度显示 const cssHeight = Math.round(targetPx); // 基线对齐:随着外显缩放,下降部也按比例缩放 const scale = targetPx / (intrinsicH * EMAIL_SVG_SUPERSAMPLE); const vAlign = (-descentPx * EMAIL_SVG_SUPERSAMPLE * scale).toFixed(2) + "px"; const img = document.createElement("img"); img.src = uri; img.alt = ""; img.decoding = "async"; img.loading = "lazy"; img.style.height = cssHeight + "px"; // —— 恒定高度 ——(关键) img.style.width = "auto"; img.style.verticalAlign = vAlign; // 基线对齐 img.style.userSelect = "none"; img.style.display = "inline-block"; // 用零行高包裹,避免撑高行盒 const wrap = document.createElement("span"); wrap.style.display = "inline-block"; wrap.style.lineHeight = "0"; wrap.appendChild(img); frag.appendChild(wrap); last = end; } if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); node.parentNode.replaceChild(frag, node); } })();