---
name: 检测画布未注册字体 (Audit Canvas Fonts)
description: 深度扫描当前活动画布的所有文本元素,精准检测是否存在未注册或缺失的字体,并提供元素高亮定位、一键系统检索载入与批量字体替换功能。
author: ymjr
version: 1.0.0
license: MIT
usage: 打开任意 Excalidraw 画布并运行此脚本。脚本将自动扫描画布所有文本元素,若发现未注册字体,面板将列出具体字体、受影响元素数量与样例文本,支持一键尝试从系统/Vault载入补全,或一键批量替换为已注册字体。
features:
- 智能扫描当前画布所有文本元素的 fontFamily
- 对比 Excalidraw 运行时注册表与内置字体标准列表
- 清晰列出所有缺失字体家族、受影响元素总数与文字样张
- 一键在画布中选中并高亮对应文本元素
- 一键从操作系统/Vault检索并补全载入(支持自动存入 Vault 持久化)
- 支持将缺失字体一键批量替换为画布现存可用字体
autorun: false
---
/*
```javascript
*/
var locales = {
zh: {
ui_title: "画布字体完备性检测",
status_healthy_title: "画布字体状态完备",
status_healthy_desc: "当前画布中全部文本元素所使用的字体均已在 Excalidraw 注册表中正常加载,无需修复。",
status_warning_title: "发现未注册字体",
status_warning_desc: "检测到 {0} 个未在当前画布注册的字体家族,共影响 {1} 个文本元素。未注册字体在渲染时可能回退显示。",
breakdown_title: "当前画布字体分布统计:",
elements_count_badge: "{0} 个元素",
btn_resolve_load: "从系统/Vault检索载入",
btn_select_canvas: "在画布中选中",
btn_replace: "替换",
btn_rescan: "重新检测",
btn_autofix_all: "一键尝试修复全部未载入字体",
sample_prefix: "文本摘录: ",
notice_select: "已在画布中选中 {0} 个使用该字体的文本元素",
notice_replaced: "已将 {0} 个元素的字体替换为 \"{1}\"",
notice_resolved: "成功检索并载入字体: {0}",
notice_resolve_fail: "未能在本机系统或 Vault 目录中找到字体: {0},建议使用右侧替换功能",
chk_save_vault: "保存至 Vault 字体目录持久化",
btn_close: "关闭"
},
en: {
ui_title: "Audit Canvas Fonts",
status_healthy_title: "All Fonts Fully Registered",
status_healthy_desc: "All text elements in the current drawing use fonts that are properly registered in the Excalidraw runtime.",
status_warning_title: "Unregistered Fonts Detected",
status_warning_desc: "Found {0} unregistered font families affecting {1} text elements. These may fall back to default fonts.",
breakdown_title: "Canvas Font Usage Breakdown:",
elements_count_badge: "{0} elements",
btn_resolve_load: "Auto-resolve & Load",
btn_select_canvas: "Select on Canvas",
btn_replace: "Replace",
btn_rescan: "Re-scan Canvas",
btn_autofix_all: "Auto-Fix All Recoverable Fonts",
sample_prefix: "Snippet: ",
notice_select: "Selected {0} text elements using this font on canvas",
notice_replaced: "Replaced font on {0} elements with \"{1}\"",
notice_resolved: "Successfully resolved and registered font: {0}",
notice_resolve_fail: "Could not find font \"{0}\" in system or vault. Consider replacing it.",
chk_save_vault: "Save to Vault font folder for persistence",
btn_close: "Close"
}
};
const lang = (ea.plugin?.settings?.displayLanguage || (typeof window !== "undefined" && window.localStorage?.getItem("language")) || "zh").startsWith("zh") ? "zh" : "en";
const t = (k, ...args) => {
let str = (locales[lang] && locales[lang][k]) || (locales.en && locales.en[k]) || k;
args.forEach((val, idx) => { str = str.replace(`{${idx}}`, val); });
return str;
};
const { Notice } = ea.obsidian;
const ICONS = {
checkCircle: '',
warningTriangle: '',
refresh: '',
search: '',
target: '',
replace: '',
bolt: '',
close: ''
};
const BUILTIN_FONTS = new Map([
[1, "Virgil (手绘默认)"],
[2, "Helvetica (无衬线)"],
[3, "Cascadia (等宽代码)"],
[4, "Local Font"],
[5, "Excalifont"],
[6, "Nunito"],
[7, "Lilita One"],
[8, "Comic Shanns"],
[9, "Liberation Sans"]
]);
function auditCanvasFonts() {
const allElements = ea.getViewElements();
const textElements = allElements.filter(el => el.type === "text" && !el.isDeleted);
const registeredFamilies = new Set((ea.getFontFamilies ? ea.getFontFamilies() : []).map(f => f.toLowerCase()));
const registeredCounts = new Map();
const missingMap = new Map();
for (const el of textElements) {
const rawFam = el.fontFamily;
let famName = null;
let isReg = false;
if (typeof rawFam === "number") {
if (BUILTIN_FONTS.has(rawFam)) {
famName = BUILTIN_FONTS.get(rawFam);
isReg = true;
} else {
famName = `内置 ID #${rawFam}`;
isReg = false;
}
} else if (typeof rawFam === "string" && rawFam.trim()) {
famName = rawFam.trim();
isReg = registeredFamilies.has(famName.toLowerCase());
} else {
famName = "默认字体 (Virgil)";
isReg = true;
}
if (isReg) {
registeredCounts.set(famName, (registeredCounts.get(famName) || 0) + 1);
} else {
let entry = missingMap.get(famName);
if (!entry) {
entry = { family: famName, count: 0, elements: [], sampleText: el.text || "" };
missingMap.set(famName, entry);
}
entry.count++;
entry.elements.push(el);
if (!entry.sampleText && el.text) {
entry.sampleText = el.text;
}
}
}
return {
totalTextCount: textElements.length,
registeredCounts,
missingFonts: Array.from(missingMap.values())
};
}
function renderAuditPanel() {
const PANEL_ID = "ea-audit-canvas-fonts-panel";
const existing = document.getElementById(PANEL_ID);
if (existing) existing.remove();
const panel = document.createElement("div");
panel.id = PANEL_ID;
panel.style.cssText = `
position: fixed;
top: 90px;
right: 48px;
width: 520px;
max-height: 82vh;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.22), 0 4px 14px rgba(0, 0, 0, 0.08);
border-radius: 12px;
z-index: 9999;
display: flex;
flex-direction: column;
font-family: var(--font-interface);
font-size: 13px;
overflow: hidden;
backdrop-filter: blur(12px);
`;
// 1. Header (Draggable)
const header = document.createElement("div");
header.style.cssText = `
padding: 12px 18px;
background: var(--background-secondary);
cursor: move;
border-bottom: 1px solid var(--background-modifier-border);
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
border-radius: 12px 12px 0 0;
`;
header.innerHTML = `
${ICONS.search}
${t("ui_title")}
`;
panel.appendChild(header);
// Content Area
const body = document.createElement("div");
body.style.cssText = `
flex: 1;
overflow-y: auto;
padding: 16px 18px 18px 18px;
display: flex;
flex-direction: column;
gap: 14px;
`;
panel.appendChild(body);
function refreshView() {
body.innerHTML = "";
const audit = auditCanvasFonts();
const availableRegistered = ea.getFontFamilies ? ea.getFontFamilies() : ["Virgil", "Helvetica", "Cascadia"];
if (audit.missingFonts.length === 0) {
// --- ALL HEALTHY STATE ---
const healthyCard = document.createElement("div");
healthyCard.style.cssText = `
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 10px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
`;
healthyCard.innerHTML = `
${ICONS.checkCircle}
${t("status_healthy_title")}
${t("status_healthy_desc")}
`;
// Breakdown chips
const breakdownSection = document.createElement("div");
breakdownSection.style.cssText = "display:flex; flex-direction:column; gap:8px; width:100%; margin-top:8px;";
breakdownSection.innerHTML = `${t("breakdown_title")}
`;
const chipsWrap = document.createElement("div");
chipsWrap.style.cssText = "display:flex; flex-wrap:wrap; gap:6px;";
for (const [fName, count] of audit.registeredCounts.entries()) {
const chip = document.createElement("span");
chip.style.cssText = "padding:4px 10px; border-radius:6px; background:var(--background-primary); border:1px solid var(--background-modifier-border); font-size:12px; color:var(--text-normal);";
chip.innerText = `${fName}: ${count}`;
chipsWrap.appendChild(chip);
}
breakdownSection.appendChild(chipsWrap);
healthyCard.appendChild(breakdownSection);
body.appendChild(healthyCard);
const footer = document.createElement("div");
footer.style.cssText = "display:flex; justify-content:flex-end; gap:8px;";
footer.innerHTML = `
`;
footer.querySelector("#btn-recheck").onclick = refreshView;
footer.querySelector("#btn-done").onclick = () => panel.remove();
body.appendChild(footer);
return;
}
// --- UNREGISTERED FONTS FOUND ---
const totalMissingElements = audit.missingFonts.reduce((acc, m) => acc + m.count, 0);
const warningBanner = document.createElement("div");
warningBanner.style.cssText = `
background: rgba(245, 159, 0, 0.08);
border: 1px solid var(--text-warning);
border-radius: 8px;
padding: 12px 16px;
display: flex;
gap: 12px;
align-items: flex-start;
`;
warningBanner.innerHTML = `
${ICONS.warningTriangle}
${t("status_warning_title")}
${t("status_warning_desc", audit.missingFonts.length, totalMissingElements)}
`;
body.appendChild(warningBanner);
// List of missing fonts cards
const listWrapper = document.createElement("div");
listWrapper.style.cssText = "display:flex; flex-direction:column; gap:10px; max-height:360px; overflow-y:auto; padding-right:4px;";
audit.missingFonts.forEach(item => {
const card = document.createElement("div");
card.style.cssText = `
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
`;
// Top row: Family name, Badge, Select Button
const rowTop = document.createElement("div");
rowTop.style.cssText = "display:flex; justify-content:space-between; align-items:center;";
rowTop.innerHTML = `
${item.family}
${t("elements_count_badge", item.count)}
`;
const btnSelect = document.createElement("button");
btnSelect.style.cssText = "padding:3px 8px; font-size:11px; border-radius:4px; border:1px solid var(--background-modifier-border); background:var(--background-primary); color:var(--text-normal); cursor:pointer; display:flex; align-items:center; gap:4px;";
btnSelect.innerHTML = `${ICONS.target} ${t("btn_select_canvas")}`;
btnSelect.onclick = () => {
ea.setViewSelectedElements(item.elements);
ea.addElementsToView();
new Notice(t("notice_select", item.count));
};
rowTop.appendChild(btnSelect);
card.appendChild(rowTop);
// Snippet preview
if (item.sampleText) {
const snippetRow = document.createElement("div");
snippetRow.style.cssText = "font-size:11px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; background:var(--background-primary); padding:4px 8px; border-radius:4px;";
snippetRow.innerText = t("sample_prefix") + item.sampleText.replace(/\n/g, " ").slice(0, 50);
card.appendChild(snippetRow);
}
// Action row: 1) Auto-resolve & load, 2) Batch Replace
const actionsRow = document.createElement("div");
actionsRow.style.cssText = "display:flex; justify-content:space-between; align-items:center; gap:8px; margin-top:2px;";
// 1. Resolve & Load button
const btnResolve = document.createElement("button");
btnResolve.style.cssText = "padding:5px 10px; font-size:11px; font-weight:500; border-radius:4px; border:none; background:var(--interactive-accent); color:var(--text-on-accent); cursor:pointer; display:flex; align-items:center; gap:5px;";
btnResolve.innerHTML = `${ICONS.search} ${t("btn_resolve_load")}`;
btnResolve.onclick = async () => {
btnResolve.disabled = true;
btnResolve.innerText = "⏳ 检索载入中...";
try {
const res = await ea.registerFont({ name: item.family, saveToVault: true });
new Notice(t("notice_resolved", res));
refreshView();
} catch (e) {
new Notice(t("notice_resolve_fail", item.family));
btnResolve.disabled = false;
btnResolve.innerHTML = `${ICONS.search} ${t("btn_resolve_load")}`;
}
};
actionsRow.appendChild(btnResolve);
// 2. Replace controls
const replaceBox = document.createElement("div");
replaceBox.style.cssText = "display:flex; gap:4px; align-items:center;";
const selReplace = document.createElement("select");
selReplace.style.cssText = "padding:3px 6px; font-size:11px; background:var(--background-modifier-form-field); border:1px solid var(--background-modifier-border); border-radius:4px; color:var(--text-normal); max-width:130px;";
availableRegistered.forEach(f => {
const opt = document.createElement("option");
opt.value = f;
opt.innerText = f;
selReplace.appendChild(opt);
});
const btnReplace = document.createElement("button");
btnReplace.style.cssText = "padding:4px 8px; font-size:11px; border-radius:4px; border:1px solid var(--background-modifier-border); background:var(--background-primary); color:var(--text-normal); cursor:pointer; display:flex; align-items:center; gap:4px;";
btnReplace.innerHTML = `${ICONS.replace} ${t("btn_replace")}`;
btnReplace.onclick = () => {
const targetFamily = selReplace.value;
for (const el of item.elements) {
el.fontFamily = targetFamily;
}
ea.copyViewElementsToEAforEditing(item.elements);
ea.addElementsToView();
new Notice(t("notice_replaced", item.elements.length, targetFamily));
refreshView();
};
replaceBox.appendChild(selReplace);
replaceBox.appendChild(btnReplace);
actionsRow.appendChild(replaceBox);
card.appendChild(actionsRow);
listWrapper.appendChild(card);
});
body.appendChild(listWrapper);
// Global bottom toolbar
const bottomBar = document.createElement("div");
bottomBar.style.cssText = "display:flex; justify-content:space-between; align-items:center; border-top:1px solid var(--background-modifier-border); padding-top:12px; margin-top:4px;";
const btnAutoFixAll = document.createElement("button");
btnAutoFixAll.style.cssText = "padding:7px 12px; font-size:12px; font-weight:600; border-radius:6px; border:none; background:var(--interactive-accent); color:var(--text-on-accent); cursor:pointer; display:flex; align-items:center; gap:6px;";
btnAutoFixAll.innerHTML = `${ICONS.bolt} ${t("btn_autofix_all")}`;
btnAutoFixAll.onclick = async () => {
btnAutoFixAll.disabled = true;
btnAutoFixAll.innerText = "⏳ 批量修复中...";
let successCount = 0;
for (const item of audit.missingFonts) {
try {
await ea.registerFont({ name: item.family, saveToVault: true });
successCount++;
} catch (e) {
// ignore individual fails
}
}
new Notice(`批量修复完毕:成功载入 ${successCount} 个字体。`);
refreshView();
};
const rightBtns = document.createElement("div");
rightBtns.style.cssText = "display:flex; gap:6px;";
const btnRescan = document.createElement("button");
btnRescan.style.cssText = "padding:7px 12px; font-size:12px; border-radius:6px; border:1px solid var(--background-modifier-border); background:var(--background-secondary); color:var(--text-normal); cursor:pointer; display:flex; align-items:center; gap:6px;";
btnRescan.innerHTML = `${ICONS.refresh} ${t("btn_rescan")}`;
btnRescan.onclick = refreshView;
rightBtns.appendChild(btnRescan);
bottomBar.appendChild(btnAutoFixAll);
bottomBar.appendChild(rightBtns);
body.appendChild(bottomBar);
}
refreshView();
// Dragging & Escape close
header.querySelector("#close-btn").onclick = () => panel.remove();
const escHandler = (e) => {
if (e.key === "Escape") {
panel.remove();
window.removeEventListener("keydown", escHandler);
}
};
window.addEventListener("keydown", escHandler);
let isDragging = false, startX = 0, startY = 0, initLeft = 0, initTop = 0;
header.onmousedown = (e) => {
if (e.target.tagName === "BUTTON") return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = panel.getBoundingClientRect();
initLeft = rect.left;
initTop = rect.top;
panel.style.right = "auto";
panel.style.left = `${initLeft}px`;
panel.style.top = `${initTop}px`;
e.preventDefault();
};
const onMouseMove = (e) => {
if (!isDragging) return;
panel.style.left = `${initLeft + (e.clientX - startX)}px`;
panel.style.top = `${initTop + (e.clientY - startY)}px`;
};
const onMouseUp = () => { isDragging = false; };
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
document.body.appendChild(panel);
}
renderAuditPanel();