@ggboy123/dsh-paper-reader 1.3.4 → 1.4.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/dist/embed.js ADDED
@@ -0,0 +1,349 @@
1
+ // embed.ts — OpenAI 兼容嵌入端点客户端 + 分块嵌入缓存 + 混合召回(纯函数,不依赖 Cordis)。
2
+ //
3
+ // 形状对齐 rerank.ts:**可选增强、默认关闭、失败降级**而不是抛给用户。与重排的分工:
4
+ // - 重排(rerank.ts):对关键词 shortlist **定序**(候选已经捞回来了);
5
+ // - 嵌入(本文件):**全量召回**——关键词一个词都没命中的语义相近片段也能进候选。
6
+ // 两者可同时启用,顺序固定:关键词候选 ∪ 嵌入候选 → RRF 融合 →(配了 Jev/TypeSafe 则)既有重排 → top-k。
7
+ //
8
+ // 缓存:`<论文>.embeddings.json`,键 = {model, dimensions, 内容哈希};命中时**不请求分块嵌入**。
9
+ // 隐私:分块文本会发往配置的端点(README 有明确提示);缓存与错误信息里都不出现 apiKey。
10
+ import { createHash } from 'node:crypto';
11
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
12
+ import { dirname } from 'node:path';
13
+ import { pageForOffset } from "./search.js";
14
+ import { sanitizeDetail } from "./mineru.js";
15
+ export const EMBED_DEFAULTS = { batchSize: 32, timeoutMs: 30_000, candidateSize: 20 };
16
+ /** 嵌入链路错误的统一类型:调用方据此**整体降级**为纯关键词,不把它抛给用户。 */
17
+ export class EmbedError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'EmbedError';
21
+ }
22
+ }
23
+ /** `<论文>.txt` → `<论文>.embeddings.json`(与 .txt/.pages.json 同目录同前缀)。 */
24
+ export function embeddingsPathFor(txtPath) {
25
+ return txtPath.replace(/\.txt$/i, '') + '.embeddings.json';
26
+ }
27
+ /**
28
+ * 分块集合的内容哈希(缓存失效条件之一):分块大小 + 分块数 + 每块长度 + 每块文本,顺序敏感。
29
+ * `.txt` 变了 / 分块大小变了 / 重新转录了 → 哈希变 → 缓存判定失效并重算。
30
+ */
31
+ export function chunkSetHash(chunks, chunkSize = 1500) {
32
+ const h = createHash('sha256');
33
+ h.update(`chunkSize:${chunkSize};count:${chunks.length}\n`);
34
+ for (const c of chunks)
35
+ h.update(`${c.text.length}:${c.text}\n`);
36
+ return h.digest('hex');
37
+ }
38
+ function isRecord(v) {
39
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
40
+ }
41
+ function isFiniteNumber(v) {
42
+ return typeof v === 'number' && Number.isFinite(v);
43
+ }
44
+ /**
45
+ * 校验嵌入响应:形状(条数一致)、维度一致、**必须都是有限数**(NaN/Infinity/字符串一律拒绝)。
46
+ * 同时容忍两种常见形状:OpenAI 的 `{index, embedding}` 与部分本地实现直接给的数组。
47
+ */
48
+ export function validateEmbeddings(raw, expected, where) {
49
+ if (!Array.isArray(raw))
50
+ throw new EmbedError(`${where}:data 不是数组`);
51
+ if (raw.length !== expected)
52
+ throw new EmbedError(`${where}:返回 ${raw.length} 条嵌入,期望 ${expected} 条`);
53
+ const out = [];
54
+ let dim = -1;
55
+ for (const row of raw) {
56
+ const vec = Array.isArray(row) ? row : isRecord(row) ? row['embedding'] : undefined;
57
+ if (!Array.isArray(vec))
58
+ throw new EmbedError(`${where}:条目缺少 embedding 数组`);
59
+ if (vec.length === 0)
60
+ throw new EmbedError(`${where}:嵌入维度为 0`);
61
+ for (const v of vec) {
62
+ if (!isFiniteNumber(v))
63
+ throw new EmbedError(`${where}:嵌入含非数值/NaN,已拒绝`);
64
+ }
65
+ if (dim === -1)
66
+ dim = vec.length;
67
+ else if (vec.length !== dim)
68
+ throw new EmbedError(`${where}:同批嵌入维度不一致(${dim} vs ${vec.length})`);
69
+ out.push(vec);
70
+ }
71
+ return out;
72
+ }
73
+ /** 端点 URL:允许用户直接填到 `/embeddings`,否则拼在根后面。 */
74
+ export function embeddingsUrl(baseUrl) {
75
+ const base = baseUrl.replace(/\/+$/, '');
76
+ return /\/embeddings$/i.test(base) ? base : `${base}/embeddings`;
77
+ }
78
+ function networkMessage(err) {
79
+ if (err instanceof Error) {
80
+ const cause = err.cause instanceof Error ? `:${err.cause.message}` : '';
81
+ return `${err.name === 'TimeoutError' ? '请求超时' : err.message}${cause}`;
82
+ }
83
+ return String(err);
84
+ }
85
+ /**
86
+ * 一次 `/embeddings` 调用(一批文本)。任何失败都抛 EmbedError(调用方整体降级)。
87
+ * 错误文案里的端点响应体片段一律经 sanitizeDetail(…, apiKey) 脱敏。
88
+ */
89
+ export async function requestEmbeddings(req, texts) {
90
+ if (texts.length === 0)
91
+ return [];
92
+ const timeoutMs = req.timeoutMs ?? EMBED_DEFAULTS.timeoutMs;
93
+ const fetchImpl = req.fetchImpl ?? fetch;
94
+ const signals = [AbortSignal.timeout(timeoutMs)];
95
+ if (req.signal)
96
+ signals.push(req.signal);
97
+ let res;
98
+ try {
99
+ res = await fetchImpl(embeddingsUrl(req.baseUrl), {
100
+ method: 'POST',
101
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${req.apiKey}` },
102
+ body: JSON.stringify({ model: req.model, input: texts, encoding_format: 'float' }),
103
+ signal: signals.length === 1 ? signals[0] : AbortSignal.any(signals),
104
+ });
105
+ }
106
+ catch (err) {
107
+ throw new EmbedError(`嵌入端点请求失败:${sanitizeDetail(networkMessage(err), req.apiKey)}`);
108
+ }
109
+ const body = await res.text().catch(() => '');
110
+ if (!res.ok) {
111
+ const detail = sanitizeDetail(body.slice(0, 200), req.apiKey);
112
+ throw new EmbedError(`嵌入端点返回 HTTP ${res.status}${detail ? `:${detail}` : ''}`);
113
+ }
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(body);
117
+ }
118
+ catch {
119
+ throw new EmbedError('嵌入端点返回的不是合法 JSON');
120
+ }
121
+ return validateEmbeddings(isRecord(parsed) ? parsed['data'] : undefined, texts.length, '嵌入端点响应');
122
+ }
123
+ // ── 缓存 ────────────────────────────────────────────────────────────────
124
+ /** 读缓存:文件缺失/损坏/形状不对一律 null(与其余 *.json 读策略一致)。 */
125
+ export async function readEmbedCache(path) {
126
+ try {
127
+ const data = JSON.parse(await readFile(path, 'utf8'));
128
+ if (!isRecord(data))
129
+ return null;
130
+ const vectors = data['vectors'];
131
+ if (!Array.isArray(vectors))
132
+ return null;
133
+ if (typeof data['model'] !== 'string' || typeof data['contentHash'] !== 'string')
134
+ return null;
135
+ if (!Number.isInteger(data['dimensions']))
136
+ return null;
137
+ return {
138
+ v: 1,
139
+ // 旧缓存没有 baseUrl 字段 → 记为空串 → 与任何真实 baseUrl 都不相等 → 判定失效重算(一次性的预期失效)
140
+ baseUrl: typeof data['baseUrl'] === 'string' ? data['baseUrl'] : '',
141
+ model: data['model'],
142
+ dimensions: data['dimensions'],
143
+ contentHash: data['contentHash'],
144
+ createdAt: typeof data['createdAt'] === 'string' ? data['createdAt'] : '',
145
+ vectors: vectors,
146
+ };
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ /** 原子写缓存(临时文件 + rename),避免半截 JSON 被当成有效缓存。 */
153
+ export async function writeEmbedCache(path, cache) {
154
+ await mkdir(dirname(path), { recursive: true });
155
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
156
+ await writeFile(tmp, JSON.stringify(cache), 'utf8');
157
+ await rename(tmp, path);
158
+ }
159
+ /** 删除缓存(模型/维度失效或用户显式清理时用)。 */
160
+ export async function clearEmbedCache(path) {
161
+ await rm(path, { force: true });
162
+ }
163
+ /**
164
+ * 惰性计算分块嵌入:缓存有效则直接复用(零分块请求),否则按 batchSize 分批请求。
165
+ * 缓存键 = `{baseUrl, model, dimensions, 内容哈希}`(F-E2:baseUrl 与查询向量备忘对齐,
166
+ * 换端点必须重算);缓存有效性 = v=1 ∧ baseUrl 一致 ∧ model 一致 ∧ 内容哈希一致 ∧ 条数一致
167
+ * ∧ 维度一致(含 expectedDimensions)∧ 全为有限数。
168
+ * 任一批次失败都抛 EmbedError(调用方整体降级),**不写缓存** —— 绝不产生半截缓存。
169
+ */
170
+ export async function ensureChunkVectors(o) {
171
+ const model = o.req.model;
172
+ const hash = chunkSetHash(o.chunks, o.chunkSize ?? 1500);
173
+ const cached = await readEmbedCache(o.cachePath);
174
+ const cacheOk = cached !== null &&
175
+ cached.baseUrl === o.req.baseUrl &&
176
+ cached.model === model &&
177
+ cached.contentHash === hash &&
178
+ cached.vectors.length === o.chunks.length &&
179
+ cached.dimensions > 0 &&
180
+ cached.vectors.every((v) => Array.isArray(v) && v.length === cached.dimensions && v.every(isFiniteNumber)) &&
181
+ (o.expectedDimensions === undefined || o.expectedDimensions === cached.dimensions);
182
+ if (cacheOk)
183
+ return { vectors: cached.vectors, dimensions: cached.dimensions, fromCache: true };
184
+ if (o.chunks.length === 0)
185
+ return { vectors: [], dimensions: o.expectedDimensions ?? 0, fromCache: false };
186
+ const batchSize = Math.max(1, o.batchSize ?? EMBED_DEFAULTS.batchSize);
187
+ const vectors = [];
188
+ let dimensions = -1;
189
+ for (let i = 0; i < o.chunks.length; i += batchSize) {
190
+ const part = o.chunks.slice(i, i + batchSize);
191
+ const got = await requestEmbeddings(o.req, part.map((c) => c.text));
192
+ for (const v of got) {
193
+ if (dimensions === -1)
194
+ dimensions = v.length;
195
+ else if (v.length !== dimensions)
196
+ throw new EmbedError(`分批嵌入维度不一致(${dimensions} vs ${v.length})`);
197
+ vectors.push(v);
198
+ }
199
+ }
200
+ if (o.expectedDimensions !== undefined && dimensions !== o.expectedDimensions) {
201
+ throw new EmbedError(`嵌入维度与查询向量不一致(${dimensions} vs ${o.expectedDimensions}),本次语义召回已放弃`);
202
+ }
203
+ await writeEmbedCache(o.cachePath, {
204
+ v: 1,
205
+ baseUrl: o.req.baseUrl,
206
+ model,
207
+ dimensions,
208
+ contentHash: hash,
209
+ createdAt: new Date().toISOString(),
210
+ vectors,
211
+ });
212
+ return { vectors, dimensions, fromCache: false };
213
+ }
214
+ /**
215
+ * 进程内查询向量备忘:同一查询重复检索(同一模型/端点)时**零网络请求**。
216
+ * 键含 endpoint+model,换模型/换端点自动失效;只留最近 32 条,不落盘(查询文本不进缓存文件)。
217
+ */
218
+ const queryMemo = new Map();
219
+ const QUERY_MEMO_MAX = 32;
220
+ function memoKey(req, text) {
221
+ return `${req.baseUrl}|${req.model}|${text}`;
222
+ }
223
+ /** 清空查询向量备忘(测试/换模型时用)。 */
224
+ export function clearEmbedQueryMemo() {
225
+ queryMemo.clear();
226
+ }
227
+ /** 查询向量(带进程内备忘)。 */
228
+ export async function embedQuery(req, text) {
229
+ const key = memoKey(req, text);
230
+ const hit = queryMemo.get(key);
231
+ if (hit)
232
+ return hit;
233
+ const [vec] = await requestEmbeddings(req, [text]);
234
+ if (!vec)
235
+ throw new EmbedError('嵌入端点未返回查询向量');
236
+ queryMemo.set(key, vec);
237
+ if (queryMemo.size > QUERY_MEMO_MAX) {
238
+ const oldest = queryMemo.keys().next().value;
239
+ if (oldest !== undefined)
240
+ queryMemo.delete(oldest);
241
+ }
242
+ return vec;
243
+ }
244
+ // ── 召回与融合 ──────────────────────────────────────────────────────────
245
+ /** 余弦相似度;维度不同/零向量一律返回 0(不抛错,避免脏数据中断检索)。 */
246
+ export function cosineSimilarity(a, b) {
247
+ if (a.length === 0 || a.length !== b.length)
248
+ return 0;
249
+ let dot = 0;
250
+ let na = 0;
251
+ let nb = 0;
252
+ for (let i = 0; i < a.length; i++) {
253
+ const x = a[i];
254
+ const y = b[i];
255
+ dot += x * y;
256
+ na += x * x;
257
+ nb += y * y;
258
+ }
259
+ if (na === 0 || nb === 0)
260
+ return 0;
261
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
262
+ }
263
+ /** 语义召回:按余弦取前 limit 块(相似度 ≤ 0 视为噪声,不进候选)。 */
264
+ export function embeddingRecall(chunks, pages, queryVector, vectors, limit) {
265
+ const scored = [];
266
+ const n = Math.min(chunks.length, vectors.length);
267
+ for (let i = 0; i < n; i++) {
268
+ const sim = cosineSimilarity(queryVector, vectors[i]);
269
+ if (sim > 0) {
270
+ scored.push({ chunk: chunks[i], score: Math.round(sim * 1000) / 1000, page: pageForOffset(pages, chunks[i].start) });
271
+ }
272
+ }
273
+ scored.sort((a, b) => b.score - a.score || a.chunk.index - b.chunk.index);
274
+ return scored.slice(0, Math.max(1, limit));
275
+ }
276
+ /**
277
+ * RRF 融合(Reciprocal Rank Fusion,k=60):关键词候选 ∪ 语义候选 → 按**名次**加权求和 → 去重。
278
+ * 为什么用名次而不是原始分数:关键词分(词频×词长)与余弦相似度量纲完全不同,直接相加没有意义。
279
+ *
280
+ * **实际语义(F-E1 如实记录,算法有意如此,不是 bug)**:
281
+ * - 只吃名次、**完全不看分数大小**:关键词第 i 名与语义第 j 名的贡献分别是 1/(60+i) 与 1/(60+j),
282
+ * 所以语义第 j 名会压过关键词第 i 名(当 j < i)——两列候选会**交替占用**最终 top-k 名额;
283
+ * - 「关键词优先」只在**精确同分**时生效(两列名次相同、分数相等时关键词那列排前面);
284
+ * - 两列都命中的片段获得两份贡献,自然排最前。
285
+ * 这正是「嵌入作为召回、与关键词候选交替占位」的预期形态:关键词检索仍在跑并持续贡献候选,
286
+ * 并未被替换。**注意**:本机没有真实嵌入端点可用于调参,故不引入「按分数插值 / 名额保底」
287
+ * 这类无法验证的融合策略;若将来要改(例如给关键词列加权或保底名额),需先回报队长并补可验证证据。
288
+ */
289
+ export function fuseHybridCandidates(keyword, semantic, limit) {
290
+ const RRF_K = 60;
291
+ const byRank = (hits) => hits.slice().sort((a, b) => b.score - a.score || a.chunk.index - b.chunk.index);
292
+ const kw = byRank(keyword);
293
+ const sem = byRank(semantic);
294
+ const byChunk = new Map();
295
+ const score = new Map();
296
+ const fromKeyword = new Set();
297
+ kw.forEach((h, i) => {
298
+ byChunk.set(h.chunk.index, h);
299
+ fromKeyword.add(h.chunk.index);
300
+ score.set(h.chunk.index, (score.get(h.chunk.index) ?? 0) + 1 / (RRF_K + i + 1));
301
+ });
302
+ sem.forEach((h, i) => {
303
+ if (!byChunk.has(h.chunk.index))
304
+ byChunk.set(h.chunk.index, h);
305
+ score.set(h.chunk.index, (score.get(h.chunk.index) ?? 0) + 1 / (RRF_K + i + 1));
306
+ });
307
+ const picked = [...byChunk.keys()]
308
+ .sort((a, b) => {
309
+ const d = (score.get(b) ?? 0) - (score.get(a) ?? 0);
310
+ if (d !== 0)
311
+ return d;
312
+ const ka = fromKeyword.has(a);
313
+ const kb = fromKeyword.has(b);
314
+ if (ka !== kb)
315
+ return ka ? -1 : 1;
316
+ return a - b;
317
+ })
318
+ .slice(0, Math.max(1, limit))
319
+ .map((idx) => byChunk.get(idx));
320
+ picked.sort((a, b) => a.chunk.index - b.chunk.index); // 与 searchChunks 一致:按原文顺序返回
321
+ return picked;
322
+ }
323
+ /**
324
+ * 惰性语义召回:查询向量(带进程内备忘)+ 分块向量(带磁盘缓存)→ 余弦 top-N。
325
+ * 任何失败(不可达/超时/HTTP 错误/形状或维度非法/非数值)都**吞成 degraded 原因**,
326
+ * 返回空候选让调用方继续用关键词结果——绝不抛错中断检索。
327
+ */
328
+ export async function embeddingRecallFor(o) {
329
+ if (!o.configured || !o.req) {
330
+ return { hits: [], configured: false, used: false, fromCache: false, degraded: null };
331
+ }
332
+ try {
333
+ const queryVector = await embedQuery(o.req, o.query);
334
+ const { vectors, fromCache } = await ensureChunkVectors({
335
+ cachePath: o.cachePath,
336
+ chunks: o.chunks,
337
+ ...(o.chunkSize !== undefined ? { chunkSize: o.chunkSize } : {}),
338
+ req: o.req,
339
+ expectedDimensions: queryVector.length,
340
+ ...(o.batchSize !== undefined ? { batchSize: o.batchSize } : {}),
341
+ });
342
+ const hits = embeddingRecall(o.chunks, o.pages, queryVector, vectors, o.limit ?? EMBED_DEFAULTS.candidateSize);
343
+ return { hits, configured: true, used: hits.length > 0, fromCache, degraded: null };
344
+ }
345
+ catch (err) {
346
+ const msg = err instanceof Error ? err.message : String(err);
347
+ return { hits: [], configured: true, used: false, fromCache: false, degraded: msg };
348
+ }
349
+ }
@@ -0,0 +1,272 @@
1
+ // formulas.ts — MinerU content_list → 阅读器可用的公式索引(纯函数 + 一次读盘)。
2
+ //
3
+ // 背景(docs/reader-ux-requirements.md §1):PDF 文本层里的公式是乱码(`𝑧𝑧1 𝑧𝑧2` 这类
4
+ // 重复字形),鼠标选中拿不到可用内容;MinerU 的 content_list 里每个 equation 块带
5
+ // LaTeX(`text`)与 `bbox`,这才是可引用的真相源。
6
+ //
7
+ // bbox 坐标系(实测 + 源码实证,见需求文档 §1.1):**页面归一化坐标 ×1000**
8
+ // x = bbox[0]/1000 * viewport.width ,y = bbox[1]/1000 * viewport.height
9
+ // 原点左上、y 向下、与缩放无关。归一化只在 page_size 存在时才成立,所以这里做一次
10
+ // 防御性判定:全页最大分量 ≤ 1000 → 'normalized-1000',否则退回 'page-units'
11
+ // (调用方按 pt/px × scale 处理),避免云端版本差异导致热区整体错位。
12
+ //
13
+ // ⚠️ 与需求文档的**显式偏离**(队长在 t2 进行中裁定的契约修订):
14
+ // 文档 §1.7 写的是「`latex` 原样含 `$$`」,本轮改为**紧凑形态**——服务端复用既有的
15
+ // 投影函数 `compressMathSpaces` + `normalizeMathBraces`(src/mineru.ts 导出,与
16
+ // content_list → .txt 的投影同一套规则)。理由:MinerU 原始输出每个 token 之间都插空格
17
+ // (`\pi ( \mathbf { s } _ { t } )`),① 与同一产品 .txt 里的检索形态自相矛盾;
18
+ // ② 用户会把 LaTeX 粘到别处,紧凑写法才是地道 LaTeX。`$$` 定界符保留(模型友好)。
19
+ // `\text{}`/`\textrm`/`\mbox` 一族按既有规则整组原样保留(组内空格是排版语义)。
20
+ import { readFile, stat } from 'node:fs/promises';
21
+ import { compressMathSpaces, normalizeMathBraces } from "./mineru.js";
22
+ /** 供测试与调用方复用的紧凑化:既有投影两步 + 算子名统一(见下)。 */
23
+ export function compactLatex(rawLatex) {
24
+ return unifyOperatorNames(normalizeMathBraces(compressMathSpaces(rawLatex)));
25
+ }
26
+ /**
27
+ * `\operatorname` / `\operatorname*` 的算子名统一(**引用路径专用**,契约 t2 第 2 次修订)。
28
+ *
29
+ * 背景:MinerU 对算子名做**逐字符空格化**(`\operatorname { D o g }`),照原样渲染会出现
30
+ * `D o g` 这类多余空格;而既有投影函数把 `\operatorname` 整组保护起来(不压空格),
31
+ * 带 `*` 的变体因命令名扫描在 `*` 处断开而**没被保护**——于是两个变体行为不一致。
32
+ *
33
+ * 精确规则(两个变体完全一致):
34
+ * · 组内 token **全部是单字符** → 判为逐字符空格化产物,合并成一个词:
35
+ * `{ m a x }`→`max`、`{ i f }`→`if`、`{ D o g }`→`Dog`、`{ s . t . }`→`s.t.`
36
+ * · 否则**原样保留**:`{ arg max }`(token 是 `arg`/`max`)绝不能被合并成 `argmax`。
37
+ * 花括号不配对 / 组缺失 → 一律不碰。
38
+ *
39
+ * 上游根因已同步修掉(src/mineru.ts 的 scanCommandName):命令名扫描原先在 `*` 处停下,
40
+ * 导致带 `*` 的变体压根没被当成受保护组、两个变体行为不一致;现在两变体走同一条路径。
41
+ * 「合并成一个词」这一步仍只放在**引用路径**(本模块):.txt 投影里 `\operatorname{a b c}`
42
+ * 保持逐字符空格是被既有断言固定的行为(test/embed-search.test.mjs H1、
43
+ * test/embed-verification.test.mjs 6.1),在共享函数里合并会破坏那两条断言与
44
+ * 「既有 182 个用例保持通过」。
45
+ */
46
+ export function unifyOperatorNames(latex) {
47
+ if (!latex.includes('\\operatorname'))
48
+ return latex;
49
+ const CMD = '\\operatorname';
50
+ let out = '';
51
+ let i = 0;
52
+ while (i < latex.length) {
53
+ const at = latex.indexOf(CMD, i);
54
+ if (at < 0) {
55
+ out += latex.slice(i);
56
+ break;
57
+ }
58
+ let k = at + CMD.length;
59
+ if (latex.startsWith('withlimits', k))
60
+ k += 'withlimits'.length;
61
+ else if (latex[k] === '*')
62
+ k += 1;
63
+ else if (isAsciiLetter(latex[k] ?? '')) {
64
+ out += latex.slice(i, k);
65
+ i = k;
66
+ continue;
67
+ } // 前缀同形命令不卷进来
68
+ const head = latex.slice(at, k);
69
+ let p = k;
70
+ while (p < latex.length && /\s/.test(latex[p]))
71
+ p++;
72
+ if (latex[p] !== '{') {
73
+ out += latex.slice(i, k);
74
+ i = k;
75
+ continue;
76
+ }
77
+ const end = matchBrace(latex, p);
78
+ if (end < 0) {
79
+ out += latex.slice(i, k);
80
+ i = k;
81
+ continue;
82
+ } // 花括号不配对:此后一律不碰
83
+ const inner = latex.slice(p + 1, end);
84
+ const tokens = inner.trim().split(/\s+/).filter((t) => t.length > 0);
85
+ if (tokens.length > 0 && tokens.every((t) => [...t].length === 1)) {
86
+ out += latex.slice(i, at) + head + '{' + tokens.join('') + '}'; // 逐字符空格化 → 合并成一个词
87
+ }
88
+ else {
89
+ // `{ arg max }` 等:组内容一字不动(绝不合并),只削掉紧贴花括号的填充空白——
90
+ // 让带 `*` 与不带 `*` 两个变体输出一致(`\operatorname{arg max}` / `\operatorname*{arg max}`)。
91
+ // 花括号内侧的空格在 \operatorname 组内无语义,削掉安全;\text 一族不走这里(保持逐字节原样)。
92
+ out += latex.slice(i, at) + head + '{' + inner.trim() + '}';
93
+ }
94
+ i = end + 1;
95
+ }
96
+ return out;
97
+ }
98
+ function isAsciiLetter(c) {
99
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
100
+ }
101
+ /** `{` 起始的配对花括号:返回对应 `}` 的下标,失败返回 -1(转义 `\{` / `\}` 跳过)。 */
102
+ function matchBrace(text, open) {
103
+ let depth = 0;
104
+ for (let i = open; i < text.length; i++) {
105
+ const c = text[i];
106
+ if (c === '\\') {
107
+ i++;
108
+ continue;
109
+ }
110
+ if (c === '{')
111
+ depth++;
112
+ else if (c === '}') {
113
+ depth--;
114
+ if (depth === 0)
115
+ return i;
116
+ }
117
+ }
118
+ return -1;
119
+ }
120
+ function isRecord(v) {
121
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
122
+ }
123
+ const EMPTY = (reason, warning) => ({
124
+ source: reason === 'no-mineru-artifact' ? 'none' : 'mineru',
125
+ producer: null,
126
+ backend: null,
127
+ pageCount: null,
128
+ pageSize: null,
129
+ bboxSpace: 'normalized-1000',
130
+ equations: [],
131
+ reason,
132
+ ...(warning ? { warning } : {}),
133
+ });
134
+ function toBbox(v) {
135
+ if (!Array.isArray(v) || v.length !== 4)
136
+ return null;
137
+ const out = [];
138
+ for (const n of v) {
139
+ if (typeof n !== 'number' || !Number.isFinite(n))
140
+ return null;
141
+ out.push(n);
142
+ }
143
+ return out;
144
+ }
145
+ /** 从 `.mineru.json` 的原始文本建索引。`raw === null` = 文件不存在(走 pdfjs 或未转录)。 */
146
+ export function buildFormulaIndex(raw, opts = {}) {
147
+ if (raw === null)
148
+ return EMPTY('no-mineru-artifact');
149
+ let parsed;
150
+ try {
151
+ parsed = JSON.parse(raw);
152
+ }
153
+ catch {
154
+ return EMPTY('bad-artifact', 'mineru.json 不是合法 JSON,已忽略');
155
+ }
156
+ if (!isRecord(parsed))
157
+ return EMPTY('bad-artifact', 'mineru.json 顶层不是对象,已忽略');
158
+ const list = parsed['contentList'];
159
+ if (list === undefined)
160
+ return EMPTY('bad-artifact', 'mineru.json 缺少 contentList,已忽略');
161
+ if (!Array.isArray(list))
162
+ return EMPTY('bad-artifact', 'contentList 不是数组,已忽略');
163
+ const producer = typeof parsed['producer'] === 'string' ? parsed['producer'] : (typeof opts.producer === 'string' ? opts.producer : null);
164
+ const backend = typeof parsed['backend'] === 'string' ? parsed['backend'] : (typeof opts.backend === 'string' ? opts.backend : null);
165
+ const pageCount = typeof parsed['pageCount'] === 'number' && Number.isFinite(parsed['pageCount'])
166
+ ? parsed['pageCount']
167
+ : (typeof opts.pageCount === 'number' && Number.isFinite(opts.pageCount) ? opts.pageCount : null);
168
+ // 逐块取 equation(缺 page_idx 的块沿用上一个有页码的块——与 transcribe.ts 的投影口径一致)
169
+ const raw_eq = [];
170
+ let lastPageIdx = 0;
171
+ let malformed = 0;
172
+ for (const b of list) {
173
+ if (!isRecord(b)) {
174
+ malformed++;
175
+ continue;
176
+ }
177
+ if (b['type'] !== 'equation')
178
+ continue;
179
+ const pi = b['page_idx'];
180
+ if (typeof pi === 'number' && Number.isFinite(pi) && pi >= 0)
181
+ lastPageIdx = Math.trunc(pi);
182
+ const text = b['text'];
183
+ const latex = typeof text === 'string' ? text : '';
184
+ const fmt = typeof b['text_format'] === 'string' ? b['text_format'] : null;
185
+ if (typeof pi !== 'number' && text === undefined)
186
+ malformed++;
187
+ raw_eq.push({ pageIdx: lastPageIdx, bbox: toBbox(b['bbox']), latex, fmt });
188
+ }
189
+ // 阅读序:先页码,再自上而下(bbox[1]),再自左而右(bbox[0])。content_list 本身是
190
+ // 版面序(先左栏后右栏),直接照抄会给错序(实测:一页里 y 是 486,688,809,409,511)。
191
+ raw_eq.sort((a, b) => {
192
+ if (a.pageIdx !== b.pageIdx)
193
+ return a.pageIdx - b.pageIdx;
194
+ const ay = a.bbox ? a.bbox[1] : Number.MAX_SAFE_INTEGER;
195
+ const by = b.bbox ? b.bbox[1] : Number.MAX_SAFE_INTEGER;
196
+ if (ay !== by)
197
+ return ay - by;
198
+ const ax = a.bbox ? a.bbox[0] : Number.MAX_SAFE_INTEGER;
199
+ const bx = b.bbox ? b.bbox[0] : Number.MAX_SAFE_INTEGER;
200
+ return ax - bx;
201
+ });
202
+ const perPage = new Map();
203
+ const equations = raw_eq.map((e) => {
204
+ const index = (perPage.get(e.pageIdx) ?? 0) + 1;
205
+ perPage.set(e.pageIdx, index);
206
+ return {
207
+ id: `${e.pageIdx}:${index}`,
208
+ page: e.pageIdx + 1,
209
+ pageIdx: e.pageIdx,
210
+ index,
211
+ bbox: e.bbox,
212
+ // 紧凑形态:既有投影两步 + 算子名统一(见文件头与 unifyOperatorNames 的说明)
213
+ latex: compactLatex(e.latex),
214
+ textFormat: e.fmt,
215
+ };
216
+ });
217
+ if (equations.length === 0) {
218
+ const reason = malformed > 0 ? 'bad-artifact' : 'no-equations';
219
+ const idx = EMPTY(reason, malformed > 0 ? `contentList 中有 ${malformed} 个无法解析的块` : undefined);
220
+ return { ...idx, producer, backend, pageCount };
221
+ }
222
+ // 坐标空间判定:全页最大分量 ≤ 1000 → 归一化(MinerU 3.x 的口径);否则按原始单位处理。
223
+ let maxComp = 0;
224
+ for (const e of equations) {
225
+ if (!e.bbox)
226
+ continue;
227
+ for (const v of e.bbox)
228
+ maxComp = Math.max(maxComp, v);
229
+ }
230
+ const bboxSpace = maxComp > 1000 ? 'page-units' : 'normalized-1000';
231
+ return {
232
+ source: 'mineru',
233
+ producer,
234
+ backend,
235
+ pageCount,
236
+ pageSize: null,
237
+ bboxSpace,
238
+ equations,
239
+ ...(bboxSpace === 'page-units' ? { warning: 'bbox 分量超过 1000,按原始单位(pt/px)处理' } : {}),
240
+ };
241
+ }
242
+ let cache = null;
243
+ /** 读 `<ref.mineruJsonPath>` 并建索引。文件缺失/损坏一律返回可用的空索引,**不抛异常**。 */
244
+ export async function readFormulaIndex(ref) {
245
+ let key = `${ref.mineruJsonPath}:absent`;
246
+ try {
247
+ const st = await stat(ref.mineruJsonPath);
248
+ key = `${ref.mineruJsonPath}:${st.mtimeMs}:${st.size}`;
249
+ if (cache && cache.key === key)
250
+ return cache.value;
251
+ const raw = await readFile(ref.mineruJsonPath, 'utf8');
252
+ const value = buildFormulaIndex(raw);
253
+ cache = { key, value };
254
+ return value;
255
+ }
256
+ catch (err) {
257
+ const code = err.code;
258
+ if (code === 'ENOENT' || code === 'EISDIR') {
259
+ const value = buildFormulaIndex(null);
260
+ if (cache?.key === key)
261
+ return cache.value;
262
+ cache = { key, value };
263
+ return value;
264
+ }
265
+ // 权限等异常也不许把阅读器打崩:降级成 bad-artifact
266
+ return EMPTY('bad-artifact', `读取 mineru.json 失败:${code ?? 'unknown'}`);
267
+ }
268
+ }
269
+ /** 仅供测试:清空记忆化。 */
270
+ export function resetFormulaCache() {
271
+ cache = null;
272
+ }