@axw_/koishi-plugin-markdown-to-image-service
Version:
一个功能强大的 Markdown 转图片服务,支持丰富的语法和主题,并能处理来自 NapCat 等适配器的文件上传。
319 lines (315 loc) • 22.4 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Config = exports.usage = exports.name = exports.inject = void 0;
exports.apply = apply;
const koishi_1 = require("koishi");
const fs = __importStar(require("fs"));
const node_path_1 = __importDefault(require("node:path"));
const crossnote_1 = require("crossnote");
const puppeteer_finder_1 = __importDefault(require("puppeteer-finder"));
const MIME_TYPES = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
};
exports.inject = {
required: ['puppeteer'],
};
exports.name = 'markdown-to-image-service';
exports.usage = `## 使用方式
1. **直接上传文件**: 在聊天中直接发送一个 \`.md\` 文件,机器人会自动将其转换为图片。
2. **使用指令**: 输入 \`markdown <你的MD文本>\` 来转换纯文本内容。
---
### Docker/WSL 用户(重要!)
如果您的 Koishi 和 OneBot 实现(如 NapCat)在不同的 Docker 容器中,您 **必须** 配置“跨环境路径映射设置”,否则**无法处理上传的文件**。
- **容器内路径前缀**: 填入 NapCat 返回的路径前缀,例如 \`/app/.config/QQ/NapCat/temp\`
- **主机/Koishi容器路径前缀**: 填入与上述路径对应的、Koishi 可以访问的路径,例如 \`/koishi/data/shared_files\`
`;
exports.Config = koishi_1.Schema.intersect([
koishi_1.Schema.object({
width: koishi_1.Schema.number().default(800).description(`视图宽度。`),
height: koishi_1.Schema.number().default(100).description(`视图高度。`),
deviceScaleFactor: koishi_1.Schema.number().default(1).description(`设备的缩放比率。`),
enableAutoCacheClear: koishi_1.Schema.boolean().default(true).description('是否启动自动删除缓存功能。此项控制插件自身生成的临时文件。'),
defaultImageFormat: koishi_1.Schema.union(['png', 'jpeg', 'webp']).default('jpeg').description('文本转图片时默认渲染的图片格式。'),
waitUntil: koishi_1.Schema.union(['load', 'domcontentloaded', 'networkidle0', 'networkidle2']).default('load').description('指定页面何时认为导航完成。如果渲染复杂图表时超时,可尝试切换为 `load`。'),
timeout: koishi_1.Schema.number().default(60000).description('Puppeteer 页面渲染超时时间(毫秒)。如果渲染复杂内容时卡死或报错,请尝试增加此值。'),
enableRunAllCodeChunks: koishi_1.Schema.boolean().default(false).description('文本转图片时是否执行代码块里的代码。'),
}).description('基础设置'),
koishi_1.Schema.object({
mermaidTheme: koishi_1.Schema.union(['default', 'dark', 'forest']).default('default').description('Mermaid 主题。'),
codeBlockTheme: koishi_1.Schema.union(['auto.css', 'default.css', 'atom-dark.css', 'atom-light.css', 'atom-material.css', 'coy.css', 'darcula.css', 'dark.css', 'funky.css', 'github.css', 'github-dark.css', 'hopscotch.css', 'monokai.css', 'okaidia.css', 'one-dark.css', 'one-light.css', 'pen-paper-coffee.css', 'pojoaque.css', 'solarized-dark.css', 'solarized-light.css', 'twilight.css', 'vue.css', 'vs.css', 'xonokai.css']).default('auto.css').description('代码块主题。如果选择 `auto.css`,那么将选择与当前预览主题最匹配的代码块主题。'),
previewTheme: koishi_1.Schema.union(['atom-dark.css', 'atom-light.css', 'atom-material.css', 'github-dark.css', 'github-light.css', 'gothic.css', 'medium.css', 'monokai.css', 'newsprint.css', 'night.css', 'none.css', 'one-dark.css', 'one-light.css', 'solarized-dark.css', 'solarized-light.css', 'vue.css']).default('github-light.css').description('预览主题。'),
revealjsTheme: koishi_1.Schema.union(['beige.css', 'black.css', 'blood.css', 'league.css', 'moon.css', 'night.css', 'serif.css', 'simple.css', 'sky.css', 'solarized.css', 'white.css', 'none.css',]).default('white.css').description('Revealjs 演示主题。')
}).description('主题相关设置'),
koishi_1.Schema.object({
breakOnSingleNewLine: koishi_1.Schema.boolean().default(true).description('在 Markdown 中,单个换行符不会在生成的 HTML 中导致换行。在 GitHub Flavored Markdown 中,情况并非如此。启用此配置选项以在渲染的 HTML 中为 Markdown 源中的单个换行符插入换行。'),
enableLinkify: koishi_1.Schema.boolean().default(true).description('启用将类似 URL 的文本转换为 Markdown 预览中的链接。'),
enableWikiLinkSyntax: koishi_1.Schema.boolean().default(true).description('启用 Wiki 链接语法支持。更多信息可以在 https://help.github.com/articles/adding-links-to-wikis/ 找到。如果选中,我们将使用 GitHub 风格的管道式 Wiki 链接,即 [[linkText|wikiLink]]。否则,我们将使用 [[wikiLink|linkText]] 作为原始 Wikipedia 风格。'),
enableEmojiSyntax: koishi_1.Schema.boolean().default(true).description('启用 emoji 和 font-awesome 插件。这仅适用于 markdown-it 解析器,而不适用于 pandoc 解析器。'),
enableExtendedTableSyntax: koishi_1.Schema.boolean().default(false).description('启用扩展表格语法以支持合并表格单元格。'),
enableCriticMarkupSyntax: koishi_1.Schema.boolean().default(false).description('启用 CriticMarkup 语法。仅适用于 markdown-it 解析器。'),
frontMatterRenderingOption: koishi_1.Schema.union(['none', 'table', 'code']).default('none').description('Front matter 渲染选项。'),
enableScriptExecution: koishi_1.Schema.boolean().default(false).description('启用执行代码块和导入 javascript 文件。这也启用了侧边栏目录。⚠ ️ 请谨慎使用此功能,因为它可能会使您的安全受到威胁!如果在启用脚本执行的情况下,有人让您打开带有恶意代码的 markdown,您的计算机可能会被黑客攻击。'),
enableHTML5Embed: koishi_1.Schema.boolean().default(false).description('启用将音频视频链接转换为 html5 嵌入音频视频标签。内部启用了 markdown-it-html5-embed 插件。'),
HTML5EmbedUseImageSyntax: koishi_1.Schema.boolean().default(true).description(`使用 ! [] () 语法启用视频/音频嵌入(默认)。`),
HTML5EmbedUseLinkSyntax: koishi_1.Schema.boolean().default(false).description('使用 [] () 语法启用视频/音频嵌入。'),
HTML5EmbedIsAllowedHttp: koishi_1.Schema.boolean().default(false).description('当 URL 中有 http:// 协议时嵌入媒体。当为 false 时忽略并且不嵌入它们。'),
HTML5EmbedAudioAttributes: koishi_1.Schema.string().default('controls preload="metadata" width="320"').description('传递给音频标签的 HTML 属性。'),
HTML5EmbedVideoAttributes: koishi_1.Schema.string().default('controls preload="metadata" width="320" height="240"').description('传递给视频标签的 HTML 属性。'),
}).description('Markdown 解析相关设置'),
koishi_1.Schema.object({
mathRenderingOption: koishi_1.Schema.union(['KaTeX', 'MathJax', 'None']).default('KaTeX').description('数学渲染引擎。'),
mathInlineDelimiters: koishi_1.Schema.array(koishi_1.Schema.array(String)).collapse().default([["$", "$"], ["\\(", "\\)"]]).description('数学公式行内分隔符。'),
mathBlockDelimiters: koishi_1.Schema.array(koishi_1.Schema.array(String)).collapse().default([["$", "$"], ["\\[", "\\]"]]).description('数学公式块分隔符。'),
mathRenderingOnlineService: koishi_1.Schema.union(['https://latex.codecogs.com/gif.latex', 'https://latex.codecogs.com/svg.latex', 'https://latex.codecogs.com/png.latex']).default('https://latex.codecogs.com/gif.latex').description('数学公式渲染在线服务。'),
mathjaxV3ScriptSrc: koishi_1.Schema.string().role('link').default('https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js').description('MathJax 脚本资源。')
}).description('数学公式渲染设置'),
koishi_1.Schema.object({
enableOffline: koishi_1.Schema.boolean().default(false).description('是否离线使用 html。'),
printBackground: koishi_1.Schema.boolean().default(true).description('是否为文件导出打印背景。如果设置为 `false`,则将使用 `github-light` 预览主题。您还可以为单个文件设置 `print_background`。'),
chromePath: koishi_1.Schema.string().default('').description('Chrome / Edge 可执行文件路径,用于 Puppeteer 导出。留空表示路径将自动找到。'),
puppeteerArgs: koishi_1.Schema.array(String).default([]).description('传递给 puppeteer.launch({args: $puppeteerArgs}) 的参数,例如 `[\'--no-sandbox\', \'--disable-setuid-sandbox\']`。'),
}).description('导出与渲染设置'),
koishi_1.Schema.object({
protocolsWhiteList: koishi_1.Schema.string().default('http://, https://, atom://, file://, mailto:, tel:').description('链接的接受协议白名单。'),
}).description('其他设置'),
koishi_1.Schema.object({
containerPathPrefix: koishi_1.Schema.string().description('【文件上传专用】OneBot 实现 (如 NapCat) 所在的容器或环境提供的路径前缀。例如: `/app/.config/QQ/NapCat/temp`。'),
hostPathPrefix: koishi_1.Schema.string().description('【文件上传专用】与上述路径对应的、Koishi 所在的容器或主机可以访问的路径前缀。例如: `/koishi/data/shared_temp`。'),
}).description('跨环境路径映射设置 (用于处理文件上传)'),
]);
// @ts-ignore
class MarkdownToImageService extends koishi_1.Service {
constructor(ctx, config) {
super(ctx, 'markdownToImage', true);
this.browser = null;
this.config = config;
this.loggerForService = ctx.logger('markdownToImage');
this.notebookDirPath = node_path_1.default.join(ctx.baseDir, 'data', 'notebook');
}
async initBrowser() {
this.ctx.inject(['puppeteer'], (ctx) => {
this.browser = ctx.puppeteer.browser;
});
}
async initNotebook() {
const { breakOnSingleNewLine, enableLinkify, mathRenderingOption, mathInlineDelimiters, mathBlockDelimiters, mathRenderingOnlineService, mathjaxV3ScriptSrc, enableWikiLinkSyntax, enableEmojiSyntax, enableExtendedTableSyntax, enableCriticMarkupSyntax, frontMatterRenderingOption, mermaidTheme, codeBlockTheme, previewTheme, revealjsTheme, protocolsWhiteList, printBackground, chromePath, enableScriptExecution, enableHTML5Embed, HTML5EmbedUseImageSyntax, HTML5EmbedUseLinkSyntax, HTML5EmbedIsAllowedHttp, HTML5EmbedAudioAttributes, HTML5EmbedVideoAttributes, puppeteerArgs, } = this.config;
const resolvedChromePath = chromePath || await (0, puppeteer_finder_1.default)();
this.notebook = await crossnote_1.Notebook.init({
notebookPath: this.notebookDirPath,
config: {
breakOnSingleNewLine, enableLinkify, mathRenderingOption,
mathInlineDelimiters, mathBlockDelimiters, mathRenderingOnlineService,
mathjaxV3ScriptSrc, enableWikiLinkSyntax, enableEmojiSyntax,
enableExtendedTableSyntax, enableCriticMarkupSyntax,
frontMatterRenderingOption, mermaidTheme, codeBlockTheme,
previewTheme, revealjsTheme, protocolsWhiteList, printBackground,
chromePath: resolvedChromePath, enableScriptExecution, enableHTML5Embed,
HTML5EmbedUseImageSyntax, HTML5EmbedUseLinkSyntax, HTML5EmbedIsAllowedHttp,
HTML5EmbedAudioAttributes, HTML5EmbedVideoAttributes, puppeteerArgs,
},
});
}
async ensureDirExists(dirPath) {
if (!fs.existsSync(dirPath)) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
}
getCurrentTimeNumberString() {
const now = new Date();
const dateString = now.toISOString().replace(/[-:]/g, '').split('.')[0];
const randomString = Math.random().toString(36).substring(2, 8);
return `${dateString}_${randomString}`;
}
async generateAndRenderImage(markdownText) {
const { height, width, deviceScaleFactor, waitUntil, timeout, enableOffline, enableRunAllCodeChunks, defaultImageFormat, enableAutoCacheClear } = this.config;
const currentTimeString = this.getCurrentTimeNumberString();
const readmeFilePath = node_path_1.default.join(this.notebookDirPath, `${currentTimeString}.md`);
const readmeHtmlPath = node_path_1.default.join(this.notebookDirPath, `${currentTimeString}.html`);
await fs.promises.writeFile(readmeFilePath, markdownText);
// 步骤 1: 使用已知的、稳定的 htmlExport 方法生成临时 HTML 文件
const engine = this.notebook.getNoteMarkdownEngine(readmeFilePath);
await engine.htmlExport({ offline: enableOffline, runAllCodeChunks: enableRunAllCodeChunks });
// 步骤 2: 将生成的 HTML 文件内容读回内存
let html = await fs.promises.readFile(readmeHtmlPath, 'utf-8');
// 步骤 3: 将 HTML 中的本地图片引用转换为 Base64 Data URI
const imgRegex = /<img src="(?!(https?|data):)([^"]+)"/g;
const replacements = [];
let match;
while ((match = imgRegex.exec(html)) !== null) {
const originalSrc = match[0];
const imagePath = match[2];
const absoluteImagePath = node_path_1.default.resolve(this.notebookDirPath, imagePath);
if (fs.existsSync(absoluteImagePath)) {
try {
const imageBuffer = await fs.promises.readFile(absoluteImagePath);
const ext = node_path_1.default.extname(imagePath).toLowerCase();
const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
const base64 = imageBuffer.toString('base64');
const newSrc = `<img src="data:${mimeType};base64,${base64}"`;
replacements.push({ find: originalSrc, replace: newSrc });
}
catch (error) {
this.loggerForService.warn(`读取本地图片失败: ${absoluteImagePath}`, error);
}
}
}
for (const rep of replacements) {
html = html.replace(rep.find, rep.replace);
}
// 步骤 4: 使用 page.setContent 直接加载 HTML 字符串
const context = await this.browser.createBrowserContext();
const page = await context.newPage();
await page.setViewport({ width, height, deviceScaleFactor });
await page.setContent(html, { waitUntil, timeout });
const imageBuffer = await page.screenshot({ fullPage: true, type: defaultImageFormat });
await page.close();
await context.close();
// 步骤 5: 清理所有临时文件
if (enableAutoCacheClear) {
await Promise.all([
fs.promises.unlink(readmeFilePath),
fs.promises.unlink(readmeHtmlPath) // 确保 html 文件也被删除
]);
}
return imageBuffer;
}
async convertToImage(markdownText) {
if (!this.browser)
await this.initBrowser();
if (!this.notebook)
await this.initNotebook();
await this.ensureDirExists(this.notebookDirPath);
try {
return await this.generateAndRenderImage(markdownText);
}
catch (error) {
this.loggerForService.error('Error converting markdown to image:', error);
throw error;
}
}
}
async function apply(ctx, config) {
ctx.plugin(MarkdownToImageService, config);
const logger = ctx.logger('markdownToImage');
ctx.inject(['markdownToImage'], (ctx) => {
ctx.middleware(async (session, next) => {
if (session.elements && session.elements.length === 1 && (session.elements[0].type === 'asset' || session.elements[0].type === 'file')) {
const fileElement = session.elements[0];
const originalFilename = fileElement.attrs.file || fileElement.attrs.src || '';
if (originalFilename.endsWith('.md')) {
logger.info(`[中间件] 检测到 Markdown 文件上传: ${originalFilename}`);
await session.send('接收到 Markdown 文件,正在处理...');
let content = '';
const fileId = fileElement.attrs.fileId;
// @ts-ignore
if (fileId && session.onebot?._request) {
try {
// @ts-ignore
const { retcode, data, message } = await session.onebot._request('get_file', { file_id: fileId });
if (retcode === 0 && data?.file) {
let localFilePath = data.file;
logger.info(`[中间件] OneBot 实现返回路径: ${localFilePath}`);
// 如果配置了路径映射,则进行转换
if (config.containerPathPrefix && config.hostPathPrefix) {
if (localFilePath.startsWith(config.containerPathPrefix)) {
const relativePath = node_path_1.default.relative(config.containerPathPrefix, localFilePath);
const hostPath = node_path_1.default.join(config.hostPathPrefix, relativePath);
logger.info(`[中间件] 应用路径映射,转换后路径为: ${hostPath}`);
localFilePath = hostPath;
}
else {
logger.warn(`[中间件] 文件路径 '${localFilePath}' 不以前缀 '${config.containerPathPrefix}' 开头,映射未生效。`);
}
}
content = await fs.promises.readFile(localFilePath, 'utf-8');
}
else {
return session.send(`请求文件下载失败: ${message || '未知错误'}`);
}
}
catch (error) {
if (error.code === 'ENOENT') {
return session.send(`处理文件时发生错误:找不到文件或目录。\n这通常意味着路径映射配置不正确或文件挂载未生效。\n尝试读取的路径: ${error.path}`);
}
return session.send(`处理文件时发生内部错误: ${error.message}`);
}
}
else {
return session.send('抱歉,当前环境不支持处理文件上传(未找到 onebot._request 方法)。');
}
if (content) {
try {
await session.send('文件内容已获取,正在生成图片...');
const imageBuffer = await ctx.markdownToImage.convertToImage(content);
return koishi_1.h.image(imageBuffer, `image/${config.defaultImageFormat}`);
}
catch (error) {
return session.send(`转换 Markdown 时发生错误:\n${error.message}`);
}
}
else {
return session.send('未能成功读取文件内容,操作中止。');
}
}
}
return next();
});
ctx.command('markdown <markdownText:text>', '将 Markdown 纯文本内容转换为图片')
.alias('markdownToImage')
.action(async ({ session }, markdownText) => {
if (!session)
return '该指令无法在无会话上下文的环境中执行。';
if (!markdownText)
return '请直接在指令后输入要转换的 Markdown 文本,或直接上传一个 .md 文件。';
try {
await session.send('接收到文本内容,正在生成图片...');
const imageBuffer = await ctx.markdownToImage.convertToImage(markdownText);
return koishi_1.h.image(imageBuffer, `image/${config.defaultImageFormat}`);
}
catch (error) {
return `转换 Markdown 时发生错误:\n${error.message}`;
}
});
});
}