Files
2026-06-20 18:37:46 +08:00

622 lines
21 KiB
TypeScript
Raw Permalink 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.
import { ipcMain, BrowserWindow } from 'electron';
import path from 'path';
import fs from 'fs-extra';
import { app } from 'electron';
import { isPackaged } from '../../lib/env';
import { AppEnv } from '../env';
/**
* 广播字体列表变更事件到所有窗口
*/
function broadcastFontListChanged() {
const windows = BrowserWindow.getAllWindows();
windows.forEach(window => {
window.webContents.send('fontManager:fontsChanged');
});
console.log('[FontManager] Broadcasted font list changed event to', windows.length, 'windows');
}
/**
* 检测字体类型(中文/英文/混合)
*/
function detectFontType(fontName: string, languages: string[] = []): 'Chinese' | 'English' | 'Mixed' {
// 中文字体关键词列表
const chineseKeywords = [
'中文', '中', '汉', '黑', '宋', '楷', '隶', '篆', '仿',
'雅黑', '微软', '思源', '猫啃', '墨趣', '古风',
'骚包', '手写', '毛峰', '张亚玲', '黑方', '朴素',
'NotoSerifCJK', 'NotoSerifCJK-VF',
'simsun', 'simhei', 'yahei', '胡晓波', '平方',
'雨晨', '字体', '书法', '装饰', '标题', '毛笔'
];
// 英文字体关键词列表
const englishKeywords = [
'Arial', 'Helvetica', 'Times', 'Courier', 'Verdana', 'Georgia',
'Comic', 'Impact', 'Trebuchet', 'Lucida', 'Palatino',
'Bodoni', 'Garamond', 'Calibri', 'Consolas', 'Monaco',
'Roboto', 'Open Sans', 'Lato', 'Raleway', 'Montserrat',
'Murecho', 'Maoken', 'Dymon', 'Fonte', 'Black', 'Bold',
'Light', 'Regular', 'Medium', 'SemiBold', 'ExtraLight'
];
const fontNameLower = fontName.toLowerCase();
// 检查支持的语言
const supportsChinese = languages.includes('zh-CN') || languages.includes('zh');
const supportsEnglish = languages.includes('en') || languages.length === 0; // 如果没有指定语言,默认认为支持英文
// 根据字体名称匹配关键词
const hasChineseKeyword = chineseKeywords.some(keyword =>
fontNameLower.includes(keyword.toLowerCase())
);
const hasEnglishKeyword = englishKeywords.some(keyword =>
fontNameLower.includes(keyword.toLowerCase())
);
// 判断逻辑
if (supportsChinese && hasChineseKeyword) {
return 'Chinese';
}
if (supportsChinese && !supportsEnglish) {
return 'Chinese';
}
if (!supportsChinese && supportsEnglish) {
return 'English';
}
// 检查字体名称中是否有中文字符
const chineseCharMatch = fontName.match(/[\u4e00-\u9fff]/);
if (chineseCharMatch) {
return 'Chinese';
}
// 根据关键词判断
if (hasChineseKeyword) {
return 'Chinese';
}
if (hasEnglishKeyword) {
return 'English';
}
// 默认判断:如果语言列表中包含中文,则为中文字体
if (supportsChinese) {
return 'Chinese';
}
return 'English'; // 默认为英文字体
}
/**
* 获取应用根目录(支持ASAR打包和非打包环境)
*/
function getAppRoot(): string {
// 优先使用 APP_ROOT 环境变量(在 env-main.ts 中设置)
if (process.env.APP_ROOT) {
return process.env.APP_ROOT;
}
// 回退方案:使用 resources 路径(生产环境)
if (process.resourcesPath) {
return process.resourcesPath;
}
// 最后回退:使用当前工作目录
return process.cwd();
}
/**
* 获取 ziti 目录路径(考虑ASAR打包)
*/
function getZitiDir(): string {
if (isPackaged) {
// 生产环境:优先使用 AppEnv.resourceBundleRoot
if (AppEnv.resourceBundleRoot && fs.existsSync(AppEnv.resourceBundleRoot)) {
const bundlePath = path.join(AppEnv.resourceBundleRoot, 'ziti');
if (fs.existsSync(bundlePath)) {
console.log('[FontManager] 使用 AppEnv.resourceBundleRoot:', bundlePath);
return bundlePath;
}
}
// 回退:通过 process.resourcesPath 计算
if (process.resourcesPath) {
const installRoot = path.dirname(process.resourcesPath);
const bundlePath = path.join(installRoot, 'resources-bundles', 'ziti');
if (fs.existsSync(bundlePath)) {
console.log('[FontManager] 使用 process.resourcesPath:', bundlePath);
return bundlePath;
}
}
// 回退:app.asar.unpacked/ziti
const appRoot = getAppRoot();
const asarUnpackedPath = path.join(appRoot, 'app.asar.unpacked', 'ziti');
if (fs.existsSync(asarUnpackedPath)) {
console.log('[FontManager] 使用 app.asar.unpacked:', asarUnpackedPath);
return asarUnpackedPath;
}
// 最后回退:返回默认路径
const fallbackRoot = process.resourcesPath ? path.dirname(process.resourcesPath) : getAppRoot();
console.warn('[FontManager] 所有 ziti 路径都不存在,返回默认路径');
return path.join(fallbackRoot, 'resources-bundles', 'ziti');
} else {
// 开发环境:ziti 在 packaging/vendor/ziti
const appRoot = getAppRoot();
const devZitiDir = path.join(appRoot, 'packaging', 'vendor', 'ziti');
if (fs.existsSync(devZitiDir)) {
return devZitiDir;
}
return path.join(appRoot, 'ziti');
}
}
const FONTS_DIR = path.join(app.getPath('userData'), 'fonts', 'custom');
const METADATA_FILE = path.join(FONTS_DIR, 'fonts_metadata.json');
// 确保字体目录存在
fs.ensureDirSync(FONTS_DIR);
/**
* 扫描 ziti 目录获取字体
*/
function scanZitiFonts(): any[] {
const zitiDir = getZitiDir();
const fonts: any[] = [];
console.log('[FontManager] 扫描ziti目录:', zitiDir);
// 字体名称映射表(英文文件名 -> 中文显示名)
const fontNameMap: Record<string, string> = {
'Dymon-ShouXieTi': 'Dymon手写体',
'MaokenAssortedSans': '猫啃杂糅体',
'MaokenAssortedSans-Lite': '猫啃杂糅体 Lite',
'Murecho-Black': 'Murecho 黑体',
'Murecho-Bold': 'Murecho 粗体',
'Murecho-ExtraBold': 'Murecho 特粗体',
'Murecho-ExtraLight': 'Murecho 特细体',
'Murecho-Light': 'Murecho 细体',
'Murecho-Medium': 'Murecho 中等',
'Murecho-Regular': 'Murecho 常规',
'Murecho-SemiBold': 'Murecho 半粗体',
'Murecho-Thin': 'Murecho 纤细体',
'けいなん丸ポップ体JP': 'けいなん丸ポップ体',
'墨趣古风体': '墨趣古风体',
'平方张亚玲黑方体': '平方张亚玲黑方体',
'胡晓波骚包体2.0': '胡晓波骚包体'
};
// 字体类型映射表
const fontTypeMap: Record<string, 'Chinese' | 'English' | 'Mixed'> = {
'Dymon-ShouXieTi': 'English',
'MaokenAssortedSans': 'Mixed',
'MaokenAssortedSans-Lite': 'Mixed',
'Murecho-Black': 'English',
'Murecho-Bold': 'English',
'Murecho-ExtraBold': 'English',
'Murecho-ExtraLight': 'English',
'Murecho-Light': 'English',
'Murecho-Medium': 'English',
'Murecho-Regular': 'English',
'Murecho-SemiBold': 'English',
'Murecho-Thin': 'English',
'けいなん丸ポップ体JP': 'English',
'墨趣古风体': 'Chinese',
'平方张亚玲黑方体': 'Chinese',
'胡晓波骚包体2.0': 'Chinese'
};
try {
if (!fs.existsSync(zitiDir)) {
console.log('[FontManager] ziti directory not found:', zitiDir);
return [];
}
const files = fs.readdirSync(zitiDir);
const fontExtensions = ['.ttf', '.otf', '.ttc', '.woff', '.woff2'];
files.forEach(file => {
const ext = path.extname(file).toLowerCase();
if (fontExtensions.includes(ext)) {
const name = path.basename(file, ext);
// 使用映射表获取中文名称,如果没有映射则使用原文件名
const displayName = fontNameMap[name] || name;
// 使用映射表获取字体类型,如果没有则自动检测
const fontType = fontTypeMap[name] || detectFontType(name, ['zh-CN', 'ja', 'en']);
fonts.push({
value: name,
label: displayName,
source: 'ziti',
fontType: fontType,
category: 'sans-serif',
languages: ['zh-CN', 'ja', 'en'],
path: path.join(zitiDir, file),
// 🔧 新增:国际字体标志(ziti字体通常不是国际字体)
isInternational: false
});
}
});
console.log('[FontManager] Scanned ziti fonts:', fonts.length);
} catch (error) {
console.error('[FontManager] Error scanning ziti:', error);
}
return fonts;
}
/**
* 获取预置字体
*/
function getBundledFonts(): any[] {
const appRoot = getAppRoot();
const metadataCandidates = [
path.join(appRoot, 'packaging', 'vendor', 'fonts', 'bundled', 'fonts_metadata.json'),
path.join(appRoot, 'fonts', 'bundled', 'fonts_metadata.json'),
];
const metadataFile = metadataCandidates.find(file => fs.existsSync(file)) || metadataCandidates[0];
try {
if (!fs.existsSync(metadataFile)) {
console.log('[FontManager] Bundled fonts metadata not found');
return [];
}
const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf-8'));
const fonts = (metadata.fonts || []).map((font: any) => {
const languages = font.languages || [];
const fontType = font.fontType || detectFontType(font.family, languages);
return {
value: font.family,
label: font.display_name || font.family,
source: 'bundled',
fontType: fontType,
category: font.category || 'sans-serif',
languages: languages,
// 🔧 关键修复:传递 isInternational 标志
isInternational: font.isInternational || false,
supportsCyrillic: font.supportsCyrillic || false,
supportsHangul: font.supportsHangul || false,
supportsKana: font.supportsKana || false
};
});
console.log('[FontManager] Bundled fonts:', fonts.length);
return fonts;
} catch (error) {
console.error('[FontManager] Error reading bundled fonts:', error);
return [];
}
}
// 🔧 字体缓存,避免频繁扫描文件系统
let fontCache: { success: boolean; data?: any[]; message?: string } | null = null;
let fontCacheTime = 0;
const FONT_CACHE_TTL = 30000; // 缓存 30 秒
/**
* 获取所有可用字体(Node.js 实现,不依赖 Python
* 🔧 添加缓存机制,避免频繁扫描导致 CPU 100%
*/
export async function getAllFonts() {
try {
// 检查缓存是否有效
const now = Date.now();
if (fontCache && (now - fontCacheTime) < FONT_CACHE_TTL) {
// console.log('[FontManager] Using cached fonts');
return fontCache;
}
console.log('[FontManager] Getting all fonts (Node.js implementation)');
// 合并所有字体源
const allFonts = [
...getBundledFonts(),
...scanZitiFonts()
];
// 去重(按 value
const seen = new Set();
const uniqueFonts = allFonts.filter(font => {
if (seen.has(font.value)) {
return false;
}
seen.add(font.value);
return true;
});
console.log('[FontManager] Total unique fonts:', uniqueFonts.length);
// 更新缓存
fontCache = {
success: true,
data: uniqueFonts
};
fontCacheTime = now;
return fontCache;
} catch (error: any) {
console.error('[FontManager] Exception:', error);
return {
success: false,
message: error.message || '获取字体列表失败'
};
}
}
/**
* 清除字体缓存(在上传/删除字体后调用)
*/
export function clearFontCache() {
fontCache = null;
fontCacheTime = 0;
console.log('[FontManager] Font cache cleared');
}
/**
* 上传自定义字体
*/
export async function uploadFont(params: {
fileName: string;
fileData?: number[];
fileDataBase64?: string;
displayName: string;
fontFamily: string;
category: string;
languages: string[];
fontType?: 'Chinese' | 'English' | 'Mixed';
uploadToZiti?: boolean;
}) {
try {
const { fileName, fileData, fileDataBase64, displayName, fontFamily, category, languages, uploadToZiti } = params;
// 确定保存目录:如果指定上传到ziti,则保存到ziti目录,否则保存到自定义字体目录
let fontPath: string;
let saveDir: string;
if (uploadToZiti) {
saveDir = getZitiDir();
fs.ensureDirSync(saveDir);
fontPath = path.join(saveDir, fileName);
} else {
fontPath = path.join(FONTS_DIR, fileName);
}
// 支持两种格式:Base64编码或数组
let buffer: Buffer;
if (fileDataBase64) {
console.log('[FontManager] 使用Base64解码, length:', fileDataBase64.length);
buffer = Buffer.from(fileDataBase64, 'base64');
} else if (fileData) {
console.log('[FontManager] 使用数组转换, length:', fileData.length);
buffer = Buffer.from(fileData);
} else {
throw new Error('缺少文件数据');
}
console.log('[FontManager] Buffer size:', buffer.length);
await fs.writeFile(fontPath, buffer);
console.log('[FontManager] Font file saved to:', fontPath);
// 只有上传到自定义目录时才需要保存元数据
if (!uploadToZiti) {
// 更新元数据
let metadata: any = { fonts: [] };
if (await fs.pathExists(METADATA_FILE)) {
metadata = await fs.readJSON(METADATA_FILE);
}
// 自动检测字体类型
const fontType = params.fontType || detectFontType(fontFamily, languages);
// 添加新字体信息
metadata.fonts.push({
family: fontFamily,
display_name: displayName,
path: fileName,
source: 'custom',
fontType: fontType,
category: category,
languages: languages,
uploadedAt: new Date().toISOString()
});
// 保存元数据
await fs.writeJSON(METADATA_FILE, metadata, { spaces: 2 });
}
// 延迟后广播字体列表变更事件,确保文件已写入磁盘
setTimeout(() => {
console.log('[FontManager] Broadcasting font list changed event');
broadcastFontListChanged();
}, 200);
return {
success: true,
message: '字体上传成功'
};
} catch (error: any) {
return {
success: false,
message: error.message || '上传字体失败'
};
}
}
/**
* 删除自定义字体
*/
export async function deleteFont(fontFamily: string) {
try {
// 读取元数据
if (!await fs.pathExists(METADATA_FILE)) {
return {
success: false,
message: '元数据文件不存在'
};
}
const metadata = await fs.readJSON(METADATA_FILE);
const fontIndex = metadata.fonts.findIndex((f: any) => f.family === fontFamily && f.source === 'custom');
if (fontIndex === -1) {
return {
success: false,
message: '字体不存在或不是自定义字体'
};
}
const font = metadata.fonts[fontIndex];
const fontPath = path.join(FONTS_DIR, font.path);
// 删除字体文件
if (await fs.pathExists(fontPath)) {
await fs.remove(fontPath);
}
// 从元数据中移除
metadata.fonts.splice(fontIndex, 1);
await fs.writeJSON(METADATA_FILE, metadata, { spaces: 2 });
// 广播字体列表变更事件
broadcastFontListChanged();
return {
success: true,
message: '字体删除成功'
};
} catch (error: any) {
return {
success: false,
message: error.message || '删除字体失败'
};
}
}
/**
* 查找字体文件路径
*/
export async function findFontPath(fontFamily: string, fontWeight: number = 400) {
const bundledFontPath = findBundledFontFile(fontFamily);
if (bundledFontPath && fs.existsSync(bundledFontPath)) {
console.log(`[FontManager] bundled font resolved: ${fontFamily} -> ${bundledFontPath}`);
return {
success: true,
data: bundledFontPath
};
}
console.warn(`[FontManager] bundled font not found: ${fontFamily}`, {
searchedDirs: getBundledFontDirs()
});
return {
success: false,
message: `未在内置字体包中找到字体: ${fontFamily}`
};
}
export function registerFontManagerHandlers() {
ipcMain.handle('fontManager:getAllFonts', async () => {
return await getAllFonts();
});
ipcMain.handle('fontManager:uploadFont', async (_, params) => {
return await uploadFont(params);
});
ipcMain.handle('fontManager:deleteFont', async (_, fontFamily) => {
return await deleteFont(fontFamily);
});
ipcMain.handle('fontManager:findFontPath', async (_, fontFamily, fontWeight) => {
return await findFontPath(fontFamily, fontWeight);
});
}
function getBundledFontDirs(): string[] {
const dirs: string[] = [];
const pushIfExists = (dir: string) => {
if (dir && fs.existsSync(dir) && !dirs.includes(dir)) {
dirs.push(dir);
}
};
const appRoot = getAppRoot();
if (AppEnv.resourceBundleRoot && fs.existsSync(AppEnv.resourceBundleRoot)) {
pushIfExists(path.join(AppEnv.resourceBundleRoot, 'ziti'));
pushIfExists(path.join(AppEnv.resourceBundleRoot, 'fonts'));
pushIfExists(path.join(AppEnv.resourceBundleRoot, 'fonts', 'ziti'));
}
if (process.resourcesPath) {
const installRoot = path.dirname(process.resourcesPath);
const bundleRoot = path.join(installRoot, 'resources-bundles');
pushIfExists(path.join(bundleRoot, 'ziti'));
pushIfExists(path.join(bundleRoot, 'fonts'));
pushIfExists(path.join(bundleRoot, 'fonts', 'ziti'));
}
pushIfExists(path.join(appRoot, 'packaging', 'vendor', 'ziti'));
pushIfExists(path.join(appRoot, 'packaging', 'vendor', 'fonts'));
pushIfExists(path.join(appRoot, 'packaging', 'vendor', 'fonts', 'ziti'));
pushIfExists(path.join(appRoot, 'ziti'));
pushIfExists(path.join(appRoot, 'resources-bundles', 'ziti'));
pushIfExists(path.join(appRoot, 'resources-bundles', 'fonts'));
pushIfExists(path.join(appRoot, 'resources-bundles', 'fonts', 'ziti'));
pushIfExists(path.join(appRoot, 'electron', 'resources', 'extra', 'common', 'fonts'));
return dirs;
}
function normalizeFontKey(value: string): string {
return String(value || '')
.toLowerCase()
.replace(/\.(ttf|otf|ttc|woff2?|ttf\.ttc)$/i, '')
.replace(/[\s_\-\.]+/g, '')
.trim();
}
function findBundledFontFile(fontFamily: string): string | null {
const requested = normalizeFontKey(fontFamily);
if (!requested) {
return null;
}
const fontExtensions = ['.ttf', '.otf', '.ttc', '.woff', '.woff2'];
const candidates: Array<{ path: string; base: string; key: string }> = [];
for (const dir of getBundledFontDirs()) {
try {
const files = fs.readdirSync(dir);
for (const file of files) {
const ext = path.extname(file).toLowerCase();
if (!fontExtensions.includes(ext)) {
continue;
}
const base = path.basename(file, ext);
candidates.push({
path: path.join(dir, file),
base,
key: normalizeFontKey(base),
});
}
} catch (error: any) {
console.warn('[FontManager] scan bundled font dir failed:', dir, error?.message || error);
}
}
const exact = candidates.find(item => item.key === requested);
if (exact) return exact.path;
const partial = candidates.find(item => item.key.includes(requested) || requested.includes(item.key));
if (partial) return partial.path;
return null;
}