@ggboy123/dsh-paper-reader 0.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/README.md +73 -0
- package/cordis.patch.yml +9 -0
- package/dist/host.js +451 -0
- package/dist/index.js +17 -0
- package/dist/library.js +103 -0
- package/dist/preset.js +31 -0
- package/dist/search.js +98 -0
- package/dist/study.js +100 -0
- package/dist/tools.js +307 -0
- package/dist/transcribe.js +185 -0
- package/dist/translate-config.js +104 -0
- package/dist/translate.js +125 -0
- package/lib/client.js +720 -0
- package/package.json +68 -0
- package/presets/paper-reader/agent.cordis.yml +57 -0
- package/presets/paper-reader/preset.yml +3 -0
- package/reader/index.html +433 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// transcribe.ts — PDF 转录(纯函数,不依赖 Cordis)。
|
|
2
|
+
//
|
|
3
|
+
// 移植 pdfqa 的三级策略(视觉兜底后置):
|
|
4
|
+
// 1. 缓存:.txt 已存在直接复用
|
|
5
|
+
// 2. 快路径:python3 + PyMuPDF 本地提取文本层(文本型 PDF 毫秒级)
|
|
6
|
+
// 3. 慢路径:视觉转录(扫描件兜底)— 暂不支持,返回明确错误
|
|
7
|
+
//
|
|
8
|
+
// 与 pdfqa 的差异:输出附带 pages.json 页码偏移表(每页文本在全文中的 [start,end) 区间),
|
|
9
|
+
// 让 search_paper 能把片段映射回页码。清洗(连字/断词/段落重排)全部在 Python 侧、
|
|
10
|
+
// 偏移计算之前完成,保证偏移与最终文本严格一致。
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
import { transcriptCached } from "./library.js";
|
|
15
|
+
const execFileP = promisify(execFile);
|
|
16
|
+
// Python 脚本:提取文本层 → 剔页眉页脚 → 清洗 → 带偏移拼页。
|
|
17
|
+
// 退出码 3 = 文本过少(扫描件),交给上层决定兜底策略。
|
|
18
|
+
const EXTRACT_SCRIPT = String.raw `
|
|
19
|
+
import fitz, sys, re, json
|
|
20
|
+
from collections import Counter
|
|
21
|
+
|
|
22
|
+
LIG = {'fi':'fi','fl':'fl','ff':'ff','ffi':'ffi','ffl':'ffl','ſt':'st'}
|
|
23
|
+
def ligature(s):
|
|
24
|
+
for k, v in LIG.items():
|
|
25
|
+
s = s.replace(k, v)
|
|
26
|
+
return s
|
|
27
|
+
|
|
28
|
+
doc = fitz.open(sys.argv[1])
|
|
29
|
+
raw_pages = []
|
|
30
|
+
for page in doc:
|
|
31
|
+
lines = [l.strip() for l in page.get_text().splitlines()]
|
|
32
|
+
raw_pages.append([l for l in lines if l])
|
|
33
|
+
|
|
34
|
+
# 页眉页脚识别:页首 2 行/页尾 3 行归一化(去数字标点,页码归一为空串),
|
|
35
|
+
# 在 >=1/5 页面边缘反复出现即剔除;只剔边缘行,正文相同行不受影响。
|
|
36
|
+
thresh = max(3, len(raw_pages) // 5)
|
|
37
|
+
def norm(l): return re.sub(r'[\d\W]+', '', l).lower()
|
|
38
|
+
edge = Counter()
|
|
39
|
+
for p in raw_pages:
|
|
40
|
+
for l in p[:2] + p[-3:]:
|
|
41
|
+
edge[norm(l)] += 1
|
|
42
|
+
bad = {l for l, n in edge.items() if n >= thresh}
|
|
43
|
+
|
|
44
|
+
page_texts = []
|
|
45
|
+
for p in raw_pages:
|
|
46
|
+
while p and norm(p[0]) in bad:
|
|
47
|
+
p.pop(0)
|
|
48
|
+
while p and norm(p[-1]) in bad:
|
|
49
|
+
p.pop()
|
|
50
|
+
t = '\n'.join(p)
|
|
51
|
+
t = ligature(t)
|
|
52
|
+
t = re.sub(r'(\w)-\n(\w)', r'\1\2', t) # 页内断词愈合
|
|
53
|
+
page_texts.append(t.strip())
|
|
54
|
+
|
|
55
|
+
total = sum(len(t) for t in page_texts)
|
|
56
|
+
if total < 100 * max(1, len(doc)):
|
|
57
|
+
sys.exit(3) # 扫描件
|
|
58
|
+
|
|
59
|
+
# 段落重排拼页:PDF 分页会把一个句子拆到相邻两页(页眉页脚剔除后就挨在一起)。
|
|
60
|
+
# 上一页不以句末标点结束、且下一页以小写字母开头 → 同句延续,用空格衔接;
|
|
61
|
+
# 该页的偏移区间只覆盖它自己的文字(跨页句引用起始页)。
|
|
62
|
+
SENT_END = re.compile(r'[.!?:;]["\'”’)\]]*$')
|
|
63
|
+
buf = ''
|
|
64
|
+
spans = []
|
|
65
|
+
for i, t in enumerate(page_texts):
|
|
66
|
+
if not t:
|
|
67
|
+
continue
|
|
68
|
+
if not buf:
|
|
69
|
+
spans.append((i + 1, 0, len(t)))
|
|
70
|
+
buf = t
|
|
71
|
+
continue
|
|
72
|
+
if not SENT_END.search(buf) and t[0].islower():
|
|
73
|
+
buf = buf.rstrip(' \n')
|
|
74
|
+
start = len(buf) + 1
|
|
75
|
+
buf = buf + ' ' + t
|
|
76
|
+
else:
|
|
77
|
+
start = len(buf) + 2
|
|
78
|
+
buf = buf + '\n\n' + t
|
|
79
|
+
spans.append((i + 1, start, len(buf)))
|
|
80
|
+
|
|
81
|
+
print(json.dumps({
|
|
82
|
+
'pageCount': len(doc),
|
|
83
|
+
'chars': len(buf),
|
|
84
|
+
'text': buf,
|
|
85
|
+
'pages': [{'page': p, 'start': s, 'end': e} for p, s, e in spans],
|
|
86
|
+
}, ensure_ascii=False))
|
|
87
|
+
`;
|
|
88
|
+
/** 逐个尝试 python 候选(多个 python 里不一定都装了 fitz)。 */
|
|
89
|
+
async function runPyMuPDF(script, pdfPath) {
|
|
90
|
+
const candidates = ['python3', '/opt/homebrew/Caskroom/miniconda/base/bin/python3', '/usr/bin/python3'];
|
|
91
|
+
let sawScanned = false;
|
|
92
|
+
for (const py of candidates) {
|
|
93
|
+
try {
|
|
94
|
+
const { stdout } = await execFileP(py, ['-c', script, pdfPath], {
|
|
95
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
96
|
+
timeout: 120_000,
|
|
97
|
+
});
|
|
98
|
+
return { ok: true, stdout, scanned: false };
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
// 退出码 3 = 扫描件信号,换一个 python 也没用,但记住这个结论
|
|
102
|
+
if (err && typeof err === 'object' && err.code === 3)
|
|
103
|
+
sawScanned = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { ok: false, stdout: '', scanned: sawScanned };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 转录一篇文献:缓存优先,否则本地 PyMuPDF 提取并落盘(.txt + .pages.json)。
|
|
110
|
+
* 扫描件(文本层过少)抛错并说明视觉兜底尚未实现。
|
|
111
|
+
*/
|
|
112
|
+
export async function transcribePaper(ref, opts = {}) {
|
|
113
|
+
if (!opts.force && transcriptCached(ref)) {
|
|
114
|
+
const text = await readFile(ref.txtPath, 'utf8');
|
|
115
|
+
let pages = await readPages(ref);
|
|
116
|
+
if (!pages) {
|
|
117
|
+
// 兼容升级:pdfqa 时代的缓存只有 .txt 没有页码索引。
|
|
118
|
+
// 重新提取生成页码索引;新旧文本长度接近才覆盖(视觉转录的扫描件缓存不容侵犯)。
|
|
119
|
+
const upgraded = await tryUpgradePageIndex(ref, text.length);
|
|
120
|
+
if (upgraded) {
|
|
121
|
+
pages = upgraded.pages;
|
|
122
|
+
return { text: upgraded.text, pages, pageCount: pages.length, chars: upgraded.text.length, source: 'cache' };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { text, pages: pages ?? [], pageCount: pages?.length ?? 0, chars: text.length, source: 'cache' };
|
|
126
|
+
}
|
|
127
|
+
const extracted = await extractAndStore(ref);
|
|
128
|
+
return extracted;
|
|
129
|
+
}
|
|
130
|
+
/** 本地提取(不落盘)。 */
|
|
131
|
+
async function extractLocal(pdfPath) {
|
|
132
|
+
const { ok, stdout, scanned } = await runPyMuPDF(EXTRACT_SCRIPT, pdfPath);
|
|
133
|
+
if (!ok) {
|
|
134
|
+
throw new Error(scanned
|
|
135
|
+
? '本地提取的文本过少,该 PDF 可能是扫描件。视觉转录兜底尚未实现(Phase 1 后置项),请换文本型 PDF 或手动放置同名 .txt 缓存。'
|
|
136
|
+
: '未找到可用的 python3 + PyMuPDF 环境。请安装:python3 -m pip install PyMuPDF');
|
|
137
|
+
}
|
|
138
|
+
const data = JSON.parse(stdout);
|
|
139
|
+
if (data.text.length < 1000) {
|
|
140
|
+
throw new Error(`转录结果过短(${data.text.length} 字符),疑似失败,请重试`);
|
|
141
|
+
}
|
|
142
|
+
return { text: data.text, pages: data.pages, pageCount: data.pageCount, chars: data.chars, source: 'local' };
|
|
143
|
+
}
|
|
144
|
+
/** 本地提取并落盘。 */
|
|
145
|
+
async function extractAndStore(ref) {
|
|
146
|
+
const t = await extractLocal(ref.pdfPath);
|
|
147
|
+
await writeFile(ref.txtPath, t.text, 'utf8');
|
|
148
|
+
await writeFile(ref.pagesPath, JSON.stringify({ pageCount: t.pageCount, pages: t.pages }, null, 2), 'utf8');
|
|
149
|
+
return t;
|
|
150
|
+
}
|
|
151
|
+
/** 为旧缓存补建页码索引:成功且长度相近才覆盖 .txt,返回新内容;否则返回 null。 */
|
|
152
|
+
async function tryUpgradePageIndex(ref, cachedLen) {
|
|
153
|
+
try {
|
|
154
|
+
const t = await extractLocal(ref.pdfPath);
|
|
155
|
+
const ratio = t.text.length / Math.max(1, cachedLen);
|
|
156
|
+
if (ratio < 0.5 || ratio > 2)
|
|
157
|
+
return null; // 差异过大:旧文本可能来自视觉转录,不回写
|
|
158
|
+
await writeFile(ref.txtPath, t.text, 'utf8');
|
|
159
|
+
await writeFile(ref.pagesPath, JSON.stringify({ pageCount: t.pageCount, pages: t.pages }, null, 2), 'utf8');
|
|
160
|
+
return { text: t.text, pages: t.pages };
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null; // 无 python 环境或扫描件:保持旧缓存,页码索引缺席
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** 读页码偏移表;旧缓存(pdfqa 时代)没有 pages.json 时返回 null。 */
|
|
167
|
+
export async function readPages(ref) {
|
|
168
|
+
try {
|
|
169
|
+
const raw = JSON.parse(await readFile(ref.pagesPath, 'utf8'));
|
|
170
|
+
return raw.pages;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** 读转录文本(不触发转录);无缓存返回 null。 */
|
|
177
|
+
export async function readTranscript(ref) {
|
|
178
|
+
try {
|
|
179
|
+
const text = await readFile(ref.txtPath, 'utf8');
|
|
180
|
+
return { text, pages: await readPages(ref) };
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// translate-config.ts — 翻译端点配置的落盘与解析(纯函数,不依赖 Cordis)。
|
|
2
|
+
//
|
|
3
|
+
// 存 $DSH_HOME/.dsh-paper-reader/translate.json(0600)——刻意不放 dataDir:
|
|
4
|
+
// dataDir 是文献库目录,可能被 git / 云盘同步出去,密钥不该跟着走。
|
|
5
|
+
// 优先级:文件 > profile 的 cordis.patch.yml config.translate(后者退为部署方默认值)。
|
|
6
|
+
// 密钥只落盘、只回显掩码:任何读接口都不返回明文(apiKeyHint 是掩码后的展示串)。
|
|
7
|
+
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { dshHome } from "./library.js";
|
|
10
|
+
export function translateConfigPath(home = dshHome()) {
|
|
11
|
+
return join(home, '.dsh-paper-reader', 'translate.json');
|
|
12
|
+
}
|
|
13
|
+
/** 三项齐全才算配好——babeldoc 的 --openai/--openai-model/--openai-base-url 缺一不可。 */
|
|
14
|
+
function isComplete(e) {
|
|
15
|
+
return Boolean(e?.baseUrl && e?.apiKey && e?.model);
|
|
16
|
+
}
|
|
17
|
+
/** 按字段合并多个来源,靠后的覆盖靠前的;空字段不参与(避免 undefined 冲掉已有值)。 */
|
|
18
|
+
function pick(...sources) {
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const s of sources) {
|
|
21
|
+
if (!s)
|
|
22
|
+
continue;
|
|
23
|
+
if (s.baseUrl)
|
|
24
|
+
out.baseUrl = s.baseUrl;
|
|
25
|
+
if (s.apiKey)
|
|
26
|
+
out.apiKey = s.apiKey;
|
|
27
|
+
if (s.model)
|
|
28
|
+
out.model = s.model;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** 读配置;文件缺失/损坏/形状不对一律返回 null(与 study.ts 的读策略一致)。 */
|
|
33
|
+
export async function readTranslateConfig(home = dshHome()) {
|
|
34
|
+
try {
|
|
35
|
+
const data = JSON.parse(await readFile(translateConfigPath(home), 'utf8'));
|
|
36
|
+
if (typeof data !== 'object' || data === null)
|
|
37
|
+
return null;
|
|
38
|
+
const rec = data;
|
|
39
|
+
const out = {};
|
|
40
|
+
if (typeof rec['baseUrl'] === 'string')
|
|
41
|
+
out.baseUrl = rec['baseUrl'];
|
|
42
|
+
if (typeof rec['apiKey'] === 'string')
|
|
43
|
+
out.apiKey = rec['apiKey'];
|
|
44
|
+
if (typeof rec['model'] === 'string')
|
|
45
|
+
out.model = rec['model'];
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** 写配置。0600:文件含明文密钥,不靠 umask。 */
|
|
53
|
+
export async function writeTranslateConfig(cfg, home = dshHome()) {
|
|
54
|
+
const file = translateConfigPath(home);
|
|
55
|
+
await mkdir(dirname(file), { recursive: true });
|
|
56
|
+
await writeFile(file, JSON.stringify(cfg, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
57
|
+
await chmod(file, 0o600); // mode 只在新建时生效;已存在的文件要显式收紧
|
|
58
|
+
}
|
|
59
|
+
/** 删除配置(回落到 profile 的 translate)。文件不存在也算成功。 */
|
|
60
|
+
export async function clearTranslateConfig(home = dshHome()) {
|
|
61
|
+
await rm(translateConfigPath(home), { force: true });
|
|
62
|
+
}
|
|
63
|
+
/** 解析生效端点:文件配全则用文件,否则用 profile,都配不全则返回能凑到的部分供报错。 */
|
|
64
|
+
export async function resolveTranslateEndpoint(profile, home = dshHome()) {
|
|
65
|
+
const file = await readTranslateConfig(home);
|
|
66
|
+
if (isComplete(file))
|
|
67
|
+
return { endpoint: file, source: 'file' };
|
|
68
|
+
if (isComplete(profile))
|
|
69
|
+
return { endpoint: profile, source: 'profile' };
|
|
70
|
+
return { endpoint: pick(profile, file), source: 'none' };
|
|
71
|
+
}
|
|
72
|
+
/** 掩码展示:只露头 3 尾 4,够用户认出是哪把 key,又不足以还原。 */
|
|
73
|
+
export function maskApiKey(key) {
|
|
74
|
+
return key.length <= 8 ? '••••' : `${key.slice(0, 3)}…${key.slice(-4)}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 连接预检:打一发极小的 chat/completions,确认 url/key/model 三者匹配。
|
|
78
|
+
* 必要性:babeldoc 一次要跑几分钟且**异步失败**(错误只落进 translate.ts 的 busy map),
|
|
79
|
+
* key 填错要等很久才知道,所以在落盘前先拦。
|
|
80
|
+
*/
|
|
81
|
+
export async function testTranslateEndpoint(ep) {
|
|
82
|
+
const { baseUrl, apiKey, model } = ep;
|
|
83
|
+
if (!baseUrl || !apiKey || !model)
|
|
84
|
+
return { ok: false, detail: '端点配置不完整(需要 url + key + 模型)' };
|
|
85
|
+
try {
|
|
86
|
+
const r = await fetch(baseUrl.replace(/\/+$/, '') + '/chat/completions', {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
89
|
+
// max_tokens:1 —— 只要能证明鉴权与模型名有效,不产生实际费用
|
|
90
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1 }),
|
|
91
|
+
signal: AbortSignal.timeout(15_000),
|
|
92
|
+
});
|
|
93
|
+
if (r.ok)
|
|
94
|
+
return { ok: true };
|
|
95
|
+
const text = (await r.text().catch(() => '')).slice(0, 200);
|
|
96
|
+
return { ok: false, detail: `HTTP ${r.status}${text ? ' ' + text : ''}` };
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
// Node 的 fetch 把真实原因(ECONNREFUSED / 证书错误 / 超时)塞在 cause 里,
|
|
100
|
+
// 只报 "fetch failed" 对用户毫无帮助
|
|
101
|
+
const cause = err instanceof Error && err.cause instanceof Error ? `: ${err.cause.message}` : '';
|
|
102
|
+
return { ok: false, detail: (err instanceof Error ? err.message : String(err)) + cause };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// translate.ts — 中文对照 PDF 生成(纯函数,不依赖 Cordis)。
|
|
2
|
+
// 移植 pdfqa 的 runPdf2zh:shell 调 babeldoc(pdf2zh 2.x),版式/图/公式原地保留,
|
|
3
|
+
// 产出 <文献>-zh.pdf(纯中文)与 <文献>-dual.pdf(中英对照)。
|
|
4
|
+
// babeldoc 只支持 OpenAI 兼容端点 → 端点/key/模型由插件配置提供。
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { mkdir, rename } from 'node:fs/promises';
|
|
8
|
+
import { glob } from 'node:fs/promises';
|
|
9
|
+
import { join, dirname, basename, extname } from 'node:path';
|
|
10
|
+
export function zhPdfPath(ref) {
|
|
11
|
+
return ref.pdfPath.slice(0, -extname(ref.pdfPath).length) + '-zh.pdf';
|
|
12
|
+
}
|
|
13
|
+
export function dualPdfPath(ref) {
|
|
14
|
+
return ref.pdfPath.slice(0, -extname(ref.pdfPath).length) + '-dual.pdf';
|
|
15
|
+
}
|
|
16
|
+
/** 进程内每篇文献一个翻译任务(幂等:进行中/已生成直接返回)。 */
|
|
17
|
+
const busy = new Map(); // key=pdfPath, value=error|null(进行中)
|
|
18
|
+
export function zhStatus(ref) {
|
|
19
|
+
const zh = existsSync(zhPdfPath(ref));
|
|
20
|
+
const dual = existsSync(dualPdfPath(ref));
|
|
21
|
+
const b = busy.get(ref.pdfPath);
|
|
22
|
+
return {
|
|
23
|
+
zh,
|
|
24
|
+
dual,
|
|
25
|
+
busy: b === null,
|
|
26
|
+
...(typeof b === 'string' ? { error: b } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** 找 babeldoc 可执行文件:pdfqa venv(文献库同级 .venv-pdf2zh)→ PATH → miniconda。 */
|
|
30
|
+
async function findBabeldoc(dataDir) {
|
|
31
|
+
const candidates = [
|
|
32
|
+
join(dirname(dataDir), '.venv-pdf2zh', 'bin', 'babeldoc'),
|
|
33
|
+
'babeldoc',
|
|
34
|
+
'/opt/homebrew/Caskroom/miniconda/base/bin/babeldoc',
|
|
35
|
+
];
|
|
36
|
+
for (const c of candidates) {
|
|
37
|
+
if (c.includes('/')) {
|
|
38
|
+
if (existsSync(c))
|
|
39
|
+
return c;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// PATH 查找
|
|
43
|
+
const found = await new Promise((res) => {
|
|
44
|
+
execFile('which', [c], (err, stdout) => res(!err && stdout.trim() !== ''));
|
|
45
|
+
});
|
|
46
|
+
if (found)
|
|
47
|
+
return c;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 启动 babeldoc 后台翻译。幂等:已生成或进行中直接返回 false 表示未新启动。
|
|
53
|
+
* 完成/失败结果写进 busy map,由 zhStatus 暴露。
|
|
54
|
+
*/
|
|
55
|
+
export async function startTranslation(ref, dataDir, endpoint) {
|
|
56
|
+
const st = zhStatus(ref);
|
|
57
|
+
if (st.zh || st.dual)
|
|
58
|
+
return { started: false, reason: 'already-exists' };
|
|
59
|
+
if (st.busy)
|
|
60
|
+
return { started: false, reason: 'busy' };
|
|
61
|
+
const bin = await findBabeldoc(dataDir);
|
|
62
|
+
if (!bin)
|
|
63
|
+
return { started: false, reason: '未找到 babeldoc(pip install babeldoc,或复用 pdfqa 的 .venv-pdf2zh)' };
|
|
64
|
+
if (!endpoint.baseUrl || !endpoint.apiKey || !endpoint.model) {
|
|
65
|
+
// code 供前端判定「该弹配置表单了」,别让它去匹配中文文案
|
|
66
|
+
return {
|
|
67
|
+
started: false,
|
|
68
|
+
code: 'no-endpoint',
|
|
69
|
+
reason: '未配置翻译端点:在阅读器里点「中」按钮填写(OpenAI 兼容端点即可),或改 profile 的 translate 配置',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const tmpDir = join(dataDir, '.pdf2zh-tmp');
|
|
73
|
+
await mkdir(tmpDir, { recursive: true });
|
|
74
|
+
busy.set(ref.pdfPath, null);
|
|
75
|
+
const stem = basename(ref.pdfPath, extname(ref.pdfPath));
|
|
76
|
+
const dstStem = ref.pdfPath.slice(0, -extname(ref.pdfPath).length);
|
|
77
|
+
const child = execFile(bin, [
|
|
78
|
+
'--files', ref.pdfPath,
|
|
79
|
+
'--lang-in', 'en', '--lang-out', 'zh-CN',
|
|
80
|
+
'--openai', '--openai-model', endpoint.model,
|
|
81
|
+
'--openai-base-url', endpoint.baseUrl, '--openai-api-key', endpoint.apiKey,
|
|
82
|
+
'--qps', '8', '--no-watermark', '--output', tmpDir,
|
|
83
|
+
'--skip-figure-text', // 图/图片区域内的文字保持原文(架构图术语不翻)
|
|
84
|
+
], {
|
|
85
|
+
env: { ...process.env, HF_ENDPOINT: 'https://hf-mirror.com' }, // 版面模型走国内镜像
|
|
86
|
+
timeout: 45 * 60 * 1000,
|
|
87
|
+
}, (err, stdout, stderr) => {
|
|
88
|
+
void (async () => {
|
|
89
|
+
if (err) {
|
|
90
|
+
const tail = (stdout + '\n' + stderr).slice(-400);
|
|
91
|
+
console.error('[dsh-paper-reader] babeldoc failed:', tail);
|
|
92
|
+
busy.set(ref.pdfPath, `babeldoc 执行失败: ${err.message}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// 产出从 scratch 挪回文献目录:<名>.zh-CN.mono.pdf → -zh.pdf,.dual.pdf → -dual.pdf
|
|
96
|
+
try {
|
|
97
|
+
for await (const f of glob(join(tmpDir, `${stem}*.zh-CN.mono.pdf`))) {
|
|
98
|
+
await rename(f, dstStem + '-zh.pdf');
|
|
99
|
+
}
|
|
100
|
+
for await (const f of glob(join(tmpDir, `${stem}*.zh-CN.dual.pdf`))) {
|
|
101
|
+
await rename(f, dstStem + '-dual.pdf');
|
|
102
|
+
}
|
|
103
|
+
if (!existsSync(zhPdfPath(ref)) && !existsSync(dualPdfPath(ref))) {
|
|
104
|
+
busy.set(ref.pdfPath, 'babeldoc 未产出译文 PDF');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
busy.delete(ref.pdfPath);
|
|
108
|
+
console.log(`[dsh-paper-reader] babeldoc done: ${ref.name}`);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
busy.set(ref.pdfPath, `移动译文产物失败: ${e instanceof Error ? e.message : String(e)}`);
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
});
|
|
115
|
+
child.unref?.();
|
|
116
|
+
return { started: true };
|
|
117
|
+
}
|
|
118
|
+
/** 解析 PDF 变体路径(原文/zh/dual)。 */
|
|
119
|
+
export function pdfVariantPath(ref, variant) {
|
|
120
|
+
if (variant === 'zh')
|
|
121
|
+
return zhPdfPath(ref);
|
|
122
|
+
if (variant === 'dual')
|
|
123
|
+
return dualPdfPath(ref);
|
|
124
|
+
return ref.pdfPath;
|
|
125
|
+
}
|