@customize-agent/knowledge 4.0.13 → 4.0.15
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/core/knowledge-base-manager.d.ts +0 -2
- package/dist/core/knowledge-base-manager.js +1 -1
- package/dist/extraction/content-extractor.d.ts +8 -9
- package/dist/extraction/content-extractor.js +294 -304
- package/dist/extraction/ocr-providers.d.ts +47 -0
- package/dist/extraction/ocr-providers.js +106 -0
- package/models/paddleocr/PP-OCRv5_mobile_det_infer.onnx +0 -0
- package/models/paddleocr/PP-OCRv5_mobile_rec_infer.onnx +0 -0
- package/models/paddleocr/ppocrv5_dict.txt +18384 -0
- package/models/tessdata/chi_sim.traineddata +0 -0
- package/models/tessdata/eng.traineddata +0 -0
- package/package.json +7 -3
- package/scripts/download-paddleocr-models.sh +63 -0
- package/scripts/render_pdf_pages.py +29 -0
|
@@ -2,17 +2,13 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
|
-
import { pathToFileURL } from 'node:url';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
import { resolveAndImport, resolvePackage } from './module-resolver.js';
|
|
7
|
+
import { createOcrProvider } from './ocr-providers.js';
|
|
8
8
|
const CAD_INTERNAL_TOKEN_RE = /\b(?:TDbPipe|TDbPipeValve|TDbPipeFitting|TDbWellh|AcDb\w+|Dwg\w+|ObjectId|Handle|ByLayer|Continuous|Model|Layout\d*)\b/giu;
|
|
9
9
|
const CAD_INTERNAL_LINE_RE = /^(?:TDb\w+|AcDb\w+|[A-F0-9]{8,}|\d+|Model|Layout\d*|ByLayer|Continuous)$/iu;
|
|
10
10
|
/** 文件内容提取器,支持文档、表格、图片、CAD 等多种文件格式的内容抽取 */
|
|
11
11
|
export class ContentExtractor {
|
|
12
|
-
externalExtractors;
|
|
13
|
-
constructor(externalExtractors = ExternalExtractorRegistry.fromEnvironment()) {
|
|
14
|
-
this.externalExtractors = externalExtractors;
|
|
15
|
-
}
|
|
16
12
|
async extract(file) {
|
|
17
13
|
const start = Date.now();
|
|
18
14
|
const warnings = [];
|
|
@@ -22,86 +18,77 @@ export class ContentExtractor {
|
|
|
22
18
|
category: file.category,
|
|
23
19
|
format: file.format,
|
|
24
20
|
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
text =
|
|
28
|
-
Object.assign(metadata,
|
|
29
|
-
warnings.push(...
|
|
21
|
+
if (file.category === 'cad') {
|
|
22
|
+
const result = await this.extractCad(file);
|
|
23
|
+
text = result.text;
|
|
24
|
+
Object.assign(metadata, result.metadata);
|
|
25
|
+
warnings.push(...result.warnings);
|
|
26
|
+
}
|
|
27
|
+
else if (file.category === 'data') {
|
|
28
|
+
const result = this.extractData(file);
|
|
29
|
+
text = result.text;
|
|
30
|
+
Object.assign(metadata, result.metadata);
|
|
31
|
+
warnings.push(...result.warnings);
|
|
32
|
+
}
|
|
33
|
+
else if (file.category === 'diagram') {
|
|
34
|
+
const result = this.extractDiagram(file);
|
|
35
|
+
text = result.text;
|
|
36
|
+
Object.assign(metadata, result.metadata);
|
|
37
|
+
warnings.push(...result.warnings);
|
|
38
|
+
}
|
|
39
|
+
else if (file.category === 'document' && file.format === 'pdf') {
|
|
40
|
+
const result = await this.extractPdf(file);
|
|
41
|
+
text = result.text;
|
|
42
|
+
Object.assign(metadata, result.metadata);
|
|
43
|
+
warnings.push(...result.warnings);
|
|
44
|
+
}
|
|
45
|
+
else if (file.category === 'document' && ['office', 'presentation'].includes(file.format)) {
|
|
46
|
+
const result = await this.extractOfficeDocument(file);
|
|
47
|
+
text = result.text;
|
|
48
|
+
Object.assign(metadata, result.metadata);
|
|
49
|
+
warnings.push(...result.warnings);
|
|
50
|
+
}
|
|
51
|
+
else if (file.category === 'spreadsheet' && ['csv', 'tsv'].includes(file.format)) {
|
|
52
|
+
const result = this.extractDelimitedText(file);
|
|
53
|
+
text = result.text;
|
|
54
|
+
Object.assign(metadata, result.metadata);
|
|
55
|
+
warnings.push(...result.warnings);
|
|
56
|
+
}
|
|
57
|
+
else if (file.category === 'spreadsheet') {
|
|
58
|
+
const result = await this.extractSpreadsheet(file);
|
|
59
|
+
text = result.text;
|
|
60
|
+
Object.assign(metadata, result.metadata);
|
|
61
|
+
warnings.push(...result.warnings);
|
|
62
|
+
}
|
|
63
|
+
else if (file.category === 'image' && file.format !== 'vector') {
|
|
64
|
+
const result = await this.extractRasterImage(file);
|
|
65
|
+
text = result.text;
|
|
66
|
+
Object.assign(metadata, result.metadata);
|
|
67
|
+
warnings.push(...result.warnings);
|
|
68
|
+
}
|
|
69
|
+
else if (file.category === 'image' && file.format === 'vector') {
|
|
70
|
+
const result = this.extractSvg(file);
|
|
71
|
+
text = result.text;
|
|
72
|
+
Object.assign(metadata, result.metadata);
|
|
73
|
+
warnings.push(...result.warnings);
|
|
74
|
+
}
|
|
75
|
+
else if (file.format === 'text_clipping') {
|
|
76
|
+
const result = this.extractTextClipping(file);
|
|
77
|
+
text = result.text;
|
|
78
|
+
Object.assign(metadata, result.metadata);
|
|
79
|
+
warnings.push(...result.warnings);
|
|
80
|
+
}
|
|
81
|
+
else if (this.isTextReadable(file)) {
|
|
82
|
+
text = fs.readFileSync(file.absolutePath, 'utf8');
|
|
83
|
+
metadata.extractionMode = 'plain_text';
|
|
84
|
+
metadata.vectorizable = true;
|
|
30
85
|
}
|
|
31
86
|
else {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
warnings.push(...result.warnings);
|
|
38
|
-
}
|
|
39
|
-
else if (file.category === 'data') {
|
|
40
|
-
const result = this.extractData(file);
|
|
41
|
-
text = result.text;
|
|
42
|
-
Object.assign(metadata, result.metadata);
|
|
43
|
-
warnings.push(...result.warnings);
|
|
44
|
-
}
|
|
45
|
-
else if (file.category === 'diagram') {
|
|
46
|
-
const result = this.extractDiagram(file);
|
|
47
|
-
text = result.text;
|
|
48
|
-
Object.assign(metadata, result.metadata);
|
|
49
|
-
warnings.push(...result.warnings);
|
|
50
|
-
}
|
|
51
|
-
else if (file.category === 'document' && file.format === 'pdf') {
|
|
52
|
-
const result = await this.extractPdf(file);
|
|
53
|
-
text = result.text;
|
|
54
|
-
Object.assign(metadata, result.metadata);
|
|
55
|
-
warnings.push(...result.warnings);
|
|
56
|
-
}
|
|
57
|
-
else if (file.category === 'document' && ['office', 'presentation'].includes(file.format)) {
|
|
58
|
-
const result = await this.extractOfficeDocument(file);
|
|
59
|
-
text = result.text;
|
|
60
|
-
Object.assign(metadata, result.metadata);
|
|
61
|
-
warnings.push(...result.warnings);
|
|
62
|
-
}
|
|
63
|
-
else if (file.category === 'spreadsheet' && ['csv', 'tsv'].includes(file.format)) {
|
|
64
|
-
const result = this.extractDelimitedText(file);
|
|
65
|
-
text = result.text;
|
|
66
|
-
Object.assign(metadata, result.metadata);
|
|
67
|
-
warnings.push(...result.warnings);
|
|
68
|
-
}
|
|
69
|
-
else if (file.category === 'spreadsheet') {
|
|
70
|
-
const result = await this.extractSpreadsheet(file);
|
|
71
|
-
text = result.text;
|
|
72
|
-
Object.assign(metadata, result.metadata);
|
|
73
|
-
warnings.push(...result.warnings);
|
|
74
|
-
}
|
|
75
|
-
else if (file.category === 'image' && file.format !== 'vector') {
|
|
76
|
-
const result = await this.extractRasterImage(file);
|
|
77
|
-
text = result.text;
|
|
78
|
-
Object.assign(metadata, result.metadata);
|
|
79
|
-
warnings.push(...result.warnings);
|
|
80
|
-
}
|
|
81
|
-
else if (file.category === 'image' && file.format === 'vector') {
|
|
82
|
-
const result = this.extractSvg(file);
|
|
83
|
-
text = result.text;
|
|
84
|
-
Object.assign(metadata, result.metadata);
|
|
85
|
-
warnings.push(...result.warnings);
|
|
86
|
-
}
|
|
87
|
-
else if (file.format === 'text_clipping') {
|
|
88
|
-
const result = this.extractTextClipping(file);
|
|
89
|
-
text = result.text;
|
|
90
|
-
Object.assign(metadata, result.metadata);
|
|
91
|
-
warnings.push(...result.warnings);
|
|
92
|
-
}
|
|
93
|
-
else if (this.isTextReadable(file)) {
|
|
94
|
-
text = fs.readFileSync(file.absolutePath, 'utf8');
|
|
95
|
-
metadata.extractionMode = 'plain_text';
|
|
96
|
-
metadata.vectorizable = true;
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
99
|
-
text = this.metadataOnlyText(file);
|
|
100
|
-
metadata.extractionMode = 'metadata_only';
|
|
101
|
-
metadata.vectorizable = true;
|
|
102
|
-
metadata.contentCoverage = 'metadata';
|
|
103
|
-
warnings.push(`暂不支持 ${file.category}/${file.format} 内容提取,未解析出正文,未入库`);
|
|
104
|
-
}
|
|
87
|
+
text = this.metadataOnlyText(file);
|
|
88
|
+
metadata.extractionMode = 'metadata_only';
|
|
89
|
+
metadata.vectorizable = true;
|
|
90
|
+
metadata.contentCoverage = 'metadata';
|
|
91
|
+
warnings.push(`暂不支持 ${file.category}/${file.format} 内容提取,未解析出正文,未入库`);
|
|
105
92
|
}
|
|
106
93
|
return {
|
|
107
94
|
text: this.cleanExtractedText(text, file).trim(),
|
|
@@ -110,37 +97,6 @@ export class ContentExtractor {
|
|
|
110
97
|
extractionTimeMs: Date.now() - start,
|
|
111
98
|
};
|
|
112
99
|
}
|
|
113
|
-
tryExternalExtractor(file) {
|
|
114
|
-
const extractors = this.externalExtractors.findAll(file);
|
|
115
|
-
if (extractors.length === 0)
|
|
116
|
-
return undefined;
|
|
117
|
-
const warnings = [];
|
|
118
|
-
for (const extractor of extractors) {
|
|
119
|
-
try {
|
|
120
|
-
const result = extractor.extract(file);
|
|
121
|
-
const text = result.text.trim();
|
|
122
|
-
if (!text) {
|
|
123
|
-
warnings.push(`外部解析器 ${extractor.name} 未提取到正文`);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
return {
|
|
127
|
-
text,
|
|
128
|
-
metadata: {
|
|
129
|
-
extractionMode: 'external_advanced_plugin',
|
|
130
|
-
vectorizable: true,
|
|
131
|
-
contentCoverage: 'external_full_text',
|
|
132
|
-
externalExtractor: extractor.id,
|
|
133
|
-
...(result.metadata ?? {}),
|
|
134
|
-
},
|
|
135
|
-
warnings: [...warnings, ...(result.warnings ?? [])],
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
catch (error) {
|
|
139
|
-
warnings.push(`外部解析器 ${extractor.name} 失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return warnings.length > 0 ? { text: '', metadata: {}, warnings } : undefined;
|
|
143
|
-
}
|
|
144
100
|
extractTextClipping(file) {
|
|
145
101
|
const buffer = fs.readFileSync(file.absolutePath);
|
|
146
102
|
const candidates = [
|
|
@@ -902,7 +858,8 @@ export class ContentExtractor {
|
|
|
902
858
|
}
|
|
903
859
|
}
|
|
904
860
|
async extractRasterImage(file) {
|
|
905
|
-
const metadata = { extractionMode: '
|
|
861
|
+
const metadata = { extractionMode: 'ocr_provider', vectorizable: true };
|
|
862
|
+
const warnings = [];
|
|
906
863
|
if (process.env.CUSTOMIZE_AGENT_DISABLE_OCR === '1') {
|
|
907
864
|
metadata.extractionMode = 'raster_image_metadata';
|
|
908
865
|
metadata.contentCoverage = 'metadata_filename';
|
|
@@ -914,67 +871,56 @@ export class ContentExtractor {
|
|
|
914
871
|
metadata.parseError = validationError;
|
|
915
872
|
return { text: '', metadata, warnings: [`图片文件无效或不完整:${validationError},未入库`] };
|
|
916
873
|
}
|
|
917
|
-
|
|
918
|
-
|
|
874
|
+
// 1. 尝试外部 PaddleOCR 命令(CUSTOMIZE_PADDLE_OCR_CMD)
|
|
875
|
+
const paddleExternal = await this.tryPaddleOcrLayout(file.absolutePath);
|
|
876
|
+
if (paddleExternal) {
|
|
919
877
|
metadata.contentCoverage = 'paddleocr_layout_regions';
|
|
920
|
-
metadata.ocrProvider = 'paddleocr';
|
|
921
|
-
metadata.ocrRegionCount =
|
|
922
|
-
return { text: [this.metadataOnlyText(file),
|
|
878
|
+
metadata.ocrProvider = 'paddleocr-external';
|
|
879
|
+
metadata.ocrRegionCount = paddleExternal.regionCount;
|
|
880
|
+
return { text: [this.metadataOnlyText(file), paddleExternal.text].join('\n'), metadata, warnings };
|
|
923
881
|
}
|
|
924
|
-
|
|
882
|
+
// 2. 加载图片像素数据
|
|
883
|
+
let imageData;
|
|
925
884
|
try {
|
|
926
|
-
|
|
885
|
+
imageData = await this.loadImagePixels(file.absolutePath);
|
|
927
886
|
}
|
|
928
887
|
catch (e) {
|
|
929
|
-
metadata.contentCoverage = '
|
|
888
|
+
metadata.contentCoverage = 'image_decode_failed';
|
|
930
889
|
metadata.parseError = e.message;
|
|
931
|
-
return { text: '', metadata, warnings: [
|
|
932
|
-
}
|
|
933
|
-
//
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
const
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
file.absolutePath,
|
|
958
|
-
], { encoding: 'utf8', timeout: 120_000, maxBuffer: 20 * 1024 * 1024 });
|
|
959
|
-
if (result.status !== 0 || result.error) {
|
|
960
|
-
const message = result.error?.message || result.stderr.trim() || `OCR 子进程退出码 ${result.status ?? 'unknown'}`;
|
|
890
|
+
return { text: '', metadata, warnings: [`图片解码失败:${e.message},未入库`] };
|
|
891
|
+
}
|
|
892
|
+
// 3. OCR Provider(PaddleOCR ONNX → Tesseract CLI → Tesseract.js)
|
|
893
|
+
let provider = null;
|
|
894
|
+
try {
|
|
895
|
+
provider = await createOcrProvider();
|
|
896
|
+
metadata.ocrProvider = provider.id;
|
|
897
|
+
const ocrResult = await provider.recognize(imageData);
|
|
898
|
+
const text = ocrResult.text.trim();
|
|
899
|
+
if (text) {
|
|
900
|
+
metadata.contentCoverage = 'ocr_provider_text';
|
|
901
|
+
metadata.ocrTextLength = text.length;
|
|
902
|
+
metadata.ocrConfidence = ocrResult.confidence;
|
|
903
|
+
metadata.ocrRegionCount = ocrResult.regions.length;
|
|
904
|
+
const regionLines = ocrResult.regions.map((r, i) => `区域 ${i + 1} [置信度 ${r.confidence.toFixed(2)}] [bbox x0=${r.box.x}, y0=${r.box.y}, x1=${r.box.x + r.box.width}, y1=${r.box.y + r.box.height}]: ${r.text}`);
|
|
905
|
+
return {
|
|
906
|
+
text: [this.metadataOnlyText(file), 'OCR 区域文本:', ...regionLines, `OCR 完整文本:\n${text}`].join('\n'),
|
|
907
|
+
metadata,
|
|
908
|
+
warnings,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
metadata.contentCoverage = 'ocr_no_text';
|
|
912
|
+
warnings.push(`${metadata.ocrProvider} 未识别到文字,未入库`);
|
|
913
|
+
return { text: '', metadata, warnings };
|
|
914
|
+
}
|
|
915
|
+
catch (e) {
|
|
961
916
|
metadata.contentCoverage = 'ocr_failed';
|
|
962
|
-
metadata.parseError = message;
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
metadata.ocrProvider = 'tesseract.js';
|
|
970
|
-
metadata.ocrLanguages = 'chi_sim+eng';
|
|
971
|
-
metadata.ocrTextLength = text.length;
|
|
972
|
-
metadata.ocrLineCount = parsed.lines.length;
|
|
973
|
-
return {
|
|
974
|
-
text: text ? [this.metadataOnlyText(file), 'OCR 区域文本:', ...regions, `OCR 完整文本:\n${text}`].join('\n') : '',
|
|
975
|
-
metadata,
|
|
976
|
-
warnings: text ? [] : ['内置 OCR 未识别到文字,未入库'],
|
|
977
|
-
};
|
|
917
|
+
metadata.parseError = e.message;
|
|
918
|
+
warnings.push(`OCR 识别失败(${metadata.ocrProvider ?? 'unknown'}):${e.message},未入库`);
|
|
919
|
+
return { text: '', metadata, warnings };
|
|
920
|
+
}
|
|
921
|
+
finally {
|
|
922
|
+
await provider?.dispose();
|
|
923
|
+
}
|
|
978
924
|
}
|
|
979
925
|
async tryPaddleOcrLayout(filePath) {
|
|
980
926
|
const command = process.env.CUSTOMIZE_PADDLE_OCR_CMD || process.env.PADDLE_OCR_CMD;
|
|
@@ -993,32 +939,6 @@ try {
|
|
|
993
939
|
return { text: ['OCR 版面分析区域:', ...lines].join('\n'), regionCount: lines.length };
|
|
994
940
|
}
|
|
995
941
|
}
|
|
996
|
-
parseOcrJson(raw) {
|
|
997
|
-
try {
|
|
998
|
-
const parsed = JSON.parse(raw);
|
|
999
|
-
return {
|
|
1000
|
-
text: parsed.text ?? raw,
|
|
1001
|
-
lines: (parsed.lines ?? []).map((line, index) => ({ index: line.index ?? index + 1, text: line.text ?? '', bbox: line.bbox })).filter(line => line.text.trim()),
|
|
1002
|
-
};
|
|
1003
|
-
}
|
|
1004
|
-
catch {
|
|
1005
|
-
return { text: raw, lines: raw.split(/\r?\n/u).map((text, index) => ({ index: index + 1, text })).filter(line => line.text.trim()) };
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
formatOcrRegions(lines) {
|
|
1009
|
-
return lines.map(line => {
|
|
1010
|
-
const bbox = this.formatBoundingBox(line.bbox);
|
|
1011
|
-
const type = this.classifyOcrRegion(line.text);
|
|
1012
|
-
return `区域 ${line.index} [${type}]${bbox ? ` ${bbox}` : ''}: ${line.text}`;
|
|
1013
|
-
});
|
|
1014
|
-
}
|
|
1015
|
-
classifyOcrRegion(text) {
|
|
1016
|
-
if (/\|/.test(text) || /\s{2,}/u.test(text) || /表\s*\d|合计|小计/u.test(text))
|
|
1017
|
-
return 'table';
|
|
1018
|
-
if (/图\s*\d|figure|image|示意图/iu.test(text))
|
|
1019
|
-
return 'image-caption';
|
|
1020
|
-
return 'text';
|
|
1021
|
-
}
|
|
1022
942
|
formatBoundingBox(value) {
|
|
1023
943
|
if (!value || typeof value !== 'object')
|
|
1024
944
|
return '';
|
|
@@ -1054,144 +974,203 @@ try {
|
|
|
1054
974
|
return undefined;
|
|
1055
975
|
}
|
|
1056
976
|
async extractPdf(file) {
|
|
977
|
+
const hybrid = await this.extractPdfHybridPages(file);
|
|
978
|
+
if (hybrid.text.trim())
|
|
979
|
+
return hybrid;
|
|
1057
980
|
const metadata = { extractionMode: 'pdf_text', vectorizable: true };
|
|
1058
|
-
const warnings = [];
|
|
1059
|
-
// 第一层:pdfjs-dist 文本提取(处理常规 PDF、压缩内容流、CJK 字体等)
|
|
981
|
+
const warnings = [...hybrid.warnings];
|
|
1060
982
|
try {
|
|
1061
983
|
const raw = fs.readFileSync(file.absolutePath);
|
|
1062
984
|
const text = await this.extractPdfText(raw);
|
|
1063
985
|
if (text.trim()) {
|
|
1064
986
|
metadata.contentCoverage = 'pdf_text_streams_layout_markdown';
|
|
1065
987
|
metadata.pdfExtractor = 'pdfjs-dist';
|
|
1066
|
-
|
|
1067
|
-
if (!this.shouldAugmentPdfWithOcr(markdownText))
|
|
1068
|
-
return { text: [this.metadataOnlyText(file), markdownText].join('\n\n'), metadata, warnings };
|
|
1069
|
-
const ocr = await this.extractScannedPdfOcr(file);
|
|
1070
|
-
if (ocr.text.trim()) {
|
|
1071
|
-
metadata.contentCoverage = 'pdf_text_streams_plus_ocr';
|
|
1072
|
-
metadata.ocrAugmented = true;
|
|
1073
|
-
return { text: [this.metadataOnlyText(file), markdownText, '## PDF OCR 备用识别文本', ocr.text].join('\n\n'), metadata: { ...metadata, ocr: ocr.metadata }, warnings: [...warnings, ...ocr.warnings] };
|
|
1074
|
-
}
|
|
1075
|
-
return { text: [this.metadataOnlyText(file), markdownText].join('\n\n'), metadata: { ...metadata, ocrRecommended: true, ocrReason: ocr.metadata.ocrReason ?? 'pdf_text_low_quality' }, warnings: [...warnings, ...ocr.warnings] };
|
|
988
|
+
return { text: [this.metadataOnlyText(file), this.toMarkdownDocument(text)].join('\n\n'), metadata, warnings };
|
|
1076
989
|
}
|
|
1077
990
|
}
|
|
1078
991
|
catch (error) {
|
|
1079
992
|
warnings.push(`PDF 文本提取失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
1080
993
|
metadata.parseError = error instanceof Error ? error.message : String(error);
|
|
1081
994
|
}
|
|
1082
|
-
// 第二层:OCR(扫描件/图片型 PDF)—— 必须保留并确保可用
|
|
1083
|
-
const ocr = await this.extractScannedPdfOcr(file);
|
|
1084
|
-
if (ocr.text.trim())
|
|
1085
|
-
return ocr;
|
|
1086
|
-
// 第三层:仅索引元数据(兜底)
|
|
1087
995
|
metadata.extractionMode = 'pdf_metadata_only';
|
|
1088
996
|
metadata.contentCoverage = 'metadata_filename';
|
|
1089
997
|
metadata.ocrRecommended = true;
|
|
1090
|
-
metadata.ocrReason =
|
|
998
|
+
metadata.ocrReason = hybrid.metadata.ocrReason ?? 'pdf_text_stream_empty_or_unavailable';
|
|
1091
999
|
metadata.pdfPageOcrSupported = true;
|
|
1092
1000
|
return {
|
|
1093
1001
|
text: this.metadataOnlyText(file),
|
|
1094
1002
|
metadata,
|
|
1095
|
-
warnings: [...warnings, 'PDF 正文暂未提取到文本,已索引文件名、路径和类型元数据'
|
|
1003
|
+
warnings: [...warnings, 'PDF 正文暂未提取到文本,已索引文件名、路径和类型元数据'],
|
|
1096
1004
|
};
|
|
1097
1005
|
}
|
|
1098
|
-
|
|
1099
|
-
const normalizedLength = this.normalizedTextLength(text);
|
|
1100
|
-
if (normalizedLength < 1200)
|
|
1101
|
-
return true;
|
|
1102
|
-
const lines = text.split(/\r?\n/u).map(line => line.trim()).filter(Boolean);
|
|
1103
|
-
if (lines.length === 0)
|
|
1104
|
-
return true;
|
|
1105
|
-
const shortLineRatio = lines.filter(line => line.length <= 12).length / lines.length;
|
|
1106
|
-
const cjkCount = (text.match(/[\p{Script=Han}]/gu) ?? []).length;
|
|
1107
|
-
return shortLineRatio > 0.65 && cjkCount < 1200;
|
|
1108
|
-
}
|
|
1109
|
-
async extractScannedPdfOcr(file) {
|
|
1006
|
+
async extractPdfHybridPages(file) {
|
|
1110
1007
|
const metadata = {
|
|
1111
|
-
extractionMode: '
|
|
1008
|
+
extractionMode: 'pdf_hybrid_pages',
|
|
1112
1009
|
vectorizable: true,
|
|
1113
|
-
|
|
1114
|
-
ocrProvider: 'tesseract.js',
|
|
1115
|
-
ocrLanguages: 'chi_sim+eng',
|
|
1116
|
-
pdfRenderer: 'pdfjs-dist + @napi-rs/canvas',
|
|
1117
|
-
pdfOcrPageLimit: 'all',
|
|
1010
|
+
contentCoverage: 'pdf_page_text_plus_selective_ocr',
|
|
1118
1011
|
};
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
let
|
|
1122
|
-
|
|
1012
|
+
const warnings = [];
|
|
1013
|
+
// OCR Provider
|
|
1014
|
+
let ocrProvider = null;
|
|
1015
|
+
const getOcrProvider = async () => {
|
|
1016
|
+
if (!ocrProvider)
|
|
1017
|
+
ocrProvider = await createOcrProvider();
|
|
1018
|
+
return ocrProvider;
|
|
1019
|
+
};
|
|
1020
|
+
const failedPages = [];
|
|
1021
|
+
const ocrPages = [];
|
|
1022
|
+
const ocrStrategies = [];
|
|
1023
|
+
// 尝试 PyMuPDF 渲染(高质量)或降级到 pdfjs-dist
|
|
1024
|
+
let pageImages;
|
|
1025
|
+
let pageCount = 0;
|
|
1026
|
+
let renderer = 'unknown';
|
|
1027
|
+
const tmpDir = fs.mkdtempSync(path.join(this.getTempRoot(), 'kb-pdf-'));
|
|
1123
1028
|
try {
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1029
|
+
// ── 方法1: PyMuPDF(300 DPI 原生渲染,质量最高) ──
|
|
1030
|
+
pageImages = this.tryRenderWithPyMuPDF(file.absolutePath, tmpDir);
|
|
1031
|
+
if (pageImages && pageImages.length > 0) {
|
|
1032
|
+
renderer = 'PyMuPDF';
|
|
1033
|
+
pageCount = pageImages.length;
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
// ── 方法2: pdfjs-dist + canvas ──
|
|
1037
|
+
const jsImages = await this.tryRenderWithPdfJs(file, tmpDir);
|
|
1038
|
+
if (jsImages && jsImages.length > 0) {
|
|
1039
|
+
renderer = 'pdfjs-dist';
|
|
1040
|
+
pageCount = jsImages.length;
|
|
1041
|
+
pageImages = jsImages;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1127
1044
|
}
|
|
1128
1045
|
catch (e) {
|
|
1129
1046
|
metadata.ocrRecommended = true;
|
|
1130
|
-
metadata.ocrReason = `
|
|
1131
|
-
return { text: '', metadata, warnings: [
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
if (nmRoot)
|
|
1137
|
-
childEnv.NODE_PATH = nmRoot;
|
|
1138
|
-
const result = spawnSync(process.execPath, [
|
|
1139
|
-
'--input-type=module',
|
|
1140
|
-
'-e',
|
|
1141
|
-
`import fs from 'node:fs';
|
|
1142
|
-
import os from 'node:os';
|
|
1143
|
-
import path from 'node:path';
|
|
1144
|
-
import { createCanvas } from ${JSON.stringify(canvasPath)};
|
|
1145
|
-
import * as pdfjs from ${JSON.stringify(pdfjsPath)};
|
|
1146
|
-
import { createWorker } from ${JSON.stringify(tesseractPath)};
|
|
1147
|
-
const filePath = process.argv[1];
|
|
1148
|
-
const bytes = new Uint8Array(fs.readFileSync(filePath));
|
|
1149
|
-
const doc = await pdfjs.getDocument({ data: bytes, verbosity: 0 }).promise;
|
|
1150
|
-
const pageLimit = doc.numPages;
|
|
1151
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-pdf-ocr-'));
|
|
1152
|
-
const worker = await createWorker('chi_sim+eng');
|
|
1153
|
-
const pages = [];
|
|
1154
|
-
try {
|
|
1155
|
-
for (let i = 1; i <= pageLimit; i += 1) {
|
|
1156
|
-
const page = await doc.getPage(i);
|
|
1157
|
-
const viewport = page.getViewport({ scale: 2 });
|
|
1158
|
-
const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
|
|
1159
|
-
const context = canvas.getContext('2d');
|
|
1160
|
-
await page.render({ canvasContext: context, viewport }).promise;
|
|
1161
|
-
const imagePath = path.join(tmpDir, 'page-' + i + '.png');
|
|
1162
|
-
fs.writeFileSync(imagePath, canvas.toBuffer('image/png'));
|
|
1163
|
-
const recognized = await worker.recognize(imagePath);
|
|
1164
|
-
const text = (recognized.data.text || '').trim();
|
|
1165
|
-
const lines = (recognized.data.lines || []).map((line, index) => ({ index: index + 1, text: line.text || '', bbox: line.bbox || null })).filter(line => line.text.trim());
|
|
1166
|
-
if (text) pages.push('PDF OCR 第 ' + i + ' 页:\\n' + lines.map(line => '区域 ' + line.index + ' ' + JSON.stringify(line.bbox || {}) + ': ' + line.text).join('\\n') + '\\n\\n完整文本:\\n' + text);
|
|
1167
|
-
}
|
|
1168
|
-
} finally {
|
|
1169
|
-
await worker.terminate();
|
|
1170
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1171
|
-
}
|
|
1172
|
-
process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text: pages.join('\\n\\n') }));`,
|
|
1173
|
-
file.absolutePath,
|
|
1174
|
-
], { encoding: 'utf8', timeout: 0, maxBuffer: 50 * 1024 * 1024, env: childEnv });
|
|
1175
|
-
if (result.status !== 0 || result.error) {
|
|
1047
|
+
metadata.ocrReason = `PDF 渲染失败: ${e.message}`;
|
|
1048
|
+
return { text: '', metadata, warnings: [`PDF 渲染失败:${metadata.ocrReason}`] };
|
|
1049
|
+
}
|
|
1050
|
+
metadata.pdfPageCount = pageCount;
|
|
1051
|
+
metadata.pdfRenderer = renderer;
|
|
1052
|
+
if (!pageImages || pageImages.length === 0) {
|
|
1176
1053
|
metadata.ocrRecommended = true;
|
|
1177
|
-
metadata.ocrReason =
|
|
1178
|
-
return { text: '', metadata, warnings: [
|
|
1054
|
+
metadata.ocrReason = '无法渲染PDF页面';
|
|
1055
|
+
return { text: '', metadata, warnings: ['无法渲染PDF页面'] };
|
|
1179
1056
|
}
|
|
1057
|
+
// 逐页 OCR
|
|
1058
|
+
const pageTexts = [];
|
|
1059
|
+
for (let i = 0; i < pageImages.length; i++) {
|
|
1060
|
+
const imgPath = pageImages[i];
|
|
1061
|
+
try {
|
|
1062
|
+
const provider = await getOcrProvider();
|
|
1063
|
+
const ocrResult = await provider.recognize({
|
|
1064
|
+
data: new Uint8Array(0),
|
|
1065
|
+
width: 0, height: 0, channels: 0,
|
|
1066
|
+
filePath: imgPath,
|
|
1067
|
+
});
|
|
1068
|
+
const ocrText = ocrResult.text.trim();
|
|
1069
|
+
if (ocrText) {
|
|
1070
|
+
ocrPages.push(i + 1);
|
|
1071
|
+
ocrStrategies.push({ page: i + 1, strategy: renderer, score: this.scoreOcrText(ocrText) });
|
|
1072
|
+
pageTexts.push(`## PDF 第 ${i + 1} 页(OCR)\n\n${ocrText}`);
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
failedPages.push({ page: i + 1, reason: 'empty_ocr' });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
catch (error) {
|
|
1079
|
+
failedPages.push({ page: i + 1, reason: error instanceof Error ? error.message : String(error) });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
// 清理临时文件
|
|
1180
1083
|
try {
|
|
1181
|
-
|
|
1182
|
-
const text = parsed.text?.trim() ?? '';
|
|
1183
|
-
metadata.ocrPageCount = parsed.pageCount ?? 0;
|
|
1184
|
-
metadata.pdfOcrPageLimit = parsed.pageLimit ?? 'all';
|
|
1185
|
-
metadata.ocrTextLength = text.length;
|
|
1186
|
-
metadata.contentCoverage = text ? 'pdf_page_ocr_text' : 'metadata_filename';
|
|
1187
|
-
return { text: text ? [this.metadataOnlyText(file), text].join('\n') : '', metadata, warnings: text ? [] : ['内置扫描 PDF OCR 未识别到文字'] };
|
|
1084
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1188
1085
|
}
|
|
1189
1086
|
catch {
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1087
|
+
warnings.push('PDF OCR 临时文件清理失败');
|
|
1088
|
+
}
|
|
1089
|
+
metadata.ocrAugmented = ocrPages.length > 0;
|
|
1090
|
+
metadata.ocrPages = ocrPages;
|
|
1091
|
+
metadata.ocrStrategies = ocrStrategies;
|
|
1092
|
+
metadata.failedPages = failedPages;
|
|
1093
|
+
metadata.ocrProvider = ocrProvider?.id ?? 'unknown';
|
|
1094
|
+
if (failedPages.length > 0) {
|
|
1095
|
+
warnings.push(`PDF 部分页解析失败: ${failedPages.map((p) => `${p.page}:${p.reason}`).join('; ')}`);
|
|
1096
|
+
}
|
|
1097
|
+
const combined = pageTexts.join('\n\n').trim();
|
|
1098
|
+
return {
|
|
1099
|
+
text: combined ? [this.metadataOnlyText(file), combined].join('\n\n') : '',
|
|
1100
|
+
metadata,
|
|
1101
|
+
warnings,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
/** PyMuPDF 渲染(300 DPI 原生提取,质量远高于 pdfjs-dist) */
|
|
1105
|
+
tryRenderWithPyMuPDF(pdfPath, outputDir) {
|
|
1106
|
+
try {
|
|
1107
|
+
const workerScript = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts', 'render_pdf_pages.py');
|
|
1108
|
+
spawnSync('python3', [workerScript, pdfPath, outputDir, '300'], {
|
|
1109
|
+
encoding: 'utf-8', timeout: 60_000, maxBuffer: 1024 * 1024,
|
|
1110
|
+
});
|
|
1111
|
+
// 检查输出文件(即使 Python 非零退出码也可能已渲染部分页面)
|
|
1112
|
+
const images = [];
|
|
1113
|
+
for (let i = 1; i <= 999; i++) {
|
|
1114
|
+
const p = path.join(outputDir, `page-${i}.png`);
|
|
1115
|
+
if (fs.existsSync(p) && fs.statSync(p).size > 100)
|
|
1116
|
+
images.push(p);
|
|
1117
|
+
else
|
|
1118
|
+
break;
|
|
1119
|
+
}
|
|
1120
|
+
return images.length > 0 ? images : null;
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
return null;
|
|
1193
1124
|
}
|
|
1194
1125
|
}
|
|
1126
|
+
/** pdfjs-dist + canvas 渲染(降级方案) */
|
|
1127
|
+
async tryRenderWithPdfJs(file, outputDir) {
|
|
1128
|
+
try {
|
|
1129
|
+
const canvasMod = await resolveAndImport('@napi-rs/canvas');
|
|
1130
|
+
const pdfjsLib = await resolveAndImport('pdfjs-dist/legacy/build/pdf.mjs');
|
|
1131
|
+
const sharpMod = await resolveAndImport('sharp');
|
|
1132
|
+
const createCanvas = canvasMod.createCanvas ?? canvasMod.default?.createCanvas;
|
|
1133
|
+
const sharpFn = sharpMod.default ?? sharpMod;
|
|
1134
|
+
if (!createCanvas || !sharpFn)
|
|
1135
|
+
return null;
|
|
1136
|
+
const raw = fs.readFileSync(file.absolutePath);
|
|
1137
|
+
const doc = await pdfjsLib.getDocument({ data: new Uint8Array(raw), verbosity: 0 }).promise;
|
|
1138
|
+
const pageCount = doc.numPages;
|
|
1139
|
+
const images = [];
|
|
1140
|
+
for (let i = 1; i <= pageCount; i++) {
|
|
1141
|
+
const page = await doc.getPage(i);
|
|
1142
|
+
const viewport = page.getViewport({ scale: 4 });
|
|
1143
|
+
const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
|
|
1144
|
+
const ctx = canvas.getContext('2d');
|
|
1145
|
+
await page.render({ canvasContext: ctx, viewport }).promise;
|
|
1146
|
+
const pngPath = path.join(outputDir, `page-${i}.png`);
|
|
1147
|
+
await sharpFn(canvas.toBuffer('image/png'))
|
|
1148
|
+
.removeAlpha().normalize().linear(3.0, -150)
|
|
1149
|
+
.withMetadata({ density: 288 }).png().toFile(pngPath);
|
|
1150
|
+
images.push(pngPath);
|
|
1151
|
+
}
|
|
1152
|
+
await doc.destroy();
|
|
1153
|
+
return images.length > 0 ? images : null;
|
|
1154
|
+
}
|
|
1155
|
+
catch {
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
scoreOcrText(value) {
|
|
1160
|
+
const text = String(value ?? '').trim();
|
|
1161
|
+
const normalizedLength = this.normalizedTextLength(text);
|
|
1162
|
+
const cjkCount = (text.match(/[\p{Script=Han}]/gu) ?? []).length;
|
|
1163
|
+
const latinCount = (text.match(/[A-Za-z]/g) ?? []).length;
|
|
1164
|
+
const replacementCount = (text.match(/[�□]/gu) ?? []).length;
|
|
1165
|
+
return cjkCount * 8 + normalizedLength - latinCount * 0.8 - replacementCount * 10;
|
|
1166
|
+
}
|
|
1167
|
+
/** 加载图片像素数据(依赖 sharp) */
|
|
1168
|
+
async loadImagePixels(filePath) {
|
|
1169
|
+
const sharpMod = await resolveAndImport('sharp');
|
|
1170
|
+
const sharpFn = sharpMod.default ?? sharpMod;
|
|
1171
|
+
const { data, info } = await sharpFn(filePath).raw().toBuffer({ resolveWithObject: true });
|
|
1172
|
+
return { data: new Uint8Array(data), width: info.width, height: info.height };
|
|
1173
|
+
}
|
|
1195
1174
|
async extractPdfText(buffer) {
|
|
1196
1175
|
let pdfjsText = '';
|
|
1197
1176
|
// 第一层:pdfjs-dist 文本提取(处理压缩内容流、CJK 字体、现代 PDF)
|
|
@@ -1218,15 +1197,26 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
|
|
|
1218
1197
|
if (process.env.KB_DEBUG === '1')
|
|
1219
1198
|
console.warn('[kb] pdfjs-dist extraction failed:', e.message);
|
|
1220
1199
|
}
|
|
1221
|
-
// 第二层:pdf-parse(兼容旧版 PDF
|
|
1200
|
+
// 第二层:pdf-parse(兼容旧版 PDF),适配 v1.x 函数导出 和 v2.x 类导出
|
|
1222
1201
|
try {
|
|
1223
1202
|
const mod = await resolveAndImport('pdf-parse');
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1203
|
+
let pdfParse;
|
|
1204
|
+
// v1.x: module.exports = function(buffer) { ... }
|
|
1205
|
+
if (typeof mod === 'function') {
|
|
1206
|
+
pdfParse = mod;
|
|
1207
|
+
}
|
|
1208
|
+
// v1.x ESM: { default: function(buffer) { ... } }
|
|
1209
|
+
else if (mod && typeof mod.default === 'function') {
|
|
1210
|
+
pdfParse = mod.default;
|
|
1211
|
+
}
|
|
1212
|
+
// v2.x: { PDFParse: class { parse(buffer) { ... } } }
|
|
1213
|
+
else if (mod && typeof mod.PDFParse === 'function') {
|
|
1214
|
+
const PDFParse = mod.PDFParse;
|
|
1215
|
+
pdfParse = (buf) => new PDFParse().parse(buf);
|
|
1216
|
+
}
|
|
1227
1217
|
if (pdfParse) {
|
|
1228
1218
|
const result = await pdfParse(buffer);
|
|
1229
|
-
const parseText = result
|
|
1219
|
+
const parseText = (result?.text ?? '').trim();
|
|
1230
1220
|
if (pdfjsText && parseText && this.normalizedTextLength(parseText) > this.normalizedTextLength(pdfjsText) * 1.08)
|
|
1231
1221
|
return [pdfjsText, '## PDF 备用解析文本', parseText].join('\n\n');
|
|
1232
1222
|
if (parseText && !pdfjsText)
|