@dsh-libing/knowledge-base 0.1.0 → 0.1.4
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/CHANGELOG.md +20 -0
- package/README.md +12 -2
- package/lib/agent-access.d.ts +23 -0
- package/lib/agent-access.js +28 -0
- package/lib/chunks.js +27 -5
- package/lib/client-types/client/JobDetails.d.ts +16 -0
- package/lib/client-types/client/JobsPanel.d.ts +6 -1
- package/lib/client-types/client/QualityReport.d.ts +3 -2
- package/lib/client-types/client/api.d.ts +83 -52
- package/lib/client-types/client/locales.d.ts +2 -0
- package/lib/client-types/client/useKnowledge.d.ts +30 -15
- package/lib/client-types/contract.d.ts +83 -52
- package/lib/client-types/data.d.ts +11 -0
- package/lib/client-types/settings.d.ts +4 -0
- package/lib/client-types/shared.d.ts +17 -0
- package/lib/client.js +40 -40
- package/lib/config.js +9 -1
- package/lib/contract.d.ts +33 -2
- package/lib/contract.js +2 -1
- package/lib/data.d.ts +11 -0
- package/lib/data.js +2 -0
- package/lib/document/convert.js +52 -16
- package/lib/document/pdf-layout.js +45 -10
- package/lib/document/pdf-session.js +10 -4
- package/lib/document/pdf-vision.js +8 -3
- package/lib/document/pdf-worker.js +76 -21
- package/lib/document/pdf.js +39 -9
- package/lib/document/zip.js +14 -6
- package/lib/figures/image-client.d.ts +5 -0
- package/lib/figures/image-client.js +63 -0
- package/lib/figures/image-gate.d.ts +12 -0
- package/lib/figures/image-gate.js +39 -0
- package/lib/figures/image-protocol.d.ts +19 -0
- package/lib/figures/image-protocol.js +9 -0
- package/lib/figures/image-worker.d.ts +1 -0
- package/lib/figures/image-worker.js +69 -0
- package/lib/figures/process.d.ts +1 -1
- package/lib/figures/process.js +19 -38
- package/lib/index.js +6 -2
- package/lib/indexes.js +17 -6
- package/lib/job-storage.d.ts +264 -0
- package/lib/job-storage.js +118 -0
- package/lib/jobs.d.ts +782 -1489
- package/lib/jobs.js +64 -31
- package/lib/models.d.ts +1 -1
- package/lib/models.js +15 -9
- package/lib/retrieval.js +44 -13
- package/lib/revision-cache.d.ts +24 -0
- package/lib/revision-cache.js +100 -0
- package/lib/routes.js +66 -20
- package/lib/runtime.d.ts +4 -0
- package/lib/runtime.js +15 -4
- package/lib/settings.d.ts +4 -0
- package/lib/settings.js +5 -2
- package/lib/shared.d.ts +17 -0
- package/lib/shared.js +3 -0
- package/lib/skills.js +6 -3
- package/lib/store.d.ts +101 -5
- package/lib/store.js +78 -29
- package/lib/tools.js +12 -4
- package/lib/util.js +2 -1
- package/package.json +1 -1
|
@@ -14,11 +14,14 @@ const matrixSchema = z.tuple([z.number(), z.number(), z.number(), z.number(), z.
|
|
|
14
14
|
const markTag = (value) => z.union([z.string(), z.object({ name: z.string() })]).transform(value => typeof value === 'string' ? value : value.name).parse(value);
|
|
15
15
|
const numeric = (value) => z.array(z.number().finite()).parse(value instanceof Float32Array || value instanceof Float64Array ? Array.from(value) : value);
|
|
16
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'))
|
|
17
|
+
const proto = Object.getPrototypeOf(createCanvas(1, 1).getContext('2d'));
|
|
18
|
+
const nativeClip = proto.clip;
|
|
18
19
|
proto.clip = function (path, rule) {
|
|
19
|
-
const original = this.getTransform()
|
|
20
|
+
const original = this.getTransform();
|
|
21
|
+
const tiny = (value) => value !== 0 && Math.abs(value) < 1e-7;
|
|
20
22
|
if (path instanceof Path2D && (tiny(original.b) || tiny(original.c))) {
|
|
21
|
-
const matrix = this.getTransform()
|
|
23
|
+
const matrix = this.getTransform();
|
|
24
|
+
const transformed = new Path2D();
|
|
22
25
|
if (tiny(matrix.b))
|
|
23
26
|
matrix.b = 0;
|
|
24
27
|
if (tiny(matrix.c))
|
|
@@ -33,13 +36,25 @@ proto.clip = function (path, rule) {
|
|
|
33
36
|
}
|
|
34
37
|
}
|
|
35
38
|
else
|
|
36
|
-
Reflect.apply(nativeClip, this,
|
|
39
|
+
Reflect.apply(nativeClip, this, (() => {
|
|
40
|
+
if (path === undefined) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
if (rule === undefined) {
|
|
44
|
+
return [path];
|
|
45
|
+
}
|
|
46
|
+
return [path, rule];
|
|
47
|
+
})());
|
|
37
48
|
};
|
|
38
|
-
const require = createRequire(import.meta.url)
|
|
39
|
-
|
|
49
|
+
const require = createRequire(import.meta.url);
|
|
50
|
+
const base = dirname(require.resolve('pdfjs-dist/package.json'));
|
|
51
|
+
const config = input.config;
|
|
52
|
+
let doc;
|
|
53
|
+
let activePage = 0;
|
|
40
54
|
const engineWarnings = new Map();
|
|
41
55
|
console.warn = (...parts) => {
|
|
42
|
-
const value = parts.map(String).join(' ').replace(/^Warning:\s*/u, '').slice(0, 1000)
|
|
56
|
+
const value = parts.map(String).join(' ').replace(/^Warning:\s*/u, '').slice(0, 1000);
|
|
57
|
+
const list = engineWarnings.get(activePage) || [];
|
|
43
58
|
if (list.length < 100 && !list.includes(value))
|
|
44
59
|
list.push(value);
|
|
45
60
|
engineWarnings.set(activePage, list);
|
|
@@ -70,7 +85,8 @@ async function info() {
|
|
|
70
85
|
for (const entry of items || []) {
|
|
71
86
|
if (outline.length >= 10000 || level > 32)
|
|
72
87
|
throw new Error('PDF 书签层级或数量超过上限');
|
|
73
|
-
let page = null
|
|
88
|
+
let page = null;
|
|
89
|
+
let y = null;
|
|
74
90
|
try {
|
|
75
91
|
const raw = typeof entry.dest === 'string' ? await doc.getDestination(entry.dest) : entry.dest;
|
|
76
92
|
if (Array.isArray(raw) && raw.length) {
|
|
@@ -93,8 +109,11 @@ async function nativePage(n) {
|
|
|
93
109
|
activePage = n;
|
|
94
110
|
const page = await doc.getPage(n);
|
|
95
111
|
try {
|
|
96
|
-
const viewport = page.getViewport({ scale: 1 })
|
|
97
|
-
const
|
|
112
|
+
const viewport = page.getViewport({ scale: 1 });
|
|
113
|
+
const content = await page.getTextContent({ includeMarkedContent: true });
|
|
114
|
+
const tree = prune(await page.getStructTree());
|
|
115
|
+
const spans = [];
|
|
116
|
+
const marks = [];
|
|
98
117
|
let seq = 0;
|
|
99
118
|
for (const item of content.items) {
|
|
100
119
|
if ('type' in item) {
|
|
@@ -106,17 +125,47 @@ async function nativePage(n) {
|
|
|
106
125
|
}
|
|
107
126
|
if (!item.str.trim())
|
|
108
127
|
continue;
|
|
109
|
-
const t = matrixSchema.parse(Util.transform(viewport.transform, item.transform))
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
const
|
|
128
|
+
const t = matrixSchema.parse(Util.transform(viewport.transform, item.transform));
|
|
129
|
+
const h = Math.hypot(t[2], t[3]) || item.height || 10;
|
|
130
|
+
const style = content.styles[item.fontName];
|
|
131
|
+
const a = style?.ascent ?? .8;
|
|
132
|
+
const a0 = [t[4] + t[2] * a, t[5] + t[3] * a];
|
|
133
|
+
const b0 = [t[4] + t[2] * (a - 1), t[5] + t[3] * (a - 1)];
|
|
134
|
+
const angle = Math.atan2(t[1], t[0]);
|
|
135
|
+
const w = item.width || h / 2;
|
|
136
|
+
const dx = Math.cos(angle) * w;
|
|
137
|
+
const dy = Math.sin(angle) * w;
|
|
138
|
+
const points = [a0, b0, [a0[0] + dx, a0[1] + dy], [b0[0] + dx, b0[1] + dy]];
|
|
139
|
+
const 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]))];
|
|
140
|
+
const rotation = ((page.rotate % 360) + 360) % 360;
|
|
141
|
+
const W = viewport.width;
|
|
142
|
+
const H = viewport.height;
|
|
143
|
+
let readingBox;
|
|
144
|
+
if (rotation === 90) {
|
|
145
|
+
readingBox = [bbox[1], W - bbox[2], bbox[3], W - bbox[0]];
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
if (rotation === 180) {
|
|
149
|
+
readingBox = [W - bbox[2], H - bbox[3], W - bbox[0], H - bbox[1]];
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
if (rotation === 270) {
|
|
153
|
+
readingBox = [H - bbox[3], bbox[0], H - bbox[1], bbox[2]];
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
readingBox = bbox;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
115
160
|
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
161
|
if (spans.length > 100000)
|
|
117
162
|
throw new Error(`PDF 第 ${n} 页文字对象过多`);
|
|
118
163
|
}
|
|
119
|
-
const operators = await page.getOperatorList()
|
|
164
|
+
const operators = await page.getOperatorList();
|
|
165
|
+
const drawings = [];
|
|
166
|
+
const stack = [];
|
|
167
|
+
const mc = [];
|
|
168
|
+
const prefix = `p${page.ref?.num || n}R_mc`;
|
|
120
169
|
let matrix = [1, 0, 0, 1, 0, 0];
|
|
121
170
|
const add = (b, kind, shape = null) => {
|
|
122
171
|
const bounds = box(matrixSchema.parse(Util.transform(viewport.transform, matrix)), b);
|
|
@@ -126,7 +175,8 @@ async function nativePage(n) {
|
|
|
126
175
|
drawings.push({ bbox: bounds, kind, shape, mcid: id === undefined ? null : prefix + id });
|
|
127
176
|
};
|
|
128
177
|
for (let i = 0; i < operators.fnArray.length; i++) {
|
|
129
|
-
const op = required(operators.fnArray[i])
|
|
178
|
+
const op = required(operators.fnArray[i]);
|
|
179
|
+
const args = operators.argsArray[i] || [];
|
|
130
180
|
if (op === OPS.save)
|
|
131
181
|
stack.push([...matrix]);
|
|
132
182
|
else if (op === OPS.restore)
|
|
@@ -147,7 +197,8 @@ async function nativePage(n) {
|
|
|
147
197
|
else if (op === OPS.endMarkedContent)
|
|
148
198
|
mc.pop();
|
|
149
199
|
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] : []
|
|
200
|
+
const paths = Array.isArray(args[1]) ? args[1] : [];
|
|
201
|
+
const path = paths[0] === undefined ? [] : numeric(paths[0]);
|
|
151
202
|
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
203
|
}
|
|
153
204
|
else if ([OPS.paintImageXObject, OPS.paintInlineImageXObject, OPS.paintImageMaskXObject].includes(op))
|
|
@@ -163,13 +214,17 @@ async function nativePage(n) {
|
|
|
163
214
|
}
|
|
164
215
|
async function render(n, bbox) {
|
|
165
216
|
activePage = n;
|
|
166
|
-
const page = await doc.getPage(n)
|
|
217
|
+
const page = await doc.getPage(n);
|
|
218
|
+
const v = page.getViewport({ scale: 1 });
|
|
219
|
+
const b = bbox || [0, 0, v.width, v.height];
|
|
167
220
|
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
221
|
throw new Error('PDF 裁切区域无效');
|
|
169
222
|
const scale = Math.min(2, Math.sqrt(config.pdfMaxPagePixels / (v.width * v.height)));
|
|
170
223
|
if (scale < .5)
|
|
171
224
|
throw new Error(`PDF 第 ${n} 页页面尺寸过大`);
|
|
172
|
-
const viewport = page.getViewport({ scale })
|
|
225
|
+
const viewport = page.getViewport({ scale });
|
|
226
|
+
const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
|
|
227
|
+
const crop = createCanvas(Math.max(1, Math.ceil((b[2] - b[0]) * scale)), Math.max(1, Math.ceil((b[3] - b[1]) * scale)));
|
|
173
228
|
try {
|
|
174
229
|
// PDF.js publishes DOM-only render types for its supported Node napi-rs canvas. This bridge uses that same tested Canvas2D implementation.
|
|
175
230
|
await page.render({ canvas: null, canvasContext: canvas.getContext('2d'), viewport, background: 'rgb(255,255,255)' }).promise;
|
package/lib/document/pdf.js
CHANGED
|
@@ -10,10 +10,14 @@ import { blockMarkdown } from "./convert.js";
|
|
|
10
10
|
import { BudgetError } from "../models.js";
|
|
11
11
|
export async function convertPdf(file, destination, job, models, signal) {
|
|
12
12
|
let session = new PdfSession(file, job.config, signal);
|
|
13
|
-
const assets = new Map()
|
|
13
|
+
const assets = new Map();
|
|
14
|
+
const pages = [];
|
|
15
|
+
const blocks = [];
|
|
16
|
+
const warnings = [];
|
|
14
17
|
job.parserVersion = PDF_PARSER_VERSION;
|
|
15
18
|
const addAsset = async (data, origin, source) => {
|
|
16
|
-
const assetId = `a-${hash(data).slice(0, 20)}
|
|
19
|
+
const assetId = `a-${hash(data).slice(0, 20)}`;
|
|
20
|
+
const asset = { assetId, mime: 'image/png', path: `assets/${assetId}.png`, bytes: data.length, origin, source };
|
|
17
21
|
if (!assets.has(assetId)) {
|
|
18
22
|
await atomicWrite(join(destination, asset.path), Buffer.from(data));
|
|
19
23
|
assets.set(assetId, asset);
|
|
@@ -21,7 +25,8 @@ export async function convertPdf(file, destination, job, models, signal) {
|
|
|
21
25
|
return asset;
|
|
22
26
|
};
|
|
23
27
|
try {
|
|
24
|
-
const info = await session.ready
|
|
28
|
+
const info = await session.ready;
|
|
29
|
+
const range = pageRange(job.pageStart, job.pageEnd, info.pageCount);
|
|
25
30
|
warnings.push(...(info.warnings || []).map(message => ({ code: 'pdf-engine-warning', message: `PDF 打开警告:${message}`, blocking: true })));
|
|
26
31
|
job.pdf = { pageCount: info.pageCount, selectedPages: range, outlineCount: info.outline.length };
|
|
27
32
|
job.progress = { ...job.progress, totalPages: range.count, parsedPages: 0, ocrPages: 0, tables: 0 };
|
|
@@ -34,7 +39,9 @@ export async function convertPdf(file, destination, job, models, signal) {
|
|
|
34
39
|
const receipt = join(destination, 'pdf-pages', `${n}-${key}.json`);
|
|
35
40
|
let result = await readJson(receipt, pageReceiptSchema, null);
|
|
36
41
|
if (!result) {
|
|
37
|
-
let page
|
|
42
|
+
let page;
|
|
43
|
+
let layout;
|
|
44
|
+
let previewAsset;
|
|
38
45
|
try {
|
|
39
46
|
if (session.closed) {
|
|
40
47
|
session = new PdfSession(file, job.config, signal);
|
|
@@ -94,7 +101,15 @@ export async function convertPdf(file, destination, job, models, signal) {
|
|
|
94
101
|
}
|
|
95
102
|
return { ...value, localIndex: i };
|
|
96
103
|
});
|
|
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:
|
|
104
|
+
result = { blocks: pageBlocks, assets: localAssets, warnings: layout.warnings, audit: { page: n, width: page.width, height: page.height, pageLabel: info.pageLabels?.[n - 1] || null, status: (() => {
|
|
105
|
+
if (layout.warnings.some(w => w.blocking)) {
|
|
106
|
+
return 'needs-review';
|
|
107
|
+
}
|
|
108
|
+
if (layout.navigation) {
|
|
109
|
+
return 'navigation';
|
|
110
|
+
}
|
|
111
|
+
return 'parsed';
|
|
112
|
+
})(), 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
113
|
}
|
|
99
114
|
catch (error) {
|
|
100
115
|
if (error instanceof BudgetError || signal?.aborted)
|
|
@@ -148,10 +163,23 @@ export async function convertPdf(file, destination, job, models, signal) {
|
|
|
148
163
|
if (pages.length >= 3) {
|
|
149
164
|
const repeated = new Map();
|
|
150
165
|
for (const b of blocks) {
|
|
151
|
-
const box = b.source?.bbox
|
|
166
|
+
const box = b.source?.bbox;
|
|
167
|
+
const p = pages.find(p => p.page === b.source?.page);
|
|
152
168
|
if (b.type !== 'paragraph' || !box || b.text.length > 160)
|
|
153
169
|
continue;
|
|
154
|
-
const key = normalize(b.text).replace(/\d+/g, '#')
|
|
170
|
+
const key = normalize(b.text).replace(/\d+/g, '#');
|
|
171
|
+
let edge;
|
|
172
|
+
if (box[1] < 40) {
|
|
173
|
+
edge = 'top';
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
if (box[1] > (p?.height || 842) - 45) {
|
|
177
|
+
edge = 'bottom';
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
edge = null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
155
183
|
if (edge) {
|
|
156
184
|
const k = edge + ':' + key;
|
|
157
185
|
repeated.set(k, [...(repeated.get(k) || []), b]);
|
|
@@ -179,11 +207,13 @@ export async function convertPdf(file, destination, job, models, signal) {
|
|
|
179
207
|
repeated.push(item);
|
|
180
208
|
previous--;
|
|
181
209
|
}
|
|
182
|
-
const a = blocks[previous]
|
|
210
|
+
const a = blocks[previous];
|
|
211
|
+
const last = a?.source?.locations?.at(-1) || a?.source;
|
|
183
212
|
const tableId = (t) => /^(?:表|Table)\s*([A-Z\d][\w.\-–]*)/iu.exec(t.caption)?.[1];
|
|
184
213
|
if (!a || a.type !== 'table' || !a.source || !last?.bbox || !a.rows[0] || !b.rows[0])
|
|
185
214
|
continue;
|
|
186
|
-
const height = pages.find(p => p.page === last.page)?.height || 842
|
|
215
|
+
const height = pages.find(p => p.page === last.page)?.height || 842;
|
|
216
|
+
const nextHeight = pages.find(p => p.page === b.source?.page)?.height || 842;
|
|
187
217
|
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
218
|
a.rows.push(...b.rows.slice(1));
|
|
189
219
|
const { locations, ...first } = a.source;
|
package/lib/document/zip.js
CHANGED
|
@@ -12,17 +12,24 @@ export function unpackDocx(buffer, config) {
|
|
|
12
12
|
}
|
|
13
13
|
if (end < 0)
|
|
14
14
|
throw new Error('无效 DOCX ZIP 文件');
|
|
15
|
-
const count = buffer.readUInt16LE(end + 10)
|
|
15
|
+
const count = buffer.readUInt16LE(end + 10);
|
|
16
|
+
const size = buffer.readUInt32LE(end + 12);
|
|
17
|
+
const start = buffer.readUInt32LE(end + 16);
|
|
16
18
|
if (count === 65535 || count > config.maxArchiveEntries || start + size > end || buffer.readUInt16LE(end + 4) !== 0)
|
|
17
19
|
throw new Error('不支持的分卷/ZIP64 或过多 ZIP 条目');
|
|
18
|
-
let pos = start
|
|
20
|
+
let pos = start;
|
|
21
|
+
let total = 0;
|
|
19
22
|
const entries = new Map();
|
|
20
23
|
for (let n = 0; n < count; n++) {
|
|
21
24
|
if (pos + 46 > end || buffer.readUInt32LE(pos) !== 0x02014b50)
|
|
22
25
|
throw new Error('损坏的 ZIP 目录');
|
|
23
|
-
const flags = buffer.readUInt16LE(pos + 8)
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
+
const flags = buffer.readUInt16LE(pos + 8);
|
|
27
|
+
const method = buffer.readUInt16LE(pos + 10);
|
|
28
|
+
const compressed = buffer.readUInt32LE(pos + 20);
|
|
29
|
+
const uncompressed = buffer.readUInt32LE(pos + 24);
|
|
30
|
+
const nameLength = buffer.readUInt16LE(pos + 28);
|
|
31
|
+
const extraLength = buffer.readUInt16LE(pos + 30);
|
|
32
|
+
const commentLength = buffer.readUInt16LE(pos + 32);
|
|
26
33
|
const next = pos + 46 + nameLength + extraLength + commentLength;
|
|
27
34
|
if (next > end || uncompressed === 0xffffffff || compressed === 0xffffffff)
|
|
28
35
|
throw new Error('损坏或不支持的 ZIP 条目');
|
|
@@ -34,7 +41,8 @@ export function unpackDocx(buffer, config) {
|
|
|
34
41
|
const local = buffer.readUInt32LE(pos + 42);
|
|
35
42
|
if (local + 30 > start || buffer.readUInt32LE(local) !== 0x04034b50)
|
|
36
43
|
throw new Error('损坏的 ZIP 本地条目');
|
|
37
|
-
const localNameLength = buffer.readUInt16LE(local + 26)
|
|
44
|
+
const localNameLength = buffer.readUInt16LE(local + 26);
|
|
45
|
+
const localExtra = buffer.readUInt16LE(local + 28);
|
|
38
46
|
if (buffer.subarray(local + 30, local + 30 + localNameLength).toString('utf8') !== name || local + 30 + localNameLength + localExtra + compressed > start)
|
|
39
47
|
throw new Error('ZIP 条目不一致');
|
|
40
48
|
total += uncompressed;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Settings } from '../config.ts';
|
|
2
|
+
export declare function runImageWorker(file: string, config: Settings, save: (data: Uint8Array, coordinates: Record<string, string | number>) => Promise<void>, signal?: AbortSignal): Promise<{
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
}>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { checkAbort, deferred } from "../util.js";
|
|
5
|
+
import { imageGate } from "./image-gate.js";
|
|
6
|
+
import { imageMessageSchema, imageOptionsSchema } from "./image-protocol.js";
|
|
7
|
+
export async function runImageWorker(file, config, save, signal) {
|
|
8
|
+
const options = imageOptionsSchema.parse({ visionMaxEdge: config.visionMaxEdge, visionTileSize: config.visionTileSize, visionMaxTiles: config.visionMaxTiles });
|
|
9
|
+
const release = await imageGate.acquire(signal);
|
|
10
|
+
let worker;
|
|
11
|
+
let saving;
|
|
12
|
+
const result = deferred();
|
|
13
|
+
const abort = () => result.reject(signal?.reason ?? new Error('图片预处理已取消'));
|
|
14
|
+
let timer;
|
|
15
|
+
let complete = false;
|
|
16
|
+
let next = 1;
|
|
17
|
+
try {
|
|
18
|
+
checkAbort(signal);
|
|
19
|
+
const js = new URL('./image-worker.js', import.meta.url);
|
|
20
|
+
const source = !existsSync(fileURLToPath(js));
|
|
21
|
+
worker = new Worker(source ? new URL('./image-worker.ts', import.meta.url) : js, { workerData: { file, options }, execArgv: source ? ['--import', 'tsx/esm'] : [], resourceLimits: { maxOldGenerationSizeMb: 512 } });
|
|
22
|
+
const current = worker;
|
|
23
|
+
timer = setTimeout(() => result.reject(new Error('图片预处理超时')), Math.min(config.imageTimeoutMs, 120000));
|
|
24
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
25
|
+
if (signal?.aborted)
|
|
26
|
+
abort();
|
|
27
|
+
current.on('error', error => result.reject(error));
|
|
28
|
+
current.on('exit', code => { if (!complete)
|
|
29
|
+
result.reject(new Error(`图片预处理进程退出(${code})`)); });
|
|
30
|
+
current.on('message', (raw) => {
|
|
31
|
+
try {
|
|
32
|
+
const value = imageMessageSchema.parse(raw);
|
|
33
|
+
if (value.kind === 'error')
|
|
34
|
+
result.reject(new Error(value.message));
|
|
35
|
+
else if (value.kind === 'done') {
|
|
36
|
+
complete = true;
|
|
37
|
+
result.resolve({ width: value.width, height: value.height });
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
if (saving || value.id !== next++ || value.data.length > 8 * 1024 * 1024)
|
|
41
|
+
throw new Error('图片预处理输出无效');
|
|
42
|
+
saving = Promise.resolve().then(() => { checkAbort(signal); return save(value.data, value.coordinates); }).then(() => { checkAbort(signal); saving = undefined; current.postMessage({ ack: value.id }); });
|
|
43
|
+
void saving.catch(error => result.reject(error));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
result.reject(error);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
return await result.promise;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
signal?.removeEventListener('abort', abort);
|
|
55
|
+
try {
|
|
56
|
+
await worker?.terminate();
|
|
57
|
+
await saving?.catch(() => { });
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
release();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** One process-wide bitmap worker, including all knowledge bases and runtime instances. */
|
|
2
|
+
export declare class ImageGate {
|
|
3
|
+
active: boolean;
|
|
4
|
+
private queue;
|
|
5
|
+
get waiting(): number;
|
|
6
|
+
acquire(signal?: AbortSignal): Promise<() => void>;
|
|
7
|
+
private release;
|
|
8
|
+
}
|
|
9
|
+
declare global {
|
|
10
|
+
var __dshLibingKnowledgeImageGateV1: ImageGate | undefined;
|
|
11
|
+
}
|
|
12
|
+
export declare const imageGate: ImageGate;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { checkAbort } from "../util.js";
|
|
2
|
+
/** One process-wide bitmap worker, including all knowledge bases and runtime instances. */
|
|
3
|
+
export class ImageGate {
|
|
4
|
+
active = false;
|
|
5
|
+
queue = [];
|
|
6
|
+
get waiting() { return this.queue.length; }
|
|
7
|
+
async acquire(signal) {
|
|
8
|
+
checkAbort(signal);
|
|
9
|
+
if (!this.active) {
|
|
10
|
+
this.active = true;
|
|
11
|
+
return this.release();
|
|
12
|
+
}
|
|
13
|
+
if (this.queue.length >= 32)
|
|
14
|
+
throw new Error('图片预处理等待队列已满,请稍后重试');
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const entry = { ...signal ? { signal } : {}, resolve, reject, abort: () => { signal?.removeEventListener('abort', entry.abort); this.queue = this.queue.filter(e => e !== entry); reject(signal?.reason ?? new Error('图片预处理已取消')); } };
|
|
17
|
+
this.queue.push(entry);
|
|
18
|
+
signal?.addEventListener('abort', entry.abort, { once: true });
|
|
19
|
+
if (signal?.aborted)
|
|
20
|
+
entry.abort();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
release() {
|
|
24
|
+
let released = false;
|
|
25
|
+
return () => {
|
|
26
|
+
if (released)
|
|
27
|
+
return;
|
|
28
|
+
released = true;
|
|
29
|
+
const next = this.queue.shift();
|
|
30
|
+
if (next) {
|
|
31
|
+
next.signal?.removeEventListener('abort', next.abort);
|
|
32
|
+
next.resolve(this.release());
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
this.active = false;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export const imageGate = globalThis.__dshLibingKnowledgeImageGateV1 ??= new ImageGate();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const imageOptionsSchema: z.ZodObject<{
|
|
3
|
+
visionMaxEdge: z.ZodNumber;
|
|
4
|
+
visionTileSize: z.ZodNumber;
|
|
5
|
+
visionMaxTiles: z.ZodNumber;
|
|
6
|
+
}, z.core.$strict>;
|
|
7
|
+
export declare const imageMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
8
|
+
kind: z.ZodLiteral<"image">;
|
|
9
|
+
id: z.ZodNumber;
|
|
10
|
+
data: z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>>;
|
|
11
|
+
coordinates: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
|
|
12
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
13
|
+
kind: z.ZodLiteral<"done">;
|
|
14
|
+
width: z.ZodNumber;
|
|
15
|
+
height: z.ZodNumber;
|
|
16
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
17
|
+
kind: z.ZodLiteral<"error">;
|
|
18
|
+
message: z.ZodString;
|
|
19
|
+
}, z.core.$strip>], "kind">;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const imageOptionsSchema = z.strictObject({
|
|
3
|
+
visionMaxEdge: z.number().int().min(1).max(4096), visionTileSize: z.number().int().min(1).max(4096), visionMaxTiles: z.number().int().min(1).max(64),
|
|
4
|
+
});
|
|
5
|
+
export const imageMessageSchema = z.discriminatedUnion('kind', [
|
|
6
|
+
z.object({ kind: z.literal('image'), id: z.number().int().positive(), data: z.instanceof(Uint8Array), coordinates: z.record(z.string(), z.union([z.string(), z.number()])) }),
|
|
7
|
+
z.object({ kind: z.literal('done'), width: z.number().int().positive(), height: z.number().int().positive() }),
|
|
8
|
+
z.object({ kind: z.literal('error'), message: z.string() }),
|
|
9
|
+
]);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { Jimp, ResizeStrategy } from 'jimp';
|
|
4
|
+
import { imageSize } from 'image-size';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { imageOptionsSchema } from "./image-protocol.js";
|
|
7
|
+
import { message, required } from "../util.js";
|
|
8
|
+
const port = required(parentPort);
|
|
9
|
+
async function run() {
|
|
10
|
+
const { file, options } = z.object({ file: z.string(), options: imageOptionsSchema }).parse(workerData);
|
|
11
|
+
if ((await stat(file)).size > 32 * 1024 * 1024)
|
|
12
|
+
throw new Error('单张图片超过 32 MiB,请提供较小的位图');
|
|
13
|
+
const data = await readFile(file);
|
|
14
|
+
const dimensions = imageSize(data);
|
|
15
|
+
if (data.length > 32 * 1024 * 1024)
|
|
16
|
+
throw new Error('单张图片超过 32 MiB,请提供较小的位图');
|
|
17
|
+
if (!dimensions.width || !dimensions.height || dimensions.width * dimensions.height > 50000000)
|
|
18
|
+
throw new Error('图片像素过大,请提供分辨率较低的版本');
|
|
19
|
+
const image = await Jimp.read(data);
|
|
20
|
+
const { width, height } = image.bitmap;
|
|
21
|
+
if (width * height > 50000000)
|
|
22
|
+
throw new Error('图片像素过大,请提供分辨率较低的版本');
|
|
23
|
+
const tile = options.visionTileSize;
|
|
24
|
+
const step = Math.max(1, Math.floor(tile * .85));
|
|
25
|
+
const tiled = Math.max(width, height) > options.visionMaxEdge;
|
|
26
|
+
const columns = Math.max(1, Math.ceil((width - tile) / step) + 1);
|
|
27
|
+
const rows = Math.max(1, Math.ceil((height - tile) / step) + 1);
|
|
28
|
+
if (tiled && columns * rows > options.visionMaxTiles)
|
|
29
|
+
throw new Error(`大图需要 ${columns * rows} 个裁片,超过 visionMaxTiles;请拆分图片或提高上限`);
|
|
30
|
+
let id = 0;
|
|
31
|
+
const send = async (bitmap, coordinates) => {
|
|
32
|
+
const bytes = await bitmap.getBuffer('image/png');
|
|
33
|
+
if (bytes.length > 8 * 1024 * 1024)
|
|
34
|
+
throw new Error('图像转换后超过单张 8 MiB,请降低 visionTileSize');
|
|
35
|
+
const packet = new Uint8Array(bytes);
|
|
36
|
+
const current = ++id;
|
|
37
|
+
const acknowledged = new Promise((resolve, reject) => port.once('message', raw => {
|
|
38
|
+
const ack = z.object({ ack: z.literal(current) }).safeParse(raw);
|
|
39
|
+
if (ack.success)
|
|
40
|
+
resolve();
|
|
41
|
+
else
|
|
42
|
+
reject(new Error('图片保存确认无效'));
|
|
43
|
+
}));
|
|
44
|
+
port.postMessage({ kind: 'image', id: current, data: packet, coordinates }, [packet.buffer]);
|
|
45
|
+
await acknowledged;
|
|
46
|
+
};
|
|
47
|
+
// A separate bitmap descriptor shares the read-only source buffer; resize allocates only the overview.
|
|
48
|
+
const overview = new Jimp({ ...image.bitmap });
|
|
49
|
+
if (tiled) {
|
|
50
|
+
const ratio = options.visionMaxEdge / Math.max(width, height);
|
|
51
|
+
overview.resize({ w: Math.max(1, Math.round(width * ratio)), h: Math.max(1, Math.round(height * ratio)), mode: ResizeStrategy.BILINEAR });
|
|
52
|
+
}
|
|
53
|
+
await send(overview, { kind: 'overview', originalWidth: width, originalHeight: height });
|
|
54
|
+
if (tiled)
|
|
55
|
+
for (let row = 0; row < rows; row++)
|
|
56
|
+
for (let col = 0; col < columns; col++) {
|
|
57
|
+
const x = Math.min(col * step, Math.max(0, width - tile));
|
|
58
|
+
const y = Math.min(row * step, Math.max(0, height - tile));
|
|
59
|
+
const w = Math.min(tile, width);
|
|
60
|
+
const h = Math.min(tile, height);
|
|
61
|
+
// Copy only the requested region; never clone the full source for a tile.
|
|
62
|
+
const bitmap = new Jimp({ width: w, height: h });
|
|
63
|
+
for (let line = 0; line < h; line++)
|
|
64
|
+
image.bitmap.data.copy(bitmap.bitmap.data, line * w * 4, ((y + line) * width + x) * 4, ((y + line) * width + x + w) * 4);
|
|
65
|
+
await send(bitmap, { kind: 'tile', x, y, width: w, height: h, originalWidth: width, originalHeight: height });
|
|
66
|
+
}
|
|
67
|
+
port.postMessage({ kind: 'done', width, height });
|
|
68
|
+
}
|
|
69
|
+
void run().catch(error => port.postMessage({ kind: 'error', message: message(error) })).finally(() => port.close());
|
package/lib/figures/process.d.ts
CHANGED
|
@@ -5,9 +5,9 @@ import type { Block, Document, Job, Overlay } from '../data.ts';
|
|
|
5
5
|
import type { Models, PreparedImage } from '../models.ts';
|
|
6
6
|
export declare function figureContext(blocks: Block[], index: number, headingPath: string[]): string;
|
|
7
7
|
export declare function prepareImage(ctx: Context, file: string, config: Settings, signal?: AbortSignal): Promise<{
|
|
8
|
-
images: PreparedImage[];
|
|
9
8
|
width: number;
|
|
10
9
|
height: number;
|
|
10
|
+
images: PreparedImage[];
|
|
11
11
|
}>;
|
|
12
12
|
export declare function figureEntries(document: Document): {
|
|
13
13
|
block: Block;
|
package/lib/figures/process.js
CHANGED
|
@@ -2,9 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { figureSchema, figureDataSchema, FIGURE_TYPES } from "../data.js";
|
|
3
3
|
import { required, message } from "../util.js";
|
|
4
4
|
const classificationSchema = z.object({ types: z.array(z.enum(FIGURE_TYPES)).min(1), reason: z.string() });
|
|
5
|
-
import {
|
|
6
|
-
import { imageSize } from 'image-size';
|
|
7
|
-
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { runImageWorker } from "./image-client.js";
|
|
8
6
|
import { join } from 'node:path';
|
|
9
7
|
import { atomicWrite, checkAbort, clip, hash, readJson } from "../util.js";
|
|
10
8
|
import { BudgetError } from "../models.js";
|
|
@@ -13,41 +11,14 @@ export function figureContext(blocks, index, headingPath) {
|
|
|
13
11
|
return JSON.stringify({ headingPath, caption: required(blocks[index]).caption, nearby: blocks.slice(Math.max(0, index - 2), index + 3).filter(b => b.type !== 'figure').map(b => clip(b.text || '', 1000)) });
|
|
14
12
|
}
|
|
15
13
|
export async function prepareImage(ctx, file, config, signal) {
|
|
16
|
-
checkAbort(signal);
|
|
17
|
-
const data = await readFile(file);
|
|
18
|
-
if (data.length > 32 * 1024 * 1024)
|
|
19
|
-
throw new Error('单张图片超过 32 MiB,请提供较小的位图');
|
|
20
|
-
const dimensions = imageSize(data);
|
|
21
|
-
if (!dimensions.width || !dimensions.height || dimensions.width * dimensions.height > 50000000)
|
|
22
|
-
throw new Error('图片像素过大,请提供分辨率较低的版本');
|
|
23
|
-
const image = await Jimp.read(data);
|
|
24
|
-
const { width, height } = image.bitmap;
|
|
25
|
-
if (width * height > 50000000)
|
|
26
|
-
throw new Error('图片像素过大,请提供分辨率较低的版本');
|
|
27
14
|
const images = [];
|
|
28
|
-
const
|
|
15
|
+
const dimensions = await runImageWorker(file, config, async (data, coordinates) => {
|
|
16
|
+
checkAbort(signal);
|
|
17
|
+
const attachment = await ctx.attachments.saveImage({ data: Buffer.from(data.buffer, data.byteOffset, data.byteLength), mediaType: 'image/png', name: 'kb-figure.png' });
|
|
29
18
|
checkAbort(signal);
|
|
30
|
-
const bytes = await bitmap.getBuffer('image/png');
|
|
31
|
-
if (bytes.length > 8 * 1024 * 1024)
|
|
32
|
-
throw new Error('图像转换后超过单张 8 MiB,请降低 visionTileSize');
|
|
33
|
-
const attachment = await ctx.attachments.saveImage({ data: bytes, mediaType: 'image/png', name: 'kb-figure.png' });
|
|
34
19
|
images.push({ attachment, coordinates });
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
if (Math.max(width, height) > config.visionMaxEdge)
|
|
38
|
-
overview.scale(config.visionMaxEdge / Math.max(width, height));
|
|
39
|
-
await add(overview, { kind: 'overview', originalWidth: width, originalHeight: height });
|
|
40
|
-
if (Math.max(width, height) > config.visionMaxEdge) {
|
|
41
|
-
const tile = config.visionTileSize, step = Math.max(1, Math.floor(tile * .85)), columns = Math.max(1, Math.ceil((width - tile) / step) + 1), rows = Math.max(1, Math.ceil((height - tile) / step) + 1);
|
|
42
|
-
if (columns * rows > config.visionMaxTiles)
|
|
43
|
-
throw new Error(`大图需要 ${columns * rows} 个裁片,超过 visionMaxTiles;请拆分图片或提高上限`);
|
|
44
|
-
for (let row = 0; row < rows; row++)
|
|
45
|
-
for (let col = 0; col < columns; col++) {
|
|
46
|
-
const x = Math.min(col * step, Math.max(0, width - tile)), y = Math.min(row * step, Math.max(0, height - tile)), w = Math.min(tile, width), h = Math.min(tile, height);
|
|
47
|
-
await add(image.clone().crop({ x, y, w, h }), { kind: 'tile', x, y, width: w, height: h, originalWidth: width, originalHeight: height });
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return { images, width, height };
|
|
20
|
+
}, signal);
|
|
21
|
+
return { images, ...dimensions };
|
|
51
22
|
}
|
|
52
23
|
export function figureEntries(document) {
|
|
53
24
|
let heading = [];
|
|
@@ -74,8 +45,10 @@ export async function processFigures(document, sourceDir, job, models, signal, o
|
|
|
74
45
|
checkAbort(signal);
|
|
75
46
|
job.progress.inflightFigures = [...(job.progress.inflightFigures || []), { occurrenceId: block.occurrenceId, caption: block.caption || block.occurrenceId }];
|
|
76
47
|
await job.checkpoint();
|
|
77
|
-
const asset = document.assets.find(x => x.assetId === block.assetId)
|
|
78
|
-
const
|
|
48
|
+
const asset = document.assets.find(x => x.assetId === block.assetId);
|
|
49
|
+
const resultPath = join(sourceDir, 'figures', block.occurrenceId + '.json');
|
|
50
|
+
const overlayKey = hash({ assetId: block.assetId, context });
|
|
51
|
+
const overlay = overlays[overlayKey];
|
|
79
52
|
const cacheKey = hash({ assetId: block.assetId, context, route: job.routes.vision, prompt: PROMPT_VERSION, params: { maxEdge: job.config.visionMaxEdge, tile: job.config.visionTileSize, maxChars: job.config.figureMaxChars } });
|
|
80
53
|
let result = await readJson(resultPath, figureSchema, null);
|
|
81
54
|
const overlayHash = hash(overlay || null);
|
|
@@ -89,7 +62,15 @@ export async function processFigures(document, sourceDir, job, models, signal, o
|
|
|
89
62
|
if (!asset)
|
|
90
63
|
throw new Error('未提取原图');
|
|
91
64
|
if (overlay)
|
|
92
|
-
result = figureSchema.parse({ ...overlay, cacheKey, overlayKey, overlayHash, assetId: block.assetId, occurrenceId: block.occurrenceId, caption: block.caption, status:
|
|
65
|
+
result = figureSchema.parse({ ...overlay, cacheKey, overlayKey, overlayHash, assetId: block.assetId, occurrenceId: block.occurrenceId, caption: block.caption, status: (() => {
|
|
66
|
+
if (overlay.ignore) {
|
|
67
|
+
return 'ignored';
|
|
68
|
+
}
|
|
69
|
+
if (overlay.skip) {
|
|
70
|
+
return 'decoration';
|
|
71
|
+
}
|
|
72
|
+
return 'reviewed';
|
|
73
|
+
})(), humanReviewed: true });
|
|
93
74
|
else if (job.config.skipImages)
|
|
94
75
|
result = figureSchema.parse({ status: 'skipped', cacheKey, overlayKey, overlayHash, assetId: block.assetId, occurrenceId: block.occurrenceId, caption: block.caption, description: '本次明确跳过图片理解', needsReview: true, uncertainties: ['未分析图片'] });
|
|
95
76
|
else {
|