feat(工具): 添加 Pyodide 浏览器端 Python 运行时

- 实现基于 WebAssembly 的 Python 运行环境
- 支持 matplotlib 图形渲染并输出为 Base64 图片
- 实现中文字体加载(Noto Sans SC)
- 预注册 seaborn-whitegrid 等多种图表样式
- 单例模式管理 Pyodide 实例,优化加载性能
This commit is contained in:
gaoziman 2025-12-19 20:18:34 +08:00
parent ba4e00a341
commit ef45e14534

View File

@ -0,0 +1,646 @@
/**
* Pyodide
* 使 CDN Pyodide Python
* matplotlib
*/
import { type LoadingCallback } from './codeAnalyzer';
// 重新导出 LoadingCallback 类型,方便外部使用
export { type LoadingCallback } from './codeAnalyzer';
// Pyodide CDN URL
const PYODIDE_CDN_URL = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
// 中文字体 CDN URL使用 Google Fonts 的思源黑体)
// 这些 URL 按优先级排序,会依次尝试
const CHINESE_FONT_URLS = [
// Google Fonts 官方 CDN - Noto Sans SC
'https://fonts.gstatic.com/s/notosanssc/v36/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYxNbPzS5HE.ttf',
// jsDelivr 上的 Noto Sans SC 镜像
'https://cdn.jsdelivr.net/npm/@aspect-dev/font-noto-sans-cjk-sc@2.001.0/NotoSansCJKsc-Regular.otf',
// 备用:使用静态托管的字体(如果上面的都失败)
'/fonts/NotoSansSC-Regular.ttf',
];
const CHINESE_FONT_FILENAME = 'NotoSansSC-Regular.ttf';
// 字体是否已加载
let chineseFontLoaded = false;
// Pyodide 实例类型定义
interface PyodideInterface {
runPythonAsync(code: string): Promise<unknown>;
loadPackagesFromImports(code: string): Promise<void>;
loadPackage(packages: string | string[]): Promise<void>;
FS: {
readFile(path: string, options?: { encoding?: string }): Uint8Array | string;
writeFile(path: string, data: string | Uint8Array): void;
unlink(path: string): void;
readdir(path: string): string[];
};
globals: {
get(name: string): unknown;
set(name: string, value: unknown): void;
};
}
// 全局 Pyodide 实例(单例模式)
let pyodideInstance: PyodideInterface | null = null;
let pyodideLoadPromise: Promise<PyodideInterface> | null = null;
let isLoading = false;
/**
* Pyodide
* 使
*/
export async function loadPyodide(onProgress?: LoadingCallback): Promise<PyodideInterface> {
// 如果已经加载完成,直接返回
if (pyodideInstance) {
onProgress?.({ stage: 'ready', message: 'Pyodide 已就绪' });
return pyodideInstance;
}
// 如果正在加载中,等待加载完成
if (pyodideLoadPromise) {
return pyodideLoadPromise;
}
// 开始加载
isLoading = true;
onProgress?.({ stage: 'loading', message: '正在加载 Python 运行时...', progress: 0 });
pyodideLoadPromise = new Promise(async (resolve, reject) => {
try {
// 动态加载 Pyodide 脚本
onProgress?.({ stage: 'loading', message: '正在下载 Pyodide 核心...', progress: 10 });
// 检查是否在浏览器环境
if (typeof window === 'undefined') {
throw new Error('Pyodide 只能在浏览器环境中运行');
}
// 检查是否已加载 Pyodide 脚本
if (!(window as unknown as { loadPyodide?: unknown }).loadPyodide) {
// 动态插入 Pyodide 脚本
await loadPyodideScript();
}
onProgress?.({ stage: 'loading', message: '正在初始化 Python 环境...', progress: 30 });
// 初始化 Pyodide
const loadPyodideFn = (window as unknown as { loadPyodide: (options: { indexURL: string }) => Promise<PyodideInterface> }).loadPyodide;
const pyodide = await loadPyodideFn({
indexURL: PYODIDE_CDN_URL,
});
onProgress?.({ stage: 'loading', message: '正在安装 matplotlib...', progress: 50 });
// 预加载常用的数据可视化包
await pyodide.loadPackage(['matplotlib', 'numpy']);
onProgress?.({ stage: 'loading', message: '正在加载中文字体...', progress: 70 });
// 加载中文字体
const fontLoaded = await loadChineseFont(pyodide, onProgress);
onProgress?.({ stage: 'loading', message: '正在配置图形后端...', progress: 90 });
// 配置 matplotlib 使用 Agg 后端(无头模式)并设置字体
const fontConfig = fontLoaded
? `
# 使 Noto Sans SC
plt.rcParams['font.family'] = 'Noto Sans SC'
plt.rcParams['font.sans-serif'] = ['Noto Sans SC', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
print("已配置 Noto Sans SC 中文字体")
`
: `
# 使
import matplotlib.font_manager as fm
available_fonts = [f.name for f in fm.fontManager.ttflist]
chinese_fonts = ['Noto Sans CJK SC', 'SimHei', 'Microsoft YaHei', 'DejaVu Sans']
selected_font = 'DejaVu Sans'
for font in chinese_fonts:
if font in available_fonts:
selected_font = font
break
plt.rcParams['font.family'] = selected_font
plt.rcParams['font.sans-serif'] = [selected_font] + chinese_fonts
plt.rcParams['axes.unicode_minus'] = False
if selected_font == 'DejaVu Sans':
print("警告: 未找到中文字体,中文可能显示为方块。建议使用英文标签。")
else:
print(f"使用字体: {selected_font}")
`;
// 配置 matplotlib 使用 Agg 后端(无头模式)
await pyodide.runPythonAsync(`
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
import io
import base64
${fontConfig}
# seaborn Pyodide seaborn
import matplotlib.style as mpl_style
# Seaborn whitegrid
seaborn_whitegrid = {
'axes.axisbelow': True,
'axes.edgecolor': '.8',
'axes.facecolor': 'white',
'axes.grid': True,
'axes.labelcolor': '.15',
'axes.linewidth': 1.0,
'figure.facecolor': 'white',
'font.family': ['Noto Sans SC', 'sans-serif'],
'grid.color': '.8',
'grid.linestyle': '-',
'grid.linewidth': 1.0,
'image.cmap': 'viridis',
'legend.frameon': False,
'legend.numpoints': 1,
'legend.scatterpoints': 1,
'lines.solid_capstyle': 'round',
'text.color': '.15',
'xtick.color': '.15',
'xtick.direction': 'out',
'xtick.major.size': 0.0,
'xtick.minor.size': 0.0,
'ytick.color': '.15',
'ytick.direction': 'out',
'ytick.major.size': 0.0,
'ytick.minor.size': 0.0,
'axes.spines.left': True,
'axes.spines.bottom': True,
'axes.spines.right': True,
'axes.spines.top': True,
}
# Seaborn darkgrid
seaborn_darkgrid = {
'axes.axisbelow': True,
'axes.edgecolor': 'white',
'axes.facecolor': '#EAEAF2',
'axes.grid': True,
'axes.labelcolor': '.15',
'axes.linewidth': 0.0,
'figure.facecolor': 'white',
'font.family': ['Noto Sans SC', 'sans-serif'],
'grid.color': 'white',
'grid.linestyle': '-',
'grid.linewidth': 1.0,
'image.cmap': 'viridis',
'legend.frameon': False,
'legend.numpoints': 1,
'legend.scatterpoints': 1,
'lines.solid_capstyle': 'round',
'text.color': '.15',
'xtick.color': '.15',
'xtick.direction': 'out',
'xtick.major.size': 0.0,
'xtick.minor.size': 0.0,
'ytick.color': '.15',
'ytick.direction': 'out',
'ytick.major.size': 0.0,
'ytick.minor.size': 0.0,
}
# 使
modern_clean = {
'axes.axisbelow': True,
'axes.edgecolor': '#333333',
'axes.facecolor': '#FAFAFA',
'axes.grid': True,
'axes.labelcolor': '#333333',
'axes.labelsize': 11,
'axes.linewidth': 0.8,
'axes.titlesize': 13,
'axes.titleweight': 'bold',
'figure.facecolor': 'white',
'figure.figsize': [10, 6],
'figure.dpi': 100,
'font.family': ['Noto Sans SC', 'sans-serif'],
'font.size': 10,
'grid.alpha': 0.4,
'grid.color': '#CCCCCC',
'grid.linestyle': '--',
'grid.linewidth': 0.5,
'legend.fontsize': 10,
'legend.frameon': True,
'legend.framealpha': 0.9,
'legend.edgecolor': '#CCCCCC',
'lines.linewidth': 2,
'lines.markersize': 6,
'text.color': '#333333',
'xtick.color': '#333333',
'xtick.labelsize': 9,
'ytick.color': '#333333',
'ytick.labelsize': 9,
'axes.spines.top': False,
'axes.spines.right': False,
}
#
dark_modern = {
'axes.axisbelow': True,
'axes.edgecolor': '#888888',
'axes.facecolor': '#2D2D2D',
'axes.grid': True,
'axes.labelcolor': '#EEEEEE',
'axes.labelsize': 11,
'axes.linewidth': 0.8,
'axes.titlesize': 13,
'axes.titleweight': 'bold',
'figure.facecolor': '#1E1E1E',
'figure.figsize': [10, 6],
'figure.dpi': 100,
'font.family': ['Noto Sans SC', 'sans-serif'],
'font.size': 10,
'grid.alpha': 0.3,
'grid.color': '#555555',
'grid.linestyle': '--',
'grid.linewidth': 0.5,
'legend.fontsize': 10,
'legend.frameon': True,
'legend.framealpha': 0.9,
'legend.edgecolor': '#555555',
'legend.facecolor': '#2D2D2D',
'legend.labelcolor': '#EEEEEE',
'lines.linewidth': 2,
'lines.markersize': 6,
'text.color': '#EEEEEE',
'xtick.color': '#EEEEEE',
'xtick.labelsize': 9,
'ytick.color': '#EEEEEE',
'ytick.labelsize': 9,
'axes.spines.top': False,
'axes.spines.right': False,
}
# matplotlib
mpl_style.library['seaborn-whitegrid'] = seaborn_whitegrid
mpl_style.library['seaborn-v0_8-whitegrid'] = seaborn_whitegrid #
mpl_style.library['seaborn-darkgrid'] = seaborn_darkgrid
mpl_style.library['seaborn-v0_8-darkgrid'] = seaborn_darkgrid
mpl_style.library['modern-clean'] = modern_clean
mpl_style.library['dark-modern'] = dark_modern
# seaborn-whitegrid
plt.style.use('seaborn-whitegrid')
print("已注册自定义样式: seaborn-whitegrid, seaborn-darkgrid, modern-clean, dark-modern")
print("默认样式: seaborn-whitegrid")
#
def _save_figure_to_base64():
"""将当前图形保存为 Base64 编码的 PNG"""
buf = io.BytesIO()
#
fig = plt.gcf()
facecolor = fig.get_facecolor()
plt.savefig(buf, format='png', dpi=150, bbox_inches='tight',
facecolor=facecolor, edgecolor='none')
buf.seek(0)
img_base64 = base64.b64encode(buf.read()).decode('utf-8')
buf.close()
plt.close('all') #
return img_base64
print("Matplotlib 配置完成")
`);
onProgress?.({ stage: 'ready', message: 'Python 环境已就绪', progress: 100 });
pyodideInstance = pyodide;
isLoading = false;
resolve(pyodide);
} catch (error) {
isLoading = false;
pyodideLoadPromise = null;
onProgress?.({ stage: 'error', message: `加载失败: ${error instanceof Error ? error.message : '未知错误'}` });
reject(error);
}
});
return pyodideLoadPromise;
}
/**
* Pyodide
*/
async function loadPyodideScript(): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `${PYODIDE_CDN_URL}pyodide.js`;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('无法加载 Pyodide 脚本'));
document.head.appendChild(script);
});
}
/**
* Pyodide
*/
async function loadChineseFont(pyodide: PyodideInterface, onProgress?: LoadingCallback): Promise<boolean> {
if (chineseFontLoaded) {
return true;
}
try {
onProgress?.({ stage: 'loading', message: '正在下载中文字体...', progress: 75 });
let fontData: ArrayBuffer | null = null;
// 尝试每个字体 URL
for (const url of CHINESE_FONT_URLS) {
try {
const response = await fetch(url, {
method: 'GET',
mode: 'cors',
});
if (response.ok) {
fontData = await response.arrayBuffer();
console.log(`成功从 ${url} 下载字体`);
break;
}
} catch (e) {
console.warn(`无法从 ${url} 下载字体:`, e);
}
}
if (!fontData) {
console.warn('所有字体 URL 都无法访问,将使用默认字体');
return false;
}
const fontBytes = new Uint8Array(fontData);
onProgress?.({ stage: 'loading', message: '正在安装中文字体...', progress: 85 });
// 在 Pyodide 虚拟文件系统中创建字体目录并写入字体文件
await pyodide.runPythonAsync(`
import os
#
font_dir = '/home/pyodide/.fonts'
os.makedirs(font_dir, exist_ok=True)
`);
// 将字体数据写入虚拟文件系统
const fontPath = `/home/pyodide/.fonts/${CHINESE_FONT_FILENAME}`;
pyodide.FS.writeFile(fontPath, fontBytes);
// 注册字体到 matplotlib
await pyodide.runPythonAsync(`
import matplotlib.font_manager as fm
# matplotlib
font_path = '${fontPath}'
try:
fm.fontManager.addfont(font_path)
print(f"成功加载中文字体: {font_path}")
except Exception as e:
print(f"加载字体失败: {e}")
`);
chineseFontLoaded = true;
onProgress?.({ stage: 'loading', message: '中文字体加载完成', progress: 90 });
return true;
} catch (error) {
console.warn('加载中文字体失败:', error);
return false;
}
}
/**
* Pyodide
*/
export interface PyodideExecutionResult {
success: boolean;
output: string;
error?: string;
/** Base64 编码的图片数组 */
images: string[];
/** 执行时间 (ms) */
executionTime: number;
}
/**
* Pyodide Python
* matplotlib
*/
export async function executePythonInPyodide(
code: string,
onProgress?: LoadingCallback
): Promise<PyodideExecutionResult> {
const startTime = Date.now();
const images: string[] = [];
let output = '';
try {
// 确保 Pyodide 已加载
const pyodide = await loadPyodide(onProgress);
// 检测代码中是否包含图形绘制
const hasGraphics = detectGraphicsCode(code);
// 包装代码以捕获输出和图形
const wrappedCode = `
import sys
from io import StringIO
#
_captured_output = StringIO()
_old_stdout = sys.stdout
sys.stdout = _captured_output
_execution_error = None
_figure_base64 = None
# plt.show()
import matplotlib.pyplot as plt
_original_show = plt.show
def _custom_show(*args, **kwargs):
global _figure_base64
if plt.get_fignums() and _figure_base64 is None:
_figure_base64 = _save_figure_to_base64()
plt.show = _custom_show
try:
${code.split('\n').map(line => ' ' + line).join('\n')}
#
if plt.get_fignums() and _figure_base64 is None:
_figure_base64 = _save_figure_to_base64()
except Exception as e:
_execution_error = str(e)
import traceback
_execution_error = traceback.format_exc()
finally:
sys.stdout = _old_stdout
plt.show = _original_show # show
#
{
'output': _captured_output.getvalue(),
'error': _execution_error,
'figure': _figure_base64
}
`;
// 执行代码
const result = await pyodide.runPythonAsync(wrappedCode);
// 解析结果 - Pyodide 返回的是 Python 字典,需要转换
let resultObj: { output: string; error: string | null; figure: string | null };
// 检查 result 是否有 toJs 方法Pyodide proxy 对象)
if (result && typeof (result as { toJs?: () => unknown }).toJs === 'function') {
const jsResult = (result as { toJs: () => Map<string, unknown> }).toJs();
// toJs() 返回 Map 对象
if (jsResult instanceof Map) {
resultObj = {
output: (jsResult.get('output') as string) || '',
error: (jsResult.get('error') as string | null) || null,
figure: (jsResult.get('figure') as string | null) || null,
};
} else {
resultObj = jsResult as { output: string; error: string | null; figure: string | null };
}
} else {
// 直接作为 JS 对象处理
resultObj = result as { output: string; error: string | null; figure: string | null };
}
output = resultObj.output || '';
if (resultObj.figure) {
images.push(resultObj.figure);
}
if (resultObj.error) {
return {
success: false,
output,
error: resultObj.error,
images,
executionTime: Date.now() - startTime,
};
}
return {
success: true,
output,
images,
executionTime: Date.now() - startTime,
};
} catch (error) {
return {
success: false,
output,
error: error instanceof Error ? error.message : '执行错误',
images,
executionTime: Date.now() - startTime,
};
}
}
/**
*
*/
export function detectGraphicsCode(code: string): boolean {
const graphicsKeywords = [
// matplotlib
'matplotlib',
'pyplot',
'plt.',
'.plot(',
'.scatter(',
'.bar(',
'.barh(',
'.hist(',
'.pie(',
'.boxplot(',
'.violinplot(',
'.heatmap(',
'.imshow(',
'.contour(',
'.fill(',
'.errorbar(',
'.stem(',
'.step(',
'savefig',
'show()',
'.figure(',
'.subplot(',
'.subplots(',
// seaborn
'seaborn',
'sns.',
// plotly
'plotly',
'px.',
'go.Figure',
// 其他可视化库
'bokeh',
'altair',
];
const lowerCode = code.toLowerCase();
return graphicsKeywords.some(keyword => lowerCode.includes(keyword.toLowerCase()));
}
/**
* 使 Pyodide
* @param code
* @param language
* @returns 使 Pyodide
*/
export function shouldUsePyodide(code: string, language: string): boolean {
// 只有 Python 代码才考虑使用 Pyodide
if (!['python', 'python3', 'py'].includes(language.toLowerCase())) {
return false;
}
// 如果代码包含图形绘制,使用 Pyodide
if (detectGraphicsCode(code)) {
return true;
}
// 如果代码包含需要交互的库,使用 Pyodide
const interactiveKeywords = ['input(', 'tkinter', 'pygame'];
if (interactiveKeywords.some(kw => code.includes(kw))) {
return false; // 这些在 Pyodide 中也不支持,使用 Piston
}
// 默认:简单 Python 代码使用 Piston更快
return false;
}
/**
* Pyodide
*/
export function getPyodideStatus(): {
isLoaded: boolean;
isLoading: boolean;
} {
return {
isLoaded: pyodideInstance !== null,
isLoading,
};
}
/**
* Pyodide
*/
export function resetPyodide(): void {
pyodideInstance = null;
pyodideLoadPromise = null;
isLoading = false;
}