koishi-plugin-latex-render 1.0.9 → 1.1.0

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.js CHANGED
@@ -40,64 +40,182 @@ var import_koishi = require("koishi");
40
40
 
41
41
  // src/renderer.ts
42
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");
43
+ var import_mhchem = require("katex/contrib/mhchem");
44
+ var import_marked = require("marked");
47
45
  function decodeHtmlEntities(text) {
48
46
  return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ");
49
47
  }
50
48
  __name(decodeHtmlEntities, "decodeHtmlEntities");
49
+ function autoWrapLatex(text) {
50
+ const lines = text.split("\n");
51
+ let inCodeBlock = false;
52
+ return lines.map((line) => {
53
+ if (line.trim().startsWith("```")) {
54
+ inCodeBlock = !inCodeBlock;
55
+ return line;
56
+ }
57
+ if (inCodeBlock) return line;
58
+ const dollarCount = (line.match(/\$/g) || []).length;
59
+ if (dollarCount >= 2 && dollarCount % 2 === 0 || line.includes("\\[") || line.includes("\\(") || line.includes("\\begin")) {
60
+ return line.replace(/\$\$(\\ce\{.+?)\$\$\}/g, (_, p1) => `$$${p1}$$`).replace(/\$(\\ce\{.+?)\$\}/g, (_, p1) => `$${p1}$`);
61
+ }
62
+ const latexPattern = /(\\[a-zA-Z]+|\^|_[0-9a-zA-Z\{])/;
63
+ if (!latexPattern.test(line)) return line;
64
+ let result = "";
65
+ const chunks = line.split(/([\u4e00-\u9fff\uff00-\uffef\u3000-\u303f]+)/);
66
+ for (let chunk of chunks) {
67
+ if (/[\u4e00-\u9fff\uff00-\uffef\u3000-\u303f]/.test(chunk)) {
68
+ result += chunk;
69
+ } else {
70
+ if (chunk.includes("http") || chunk.includes("](")) {
71
+ result += chunk;
72
+ continue;
73
+ }
74
+ const trimmed = chunk.trim();
75
+ if (trimmed.length > 0 && latexPattern.test(trimmed)) {
76
+ const isEnglishSentence = /^[a-zA-Z0-9_\s\.,!?'"-]+$/.test(trimmed) && trimmed.split(/\s+/).length > 3 && !/(\\[a-zA-Z]+|[\+\-\=\/\<\>\*])/.test(trimmed);
77
+ if (isEnglishSentence) {
78
+ result += chunk;
79
+ continue;
80
+ }
81
+ const leading = chunk.slice(0, chunk.indexOf(trimmed));
82
+ const trailing = chunk.slice(chunk.indexOf(trimmed) + trimmed.length);
83
+ const mathMatch = trimmed.match(/^([\s::,,。;;]*)(.*?)([\s::,,。;;]*)$/);
84
+ const prefix = mathMatch ? mathMatch[1] : "";
85
+ const coreMath = mathMatch ? mathMatch[2] : trimmed;
86
+ const suffix = mathMatch ? mathMatch[3] : "";
87
+ const listMatch = coreMath.match(/^((?:[-*+]|\d+\.)\s+)(.*)/);
88
+ const isAlreadyWrapped = coreMath.startsWith("$") || coreMath.startsWith("\\[") || coreMath.startsWith("\\(") || coreMath.startsWith("\\begin");
89
+ if (listMatch) {
90
+ result += `${leading}${prefix}${listMatch[1]}$${listMatch[2].replace(/^\$+|\$+$/g, "")}$${suffix}${trailing}`;
91
+ } else if (isAlreadyWrapped) {
92
+ result += `${leading}${prefix}${coreMath}${suffix}${trailing}`;
93
+ } else {
94
+ result += `${leading}${prefix}$${coreMath}$${suffix}${trailing}`;
95
+ }
96
+ } else {
97
+ result += chunk;
98
+ }
99
+ }
100
+ }
101
+ return result;
102
+ }).join("\n");
103
+ }
104
+ __name(autoWrapLatex, "autoWrapLatex");
105
+ function containsMarkdown(content) {
106
+ return /^#{1,6}\s/m.test(content) || /\*\*[^*]+\*\*/.test(content) || /\*[^*]+\*/.test(content) || /`[^`]+`/.test(content) || /```[\s\S]+?```/.test(content) || /^\s*[-*+]\s/m.test(content) || /^\s*\d+\.\s/m.test(content) || /^\s*>\s/m.test(content) || /\[.+?\]\(.+?\)/.test(content);
107
+ }
108
+ __name(containsMarkdown, "containsMarkdown");
51
109
  function parseMessage(content) {
52
- content = decodeHtmlEntities(content);
53
110
  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;
111
+ const regex = /(\$\$[\s\S]+?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)|\$[^\$\n]+?\$|\\begin\{[a-zA-Z*]+\}[\s\S]*?\\end\{[a-zA-Z*]+\})/g;
55
112
  let lastIndex = 0;
56
113
  let match;
57
114
  while ((match = regex.exec(content)) !== null) {
58
115
  if (match.index > lastIndex) {
59
- const text = content.slice(lastIndex, match.index).trim();
60
- if (text) {
61
- result.push({ type: "text", content: text });
62
- }
116
+ const text = content.slice(lastIndex, match.index);
117
+ if (text) result.push({ type: "text", content: text });
63
118
  }
64
119
  const latex = match[1];
65
120
  let isDisplay = false;
66
- let formula = latex;
67
- if (latex.startsWith("$$") && latex.endsWith("$$")) {
121
+ let formula = latex.trim();
122
+ if (formula.startsWith("$$") || formula.startsWith("\\[")) {
68
123
  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")) {
124
+ } else if (formula.startsWith("\\begin")) {
78
125
  isDisplay = true;
79
126
  }
80
- result.push({
81
- type: "latex",
82
- content: formula,
83
- display: isDisplay
84
- });
127
+ formula = formula.replace(/^\s*\\\[|\s*\\\]$/g, "").replace(/^\s*\\\(|\s*\\\)$/g, "").replace(/^\s*\$\$|\s*\$\$$/g, "").replace(/^\s*\$|\s*\$$/g, "");
128
+ formula = formula.replace(/\\\\/g, "卐NEWLINE卍").replace(/(?<!\\)\$/g, "").replace(/卐NEWLINE卍/g, "\\\\").trim();
129
+ result.push({ type: "latex", content: formula, display: isDisplay });
85
130
  lastIndex = match.index + match[0].length;
86
131
  }
87
132
  if (lastIndex < content.length) {
88
- const text = content.slice(lastIndex).trim();
89
- if (text) {
90
- result.push({ type: "text", content: text });
91
- }
133
+ const text = content.slice(lastIndex);
134
+ if (text) result.push({ type: "text", content: text });
92
135
  }
93
136
  return result;
94
137
  }
95
138
  __name(parseMessage, "parseMessage");
96
- function generateHtml(parsed, config) {
139
+ function processChineseInLatex(formula) {
140
+ return formula.replace(/(?<!\\text\{)([\u4e00-\u9fff]+)(?!\})/g, "\\text{$1}").replace(/\\_/g, "_");
141
+ }
142
+ __name(processChineseInLatex, "processChineseInLatex");
143
+ function escapeHtml(text) {
144
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
145
+ }
146
+ __name(escapeHtml, "escapeHtml");
147
+ function generateHtml(content, config) {
97
148
  const textColor = config.textColor || "#333333";
98
149
  const bgColor = config.backgroundColor || "#ffffff";
99
150
  const width = config.width || 800;
100
- let html = `<!DOCTYPE html>
151
+ let decodedContent = decodeHtmlEntities(content);
152
+ const wrappedContent = autoWrapLatex(decodedContent);
153
+ const items = parseMessage(wrappedContent);
154
+ const hasMarkdown = items.some((item) => item.type === "text" && containsMarkdown(item.content));
155
+ let htmlContent;
156
+ const katexOptions = {
157
+ throwOnError: false,
158
+ // 保持不报错降级
159
+ strict: false
160
+ };
161
+ if (hasMarkdown) {
162
+ const latexCache = [];
163
+ let combinedMarkdown = "";
164
+ let latexIndex = 0;
165
+ for (const item of items) {
166
+ if (item.type === "latex") {
167
+ try {
168
+ const processedContent = processChineseInLatex(item.content);
169
+ const latexHtml = import_katex.default.renderToString(processedContent, { ...katexOptions, displayMode: item.display || false });
170
+ const placeholder = `卐LATEX${latexIndex}卍`;
171
+ if (item.display) {
172
+ latexCache.push({ placeholder, html: `<div class="latex-display">${latexHtml}</div>`, isDisplay: true });
173
+ combinedMarkdown += `
174
+
175
+ ${placeholder}
176
+
177
+ `;
178
+ } else {
179
+ latexCache.push({ placeholder, html: `<span class="latex-inline">${latexHtml}</span>`, isDisplay: false });
180
+ combinedMarkdown += placeholder;
181
+ }
182
+ latexIndex++;
183
+ } catch (e) {
184
+ console.warn(`[latex-render] 公式渲染降级: ${item.content},原因: ${e.message}`);
185
+ combinedMarkdown += `\`${item.content}\``;
186
+ }
187
+ } else {
188
+ combinedMarkdown += item.content;
189
+ }
190
+ }
191
+ htmlContent = import_marked.marked.parse(combinedMarkdown, { async: false });
192
+ for (const { placeholder, html, isDisplay } of latexCache) {
193
+ if (isDisplay) {
194
+ htmlContent = htmlContent.replace(`<p>${placeholder}</p>`, html);
195
+ }
196
+ htmlContent = htmlContent.replace(placeholder, html);
197
+ }
198
+ } else {
199
+ const parts = [];
200
+ for (const item of items) {
201
+ if (item.type === "latex") {
202
+ try {
203
+ const processedContent = processChineseInLatex(item.content);
204
+ const latexHtml = import_katex.default.renderToString(processedContent, { ...katexOptions, displayMode: item.display || false });
205
+ const className = item.display ? "latex-display block" : "latex-inline";
206
+ parts.push(`<span class="${className}">${latexHtml}</span>`);
207
+ } catch (e) {
208
+ console.warn(`[latex-render] 公式渲染降级: ${item.content},原因: ${e.message}`);
209
+ parts.push(`<code>${escapeHtml(item.content)}</code>`);
210
+ }
211
+ } else {
212
+ const textHtml = escapeHtml(item.content).replace(/\n/g, "<br>");
213
+ parts.push(`<span class="text-line">${textHtml}</span>`);
214
+ }
215
+ }
216
+ htmlContent = parts.join("");
217
+ }
218
+ return `<!DOCTYPE html>
101
219
  <html>
102
220
  <head>
103
221
  <meta charset="UTF-8">
@@ -107,142 +225,104 @@ function generateHtml(parsed, config) {
107
225
  body {
108
226
  font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
109
227
  font-size: 16px;
110
- line-height: 1.8;
228
+ line-height: 1.5; /* 稍微收紧基础行高 */
111
229
  color: ${textColor};
112
230
  background-color: ${bgColor};
113
- padding: 24px;
231
+ padding: 24px 30px;
114
232
  width: ${width}px;
115
- min-height: 100px;
116
233
  }
117
234
  .content {
118
235
  word-wrap: break-word;
119
- white-space: pre-wrap;
236
+ /* !!!这里必须删掉之前加的 white-space: pre-wrap; !!! */
120
237
  }
238
+
239
+ /* 严格控制基础段落间距 */
240
+ .content p { margin: 6px 0; }
241
+ .content p:empty { display: none; }
242
+
243
+ /* 🌟 核心修复:干掉 KaTeX 默认自带的巨大上下 Margin (1em) */
244
+ .katex-display { margin: 0 !important; }
245
+
246
+ /* 将公式容器设为 block,并接管精确的间距 */
121
247
  .latex-display {
122
- margin: 12px 0;
248
+ display: block;
249
+ margin: 10px 0; /* 这是真正的块级公式间距 */
123
250
  text-align: center;
124
251
  overflow-x: auto;
125
252
  }
126
- .latex-inline {
127
- margin: 0 2px;
128
- }
129
- .text-line {
130
- margin: 4px 0;
131
- }
253
+ /* 兼容纯文本分支的 span */
254
+ .latex-display.block { display: block; }
255
+
256
+ .latex-inline { margin: 0 2px; }
257
+ .text-line { margin: 2px 0; }
258
+
259
+ /* 标题和列表排版优化,使其更紧凑 */
260
+ .content h1, .content h2, .content h3, .content h4 { margin: 16px 0 8px 0; font-weight: 600; }
261
+ .content ul, .content ol { margin: 6px 0; padding-left: 24px; }
262
+ .content li { margin: 4px 0; }
263
+
264
+ .content blockquote { margin: 8px 0; padding: 8px 16px; border-left: 4px solid #ddd; background-color: #f5f5f5; }
265
+ .content code { background-color: #f0f0f0; padding: 2px 6px; border-radius: 4px; font-family: "Consolas", monospace; font-size: 0.9em; }
266
+ .content pre { background-color: #f5f5f5; padding: 12px; border-radius: 6px; overflow-x: auto; margin: 10px 0; }
267
+ .content pre code { background: none; padding: 0; }
268
+ .content strong { font-weight: 600; }
269
+ .content em { font-style: italic; }
270
+ .content a { color: #0066cc; text-decoration: none; }
132
271
  </style>
133
272
  </head>
134
273
  <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;
274
+ <div class="content">${htmlContent}</div>
275
+ </body>
276
+ </html>`;
157
277
  }
158
278
  __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);
279
+ function estimateHeight(content) {
280
+ const lines = Math.ceil(content.length / 35);
169
281
  return Math.max(100, lines * 24 + 48);
170
282
  }
171
283
  __name(estimateHeight, "estimateHeight");
172
284
  async function renderLatex(ctx, content, config) {
173
285
  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
286
  const width = config.width || 800;
186
- const height = estimateHeight(parsed);
287
+ const height = estimateHeight(content);
187
288
  let html;
188
289
  try {
189
- html = generateHtml(parsed, config);
190
- console.log("[latex-render] HTML 生成完成,长度:", html.length);
290
+ html = generateHtml(content, config);
291
+ console.log("[latex-render] HTML 生成完成");
191
292
  } catch (error) {
192
- console.error("[latex-render] HTML 生成失败:", error);
193
293
  throw new Error(`HTML 生成失败: ${error}`);
194
294
  }
195
295
  let page = null;
196
296
  try {
197
297
  page = await ctx.puppeteer.page();
198
- console.log("[latex-render] Puppeteer 页面获取成功");
199
298
  await page.setContent(html, {
200
- waitUntil: "networkidle2",
299
+ waitUntil: "networkidle0",
201
300
  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
301
  });
302
+ await new Promise((resolve) => setTimeout(resolve, 800));
303
+ const actualHeight = await page.evaluate(() => document.body ? document.body.scrollHeight : 0);
213
304
  const finalHeight = Math.max(actualHeight + 20, height);
214
- console.log("[latex-render] 实际高度:", finalHeight);
215
305
  const buffer = await page.screenshot({
216
- clip: {
217
- x: 0,
218
- y: 0,
219
- width,
220
- height: finalHeight
221
- },
306
+ clip: { x: 0, y: 0, width, height: finalHeight },
222
307
  type: "png"
223
308
  });
224
- console.log("[latex-render] 截图完成,buffer 长度:", buffer.length);
225
309
  await page.close().catch(() => {
226
310
  });
227
311
  page = null;
228
312
  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;
313
+ return await ctx.assets.upload(`data:image/png;base64,${base64}`, "latex-render.png");
233
314
  } 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
- }
315
+ if (page) await page.close().catch(() => {
316
+ });
242
317
  throw new Error(`LaTeX 渲染失败: ${error?.message || error}`);
243
318
  }
244
319
  }
245
320
  __name(renderLatex, "renderLatex");
321
+ function containsLatex(content) {
322
+ const decoded = decodeHtmlEntities(content);
323
+ return /\$\$/.test(decoded) || /\\begin\{/.test(decoded) || /\\\[[\s\S]*?\\\]/.test(decoded) || /\\\([\s\S]*?\\\)/.test(decoded) || /\\[a-zA-Z]+/.test(decoded) || /\^[^{]/.test(decoded) || /_[^{]/.test(decoded) || /\$[^\$\n]/.test(decoded);
324
+ }
325
+ __name(containsLatex, "containsLatex");
246
326
 
247
327
  // src/index.ts
248
328
  var name = "latex-render";
package/lib/renderer.d.ts CHANGED
@@ -1,15 +1,10 @@
1
+ import 'katex/contrib/mhchem';
1
2
  import { Context } from 'koishi';
2
3
  interface Config {
3
4
  width?: number;
4
5
  backgroundColor?: string;
5
6
  textColor?: string;
6
7
  }
7
- /**
8
- * 检测是否为 LaTeX 公式(增强版)
9
- */
10
- export declare function containsLatex(content: string): boolean;
11
- /**
12
- * 主渲染函数 - 使用 Puppeteer + KaTeX
13
- */
14
8
  export declare function renderLatex(ctx: Context, content: string, config: Config): Promise<string>;
9
+ export declare function containsLatex(content: string): boolean;
15
10
  export {};
package/package.json CHANGED
@@ -1,15 +1,10 @@
1
1
  {
2
2
  "name": "koishi-plugin-latex-render",
3
3
  "description": "ChatLuna 专用 - 将 AI 返回的 LaTeX 公式渲染为图片",
4
- "version": "1.0.9",
5
- "main": "index.js",
6
- "typings": "index.d.ts",
7
- "exports": {
8
- ".": "./index.js"
9
- },
4
+ "version": "1.1.0",
5
+ "main": "lib/index.js",
6
+ "typings": "lib/index.d.ts",
10
7
  "files": [
11
- "index.js",
12
- "index.d.ts",
13
8
  "lib"
14
9
  ],
15
10
  "license": "MIT",
@@ -31,7 +26,8 @@
31
26
  "koishi": "^4.18.7"
32
27
  },
33
28
  "dependencies": {
34
- "katex": "^0.16.0"
29
+ "katex": "^0.16.0",
30
+ "marked": "^12.0.0"
35
31
  },
36
32
  "koishi": {
37
33
  "preview": true,
package/readme.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 简介
6
6
 
7
- **本插件专为 [ChatLuna](https://github.com/ChatLuna/ChatLuna) 设计**,用于将 AI 返回的 LaTeX 公式渲染为图片并发送。
7
+ **本插件专为 [ChatLuna](https://github.com/ChatLunaLab/chatluna) 设计**,用于将 AI 返回的 LaTeX 公式渲染为图片并发送。
8
8
 
9
9
  当 ChatLuna 的 AI 返回包含 LaTeX 公式的消息时,本插件会自动:
10
10
  1. 拦截消息
package/index.d.ts DELETED
@@ -1,15 +0,0 @@
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/index.js DELETED
@@ -1,306 +0,0 @@
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
- });