@ggboy123/dsh-paper-reader 0.4.0 → 0.6.2
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/host.js +3 -0
- package/dist/origin.js +13 -0
- package/dist/tools.js +26 -3
- package/dist/translate-config.js +3 -1
- package/lib/client.js +48 -2
- package/package.json +1 -1
- package/presets/paper-reader/agent.cordis.yml +9 -4
- package/reader/index.html +97 -8
package/dist/host.js
CHANGED
|
@@ -9,6 +9,7 @@ import { readTranscript, transcribePaper } from "./transcribe.js";
|
|
|
9
9
|
import { pdfVariantPath, restartTranslation, startTranslation, zhStatus } from "./translate.js";
|
|
10
10
|
import { clearTranslateConfig, maskApiKey, readTranslateConfig, resolveTranslateEndpoint, testTranslateEndpoint, writeTranslateConfig, } from "./translate-config.js";
|
|
11
11
|
import { installPaperPreset, PAPER_PRESET_ID } from "./preset.js";
|
|
12
|
+
import { noteOrigin } from "./origin.js";
|
|
12
13
|
function json(res, status, body) {
|
|
13
14
|
const buf = Buffer.from(JSON.stringify(body), 'utf8');
|
|
14
15
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': buf.length });
|
|
@@ -464,6 +465,8 @@ export function registerRoutes(ctx, config) {
|
|
|
464
465
|
json(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' });
|
|
465
466
|
return;
|
|
466
467
|
}
|
|
468
|
+
// 借浏览器请求的 Host 头记录服务 origin(工具结果里的 readerUrl 链接基址靠它拼)
|
|
469
|
+
noteOrigin(req.headers.host);
|
|
467
470
|
const url = new URL(req.url ?? '/', 'http://x');
|
|
468
471
|
dispatch(req, res, url).catch((err) => {
|
|
469
472
|
if (!res.headersSent)
|
package/dist/origin.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// origin.ts — 记录 dsh web 服务的外部地址(从插件路由收到的请求 Host 头捕获)。
|
|
2
|
+
// 工具进程不是 HTTP 请求,自己不知道服务端口;但浏览器客户端会持续打 /paper-reader/api
|
|
3
|
+
// (文献库 30s 轮询等),借它的 Host 头还原出可点击链接的基址。
|
|
4
|
+
let origin = null;
|
|
5
|
+
/** 每次插件路由被请求时调用;Host 已过宿主围栏校验,可信。 */
|
|
6
|
+
export function noteOrigin(host) {
|
|
7
|
+
if (typeof host === 'string' && host.length > 0)
|
|
8
|
+
origin = `http://${host}`;
|
|
9
|
+
}
|
|
10
|
+
/** 当前服务 origin;尚无任何请求时为 null(此时工具输出不带链接基址)。 */
|
|
11
|
+
export function readerOrigin() {
|
|
12
|
+
return origin;
|
|
13
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -4,6 +4,7 @@ import { listPapers, listTopics, resolveDataDir, resolvePaper } from "./library.
|
|
|
4
4
|
import { transcribePaper, readTranscript } from "./transcribe.js";
|
|
5
5
|
import { chunkText, searchChunks, formatHits } from "./search.js";
|
|
6
6
|
import { completeStep, formatStudy, readStudy, recordQuiz, setPlan } from "./study.js";
|
|
7
|
+
import { readerOrigin } from "./origin.js";
|
|
7
8
|
const locateParams = {
|
|
8
9
|
path: { type: 'string', description: 'PDF 绝对路径。与 topic+name 二选一。' },
|
|
9
10
|
topic: { type: 'string', description: '专题名(文献库一级目录)。' },
|
|
@@ -42,6 +43,21 @@ export function paperFromSessionId(sessionId) {
|
|
|
42
43
|
return null;
|
|
43
44
|
}
|
|
44
45
|
}
|
|
46
|
+
/** 某篇论文的阅读器链接基址(拼 &page=N 即跳转链接);origin 未捕获到时为 null。 */
|
|
47
|
+
function readerUrl(ref) {
|
|
48
|
+
const o = readerOrigin();
|
|
49
|
+
if (!o)
|
|
50
|
+
return null;
|
|
51
|
+
return `${o}/paper-reader/?topic=${encodeURIComponent(ref.topic)}&name=${encodeURIComponent(ref.name)}`;
|
|
52
|
+
}
|
|
53
|
+
/** 输出 schema 里的 readerUrl 字段声明(nullable:没有任何浏览器请求过时尚未捕获 origin)。 */
|
|
54
|
+
const readerUrlSchema = {
|
|
55
|
+
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
56
|
+
required: true,
|
|
57
|
+
description: '阅读器链接基址。引用页码时在末尾拼 &page=N 生成跳转链接;要定位到具体段落再拼 &q=<该段落原文短引文,3~8个词,URL编码>,点击会高亮对应片段。为 null 时本次不带链接。',
|
|
58
|
+
};
|
|
59
|
+
/** render 文本尾部统一带出的链接基址行(preset 规定了用法,这里只暴露值)。 */
|
|
60
|
+
const readerUrlLine = (url) => (url ? `\n阅读器链接基址:${url}` : '');
|
|
45
61
|
export function registerTools(ctx, config) {
|
|
46
62
|
const dataDir = resolveDataDir(config.dataDir);
|
|
47
63
|
ctx.tools.register(defineTool({
|
|
@@ -102,6 +118,7 @@ export function registerTools(ctx, config) {
|
|
|
102
118
|
pageCount: { type: 'integer', required: true },
|
|
103
119
|
hasPageIndex: { type: 'boolean', required: true, description: '是否有页码索引(检索结果能否带页码)。' },
|
|
104
120
|
source: { type: 'string', required: true, enum: ['cache', 'local'] },
|
|
121
|
+
readerUrl: readerUrlSchema,
|
|
105
122
|
},
|
|
106
123
|
additionalProperties: false,
|
|
107
124
|
},
|
|
@@ -110,7 +127,8 @@ export function registerTools(ctx, config) {
|
|
|
110
127
|
type: 'text',
|
|
111
128
|
text: `已转录《${value.paper}》:${value.chars} 字符,${value.pageCount} 页` +
|
|
112
129
|
`(${value.source === 'cache' ? '缓存复用' : '本地提取'}${value.hasPageIndex ? ',含页码索引' : ',无页码索引'})。` +
|
|
113
|
-
`接下来可用 search_paper
|
|
130
|
+
`接下来可用 search_paper 检索具体内容。` +
|
|
131
|
+
readerUrlLine(value.readerUrl),
|
|
114
132
|
},
|
|
115
133
|
],
|
|
116
134
|
},
|
|
@@ -124,6 +142,7 @@ export function registerTools(ctx, config) {
|
|
|
124
142
|
pageCount: t.pageCount,
|
|
125
143
|
hasPageIndex: t.pages.length > 0,
|
|
126
144
|
source: t.source,
|
|
145
|
+
readerUrl: readerUrl(ref),
|
|
127
146
|
};
|
|
128
147
|
},
|
|
129
148
|
}));
|
|
@@ -162,6 +181,7 @@ export function registerTools(ctx, config) {
|
|
|
162
181
|
additionalProperties: false,
|
|
163
182
|
},
|
|
164
183
|
},
|
|
184
|
+
readerUrl: readerUrlSchema,
|
|
165
185
|
},
|
|
166
186
|
additionalProperties: false,
|
|
167
187
|
},
|
|
@@ -172,7 +192,7 @@ export function registerTools(ctx, config) {
|
|
|
172
192
|
chunk: { index: h.chunk, start: 0, end: 0, text: h.text },
|
|
173
193
|
score: 0,
|
|
174
194
|
page: h.page,
|
|
175
|
-
})), value.totalChunks, value.query),
|
|
195
|
+
})), value.totalChunks, value.query) + readerUrlLine(value.readerUrl),
|
|
176
196
|
},
|
|
177
197
|
],
|
|
178
198
|
},
|
|
@@ -193,6 +213,7 @@ export function registerTools(ctx, config) {
|
|
|
193
213
|
query: args.query,
|
|
194
214
|
totalChunks: chunks.length,
|
|
195
215
|
hits: hits.map((h) => ({ chunk: h.chunk.index, page: h.page, text: h.chunk.text })),
|
|
216
|
+
readerUrl: readerUrl(ref),
|
|
196
217
|
};
|
|
197
218
|
},
|
|
198
219
|
}));
|
|
@@ -235,13 +256,14 @@ export function registerTools(ctx, config) {
|
|
|
235
256
|
additionalProperties: false,
|
|
236
257
|
},
|
|
237
258
|
},
|
|
259
|
+
readerUrl: readerUrlSchema,
|
|
238
260
|
},
|
|
239
261
|
additionalProperties: false,
|
|
240
262
|
},
|
|
241
263
|
render: (_args, value) => [
|
|
242
264
|
{
|
|
243
265
|
type: 'text',
|
|
244
|
-
text: `《${value.paper}》学习档案:\n` + formatStudy(value.hasRecord ? { paper: value.paper, plan: value.plan, quizzes: value.quizzes } : null),
|
|
266
|
+
text: `《${value.paper}》学习档案:\n` + formatStudy(value.hasRecord ? { paper: value.paper, plan: value.plan, quizzes: value.quizzes } : null) + readerUrlLine(value.readerUrl),
|
|
245
267
|
},
|
|
246
268
|
],
|
|
247
269
|
},
|
|
@@ -253,6 +275,7 @@ export function registerTools(ctx, config) {
|
|
|
253
275
|
hasRecord: record !== null,
|
|
254
276
|
plan: record?.plan ?? [],
|
|
255
277
|
quizzes: record?.quizzes ?? [],
|
|
278
|
+
readerUrl: readerUrl(ref),
|
|
256
279
|
};
|
|
257
280
|
},
|
|
258
281
|
}));
|
package/dist/translate-config.js
CHANGED
|
@@ -93,7 +93,9 @@ export async function testTranslateEndpoint(ep) {
|
|
|
93
93
|
if (r.ok) {
|
|
94
94
|
// 智谱系网关切错路径时(如 /api/v1)会回 HTTP 200 + {"code":1001,"success":false},
|
|
95
95
|
// 只看状态码会误判成功,babeldoc 跑起来才炸。真成功必须有 choices。
|
|
96
|
-
|
|
96
|
+
// 上限放宽到 4000:GLM 等响应字段多,max_tokens:1 也有几百字节;
|
|
97
|
+
// 历史上 slice(0,300) 把合法 JSON 截断导致 parse 失败、误报「不是补全结果」(0.6.2 修复)
|
|
98
|
+
const text = (await r.text().catch(() => '')).slice(0, 4000);
|
|
97
99
|
try {
|
|
98
100
|
const data = JSON.parse(text);
|
|
99
101
|
if (Array.isArray(data.choices))
|
package/lib/client.js
CHANGED
|
@@ -54,6 +54,29 @@ window.__ModuleLoader__.load({
|
|
|
54
54
|
window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { topic, name, n } }))
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/* ---------- 会话消息里的阅读器跳转链接 ----------
|
|
58
|
+
agent 引用页码时给 [文字](http://<host>/paper-reader/?topic=..&name=..&page=N)
|
|
59
|
+
(dsh 渲染器对 http(s) 链接加 target=_blank,capture 拦截是应用内跳转的唯一窗口)。
|
|
60
|
+
同论文 → 阅读器 iframe 原地跳页:不重载文档,用户正在看中文版就落在中文版该页;
|
|
61
|
+
跨论文/iframe 未就绪 → setCurrentPaper 换论文,页码经 pendingJumpPage 挂上 src,
|
|
62
|
+
阅读器 bootstrap 直达目标页。 */
|
|
63
|
+
let readerIframeEl = null
|
|
64
|
+
let pendingJump = null // { page, q }:跨论文/iframe 未就绪时挂上 src,由阅读器 bootstrap 消费
|
|
65
|
+
function jumpToPaperPage(topic, name, page, q) {
|
|
66
|
+
// 阅读器页签若关着尽量展开(seat 未绑定时静默失败,跳转本身仍生效)
|
|
67
|
+
try { clientApi?.sidebarRight?.openTab('dpr-reader', { revealIfOpened: true }) } catch {}
|
|
68
|
+
const cur = JSON.parse(localStorage.getItem('dpr.last') || 'null')
|
|
69
|
+
const tp = topic || cur?.topic, nm = name || cur?.name
|
|
70
|
+
if (!tp || !nm) return
|
|
71
|
+
const win = readerIframeEl?.contentWindow
|
|
72
|
+
if (cur && tp === cur.topic && nm === cur.name && win?.dpr?.jumpPage) {
|
|
73
|
+
win.dpr.jumpPage(page, q || undefined)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
pendingJump = { page, q }
|
|
77
|
+
setCurrentPaper(tp, nm, cur && tp === cur.topic && nm === cur.name ? cur.n : 0)
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
async function api(path, opts) {
|
|
58
81
|
const r = await fetch('/paper-reader/api' + path, opts)
|
|
59
82
|
if (!r.ok) {
|
|
@@ -116,7 +139,12 @@ window.__ModuleLoader__.load({
|
|
|
116
139
|
const [geo, setGeo] = useState({ visible: false, left: 0, top: 0, width: 0, height: 0 })
|
|
117
140
|
const lastShownRef = React.useRef(0)
|
|
118
141
|
useEffect(() => {
|
|
119
|
-
const on = (e) =>
|
|
142
|
+
const on = (e) => {
|
|
143
|
+
// 跳转链接挂的页码/引文(jumpToPaperPage 的跨论文路径):随 src 传给阅读器 bootstrap
|
|
144
|
+
const j = pendingJump
|
|
145
|
+
pendingJump = null
|
|
146
|
+
setCur({ topic: e.detail.topic, name: e.detail.name, page: j?.page, q: j?.q })
|
|
147
|
+
}
|
|
120
148
|
window.addEventListener('dpr:paper', on)
|
|
121
149
|
return () => window.removeEventListener('dpr:paper', on)
|
|
122
150
|
}, [])
|
|
@@ -185,9 +213,10 @@ window.__ModuleLoader__.load({
|
|
|
185
213
|
return () => { alive = false; clearInterval(timer) }
|
|
186
214
|
}, [])
|
|
187
215
|
if (!cur) return null
|
|
188
|
-
const src = `/paper-reader/?topic=${encodeURIComponent(cur.topic)}&name=${encodeURIComponent(cur.name)}`
|
|
216
|
+
const src = `/paper-reader/?topic=${encodeURIComponent(cur.topic)}&name=${encodeURIComponent(cur.name)}${cur.page ? '&page=' + cur.page : ''}${cur.q ? '&q=' + encodeURIComponent(cur.q) : ''}`
|
|
189
217
|
return createPortal(
|
|
190
218
|
h('iframe', {
|
|
219
|
+
ref: (el) => { readerIframeEl = el },
|
|
191
220
|
src, title: t('pdfTab'),
|
|
192
221
|
'data-dsh-plugin': 'dsh-paper-reader', 'data-dsh-surface': 'reader-overlay',
|
|
193
222
|
style: {
|
|
@@ -752,6 +781,23 @@ window.__ModuleLoader__.load({
|
|
|
752
781
|
|
|
753
782
|
clientApi = { slots: ctx.slots, layout: ctx.layout, uiWorkspace: ctx.uiWorkspace, sidebarRight: ctx.sidebarRight }
|
|
754
783
|
|
|
784
|
+
// 阅读器跳转链接拦截(capture 阶段,一次绑定):渲染器给 http(s) 链接加了
|
|
785
|
+
// target=_blank,不拦会新开浏览器标签页 —— 拦下后交给应用内跳转
|
|
786
|
+
if (!window.__dprJumpLinkBound) {
|
|
787
|
+
window.__dprJumpLinkBound = true
|
|
788
|
+
document.addEventListener('click', (e) => {
|
|
789
|
+
const a = e.target?.closest?.('a[href*="/paper-reader/"]')
|
|
790
|
+
if (!a) return
|
|
791
|
+
let url
|
|
792
|
+
try { url = new URL(a.href) } catch { return }
|
|
793
|
+
const page = +(url.searchParams.get('page') || 0)
|
|
794
|
+
if (!page) return
|
|
795
|
+
e.preventDefault()
|
|
796
|
+
e.stopImmediatePropagation()
|
|
797
|
+
jumpToPaperPage(url.searchParams.get('topic'), url.searchParams.get('name'), page, url.searchParams.get('q') || '')
|
|
798
|
+
}, true)
|
|
799
|
+
}
|
|
800
|
+
|
|
755
801
|
// 常驻浮层(与模式开关无关):阅读器 iframe + 对话头部历史/新建按钮组。
|
|
756
802
|
// shell.overlay 是官方钦定的整页浮层自留地(list slot,additive,不遮蔽任何人)。
|
|
757
803
|
// (对话头部按钮不走 slot —— session 作用域 slot 不渲染动态包条目,只能自渲染。)
|
package/package.json
CHANGED
|
@@ -23,19 +23,24 @@
|
|
|
23
23
|
- study_progress: read the student's learning record for a paper (plan + quiz history), stored in <论文>.study.json next to the PDF.
|
|
24
24
|
- study_update: update that record — set_plan (3~6 steps), complete_step (with your feedback note), record_quiz (score/total + weak-point summary).
|
|
25
25
|
|
|
26
|
+
Tool results carry a `阅读器链接基址` (readerUrl) line. EVERY page reference in your replies MUST be a clickable markdown link built from it: [链接文字](<readerUrl 原样照搬>&page=N) — e.g. [第 4 页](…&page=4). Clicking it jumps the student's built-in reader straight to that page, in whichever language version (原文/中英对照/纯中文) they are currently viewing — so never add any language/variant parameter yourself.
|
|
27
|
+
|
|
28
|
+
When pointing at a specific passage (a re-read assignment, the evidence for an answer), ALSO append a quote parameter so the reader highlights the exact passage: &q=<a verbatim 3~8 word phrase lifted from that passage, URL-encoded, e.g. &q=inexpensive%20commodity%20hardware>. Rules for q: copy it character-for-character from the search_paper/transcript text (never paraphrase), prefer a phrase at the START of the passage, no line breaks inside it, always percent-encode spaces (%20) and other non-ASCII characters. If you're unsure of the exact wording, omit q rather than guess — a wrong q silently degrades to a plain page jump. Bare "(第 N 页)" text citations are a formatting failure; always link. If the latest tool result has no readerUrl (null), fall back to plain text page numbers.
|
|
29
|
+
|
|
26
30
|
Your session is bound to one paper: every paper tool auto-locates it when you omit topic/name, so NEVER ask the user which paper they mean in a paper session — just call the tool. Only pass topic/name explicitly when the user asks about a DIFFERENT paper.
|
|
27
31
|
|
|
28
32
|
Two modes:
|
|
29
33
|
|
|
30
|
-
1) Quick Q&A. When the user asks a factual question (often a passage selected in the reader, with its page number), just answer it: search_paper first, answer with explicit page
|
|
34
|
+
1) Quick Q&A. When the user asks a factual question (often a passage selected in the reader, with its page number), just answer it: search_paper first, answer with explicit page links like [第 4 页](…&page=4), match the user's language. No lesson, no plan, no quiz.
|
|
31
35
|
|
|
32
36
|
2) Guided learning. When the user wants to LEARN the paper ("带我读", "我想学这篇", "继续", "检验我", or is working through a plan), act as the examiner-coach:
|
|
33
37
|
- Always start by calling study_progress to see where the student is.
|
|
34
38
|
- No plan yet: skim the paper yourself (transcribe/search the abstract, introduction, section headings), then design a 3~6 step plan with set_plan. The FIRST step is always an independent full read (独立通读) verified by 2~4 simple check questions; the remaining steps are theme-by-theme 精读, each verified by questions. After saving the plan, present ONLY the step-1 assignment: which pages to read and the check questions. Do NOT preview the paper — no abstract recap, no "this paper is about...". The questions themselves are the orientation.
|
|
35
|
-
- Step 1 (独立通读): pose 2~4 simple questions (e.g. 这篇论文要解决什么问题?方法用一句话怎么概括?最关键的实验结果/结论是什么?), point at the pages that matter if the paper is long, then STOP and wait. The student reads on their own and comes back with answers in their own words. Grade each answer specifically — what's right, what's vague, what's wrong, with page
|
|
36
|
-
- 精读 steps (question-driven): for each step, FIRST give the question(s) and the exact pages to read, then let the student go read and answer. Questions must force engagement: "为什么这里用 X 而不是 Y?", "第 N 页的图/公式说明了什么?", "去掉这个设计会怎样?" — never "请总结第 N 节".
|
|
39
|
+
- Step 1 (独立通读): pose 2~4 simple questions (e.g. 这篇论文要解决什么问题?方法用一句话怎么概括?最关键的实验结果/结论是什么?), point at the pages that matter if the paper is long, then STOP and wait. The student reads on their own and comes back with answers in their own words. Grade each answer specifically — what's right, what's vague, what's wrong, with page links. If an answer is hollow, ask ONE sharper follow-up on the same point before deciding. complete_step (with a short feedback note) only when the answers demonstrate real grasp.
|
|
40
|
+
- 精读 steps (question-driven): for each step, FIRST give the question(s) and the exact pages to read (as links), then let the student go read and answer. Questions must force engagement: "为什么这里用 X 而不是 Y?", "第 N 页的图/公式说明了什么?", "去掉这个设计会怎样?" — never "请总结第 N 节".
|
|
41
|
+
- 严格师傅原则(答案不如意时的固定动作): when an answer is vague, wrong, or smells pasted, your FIRST move is NOT to explain and NOT merely to follow up verbally — send the student back to the source: name the exact section and give a re-read link with q so the passage highlights on click ([重读 3.2 节(第 5 页)](…&page=5&q=…)), tell them what specific thing to look for, and have them re-answer the same question afterwards. Escalate up the hint ladder only across re-attempts: re-read assignment → a smaller sub-question → pointing at the exact paragraph. Explain directly ONLY after a genuine failed re-attempt — and then explain just that point, not the whole section.
|
|
37
42
|
- complete_step is earned by demonstrated understanding (a correct answer in the student's own words), never by "我看完了". Feedback without the complete_step call in the same turn is a bookkeeping failure; if you skipped earlier bookkeeping, read study_progress and bring it up to date first.
|
|
38
|
-
- After the plan is done, or when the student asks to be tested: run a quiz — 3~5 questions grounded in the paper (mix recall, "why is it designed this way", and "apply it to a new case"); grade each answer specifically, and IN THE SAME TURN as the final grading call record_quiz with the score and a weak-point summary, then finish with targeted review advice
|
|
43
|
+
- After the plan is done, or when the student asks to be tested: run a quiz — 3~5 questions grounded in the paper (mix recall, "why is it designed this way", and "apply it to a new case"); grade each answer specifically, and IN THE SAME TURN as the final grading call record_quiz with the score and a weak-point summary, then finish with targeted review advice — each weak point gets a re-read link to the exact page.
|
|
39
44
|
- Be warm but demanding. Praise genuine understanding; never wave through a wrong answer. If an answer smells pasted from the abstract or another AI, ask them to close the paper and re-answer from memory in their own words. Chinese students get Chinese, English students get English.
|
|
40
45
|
|
|
41
46
|
The conversation itself is durable (the harness persists it) — never offer to save, archive, or export the conversation, and never mention file paths or tool names in your reply.
|
package/reader/index.html
CHANGED
|
@@ -42,9 +42,33 @@
|
|
|
42
42
|
#pdf-pages { display: flex; flex-direction: column; align-items: safe center; gap: 14px; }
|
|
43
43
|
.page { position: relative; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.18); }
|
|
44
44
|
.page.flash { outline: 3px solid var(--hl); outline-offset: 2px; transition: outline .15s; }
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
/* textLayer CSS 必须是 pdf.js 6.x 官方规则的子集:TextLayer 只往 span 写 --font-height/--scale-x/--rotate
|
|
46
|
+
变量,font-size 和 transform 全靠这里的 calc 表达式撑开;缺了的话 span 会按 body 字号渲染,
|
|
47
|
+
盒子与 canvas 字形错位 —— 表现为选中高亮缺字符。--scale-factor 由 JS 在渲染时写入 */
|
|
48
|
+
.textLayer {
|
|
49
|
+
position: absolute; inset: 0; overflow: clip; line-height: 1; text-align: initial;
|
|
50
|
+
letter-spacing: normal; word-spacing: normal; text-size-adjust: none;
|
|
51
|
+
forced-color-adjust: none; transform-origin: 0 0; caret-color: CanvasText; z-index: 0;
|
|
52
|
+
--total-scale-factor: calc(var(--scale-factor, 1) * var(--user-unit, 1));
|
|
53
|
+
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size, 1));
|
|
54
|
+
--min-font-size-inv: calc(1 / var(--min-font-size, 1));
|
|
55
|
+
--scale-round-x: 1px; --scale-round-y: 1px;
|
|
56
|
+
}
|
|
57
|
+
.textLayer :is(span, br) { color: transparent; position: absolute; white-space: pre; cursor: text; transform-origin: 0 0; user-select: text; }
|
|
58
|
+
.textLayer > :not(.markedContent),
|
|
59
|
+
.textLayer .markedContent span:not(.markedContent) {
|
|
60
|
+
z-index: 1;
|
|
61
|
+
--font-height: 0;
|
|
62
|
+
font-size: calc(var(--text-scale-factor) * var(--font-height));
|
|
63
|
+
--scale-x: 1; --rotate: 0deg;
|
|
64
|
+
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
|
|
65
|
+
}
|
|
66
|
+
.textLayer .markedContent { display: contents; }
|
|
47
67
|
.textLayer ::selection { background: rgba(22,93,255,.3); }
|
|
68
|
+
.textLayer br::selection { background: transparent; }
|
|
69
|
+
/* 跳转链接的片段高亮(&q= 引文定位):常驻不淡出,用户在阅读器里任意点一下即消
|
|
70
|
+
(同代码编辑器的选中高亮语义:消失由用户触发,不由计时器触发) */
|
|
71
|
+
.textLayer span.dpr-hl { background: rgba(255,196,0,.45); border-radius: 2px; }
|
|
48
72
|
#empty { margin: 60px auto; color: var(--text-2); text-align: center; max-width: 420px; }
|
|
49
73
|
|
|
50
74
|
/* ---------- 选中即问浮层 ---------- */
|
|
@@ -149,6 +173,9 @@ function esc(s) { return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<
|
|
|
149
173
|
let current = null; // { topic, name }
|
|
150
174
|
let zhState = { zh: false, dual: false, busy: false };
|
|
151
175
|
let zhVariant = null; // null=原文 'dual'=中英对照 'zh'=纯中文
|
|
176
|
+
// 每篇论文记住上次用的语言版本:伴读会话的跳转链接原地跳页不重载,
|
|
177
|
+
// 加上这里的恢复,「中文界面跳中文」在同篇跳转/重开/跨论文三条路径上都成立
|
|
178
|
+
const variantKey = (topic, name) => `dpr.variant.${topic}/${name}`;
|
|
152
179
|
|
|
153
180
|
/* ================= 打开文献 ================= */
|
|
154
181
|
function pdfUrl(variant) {
|
|
@@ -160,8 +187,6 @@ async function openPaper(topic, name, opts = {}) {
|
|
|
160
187
|
if (!name) return;
|
|
161
188
|
current = { topic, name };
|
|
162
189
|
localStorage.setItem('dpr.last', JSON.stringify(current));
|
|
163
|
-
zhVariant = null;
|
|
164
|
-
$('zh-toggle').classList.remove('active');
|
|
165
190
|
$('status').textContent = '加载 PDF…';
|
|
166
191
|
const q = `topic=${encodeURIComponent(topic)}&name=${encodeURIComponent(name)}`;
|
|
167
192
|
const meta = await api('/paper?' + q);
|
|
@@ -169,8 +194,13 @@ async function openPaper(topic, name, opts = {}) {
|
|
|
169
194
|
? `${name} · ${meta.pageCount ?? '?'} 页`
|
|
170
195
|
: `${name} · 未转录(提问时会自动转录)`;
|
|
171
196
|
await refreshZhState();
|
|
172
|
-
|
|
173
|
-
|
|
197
|
+
// 恢复该论文上次的语言版本(译文已不存在时回落原文)
|
|
198
|
+
const saved = localStorage.getItem(variantKey(topic, name));
|
|
199
|
+
zhVariant = (saved === 'zh' && zhState.zh) || (saved === 'dual' && zhState.dual) ? saved : null;
|
|
200
|
+
$('zh-toggle').classList.toggle('active', zhVariant !== null);
|
|
201
|
+
if (zhVariant) { fitMode = true; fitBtn.classList.add('active'); } // 译文版页宽不同,强制适宽
|
|
202
|
+
await loadPdf(pdfUrl(zhVariant));
|
|
203
|
+
if (opts.page) jumpTo(opts.page, opts.q); // 不 await:跳转内部自带「等页面渲出来」轮询
|
|
174
204
|
}
|
|
175
205
|
|
|
176
206
|
/* ================= 中英切换 ================= */
|
|
@@ -197,6 +227,7 @@ $('zh-toggle').onclick = async () => {
|
|
|
197
227
|
if (zhState.dual || zhState.zh) {
|
|
198
228
|
// 两态切换:原文 ↔ 纯中文;只存在对照版时退到对照
|
|
199
229
|
zhVariant = zhVariant === null ? (zhState.zh ? 'zh' : 'dual') : null;
|
|
230
|
+
localStorage.setItem(variantKey(current.topic, current.name), zhVariant ?? '');
|
|
200
231
|
$('zh-toggle').classList.toggle('active', zhVariant !== null);
|
|
201
232
|
// 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×),不重算会溢出裁切
|
|
202
233
|
fitMode = true; fitBtn.classList.add('active');
|
|
@@ -458,6 +489,55 @@ function jumpToPage(n) {
|
|
|
458
489
|
setTimeout(() => div.classList.remove('flash'), 1600);
|
|
459
490
|
}
|
|
460
491
|
|
|
492
|
+
/* 片段定位高亮(跳转链接的 &q= 引文):在指定页 textLayer 里找 q、高亮并滚到它。
|
|
493
|
+
归一化时忽略空白/连字符/软连字符、大小写 —— 论文跨行断词(dis-tributed)也能命中。
|
|
494
|
+
找不到(如用户在中文界面而 q 是英文原文)返回 false,调用方退回仅跳页。 */
|
|
495
|
+
function clearHighlights() {
|
|
496
|
+
document.querySelectorAll('.textLayer span.dpr-hl').forEach((sp) => sp.classList.remove('dpr-hl'));
|
|
497
|
+
}
|
|
498
|
+
// 高亮的消失交给用户:阅读器里任意点一下即清(mousedown 比 click 早,划选文字的那一下也算)
|
|
499
|
+
document.addEventListener('mousedown', clearHighlights);
|
|
500
|
+
|
|
501
|
+
function highlightSnippet(page, q) {
|
|
502
|
+
const spans = [...(document.querySelector(`.page[data-page="${page}"]`)?.querySelectorAll('.textLayer span:not(.markedContent)') ?? [])];
|
|
503
|
+
if (!spans.length || !q) return false;
|
|
504
|
+
let hay = '';
|
|
505
|
+
const startOf = [];
|
|
506
|
+
for (const sp of spans) { startOf.push(hay.length); hay += sp.textContent; }
|
|
507
|
+
// 归一化串 + 回原串的索引映射(归一化改变长度,不能直接用原索引)
|
|
508
|
+
const map = [];
|
|
509
|
+
let nh = '';
|
|
510
|
+
for (let i = 0; i < hay.length; i++) {
|
|
511
|
+
const c = hay[i].toLowerCase();
|
|
512
|
+
if (/\s/.test(c) || c === '-' || c === '') continue;
|
|
513
|
+
nh += c; map.push(i);
|
|
514
|
+
}
|
|
515
|
+
const needle = q.toLowerCase().replace(/[\s\-]+/g, '');
|
|
516
|
+
const at = needle ? nh.indexOf(needle) : -1;
|
|
517
|
+
if (at < 0) return false;
|
|
518
|
+
const from = map[at], to = map[at + needle.length - 1] + 1;
|
|
519
|
+
const hit = spans.filter((sp, i) => startOf[i] < to && startOf[i] + sp.textContent.length > from);
|
|
520
|
+
if (!hit.length) return false;
|
|
521
|
+
clearHighlights();
|
|
522
|
+
for (const sp of hit) sp.classList.add('dpr-hl');
|
|
523
|
+
hit[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/* 统一跳转入口:先等目标页渲染出来(fit 模式下 loadPdf 返回时页面可能还在渲),
|
|
528
|
+
有 q 就尝试片段高亮,否则整页跳转 + 边框闪烁 */
|
|
529
|
+
async function jumpTo(page, q) {
|
|
530
|
+
const p0 = Math.max(1, (page | 0) || 1);
|
|
531
|
+
for (let i = 0; i < 100; i++) {
|
|
532
|
+
if (pdfDoc && document.querySelector(`.page[data-page="${Math.min(p0, pdfDoc.numPages)}"]`)) {
|
|
533
|
+
const p = Math.min(p0, pdfDoc.numPages);
|
|
534
|
+
if (!(q && highlightSnippet(p, q))) jumpToPage(p);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
461
541
|
/* ================= 选中即问 ================= */
|
|
462
542
|
const askPop = $('ask-pop');
|
|
463
543
|
let askSelection = null;
|
|
@@ -510,6 +590,9 @@ $('ask-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.
|
|
|
510
590
|
async function bootstrap() {
|
|
511
591
|
const params = new URLSearchParams(location.search);
|
|
512
592
|
const pTopic = params.get('topic'), pName = params.get('name');
|
|
593
|
+
// 伴读会话里的跳转链接([重读第 N 页](…?page=N))经此直达目标页
|
|
594
|
+
const pPage = +(params.get('page') || 0) || undefined;
|
|
595
|
+
const pQ = params.get('q') || undefined;
|
|
513
596
|
const lib = await api('/library');
|
|
514
597
|
if (lib.topics.length === 0) {
|
|
515
598
|
$('empty').style.display = 'block';
|
|
@@ -523,14 +606,20 @@ async function bootstrap() {
|
|
|
523
606
|
if (last && lib.papers.some((p) => p.topic === last.topic && p.name === last.name)) target = last;
|
|
524
607
|
else target = lib.papers[0];
|
|
525
608
|
}
|
|
526
|
-
if (target) await openPaper(target.topic, target.name);
|
|
609
|
+
if (target) await openPaper(target.topic, target.name, { page: pPage, q: pQ });
|
|
527
610
|
}
|
|
528
611
|
bootstrap().catch((e) => {
|
|
529
612
|
$('empty').style.display = 'block';
|
|
530
613
|
$('empty').textContent = '文献库加载失败:' + e.message;
|
|
531
614
|
});
|
|
532
615
|
|
|
533
|
-
window.dpr = {
|
|
616
|
+
window.dpr = {
|
|
617
|
+
openPaper: (topic, name, page, q) => openPaper(topic, name, { page, q }),
|
|
618
|
+
jumpToPage,
|
|
619
|
+
// 伴读会话的跳转链接用:当前文档里原地跳转,不重载 —— 用户正在看中文版就落在中文版;
|
|
620
|
+
// 带 q(原文短引文)时定位并闪烁高亮对应片段,找不到则退回整页跳转
|
|
621
|
+
jumpPage: (n, q) => { jumpTo(n, q); },
|
|
622
|
+
};
|
|
534
623
|
</script>
|
|
535
624
|
</body>
|
|
536
625
|
</html>
|