@ggboy123/dsh-paper-reader 1.4.0 → 1.5.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.
Files changed (33) hide show
  1. package/README.md +36 -297
  2. package/dist/babeldoc-install.js +6 -2
  3. package/dist/formulas.js +87 -14
  4. package/dist/host.js +107 -3
  5. package/dist/translate-config.js +4 -1
  6. package/dist/translate-selection.js +772 -0
  7. package/lib/client.js +70 -0
  8. package/package.json +1 -1
  9. package/reader/index.html +1064 -64
  10. package/reader/vendor/KaTeX_AMS-Regular.woff2 +0 -0
  11. package/reader/vendor/KaTeX_Caligraphic-Bold.woff2 +0 -0
  12. package/reader/vendor/KaTeX_Caligraphic-Regular.woff2 +0 -0
  13. package/reader/vendor/KaTeX_Fraktur-Bold.woff2 +0 -0
  14. package/reader/vendor/KaTeX_Fraktur-Regular.woff2 +0 -0
  15. package/reader/vendor/KaTeX_Main-Bold.woff2 +0 -0
  16. package/reader/vendor/KaTeX_Main-BoldItalic.woff2 +0 -0
  17. package/reader/vendor/KaTeX_Main-Italic.woff2 +0 -0
  18. package/reader/vendor/KaTeX_Main-Regular.woff2 +0 -0
  19. package/reader/vendor/KaTeX_Math-BoldItalic.woff2 +0 -0
  20. package/reader/vendor/KaTeX_Math-Italic.woff2 +0 -0
  21. package/reader/vendor/KaTeX_SansSerif-Bold.woff2 +0 -0
  22. package/reader/vendor/KaTeX_SansSerif-Italic.woff2 +0 -0
  23. package/reader/vendor/KaTeX_SansSerif-Regular.woff2 +0 -0
  24. package/reader/vendor/KaTeX_Script-Regular.woff2 +0 -0
  25. package/reader/vendor/KaTeX_Size1-Regular.woff2 +0 -0
  26. package/reader/vendor/KaTeX_Size2-Regular.woff2 +0 -0
  27. package/reader/vendor/KaTeX_Size3-Regular.woff2 +0 -0
  28. package/reader/vendor/KaTeX_Size4-Regular.woff2 +0 -0
  29. package/reader/vendor/KaTeX_Typewriter-Regular.woff2 +0 -0
  30. package/reader/vendor/katex.LICENSE +21 -0
  31. package/reader/vendor/katex.NOTICE.md +26 -0
  32. package/reader/vendor/katex.min.css +1 -0
  33. package/reader/vendor/katex.min.js +1 -0
package/dist/host.js CHANGED
@@ -6,7 +6,8 @@ import { randomUUID } from 'node:crypto';
6
6
  import { basename, join, resolve } from 'node:path';
7
7
  import { listPapers, listTopics, resolveDataDir, resolvePaper, resolveUploadTopic, validateEntryBoundary, validateEntryName, } from "./library.js";
8
8
  import { ManageError, assertInside, clearTopic, planTopicClear, deletePaper, deleteTopic, isManageError, planPaperDelete, planTopicDelete, realDataDir, renamePaper, renameTopic, resolveTopicDir, validateName, } from "./library-manage.js";
9
- import { readFormulaIndex } from "./formulas.js";
9
+ import { readFormulaIndex, readTextBlockIndex } from "./formulas.js";
10
+ import { MAX_CHUNKS, MAX_LATEX_CHARS, MAX_TEXT_CHARS, MAX_TOTAL_CHARS, SelectionTranslateError, assembleSource, formulaStatusOf, translateSelection, } from "./translate-selection.js";
10
11
  import { MineruError, sanitizeDetail } from "./mineru.js";
11
12
  import { readTranscript, transcribePaper, ScannedPdfError, ShortTextError } from "./transcribe.js";
12
13
  import { pdfVariantPath, restartTranslation, startTranslation, zhStatus } from "./translate.js";
@@ -347,10 +348,17 @@ export function registerRoutes(ctx, config) {
347
348
  json(res, 400, { error: 'bad asset name' });
348
349
  return;
349
350
  }
350
- const mime = { '.mjs': 'text/javascript', '.js': 'text/javascript', '.css': 'text/css', '.map': 'application/json' };
351
+ const mime = { '.mjs': 'text/javascript', '.js': 'text/javascript', '.css': 'text/css', '.map': 'application/json',
352
+ // 字体(vendored KaTeX 的 woff2 就靠这条;不给对 MIME 时某些浏览器会拒绝 @font-face)
353
+ '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf' };
351
354
  try {
352
355
  const body = await readFile(new URL(name, readerVendorDir));
353
- res.writeHead(200, { 'content-type': (mime[name.slice(name.lastIndexOf('.'))] ?? 'application/octet-stream') + '; charset=utf-8', 'content-length': body.length, 'cache-control': 'no-cache' });
356
+ const ext = name.slice(name.lastIndexOf('.'));
357
+ const type = mime[ext] ?? 'application/octet-stream';
358
+ // 只有文本类才追加 charset:给 font/woff2 这类二进制资源加 `; charset=utf-8` 是错的
359
+ // (实测 font/woff2 被浏览器按未知类型处理的风险;见 test/reader-vendor-http.test.mjs)
360
+ const texty = type.startsWith('text/') || type === 'application/json';
361
+ res.writeHead(200, { 'content-type': texty ? type + '; charset=utf-8' : type, 'content-length': body.length, 'cache-control': 'no-cache' });
354
362
  res.end(body);
355
363
  }
356
364
  catch {
@@ -777,6 +785,102 @@ export function registerRoutes(ctx, config) {
777
785
  json(res, 200, await readFormulaIndex(ref));
778
786
  return;
779
787
  }
788
+ /* ── 划词翻译(公式保留,契约 docs/selection-translate-requirements.md §3.9)─────
789
+ 客户端只送「按页 + 按 display 归属切好的 chunk」;行内公式的还原(选区文本 ↔ MinerU
790
+ text 块对齐)在服务端做,所以请求体里**没有任何矩形/bbox/pageSize**。
791
+ 本接口只读(不写盘)⇒ 定位沿用 /api/formulas 的读路径语义,不做写路径边界。
792
+ API key 只存在于本进程:请求体只有 model(客户端从 /api/zh 拿到的 apiKeyHint 仅用于展示),
793
+ 响应体与错误文案一律不带 key。 */
794
+ if (sub === '/api/translate-selection' && req.method === 'POST') {
795
+ const body = (await readBody(req));
796
+ const bad = (msg, code = 'bad-request') => json(res, 400, { error: msg, code });
797
+ if (!Array.isArray(body.chunks))
798
+ return bad('chunks 必须是数组');
799
+ if (body.chunks.length < 1 || body.chunks.length > MAX_CHUNKS) {
800
+ return bad(`chunks 数量必须在 1~${MAX_CHUNKS} 之间(收到 ${body.chunks.length})`, 'too-large');
801
+ }
802
+ const chunks = [];
803
+ let total = 0;
804
+ for (const raw of body.chunks) {
805
+ if (typeof raw !== 'object' || raw === null)
806
+ return bad('chunks 的每一项必须是对象');
807
+ const c = raw;
808
+ const page = c.page;
809
+ if (typeof page !== 'number' || !Number.isInteger(page) || page < 1)
810
+ return bad('chunk.page 必须是 ≥1 的整数');
811
+ const text = typeof c.text === 'string' ? c.text : '';
812
+ const latex = typeof c.latex === 'string' ? c.latex : '';
813
+ if (c.kind === 'formula') {
814
+ if (!latex)
815
+ return bad('formula chunk 的 latex 不能为空');
816
+ if (latex.length > MAX_LATEX_CHARS)
817
+ return bad(`latex 超过 ${MAX_LATEX_CHARS} 字符`, 'too-large');
818
+ total += latex.length;
819
+ chunks.push({ page, kind: 'formula', latex });
820
+ continue;
821
+ }
822
+ if (c.kind !== 'text')
823
+ return bad("chunk.kind 必须为 'text' 或 'formula'");
824
+ if (text.length > MAX_TEXT_CHARS)
825
+ return bad(`text 超过 ${MAX_TEXT_CHARS} 字符`, 'too-large');
826
+ total += text.length;
827
+ chunks.push({ page, kind: 'text', text });
828
+ }
829
+ if (total === 0)
830
+ return bad('选区为空');
831
+ if (total > MAX_TOTAL_CHARS)
832
+ return bad(`选区总长超过 ${MAX_TOTAL_CHARS} 字符`, 'too-large');
833
+ let ref;
834
+ try {
835
+ const args = {};
836
+ if (typeof body.path === 'string' && body.path)
837
+ args.path = body.path;
838
+ else {
839
+ if (typeof body.topic !== 'string' || typeof body.name !== 'string' || !body.topic || !body.name) {
840
+ return bad('必须提供 topic + name(或 path)');
841
+ }
842
+ args.topic = body.topic;
843
+ args.name = body.name;
844
+ }
845
+ ref = resolvePaper(dataDir, args);
846
+ }
847
+ catch {
848
+ throw notFound('文献不存在(划词翻译需要先能定位到该文献)');
849
+ }
850
+ const { endpoint, source } = await resolveTranslateEndpoint(config.translate);
851
+ if (source === 'none') {
852
+ json(res, 503, { error: '未配置翻译端点:在阅读器里点 ⚙ 填写(OpenAI 兼容端点即可)', code: 'no-endpoint' });
853
+ return;
854
+ }
855
+ const index = await readTextBlockIndex(ref);
856
+ const asm = assembleSource(chunks, index);
857
+ try {
858
+ const r = await translateSelection(endpoint, asm.source);
859
+ json(res, 200, {
860
+ translation: r.translation,
861
+ model: r.model,
862
+ cached: r.cached,
863
+ formulaTotal: r.formulaTotal,
864
+ formulaKept: r.formulaKept,
865
+ formulaLost: r.formulaLost,
866
+ formulaApplied: asm.applied,
867
+ formulaSkipped: asm.skipped,
868
+ formulaStatus: formulaStatusOf(asm),
869
+ hasBlockIndex: asm.hasBlockIndex,
870
+ ms: r.ms,
871
+ });
872
+ }
873
+ catch (err) {
874
+ if (!(err instanceof SelectionTranslateError))
875
+ throw err;
876
+ json(res, err.status, {
877
+ error: err.message,
878
+ code: err.code,
879
+ ...(err.upstreamStatus !== null ? { upstreamStatus: err.upstreamStatus } : {}),
880
+ });
881
+ }
882
+ return;
883
+ }
780
884
  // 新建伴读会话(会话N+1)。最新可见会话还是空白草稿时直接复用它——
781
885
  // 连点「新建」不再堆积空会话(毫无意义的点击什么都不存)。
782
886
  // n 用全量扫描分配(含归档/空白),绝不与磁盘上已有目录撞号。
@@ -117,6 +117,9 @@ export async function testTranslateEndpoint(ep) {
117
117
  // Node 的 fetch 把真实原因(ECONNREFUSED / 证书错误 / 超时)塞在 cause 里,
118
118
  // 只报 "fetch failed" 对用户毫无帮助
119
119
  const cause = err instanceof Error && err.cause instanceof Error ? `: ${err.cause.message}` : '';
120
- return { ok: false, detail: (err instanceof Error ? err.message : String(err)) + cause };
120
+ // ⚠️ t7(t4 评审 R-04 的同类项):这条 detail 会进响应体、再上屏(设置浮层)。
121
+ // fetch 抛出的原因串可能带上 baseUrl(含凭据/查询参数)或回显 Authorization ——
122
+ // 与同函数上面两条分支一样先过 sanitizeDetail;可读原因(ECONNREFUSED 等)不受影响。
123
+ return { ok: false, detail: sanitizeDetail((err instanceof Error ? err.message : String(err)) + cause, apiKey) };
121
124
  }
122
125
  }