@dsh-libing/knowledge-base 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.
Files changed (86) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +80 -0
  3. package/cordis.patch.yml +3 -0
  4. package/icon.svg +6 -0
  5. package/lib/chunks.d.ts +8 -0
  6. package/lib/chunks.js +197 -0
  7. package/lib/client-types/client/BrowsePanel.d.ts +13 -0
  8. package/lib/client-types/client/Icon.d.ts +4 -0
  9. package/lib/client-types/client/ImageReview.d.ts +20 -0
  10. package/lib/client-types/client/ImportPanel.d.ts +19 -0
  11. package/lib/client-types/client/JobsPanel.d.ts +6 -0
  12. package/lib/client-types/client/KnowledgePage.d.ts +7 -0
  13. package/lib/client-types/client/Metadata.d.ts +10 -0
  14. package/lib/client-types/client/QualityReport.d.ts +6 -0
  15. package/lib/client-types/client/SettingsPanel.d.ts +6 -0
  16. package/lib/client-types/client/SourceText.d.ts +3 -0
  17. package/lib/client-types/client/UsageGuide.d.ts +8 -0
  18. package/lib/client-types/client/api.d.ts +953 -0
  19. package/lib/client-types/client/common.d.ts +11 -0
  20. package/lib/client-types/client/index.d.ts +14 -0
  21. package/lib/client-types/client/locales.d.ts +284 -0
  22. package/lib/client-types/client/searchCommand.d.ts +2 -0
  23. package/lib/client-types/client/useKnowledge.d.ts +251 -0
  24. package/lib/client-types/contract.d.ts +1197 -0
  25. package/lib/client-types/data.d.ts +1972 -0
  26. package/lib/client-types/settings.d.ts +33 -0
  27. package/lib/client-types/shared.d.ts +95 -0
  28. package/lib/client.js +305 -0
  29. package/lib/config.d.ts +13 -0
  30. package/lib/config.js +13 -0
  31. package/lib/contract.d.ts +1197 -0
  32. package/lib/contract.js +18 -0
  33. package/lib/data.d.ts +1972 -0
  34. package/lib/data.js +80 -0
  35. package/lib/document/convert.d.ts +5 -0
  36. package/lib/document/convert.js +325 -0
  37. package/lib/document/pdf-data.d.ts +99 -0
  38. package/lib/document/pdf-data.js +9 -0
  39. package/lib/document/pdf-layout.d.ts +24 -0
  40. package/lib/document/pdf-layout.js +234 -0
  41. package/lib/document/pdf-session.d.ts +39 -0
  42. package/lib/document/pdf-session.js +127 -0
  43. package/lib/document/pdf-vision.d.ts +26 -0
  44. package/lib/document/pdf-vision.js +115 -0
  45. package/lib/document/pdf-worker.d.ts +1 -0
  46. package/lib/document/pdf-worker.js +210 -0
  47. package/lib/document/pdf.d.ts +3 -0
  48. package/lib/document/pdf.js +218 -0
  49. package/lib/document/zip.d.ts +3 -0
  50. package/lib/document/zip.js +59 -0
  51. package/lib/figures/process.d.ts +187 -0
  52. package/lib/figures/process.js +159 -0
  53. package/lib/figures/prompts.d.ts +7 -0
  54. package/lib/figures/prompts.js +27 -0
  55. package/lib/index.d.ts +8 -0
  56. package/lib/index.js +42 -0
  57. package/lib/indexes.d.ts +38 -0
  58. package/lib/indexes.js +101 -0
  59. package/lib/jobs.d.ts +2806 -0
  60. package/lib/jobs.js +435 -0
  61. package/lib/models.d.ts +24 -0
  62. package/lib/models.js +120 -0
  63. package/lib/retrieval.d.ts +209 -0
  64. package/lib/retrieval.js +173 -0
  65. package/lib/routes.d.ts +3 -0
  66. package/lib/routes.js +152 -0
  67. package/lib/runtime.d.ts +48 -0
  68. package/lib/runtime.js +41 -0
  69. package/lib/settings.d.ts +33 -0
  70. package/lib/settings.js +19 -0
  71. package/lib/shared.d.ts +95 -0
  72. package/lib/shared.js +17 -0
  73. package/lib/skills.d.ts +3 -0
  74. package/lib/skills.js +21 -0
  75. package/lib/store.d.ts +409 -0
  76. package/lib/store.js +328 -0
  77. package/lib/tools.d.ts +3 -0
  78. package/lib/tools.js +72 -0
  79. package/lib/util.d.ts +25 -0
  80. package/lib/util.js +89 -0
  81. package/package.json +125 -0
  82. package/skills/libing-kb-build/SKILL.md +10 -0
  83. package/skills/libing-kb-search/SKILL.md +10 -0
  84. package/skills/libing-knowledge-base/SKILL.md +15 -0
  85. package/skills/libing-knowledge-base/references/build.md +23 -0
  86. package/skills/libing-knowledge-base/references/search.md +21 -0
@@ -0,0 +1,210 @@
1
+ /** Isolated PDF.js engine: original strings and bounded raster previews, without shell dependencies. */
2
+ import { parentPort, workerData } from 'node:worker_threads';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { createRequire } from 'node:module';
6
+ import { getDocument, OPS, Util } from 'pdfjs-dist/legacy/build/pdf.mjs';
7
+ import { createCanvas, Path2D } from '@napi-rs/canvas';
8
+ import { z } from 'zod';
9
+ import { bboxSchema } from "../data.js";
10
+ import { message, required } from "../util.js";
11
+ const port = required(parentPort, 'PDF worker port');
12
+ const input = z.object({ file: z.string(), config: z.object({ maxInputBytes: z.number().positive(), pdfMaxPages: z.number().positive(), pdfMaxPagePixels: z.number().positive() }) }).parse(workerData);
13
+ const matrixSchema = z.tuple([z.number(), z.number(), z.number(), z.number(), z.number(), z.number()]);
14
+ const markTag = (value) => z.union([z.string(), z.object({ name: z.string() })]).transform(value => typeof value === 'string' ? value : value.name).parse(value);
15
+ const numeric = (value) => z.array(z.number().finite()).parse(value instanceof Float32Array || value instanceof Float64Array ? Array.from(value) : value);
16
+ // PDF.js uses napi-rs on Node. Correct only subpixel clip skew in this isolated worker's supported canvas prototype.
17
+ const proto = Object.getPrototypeOf(createCanvas(1, 1).getContext('2d')), nativeClip = proto.clip;
18
+ proto.clip = function (path, rule) {
19
+ const original = this.getTransform(), tiny = (value) => value !== 0 && Math.abs(value) < 1e-7;
20
+ if (path instanceof Path2D && (tiny(original.b) || tiny(original.c))) {
21
+ const matrix = this.getTransform(), transformed = new Path2D();
22
+ if (tiny(matrix.b))
23
+ matrix.b = 0;
24
+ if (tiny(matrix.c))
25
+ matrix.c = 0;
26
+ transformed.addPath(path, matrix);
27
+ this.resetTransform();
28
+ try {
29
+ Reflect.apply(nativeClip, this, rule === undefined ? [transformed] : [transformed, rule]);
30
+ }
31
+ finally {
32
+ this.setTransform(original);
33
+ }
34
+ }
35
+ else
36
+ Reflect.apply(nativeClip, this, path === undefined ? [] : rule === undefined ? [path] : [path, rule]);
37
+ };
38
+ const require = createRequire(import.meta.url), base = dirname(require.resolve('pdfjs-dist/package.json')), config = input.config;
39
+ let doc, activePage = 0;
40
+ const engineWarnings = new Map();
41
+ console.warn = (...parts) => {
42
+ const value = parts.map(String).join(' ').replace(/^Warning:\s*/u, '').slice(0, 1000), list = engineWarnings.get(activePage) || [];
43
+ if (list.length < 100 && !list.includes(value))
44
+ list.push(value);
45
+ engineWarnings.set(activePage, list);
46
+ };
47
+ const point = (m, x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
48
+ function box(m, b) { const p = [point(m, b[0], b[1]), point(m, b[2], b[1]), point(m, b[0], b[3]), point(m, b[2], b[3])]; return [Math.min(...p.map(v => v[0])), Math.min(...p.map(v => v[1])), Math.max(...p.map(v => v[0])), Math.max(...p.map(v => v[1]))]; }
49
+ function prune(node) {
50
+ if (!node)
51
+ return null;
52
+ if ('type' in node)
53
+ return node.type === 'content' ? { type: 'content', id: node.id } : null;
54
+ const children = node.children.flatMap(child => { const value = prune(child); return value ? [value] : []; });
55
+ return children.length ? { role: node.role, children } : null;
56
+ }
57
+ async function info() {
58
+ const data = await readFile(input.file);
59
+ if (data.length > config.maxInputBytes)
60
+ throw new Error('PDF 文件超过大小上限');
61
+ if (!data.subarray(0, 1024).includes(Buffer.from('%PDF-')))
62
+ throw new Error('PDF 文件头无效');
63
+ const loading = getDocument({ data: new Uint8Array(data), verbosity: 1, stopAtErrors: true, maxImageSize: config.pdfMaxPagePixels, cMapUrl: join(base, 'cmaps') + '/', cMapPacked: true, standardFontDataUrl: join(base, 'standard_fonts') + '/', wasmUrl: join(base, 'wasm') + '/', useSystemFonts: true });
64
+ loading.onPassword = () => { void loading.destroy(); port.postMessage({ fatal: 'PDF 已加密,请提供解除密码保护的副本' }); };
65
+ doc = await loading.promise;
66
+ if (doc.numPages > config.pdfMaxPages)
67
+ throw new Error(`PDF 共 ${doc.numPages} 页,超过 pdfMaxPages=${config.pdfMaxPages}`);
68
+ const outline = [];
69
+ async function visit(items, level = 1) {
70
+ for (const entry of items || []) {
71
+ if (outline.length >= 10000 || level > 32)
72
+ throw new Error('PDF 书签层级或数量超过上限');
73
+ let page = null, y = null;
74
+ try {
75
+ const raw = typeof entry.dest === 'string' ? await doc.getDestination(entry.dest) : entry.dest;
76
+ if (Array.isArray(raw) && raw.length) {
77
+ const ref = z.union([z.number().int().nonnegative(), z.object({ num: z.number().int(), gen: z.number().int() })]).parse(raw[0]);
78
+ page = (typeof ref === 'number' ? ref : await doc.getPageIndex(ref)) + 1;
79
+ const kind = z.object({ name: z.string() }).safeParse(raw[1]);
80
+ if (kind.success && kind.data.name === 'XYZ' && typeof raw[3] === 'number')
81
+ y = raw[3];
82
+ }
83
+ }
84
+ catch { /* An unresolved bookmark remains visible with no invented page location. */ }
85
+ outline.push({ title: String(entry.title || '').slice(0, 1000), level, page, y });
86
+ await visit(entry.items, level + 1);
87
+ }
88
+ }
89
+ await visit(await doc.getOutline());
90
+ return { warnings: engineWarnings.get(0) || [], pageCount: doc.numPages, pageLabels: await doc.getPageLabels(), outline };
91
+ }
92
+ async function nativePage(n) {
93
+ activePage = n;
94
+ const page = await doc.getPage(n);
95
+ try {
96
+ const viewport = page.getViewport({ scale: 1 }), content = await page.getTextContent({ includeMarkedContent: true }), tree = prune(await page.getStructTree());
97
+ const spans = [], marks = [];
98
+ let seq = 0;
99
+ for (const item of content.items) {
100
+ if ('type' in item) {
101
+ if (item.type.startsWith('beginMarkedContent'))
102
+ marks.push({ id: item.id, artifact: 'tag' in item && item.tag === 'Artifact' });
103
+ else if (item.type === 'endMarkedContent')
104
+ marks.pop();
105
+ continue;
106
+ }
107
+ if (!item.str.trim())
108
+ continue;
109
+ const t = matrixSchema.parse(Util.transform(viewport.transform, item.transform)), h = Math.hypot(t[2], t[3]) || item.height || 10, style = content.styles[item.fontName], a = style?.ascent ?? .8;
110
+ const a0 = [t[4] + t[2] * a, t[5] + t[3] * a], b0 = [t[4] + t[2] * (a - 1), t[5] + t[3] * (a - 1)];
111
+ const angle = Math.atan2(t[1], t[0]), w = item.width || h / 2, dx = Math.cos(angle) * w, dy = Math.sin(angle) * w;
112
+ const points = [a0, b0, [a0[0] + dx, a0[1] + dy], [b0[0] + dx, b0[1] + dy]], bbox = [Math.min(...points.map(p => p[0])), Math.min(...points.map(p => p[1])), Math.max(...points.map(p => p[0])), Math.max(...points.map(p => p[1]))];
113
+ const rotation = ((page.rotate % 360) + 360) % 360, W = viewport.width, H = viewport.height;
114
+ const readingBox = rotation === 90 ? [bbox[1], W - bbox[2], bbox[3], W - bbox[0]] : rotation === 180 ? [W - bbox[2], H - bbox[3], W - bbox[0], H - bbox[1]] : rotation === 270 ? [H - bbox[3], bbox[0], H - bbox[1], bbox[2]] : bbox;
115
+ spans.push({ id: `s${++seq}`, text: item.str, mcid: [...marks].reverse().find(m => m.id)?.id || null, artifact: marks.some(m => m.artifact), bbox, readingBox, fontSize: h, fontName: style?.fontFamily || item.fontName });
116
+ if (spans.length > 100000)
117
+ throw new Error(`PDF 第 ${n} 页文字对象过多`);
118
+ }
119
+ const operators = await page.getOperatorList(), drawings = [], stack = [], mc = [], prefix = `p${page.ref?.num || n}R_mc`;
120
+ let matrix = [1, 0, 0, 1, 0, 0];
121
+ const add = (b, kind, shape = null) => {
122
+ const bounds = box(matrixSchema.parse(Util.transform(viewport.transform, matrix)), b);
123
+ if (mc.some(x => x.tag === 'Artifact'))
124
+ return;
125
+ const id = [...mc].reverse().find(x => Number.isInteger(x.id))?.id;
126
+ drawings.push({ bbox: bounds, kind, shape, mcid: id === undefined ? null : prefix + id });
127
+ };
128
+ for (let i = 0; i < operators.fnArray.length; i++) {
129
+ const op = required(operators.fnArray[i]), args = operators.argsArray[i] || [];
130
+ if (op === OPS.save)
131
+ stack.push([...matrix]);
132
+ else if (op === OPS.restore)
133
+ matrix = stack.pop() || matrix;
134
+ else if (op === OPS.transform)
135
+ matrix = matrixSchema.parse(Util.transform(matrix, matrixSchema.parse(args)));
136
+ else if (op === OPS.paintFormXObjectBegin) {
137
+ stack.push([...matrix]);
138
+ if (args[0])
139
+ matrix = matrixSchema.parse(Util.transform(matrix, matrixSchema.parse(args[0])));
140
+ }
141
+ else if (op === OPS.paintFormXObjectEnd)
142
+ matrix = stack.pop() || matrix;
143
+ else if (op === OPS.beginMarkedContentProps)
144
+ mc.push({ tag: markTag(args[0]), ...Number.isInteger(args[1]) ? { id: z.number().parse(args[1]) } : {} });
145
+ else if (op === OPS.beginMarkedContent)
146
+ mc.push({ tag: markTag(args[0]) });
147
+ else if (op === OPS.endMarkedContent)
148
+ mc.pop();
149
+ else if (op === OPS.constructPath && [OPS.stroke, OPS.fill, OPS.eoFill, OPS.fillStroke, OPS.eoFillStroke].includes(z.number().parse(args[0])) && args[2]) {
150
+ const paths = Array.isArray(args[1]) ? args[1] : [], path = paths[0] === undefined ? [] : numeric(paths[0]);
151
+ add(bboxSchema.parse(numeric(args[2])), 'path', path.length === 13 && path[0] === 0 && path[3] === 1 && path[6] === 1 && path[9] === 1 && path[12] === 4 ? 'rect' : null);
152
+ }
153
+ else if ([OPS.paintImageXObject, OPS.paintInlineImageXObject, OPS.paintImageMaskXObject].includes(op))
154
+ add([0, 0, 1, 1], 'image');
155
+ if (drawings.length > 100000)
156
+ throw new Error(`PDF 第 ${n} 页图形对象过多`);
157
+ }
158
+ return { warnings: engineWarnings.get(n) || [], page: n, width: viewport.width, height: viewport.height, rotation: page.rotate, viewportTransform: viewport.transform, spans, tree, drawings };
159
+ }
160
+ finally {
161
+ page.cleanup();
162
+ }
163
+ }
164
+ async function render(n, bbox) {
165
+ activePage = n;
166
+ const page = await doc.getPage(n), v = page.getViewport({ scale: 1 }), b = bbox || [0, 0, v.width, v.height];
167
+ if (b[0] < 0 || b[1] < 0 || b[2] > v.width + .1 || b[3] > v.height + .1 || b[2] <= b[0] || b[3] <= b[1])
168
+ throw new Error('PDF 裁切区域无效');
169
+ const scale = Math.min(2, Math.sqrt(config.pdfMaxPagePixels / (v.width * v.height)));
170
+ if (scale < .5)
171
+ throw new Error(`PDF 第 ${n} 页页面尺寸过大`);
172
+ const viewport = page.getViewport({ scale }), canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height)), crop = createCanvas(Math.max(1, Math.ceil((b[2] - b[0]) * scale)), Math.max(1, Math.ceil((b[3] - b[1]) * scale)));
173
+ try {
174
+ // PDF.js publishes DOM-only render types for its supported Node napi-rs canvas. This bridge uses that same tested Canvas2D implementation.
175
+ await page.render({ canvas: null, canvasContext: canvas.getContext('2d'), viewport, background: 'rgb(255,255,255)' }).promise;
176
+ crop.getContext('2d').drawImage(canvas, b[0] * scale, b[1] * scale, crop.width, crop.height, 0, 0, crop.width, crop.height);
177
+ return { data: new Uint8Array(crop.toBuffer('image/png')), scale, bbox: b, warnings: engineWarnings.get(n) || [] };
178
+ }
179
+ finally {
180
+ canvas.width = canvas.height = 1;
181
+ crop.width = crop.height = 1;
182
+ page.cleanup();
183
+ }
184
+ }
185
+ try {
186
+ port.postMessage({ ready: await info() });
187
+ }
188
+ catch (error) {
189
+ port.postMessage({ fatal: message(error) });
190
+ }
191
+ const commandSchema = z.object({ id: z.number().int().positive(), command: z.enum(['page', 'render']), page: z.number().int().positive(), bbox: bboxSchema.optional() });
192
+ let tail = Promise.resolve();
193
+ port.on('message', (raw) => {
194
+ tail = tail.then(async () => {
195
+ let id = 0;
196
+ try {
197
+ const request = commandSchema.parse(raw);
198
+ id = request.id;
199
+ if (request.command === 'page')
200
+ port.postMessage({ id, value: await nativePage(request.page) });
201
+ else {
202
+ const value = await render(request.page, request.bbox);
203
+ port.postMessage({ id, value }, [value.data.buffer]);
204
+ }
205
+ }
206
+ catch (error) {
207
+ port.postMessage({ id, error: message(error) });
208
+ }
209
+ });
210
+ });
@@ -0,0 +1,3 @@
1
+ import type { Document, Job } from '../data.ts';
2
+ import type { Models } from '../models.ts';
3
+ export declare function convertPdf(file: string, destination: string, job: Job, models: Models, signal: AbortSignal): Promise<Document>;
@@ -0,0 +1,218 @@
1
+ import { block as makeBlock, documentSchema, pageReceiptSchema } from "../data.js";
2
+ import { message, required } from "../util.js";
3
+ import { nativePageSchema, pdfBlock } from "./pdf-data.js";
4
+ import { join } from 'node:path';
5
+ import { PdfSession, pageRange } from "./pdf-session.js";
6
+ import { nativeLayout, PDF_PARSER_VERSION, tableMarkdown, normalize } from "./pdf-layout.js";
7
+ import { restoreLayout, PDF_VISION_VERSION } from "./pdf-vision.js";
8
+ import { atomicWrite, checkAbort, hash, readJson } from "../util.js";
9
+ import { blockMarkdown } from "./convert.js";
10
+ import { BudgetError } from "../models.js";
11
+ export async function convertPdf(file, destination, job, models, signal) {
12
+ let session = new PdfSession(file, job.config, signal);
13
+ const assets = new Map(), pages = [], blocks = [], warnings = [];
14
+ job.parserVersion = PDF_PARSER_VERSION;
15
+ const addAsset = async (data, origin, source) => {
16
+ const assetId = `a-${hash(data).slice(0, 20)}`, asset = { assetId, mime: 'image/png', path: `assets/${assetId}.png`, bytes: data.length, origin, source };
17
+ if (!assets.has(assetId)) {
18
+ await atomicWrite(join(destination, asset.path), Buffer.from(data));
19
+ assets.set(assetId, asset);
20
+ }
21
+ return asset;
22
+ };
23
+ try {
24
+ const info = await session.ready, range = pageRange(job.pageStart, job.pageEnd, info.pageCount);
25
+ warnings.push(...(info.warnings || []).map(message => ({ code: 'pdf-engine-warning', message: `PDF 打开警告:${message}`, blocking: true })));
26
+ job.pdf = { pageCount: info.pageCount, selectedPages: range, outlineCount: info.outline.length };
27
+ job.progress = { ...job.progress, totalPages: range.count, parsedPages: 0, ocrPages: 0, tables: 0 };
28
+ await job.checkpoint();
29
+ for (let n = range.start; n <= range.end; n++) {
30
+ checkAbort(signal);
31
+ job.progress.currentPage = n;
32
+ await job.checkpoint();
33
+ const key = hash({ source: job.sourceHash, page: n, parser: PDF_PARSER_VERSION, visionVersion: PDF_VISION_VERSION, route: job.routes.vision, skip: job.config.skipImages, schema: job.schemaHash, pixels: job.config.pdfMaxPagePixels });
34
+ const receipt = join(destination, 'pdf-pages', `${n}-${key}.json`);
35
+ let result = await readJson(receipt, pageReceiptSchema, null);
36
+ if (!result) {
37
+ let page, layout, previewAsset;
38
+ try {
39
+ if (session.closed) {
40
+ session = new PdfSession(file, job.config, signal);
41
+ await session.ready;
42
+ }
43
+ const nativeFile = join(destination, 'pdf-pages', `${n}-native-${hash({ source: job.sourceHash, parser: PDF_PARSER_VERSION })}.json`);
44
+ page = (await readJson(nativeFile, nativePageSchema, null)) ?? undefined;
45
+ if (!page) {
46
+ page = await session.call('page', n);
47
+ await atomicWrite(nativeFile, page);
48
+ }
49
+ layout = nativeLayout(page, info);
50
+ const render = async (bbox) => {
51
+ const r = await session.call('render', n, bbox);
52
+ for (const message of r.warnings || [])
53
+ if (!required(layout).warnings.some(w => w.message.endsWith(message)))
54
+ required(layout).warnings.push({ code: 'pdf-engine-warning', page: n, message: `PDF 第 ${n} 页渲染警告:${message}`, blocking: true });
55
+ return r;
56
+ };
57
+ if (layout.needsLayout) {
58
+ previewAsset = await addAsset((await render()).data, `PDF 第 ${n} 页预览`, layout.source([0, 0, page.width, page.height]));
59
+ try {
60
+ const restored = await restoreLayout(page, layout, join(destination, previewAsset.path), job, models, signal);
61
+ layout = { ...layout, ...restored, warnings: [...layout.warnings, ...restored.warnings] };
62
+ }
63
+ catch (error) {
64
+ if (error instanceof BudgetError || signal?.aborted)
65
+ throw error;
66
+ required(layout).warnings.push({ code: 'pdf-layout-gap', page: n, message: `PDF 第 ${n} 页版面/OCR 未完成:${message(error)}`, blocking: true });
67
+ layout.blocks.push({ ...pdfBlock({ type: 'figure', source: layout.source([0, 0, page.width, page.height]), caption: `PDF 第 ${n} 页未解析区域(整页参考)`, text: '', nativeLabels: [], needsReview: true }), previewAsset: required(previewAsset) });
68
+ }
69
+ }
70
+ const localAssets = [];
71
+ for (const block of layout.blocks) {
72
+ if (block.type === 'figure' || block.type === 'table') {
73
+ const asset = block.previewAsset || await addAsset((await render(block.source.bbox)).data, `PDF 第 ${n} 页 ${block.caption || block.type}`, block.source);
74
+ if (block.type === 'figure')
75
+ block.assetId = asset.assetId;
76
+ else
77
+ block.sourcePreviewAssetId = asset.assetId;
78
+ localAssets.push(asset);
79
+ delete block.previewAsset;
80
+ }
81
+ }
82
+ if (previewAsset)
83
+ localAssets.push(previewAsset);
84
+ // Caption association uses temporary object links, not persistent cyclic references.
85
+ const pageBlocks = layout.blocks.map((b, i) => {
86
+ const { captionFor, captionBlock: _captionBlock, ...value } = b;
87
+ if (captionFor?.type === 'figure') {
88
+ value.type = 'caption';
89
+ value.captionFigureIndex = required(layout).blocks.indexOf(captionFor);
90
+ }
91
+ if (captionFor?.type === 'table') {
92
+ value.type = 'caption';
93
+ value.tableCaption = true;
94
+ }
95
+ return { ...value, localIndex: i };
96
+ });
97
+ result = { blocks: pageBlocks, assets: localAssets, warnings: layout.warnings, audit: { page: n, width: page.width, height: page.height, pageLabel: info.pageLabels?.[n - 1] || null, status: layout.warnings.some(w => w.blocking) ? 'needs-review' : layout.navigation ? 'navigation' : 'parsed', ocr: layout.ocr, spanCount: layout.spanCount, artifactCount: layout.artifactCount, excluded: layout.excluded, tables: pageBlocks.filter(b => b.type === 'table').length, figures: pageBlocks.filter(b => b.type === 'figure').length, headings: pageBlocks.filter(b => b.type === 'heading').map(b => ({ text: b.text, basis: b.headingBasis, level: b.level })), previewAssetId: previewAsset?.assetId || null } };
98
+ }
99
+ catch (error) {
100
+ if (error instanceof BudgetError || signal?.aborted)
101
+ throw error;
102
+ const source = { page: n, width: page?.width ?? null, height: page?.height ?? null, pageLabel: info.pageLabels?.[n - 1] || null, bbox: page ? [0, 0, page.width, page.height] : null, coordinateSystem: 'viewport-points-top-left', method: 'unprocessed' };
103
+ // A page render failure does not erase native text or earlier page checkpoints.
104
+ const fallback = (layout?.blocks || []).map(({ captionFor: _captionFor, captionBlock: _captionBlock, previewAsset: _previewAsset, ...b }) => b).filter(b => b.type !== 'figure' || b.assetId);
105
+ fallback.push(makeBlock({ type: 'note', text: `[PDF 第 ${n} 页处理未完成:${message(error)}]`, source, needsReview: true }));
106
+ result = { blocks: fallback, assets: [], warnings: [{ code: 'pdf-page-failed', page: n, message: `PDF 第 ${n} 页处理失败:${message(error)}`, blocking: true }], audit: { page: n, pageLabel: source.pageLabel, status: 'failed', error: message(error), ocr: layout?.ocr || false, tables: fallback.filter(b => b.type === 'table').length, figures: 0, headings: [] } };
107
+ }
108
+ // Failed/gap pages are deliberately retried; successful pages and model receipts are durable.
109
+ if (result.audit.status !== 'failed' && !result.warnings.some(w => w.code === 'pdf-layout-gap'))
110
+ await atomicWrite(receipt, result);
111
+ }
112
+ for (const a of result.assets)
113
+ assets.set(a.assetId, a);
114
+ if (result.blocks.some(b => b.openEnd) && n === range.end && range.partial)
115
+ result.audit.status = 'needs-review';
116
+ pages.push(result.audit);
117
+ warnings.push(...result.warnings);
118
+ if (result.audit.status !== 'navigation') {
119
+ const figures = new Map();
120
+ for (const b of result.blocks)
121
+ if (b.type === 'figure') {
122
+ const occurrenceId = `fig-p${String(n).padStart(5, '0')}-${String(b.localIndex ?? result.blocks.indexOf(b)).padStart(4, '0')}`;
123
+ figures.set(b.localIndex, occurrenceId);
124
+ b.occurrenceId = occurrenceId;
125
+ }
126
+ for (const b of result.blocks) {
127
+ if (b.openEnd) {
128
+ b.text += '\n[表格可能延续到下一页,当前页记录不是完整表;请按来源页及关联片段核对。]';
129
+ b.markdown = (b.markdown || '') + '\n\n> 表格可能延续到下一页,当前页记录不是完整表;请按来源页及关联片段核对。';
130
+ warnings.push({ code: 'pdf-table-continuation', page: n, message: `PDF 第 ${n} 页表格下边界未闭合${n === range.end && range.partial ? ';所选范围在此结束,请补充下一页或接受覆盖缺口' : ''}`, blocking: n === range.end && range.partial });
131
+ }
132
+ if (b.captionFigureIndex !== undefined)
133
+ b.figureRef = required(figures.get(b.captionFigureIndex));
134
+ delete b.captionFigureIndex;
135
+ delete b.localIndex;
136
+ blocks.push(b);
137
+ }
138
+ }
139
+ job.progress.parsedPages = (job.progress.parsedPages || 0) + 1;
140
+ if (result.audit.ocr)
141
+ job.progress.ocrPages = (job.progress.ocrPages || 0) + 1;
142
+ job.progress.tables = (job.progress.tables || 0) + result.audit.tables;
143
+ await job.checkpoint();
144
+ }
145
+ if (range.partial)
146
+ warnings.push({ code: 'pdf-selected-pages', message: `仅导入 PDF 实际第 ${range.start}–${range.end} 页 / ${range.total};首尾章节、段落和表格可能延续到范围外,请按原件核对`, blocking: false });
147
+ // Only repeated marginal paragraphs are excluded; headings and body text remain even when repeated.
148
+ if (pages.length >= 3) {
149
+ const repeated = new Map();
150
+ for (const b of blocks) {
151
+ const box = b.source?.bbox, p = pages.find(p => p.page === b.source?.page);
152
+ if (b.type !== 'paragraph' || !box || b.text.length > 160)
153
+ continue;
154
+ const key = normalize(b.text).replace(/\d+/g, '#'), edge = box[1] < 40 ? 'top' : box[1] > (p?.height || 842) - 45 ? 'bottom' : null;
155
+ if (edge) {
156
+ const k = edge + ':' + key;
157
+ repeated.set(k, [...(repeated.get(k) || []), b]);
158
+ }
159
+ }
160
+ for (const candidates of repeated.values())
161
+ if (new Set(candidates.map(b => required(b.source).page)).size >= Math.max(3, Math.ceil(pages.length * .6)))
162
+ for (const b of candidates) {
163
+ (required(pages.find(p => p.page === required(b.source).page)).excluded ||= []).push({ kind: 'repeated-header-footer', text: b.text, bbox: required(b.source).bbox });
164
+ blocks.splice(blocks.indexOf(b), 1);
165
+ }
166
+ }
167
+ // Across-page tables remain separate unless table number, header and column structure all agree.
168
+ for (let i = 1; i < blocks.length; i++) {
169
+ const b = required(blocks[i]);
170
+ if (b.type !== 'table' || !b.source?.bbox)
171
+ continue;
172
+ let previous = i - 1;
173
+ const repeated = [];
174
+ while (previous >= 0) {
175
+ const item = required(blocks[previous]);
176
+ if (!item.mergeRemove && !(item.tableCaption && normalize(item.text) === normalize(b.caption)))
177
+ break;
178
+ if (!item.mergeRemove)
179
+ repeated.push(item);
180
+ previous--;
181
+ }
182
+ const a = blocks[previous], last = a?.source?.locations?.at(-1) || a?.source;
183
+ const tableId = (t) => /^(?:表|Table)\s*([A-Z\d][\w.\-–]*)/iu.exec(t.caption)?.[1];
184
+ if (!a || a.type !== 'table' || !a.source || !last?.bbox || !a.rows[0] || !b.rows[0])
185
+ continue;
186
+ const height = pages.find(p => p.page === last.page)?.height || 842, nextHeight = pages.find(p => p.page === b.source?.page)?.height || 842;
187
+ if (b.source.page === last.page + 1 && tableId(a) && tableId(a) === tableId(b) && last.bbox[3] > height * .8 && b.source.bbox[1] < nextHeight * .25 && !a.needsReview && !b.needsReview && JSON.stringify(a.rows[0].map(c => normalize(c.text))) === JSON.stringify(b.rows[0].map(c => normalize(c.text))) && a.rows[0].length === b.rows[0].length && [...a.rows, ...b.rows].every(r => r.length === required(a.rows[0]).length && r.every(c => c.rowspan === 1 && c.colspan === 1))) {
188
+ a.rows.push(...b.rows.slice(1));
189
+ const { locations, ...first } = a.source;
190
+ a.source.locations = [...(locations || [first]), b.source];
191
+ a.sourcePreviewAssetIds = [...(a.sourcePreviewAssetIds || [a.sourcePreviewAssetId]), b.sourcePreviewAssetId].flatMap(id => id ? [id] : []);
192
+ a.markdown = tableMarkdown(a.rows);
193
+ a.text = a.rows.map(r => r.map(c => c.text).join(' | ')).join('\n');
194
+ b.mergeRemove = true;
195
+ for (const caption of repeated)
196
+ caption.mergeRemove = true;
197
+ }
198
+ }
199
+ for (let i = blocks.length - 1; i >= 0; i--)
200
+ if (required(blocks[i]).mergeRemove)
201
+ blocks.splice(i, 1);
202
+ for (let i = 0; i < blocks.length; i++)
203
+ required(blocks[i]).blockId = `b-${String(i + 1).padStart(6, '0')}`;
204
+ if (!blocks.some(b => b.type !== 'heading' && (b.text?.trim() || b.type === 'figure')))
205
+ throw new Error('所选 PDF 页没有可导入内容,请核对页范围及质量报告');
206
+ if (!blocks.some(b => b.type === 'heading'))
207
+ warnings.push({ code: 'pdf-no-headings', message: '所选 PDF 页未匹配可靠正文标题,按正文分组,请核对目录层级', blocking: false });
208
+ const document = documentSchema.parse({ formatVersion: 1, sourceFormat: 'pdf', pageCount: info.pageCount, selectedPages: range, parserVersion: PDF_PARSER_VERSION, sourceHash: job.sourceHash, blocks, assets: [...assets.values()], internalRefs: [], warnings, audit: { pages, outlineCount: info.outline.length, matchedBookmarks: blocks.filter(b => b.headingBasis === 'bookmark').length, convertedFigures: blocks.filter(b => b.type === 'figure').length, convertedTables: blocks.filter(b => b.type === 'table').length } });
209
+ await atomicWrite(join(destination, 'document.json'), document);
210
+ await atomicWrite(join(destination, 'converted.md'), `> PDF 实际页序 ${range.start}–${range.end} / ${range.total}。${range.partial ? '仅覆盖所选范围。' : ''}\n\n` + blocks.map(b => blockMarkdown(b, document.assets)).join('\n\n') + '\n');
211
+ delete job.progress.currentPage;
212
+ await job.checkpoint();
213
+ return document;
214
+ }
215
+ finally {
216
+ await session.close();
217
+ }
218
+ }
@@ -0,0 +1,3 @@
1
+ import type { Settings } from '../config.ts';
2
+ /** Inspect central-directory limits before any decompression, including non-XML media. */
3
+ export declare function unpackDocx(buffer: Buffer, config: Settings): Record<string, Uint8Array>;
@@ -0,0 +1,59 @@
1
+ import { unzipSync } from 'fflate';
2
+ /** Inspect central-directory limits before any decompression, including non-XML media. */
3
+ export function unpackDocx(buffer, config) {
4
+ if (buffer.length > config.maxInputBytes)
5
+ throw new Error('Word 文件超过大小限制');
6
+ let end = -1;
7
+ for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 65557); i--) {
8
+ if (buffer.readUInt32LE(i) === 0x06054b50 && i + 22 + buffer.readUInt16LE(i + 20) === buffer.length) {
9
+ end = i;
10
+ break;
11
+ }
12
+ }
13
+ if (end < 0)
14
+ throw new Error('无效 DOCX ZIP 文件');
15
+ const count = buffer.readUInt16LE(end + 10), size = buffer.readUInt32LE(end + 12), start = buffer.readUInt32LE(end + 16);
16
+ if (count === 65535 || count > config.maxArchiveEntries || start + size > end || buffer.readUInt16LE(end + 4) !== 0)
17
+ throw new Error('不支持的分卷/ZIP64 或过多 ZIP 条目');
18
+ let pos = start, total = 0;
19
+ const entries = new Map();
20
+ for (let n = 0; n < count; n++) {
21
+ if (pos + 46 > end || buffer.readUInt32LE(pos) !== 0x02014b50)
22
+ throw new Error('损坏的 ZIP 目录');
23
+ const flags = buffer.readUInt16LE(pos + 8), method = buffer.readUInt16LE(pos + 10);
24
+ const compressed = buffer.readUInt32LE(pos + 20), uncompressed = buffer.readUInt32LE(pos + 24);
25
+ const nameLength = buffer.readUInt16LE(pos + 28), extraLength = buffer.readUInt16LE(pos + 30), commentLength = buffer.readUInt16LE(pos + 32);
26
+ const next = pos + 46 + nameLength + extraLength + commentLength;
27
+ if (next > end || uncompressed === 0xffffffff || compressed === 0xffffffff)
28
+ throw new Error('损坏或不支持的 ZIP 条目');
29
+ const name = buffer.subarray(pos + 46, pos + 46 + nameLength).toString('utf8');
30
+ if (!name || (name.includes('\\') || name.includes('\0')) || name.startsWith('/') || /^[A-Za-z]:/.test(name) || name.split('/').some(x => x === '..') || entries.has(name))
31
+ throw new Error('ZIP 条目路径非法或重复');
32
+ if (flags & 1 || ![0, 8].includes(method) || ((buffer.readUInt32LE(pos + 38) >>> 16) & 0xf000) === 0xa000)
33
+ throw new Error('不支持加密、特殊压缩或符号链接 ZIP');
34
+ const local = buffer.readUInt32LE(pos + 42);
35
+ if (local + 30 > start || buffer.readUInt32LE(local) !== 0x04034b50)
36
+ throw new Error('损坏的 ZIP 本地条目');
37
+ const localNameLength = buffer.readUInt16LE(local + 26), localExtra = buffer.readUInt16LE(local + 28);
38
+ if (buffer.subarray(local + 30, local + 30 + localNameLength).toString('utf8') !== name || local + 30 + localNameLength + localExtra + compressed > start)
39
+ throw new Error('ZIP 条目不一致');
40
+ total += uncompressed;
41
+ if (total > config.maxUncompressedBytes || (name.endsWith('.xml') && uncompressed > 16 * 1024 * 1024))
42
+ throw new Error('DOCX 解压大小超过限制');
43
+ entries.set(name, uncompressed);
44
+ pos = next;
45
+ }
46
+ if (pos !== start + size)
47
+ throw new Error('ZIP 目录长度不一致');
48
+ const files = unzipSync(buffer, { filter: file => {
49
+ if (!entries.has(file.name) || entries.get(file.name) !== file.originalSize)
50
+ throw new Error('ZIP 解压元数据不一致');
51
+ return true;
52
+ } });
53
+ for (const [name, data] of Object.entries(files))
54
+ if (data.length !== entries.get(name))
55
+ throw new Error('ZIP 解压长度不一致');
56
+ if (!files['word/document.xml'] || !files['[Content_Types].xml'])
57
+ throw new Error('文件不是有效的 Word DOCX');
58
+ return files;
59
+ }