koishi-plugin-latex-render 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { Context, Schema } from 'koishi';
2
+ export declare const name = "latex-render";
3
+ export declare const inject: string[];
4
+ export interface Config {
5
+ /** 图片宽度 */
6
+ width?: number;
7
+ /** 背景色 */
8
+ backgroundColor?: string;
9
+ /** 文字颜色 */
10
+ textColor?: string;
11
+ /** 调试模式 */
12
+ debug?: boolean;
13
+ }
14
+ export declare const Config: Schema<Config>;
15
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js ADDED
@@ -0,0 +1,306 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
8
+ var __export = (target, all) => {
9
+ for (var name2 in all)
10
+ __defProp(target, name2, { get: all[name2], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ Config: () => Config,
34
+ apply: () => apply,
35
+ inject: () => inject,
36
+ name: () => name
37
+ });
38
+ module.exports = __toCommonJS(src_exports);
39
+ var import_koishi = require("koishi");
40
+
41
+ // src/renderer.ts
42
+ var import_katex = __toESM(require("katex"));
43
+ function containsLatex(content) {
44
+ return /\$\$/.test(content) || /\\begin\{/.test(content) || /\\\[[\s\S]*?\\\]/.test(content) || /\\\([\s\S]*?\\\)/.test(content) || /\$[^\$\n]/.test(content);
45
+ }
46
+ __name(containsLatex, "containsLatex");
47
+ function decodeHtmlEntities(text) {
48
+ return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ");
49
+ }
50
+ __name(decodeHtmlEntities, "decodeHtmlEntities");
51
+ function parseMessage(content) {
52
+ content = decodeHtmlEntities(content);
53
+ const result = [];
54
+ const regex = /(\$\$[\s\S]+?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)|\$(?:[^\$\n]|\$(?!\$)|[\s\S])*?\$|\\begin\{[a-zA-Z*]+\}[\s\S]*?\\end\{[a-zA-Z*]+\})/g;
55
+ let lastIndex = 0;
56
+ let match;
57
+ while ((match = regex.exec(content)) !== null) {
58
+ if (match.index > lastIndex) {
59
+ const text = content.slice(lastIndex, match.index).trim();
60
+ if (text) {
61
+ result.push({ type: "text", content: text });
62
+ }
63
+ }
64
+ const latex = match[1];
65
+ let isDisplay = false;
66
+ let formula = latex;
67
+ if (latex.startsWith("$$") && latex.endsWith("$$")) {
68
+ isDisplay = true;
69
+ formula = latex.slice(2, -2).trim();
70
+ } else if (latex.startsWith("\\[") && latex.endsWith("\\]")) {
71
+ isDisplay = true;
72
+ formula = latex.slice(2, -2).trim();
73
+ } else if (latex.startsWith("\\(") && latex.endsWith("\\)")) {
74
+ formula = latex.slice(2, -2).trim();
75
+ } else if (latex.startsWith("$") && latex.endsWith("$")) {
76
+ formula = latex.slice(1, -1).trim();
77
+ } else if (latex.startsWith("\\begin")) {
78
+ isDisplay = true;
79
+ }
80
+ result.push({
81
+ type: "latex",
82
+ content: formula,
83
+ display: isDisplay
84
+ });
85
+ lastIndex = match.index + match[0].length;
86
+ }
87
+ if (lastIndex < content.length) {
88
+ const text = content.slice(lastIndex).trim();
89
+ if (text) {
90
+ result.push({ type: "text", content: text });
91
+ }
92
+ }
93
+ return result;
94
+ }
95
+ __name(parseMessage, "parseMessage");
96
+ function generateHtml(parsed, config) {
97
+ const textColor = config.textColor || "#333333";
98
+ const bgColor = config.backgroundColor || "#ffffff";
99
+ const width = config.width || 800;
100
+ let html = `<!DOCTYPE html>
101
+ <html>
102
+ <head>
103
+ <meta charset="UTF-8">
104
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
105
+ <style>
106
+ * { margin: 0; padding: 0; box-sizing: border-box; }
107
+ body {
108
+ font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
109
+ font-size: 16px;
110
+ line-height: 1.8;
111
+ color: ${textColor};
112
+ background-color: ${bgColor};
113
+ padding: 24px;
114
+ width: ${width}px;
115
+ min-height: 100px;
116
+ }
117
+ .content {
118
+ word-wrap: break-word;
119
+ white-space: pre-wrap;
120
+ }
121
+ .latex-display {
122
+ margin: 12px 0;
123
+ text-align: center;
124
+ overflow-x: auto;
125
+ }
126
+ .latex-inline {
127
+ margin: 0 2px;
128
+ }
129
+ .text-line {
130
+ margin: 4px 0;
131
+ }
132
+ </style>
133
+ </head>
134
+ <body>
135
+ <div class="content">`;
136
+ for (const item of parsed) {
137
+ if (item.type === "text") {
138
+ const escaped = item.content.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
139
+ html += `<span class="text-line">${escaped}</span>`;
140
+ } else {
141
+ try {
142
+ const htmlContent = import_katex.default.renderToString(item.content, {
143
+ throwOnError: false,
144
+ displayMode: item.display || false,
145
+ trust: true,
146
+ strict: false
147
+ });
148
+ const className = item.display ? "latex-display" : "latex-inline";
149
+ html += `<span class="${className}">${htmlContent}</span>`;
150
+ } catch (e) {
151
+ html += `<span class="latex-display"><code>${item.content}</code></span>`;
152
+ }
153
+ }
154
+ }
155
+ html += `</div></body></html>`;
156
+ return html;
157
+ }
158
+ __name(generateHtml, "generateHtml");
159
+ function estimateHeight(parsed) {
160
+ let charCount = 0;
161
+ for (const item of parsed) {
162
+ if (item.type === "text") {
163
+ charCount += item.content.length;
164
+ } else {
165
+ charCount += item.content.length * 1.5;
166
+ }
167
+ }
168
+ const lines = Math.ceil(charCount / 35);
169
+ return Math.max(100, lines * 24 + 48);
170
+ }
171
+ __name(estimateHeight, "estimateHeight");
172
+ async function renderLatex(ctx, content, config) {
173
+ console.log("[latex-render] 开始渲染...");
174
+ let parsed;
175
+ try {
176
+ parsed = parseMessage(content);
177
+ console.log("[latex-render] 解析完成,共", parsed.length, "个片段");
178
+ } catch (error) {
179
+ console.error("[latex-render] 解析消息失败:", error);
180
+ throw new Error(`消息解析失败: ${error}`);
181
+ }
182
+ if (parsed.length === 0) {
183
+ throw new Error("No content to render");
184
+ }
185
+ const width = config.width || 800;
186
+ const height = estimateHeight(parsed);
187
+ let html;
188
+ try {
189
+ html = generateHtml(parsed, config);
190
+ console.log("[latex-render] HTML 生成完成,长度:", html.length);
191
+ } catch (error) {
192
+ console.error("[latex-render] HTML 生成失败:", error);
193
+ throw new Error(`HTML 生成失败: ${error}`);
194
+ }
195
+ let page = null;
196
+ try {
197
+ page = await ctx.puppeteer.page();
198
+ console.log("[latex-render] Puppeteer 页面获取成功");
199
+ await page.setContent(html, {
200
+ waitUntil: "networkidle2",
201
+ timeout: 3e4
202
+ // 30秒超时等待 CSS
203
+ });
204
+ console.log("[latex-render] HTML 内容设置完成");
205
+ await new Promise((resolve) => setTimeout(resolve, 500));
206
+ const actualHeight = await page.evaluate(() => {
207
+ const body = document.body;
208
+ return body ? body.scrollHeight : 0;
209
+ }).catch((e) => {
210
+ console.warn("[latex-render] 获取高度失败,使用预估高度:", e);
211
+ return height;
212
+ });
213
+ const finalHeight = Math.max(actualHeight + 20, height);
214
+ console.log("[latex-render] 实际高度:", finalHeight);
215
+ const buffer = await page.screenshot({
216
+ clip: {
217
+ x: 0,
218
+ y: 0,
219
+ width,
220
+ height: finalHeight
221
+ },
222
+ type: "png"
223
+ });
224
+ console.log("[latex-render] 截图完成,buffer 长度:", buffer.length);
225
+ await page.close().catch(() => {
226
+ });
227
+ page = null;
228
+ const base64 = Buffer.from(buffer).toString("base64");
229
+ const dataUrl = `data:image/png;base64,${base64}`;
230
+ const url = await ctx.assets.upload(dataUrl, "latex-render.png");
231
+ console.log("[latex-render] 图片上传完成,URL:", url);
232
+ return url;
233
+ } catch (error) {
234
+ console.error("[latex-render] Puppeteer 渲染失败:", error?.message || error);
235
+ console.error("[latex-render] 错误详情:", error?.stack || "无堆栈信息");
236
+ if (page) {
237
+ try {
238
+ await page.close();
239
+ } catch (e) {
240
+ }
241
+ }
242
+ throw new Error(`LaTeX 渲染失败: ${error?.message || error}`);
243
+ }
244
+ }
245
+ __name(renderLatex, "renderLatex");
246
+
247
+ // src/index.ts
248
+ var name = "latex-render";
249
+ var inject = ["assets", "puppeteer"];
250
+ var Config = import_koishi.Schema.object({
251
+ width: import_koishi.Schema.number().default(800).description("图片宽度"),
252
+ backgroundColor: import_koishi.Schema.string().default("#ffffff").description("背景色"),
253
+ textColor: import_koishi.Schema.string().default("#333333").description("文字颜色"),
254
+ debug: import_koishi.Schema.boolean().default(false).description("调试模式")
255
+ });
256
+ function apply(ctx, config) {
257
+ const debug = config.debug || false;
258
+ const handler = /* @__PURE__ */ __name(async (conversationId, sourceMessage, displayResponse, promptVariables, chatInterface, session) => {
259
+ try {
260
+ let content;
261
+ if (Array.isArray(displayResponse)) {
262
+ content = displayResponse[0]?.content;
263
+ } else if (displayResponse?.content) {
264
+ content = displayResponse.content;
265
+ } else if (typeof displayResponse === "string") {
266
+ content = displayResponse;
267
+ }
268
+ if (!content) return;
269
+ const contentStr = typeof content === "string" ? content : JSON.stringify(content);
270
+ if (containsLatex(contentStr)) {
271
+ if (debug) {
272
+ console.log("[latex-render] 检测到 LaTeX 公式,开始渲染...");
273
+ }
274
+ try {
275
+ const imageUrl = await renderLatex(ctx, contentStr, config);
276
+ if (session) {
277
+ await session.send(import_koishi.h.image(imageUrl));
278
+ }
279
+ const imageContent = import_koishi.h.image(imageUrl).toString();
280
+ if (Array.isArray(displayResponse)) {
281
+ displayResponse[0].content = imageContent;
282
+ } else {
283
+ displayResponse.content = imageContent;
284
+ }
285
+ } catch (error) {
286
+ if (debug) {
287
+ console.error("[latex-render] 渲染失败:", error);
288
+ }
289
+ }
290
+ }
291
+ } catch (error) {
292
+ if (debug) {
293
+ console.error("[latex-render] 处理失败:", error);
294
+ }
295
+ }
296
+ }, "handler");
297
+ ctx.on("chatluna/after-chat", handler);
298
+ }
299
+ __name(apply, "apply");
300
+ // Annotate the CommonJS export names for ESM import in node:
301
+ 0 && (module.exports = {
302
+ Config,
303
+ apply,
304
+ inject,
305
+ name
306
+ });
@@ -0,0 +1,15 @@
1
+ import { Context } from 'koishi';
2
+ interface Config {
3
+ width?: number;
4
+ backgroundColor?: string;
5
+ textColor?: string;
6
+ }
7
+ /**
8
+ * 检测是否为 LaTeX 公式(增强版)
9
+ */
10
+ export declare function containsLatex(content: string): boolean;
11
+ /**
12
+ * 主渲染函数 - 使用 Puppeteer + KaTeX
13
+ */
14
+ export declare function renderLatex(ctx: Context, content: string, config: Config): Promise<string>;
15
+ export {};
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "koishi-plugin-latex-render",
3
+ "description": "ChatLuna 专用 - 将 AI 返回的 LaTeX 公式渲染为图片",
4
+ "version": "1.0.1",
5
+ "main": "lib/index.js",
6
+ "typings": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "dist"
10
+ ],
11
+ "license": "MIT",
12
+ "homepage": "https://github.com/gt4404gb/koishi-plugin-latex-render",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/gt4404gb/koishi-plugin-latex-render.git"
16
+ },
17
+ "keywords": [
18
+ "koishi",
19
+ "plugin",
20
+ "latex",
21
+ "chatluna",
22
+ "ai",
23
+ "katex"
24
+ ],
25
+ "peerDependencies": {
26
+ "koishi": "^4.18.7"
27
+ },
28
+ "koishi": {
29
+ "description": {
30
+ "zh": "ChatLuna 专用 - 将 AI 返回的 LaTeX 公式渲染为图片",
31
+ "en": "ChatLuna exclusive - Render LaTeX formulas returned by AI as images"
32
+ },
33
+ "service": {
34
+ "optional": [
35
+ "assets"
36
+ ],
37
+ "required": [
38
+ "puppeteer"
39
+ ]
40
+ }
41
+ },
42
+ "dependencies": {
43
+ "katex": "^0.16.0"
44
+ }
45
+ }
package/readme.md ADDED
@@ -0,0 +1,93 @@
1
+ # koishi-plugin-latex-render
2
+
3
+ [![npm](https://img.shields.io/npm/v/koishi-plugin-latex-render?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-latex-render)
4
+
5
+ ## 简介
6
+
7
+ **本插件专为 [ChatLuna](https://github.com/ChatLuna/ChatLuna) 设计**,用于将 AI 返回的 LaTeX 公式渲染为图片并发送。
8
+
9
+ 当 ChatLuna 的 AI 返回包含 LaTeX 公式的消息时,本插件会自动:
10
+ 1. 拦截消息
11
+ 2. 使用 KaTeX 解析并渲染公式
12
+ 3. 使用 Puppeteer 截图生成图片
13
+ 4. 将图片发送给用户
14
+
15
+ ## 特性
16
+
17
+ - **整条消息渲染为一张图片** - 不是每个公式单独一张
18
+ - **支持多种 LaTeX 格式** - `$$...$$`, `\[...\]`, `\(...\)`, `$...$`, `\begin{}...\end{}`
19
+ - **中文支持** - 使用微软雅黑字体
20
+ - **可自定义样式** - 支持配置背景色、文字颜色、图片宽度
21
+
22
+ ## 依赖
23
+
24
+ 本插件依赖以下 Koishi 插件:
25
+ - `koishi-plugin-puppeteer` - 用于浏览器截图
26
+ - `koishi-plugin-assets-local` - 用于图片上传托管(可选)
27
+
28
+ ## 安装
29
+
30
+ ```bash
31
+ # 使用 yarn
32
+ yarn add koishi-plugin-latex-render
33
+
34
+ # 或使用 npm
35
+ npm install koishi-plugin-latex-render
36
+ ```
37
+
38
+ ## 配置
39
+
40
+ 在 `koishi.yml` 中配置插件:
41
+
42
+ ```yaml
43
+ latex-render:
44
+ width: 800 # 图片宽度,默认 800
45
+ backgroundColor: "#ffffff" # 背景色,默认白色
46
+ textColor: "#333333" # 文字颜色,默认深灰色
47
+
48
+ # 必须启用 puppeteer 插件
49
+ puppeteer: {}
50
+
51
+ # 建议配置 assets-local 的 selfUrl
52
+ assets-local:5fyoiw:
53
+ selfUrl: http://127.0.0.1:5140
54
+ ```
55
+
56
+ ## 工作原理
57
+
58
+ 1. 监听 `chatluna/after-chat` 事件
59
+ 2. 检测消息中是否包含 LaTeX 公式
60
+ 3. 使用 KaTeX 将公式渲染为 HTML
61
+ 4. 使用 Puppeteer 打开 HTML 并截图
62
+ 5. 通过 assets 服务上传图片
63
+ 6. 发送图片消息给用户
64
+
65
+ ## 支持的 LaTeX 格式
66
+
67
+ | 格式 | 示例 | 类型 |
68
+ |------|------|------|
69
+ | 行内公式 | `$x^2$` | inline |
70
+ | 行内公式 | `\(x^2\)` | inline |
71
+ | 块级公式 | `$$x^2$$` | display |
72
+ | 块级公式 | `\[x^2\]` | display |
73
+ | 环境 | `\begin{cases}...\end{cases}` | display |
74
+
75
+ ## 注意事项
76
+
77
+ - **本插件极度特化于 ChatLuna** - 仅监听 ChatLuna 的消息事件
78
+ - 需要网络访问 `cdn.jsdelivr.net` 加载 KaTeX CSS
79
+ - Puppeteer 插件必须在插件列表中启用
80
+
81
+ ## 开发
82
+
83
+ ```bash
84
+ # 构建
85
+ yarn build
86
+
87
+ # 或在项目根目录
88
+ npm run build latex-render
89
+ ```
90
+
91
+ ## License
92
+
93
+ MIT