koishi-plugin-latex-render 1.1.0 → 1.1.3

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/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/index.js ADDED
@@ -0,0 +1,388 @@
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
+ var import_mhchem = require("katex/contrib/mhchem");
44
+ var import_marked = require("marked");
45
+ function decodeHtmlEntities(text) {
46
+ return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ");
47
+ }
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");
109
+ function parseMessage(content) {
110
+ const result = [];
111
+ const regex = /(\$\$[\s\S]+?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)|\$[^\$\n]+?\$|\\begin\{[a-zA-Z*]+\}[\s\S]*?\\end\{[a-zA-Z*]+\})/g;
112
+ let lastIndex = 0;
113
+ let match;
114
+ while ((match = regex.exec(content)) !== null) {
115
+ if (match.index > lastIndex) {
116
+ const text = content.slice(lastIndex, match.index);
117
+ if (text) result.push({ type: "text", content: text });
118
+ }
119
+ const latex = match[1];
120
+ let isDisplay = false;
121
+ let formula = latex.trim();
122
+ if (formula.startsWith("$$") || formula.startsWith("\\[")) {
123
+ isDisplay = true;
124
+ } else if (formula.startsWith("\\begin")) {
125
+ isDisplay = true;
126
+ }
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 });
130
+ lastIndex = match.index + match[0].length;
131
+ }
132
+ if (lastIndex < content.length) {
133
+ const text = content.slice(lastIndex);
134
+ if (text) result.push({ type: "text", content: text });
135
+ }
136
+ return result;
137
+ }
138
+ __name(parseMessage, "parseMessage");
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) {
148
+ const textColor = config.textColor || "#333333";
149
+ const bgColor = config.backgroundColor || "#ffffff";
150
+ const width = config.width || 800;
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>
219
+ <html>
220
+ <head>
221
+ <meta charset="UTF-8">
222
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
223
+ <style>
224
+ * { margin: 0; padding: 0; box-sizing: border-box; }
225
+ body {
226
+ font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
227
+ font-size: 16px;
228
+ line-height: 1.5; /* 稍微收紧基础行高 */
229
+ color: ${textColor};
230
+ background-color: ${bgColor};
231
+ padding: 24px 30px;
232
+ width: ${width}px;
233
+ }
234
+ .content {
235
+ word-wrap: break-word;
236
+ /* !!!这里必须删掉之前加的 white-space: pre-wrap; !!! */
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,并接管精确的间距 */
247
+ .latex-display {
248
+ display: block;
249
+ margin: 10px 0; /* 这是真正的块级公式间距 */
250
+ text-align: center;
251
+ overflow-x: auto;
252
+ }
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; }
271
+ </style>
272
+ </head>
273
+ <body>
274
+ <div class="content">${htmlContent}</div>
275
+ </body>
276
+ </html>`;
277
+ }
278
+ __name(generateHtml, "generateHtml");
279
+ function estimateHeight(content) {
280
+ const lines = Math.ceil(content.length / 35);
281
+ return Math.max(100, lines * 24 + 48);
282
+ }
283
+ __name(estimateHeight, "estimateHeight");
284
+ async function renderLatex(ctx, content, config) {
285
+ console.log("[latex-render] 开始渲染...");
286
+ const width = config.width || 800;
287
+ const height = estimateHeight(content);
288
+ let html;
289
+ try {
290
+ html = generateHtml(content, config);
291
+ console.log("[latex-render] HTML 生成完成");
292
+ } catch (error) {
293
+ throw new Error(`HTML 生成失败: ${error}`);
294
+ }
295
+ let page = null;
296
+ try {
297
+ page = await ctx.puppeteer.page();
298
+ await page.setContent(html, {
299
+ waitUntil: "networkidle2",
300
+ // 修改 1:改成 networkidle2,防止国内 CDN 少量挂起导致超时
301
+ timeout: 3e4
302
+ });
303
+ await page.evaluateHandle("document.fonts.ready");
304
+ await new Promise((resolve) => setTimeout(resolve, 500));
305
+ const actualHeight = await page.evaluate(() => document.body ? document.body.scrollHeight : 0);
306
+ const finalHeight = Math.max(actualHeight + 20, height);
307
+ const buffer = await page.screenshot({
308
+ clip: { x: 0, y: 0, width, height: finalHeight },
309
+ type: "png"
310
+ });
311
+ await page.close().catch(() => {
312
+ });
313
+ page = null;
314
+ const base64 = Buffer.from(buffer).toString("base64");
315
+ return await ctx.assets.upload(`data:image/png;base64,${base64}`, "latex-render.png");
316
+ } catch (error) {
317
+ if (page) await page.close().catch(() => {
318
+ });
319
+ throw new Error(`LaTeX 渲染失败: ${error?.message || error}`);
320
+ }
321
+ }
322
+ __name(renderLatex, "renderLatex");
323
+ function containsLatex(content) {
324
+ const decoded = decodeHtmlEntities(content);
325
+ 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);
326
+ }
327
+ __name(containsLatex, "containsLatex");
328
+
329
+ // src/index.ts
330
+ var name = "latex-render";
331
+ var inject = ["assets", "puppeteer"];
332
+ var Config = import_koishi.Schema.object({
333
+ width: import_koishi.Schema.number().default(800).description("图片宽度"),
334
+ backgroundColor: import_koishi.Schema.string().default("#ffffff").description("背景色"),
335
+ textColor: import_koishi.Schema.string().default("#333333").description("文字颜色"),
336
+ debug: import_koishi.Schema.boolean().default(false).description("调试模式")
337
+ });
338
+ function apply(ctx, config) {
339
+ const debug = config.debug || false;
340
+ const handler = /* @__PURE__ */ __name(async (conversationId, sourceMessage, displayResponse, promptVariables, chatInterface, session) => {
341
+ try {
342
+ let content;
343
+ if (Array.isArray(displayResponse)) {
344
+ content = displayResponse[0]?.content;
345
+ } else if (displayResponse?.content) {
346
+ content = displayResponse.content;
347
+ } else if (typeof displayResponse === "string") {
348
+ content = displayResponse;
349
+ }
350
+ if (!content) return;
351
+ const contentStr = typeof content === "string" ? content : JSON.stringify(content);
352
+ if (containsLatex(contentStr)) {
353
+ if (debug) {
354
+ console.log("[latex-render] 检测到 LaTeX 公式,开始渲染...");
355
+ }
356
+ try {
357
+ const imageUrl = await renderLatex(ctx, contentStr, config);
358
+ if (session) {
359
+ await session.send(import_koishi.h.image(imageUrl));
360
+ }
361
+ const imageContent = import_koishi.h.image(imageUrl).toString();
362
+ if (Array.isArray(displayResponse)) {
363
+ displayResponse[0].content = imageContent;
364
+ } else {
365
+ displayResponse.content = imageContent;
366
+ }
367
+ } catch (error) {
368
+ if (debug) {
369
+ console.error("[latex-render] 渲染失败:", error);
370
+ }
371
+ }
372
+ }
373
+ } catch (error) {
374
+ if (debug) {
375
+ console.error("[latex-render] 处理失败:", error);
376
+ }
377
+ }
378
+ }, "handler");
379
+ ctx.on("chatluna/after-chat", handler);
380
+ }
381
+ __name(apply, "apply");
382
+ // Annotate the CommonJS export names for ESM import in node:
383
+ 0 && (module.exports = {
384
+ Config,
385
+ apply,
386
+ inject,
387
+ name
388
+ });
package/lib/index.js CHANGED
@@ -80,7 +80,7 @@ function autoWrapLatex(text) {
80
80
  }
81
81
  const leading = chunk.slice(0, chunk.indexOf(trimmed));
82
82
  const trailing = chunk.slice(chunk.indexOf(trimmed) + trimmed.length);
83
- const mathMatch = trimmed.match(/^([\s::,,。;;]*)(.*?)([\s::,,。;;]*)$/);
83
+ const mathMatch = trimmed.match(/^([\s::,,。;;\*\#`~]*)(.*?)([\s::,,。;;\*\#`~]*)$/);
84
84
  const prefix = mathMatch ? mathMatch[1] : "";
85
85
  const coreMath = mathMatch ? mathMatch[2] : trimmed;
86
86
  const suffix = mathMatch ? mathMatch[3] : "";
@@ -296,10 +296,12 @@ async function renderLatex(ctx, content, config) {
296
296
  try {
297
297
  page = await ctx.puppeteer.page();
298
298
  await page.setContent(html, {
299
- waitUntil: "networkidle0",
299
+ waitUntil: "networkidle2",
300
+ // 修改 1:改成 networkidle2,防止国内 CDN 少量挂起导致超时
300
301
  timeout: 3e4
301
302
  });
302
- await new Promise((resolve) => setTimeout(resolve, 800));
303
+ await page.evaluateHandle("document.fonts.ready");
304
+ await new Promise((resolve) => setTimeout(resolve, 500));
303
305
  const actualHeight = await page.evaluate(() => document.body ? document.body.scrollHeight : 0);
304
306
  const finalHeight = Math.max(actualHeight + 20, height);
305
307
  const buffer = await page.screenshot({
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "koishi-plugin-latex-render",
3
3
  "description": "ChatLuna 专用 - 将 AI 返回的 LaTeX 公式渲染为图片",
4
- "version": "1.1.0",
4
+ "version": "1.1.3",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
8
- "lib"
8
+ "lib",
9
+ "index.js",
10
+ "index.d.ts"
9
11
  ],
10
12
  "license": "MIT",
11
13
  "homepage": "https://github.com/gt4404gb/koishi-plugin-latex-render",