@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.
@@ -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 { ExternalExtractorRegistry } from './external-extractor.js';
7
- import { resolveAndImport, resolvePackage, getNodeModulesRoot } from './module-resolver.js';
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
- const external = this.tryExternalExtractor(file);
26
- if (external?.text.trim()) {
27
- text = external.text;
28
- Object.assign(metadata, external.metadata);
29
- warnings.push(...external.warnings);
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
- warnings.push(...(external?.warnings ?? []));
33
- if (file.category === 'cad') {
34
- const result = await this.extractCad(file);
35
- text = result.text;
36
- Object.assign(metadata, result.metadata);
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: 'builtin_tesseract_ocr_isolated', vectorizable: true };
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
- const paddle = await this.tryPaddleOcrLayout(file.absolutePath);
918
- if (paddle) {
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 = paddle.regionCount;
922
- return { text: [this.metadataOnlyText(file), paddle.text].join('\n'), metadata, warnings: [] };
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
- let tesseractPath;
882
+ // 2. 加载图片像素数据
883
+ let imageData;
925
884
  try {
926
- tesseractPath = resolvePackage('tesseract.js');
885
+ imageData = await this.loadImagePixels(file.absolutePath);
927
886
  }
928
887
  catch (e) {
929
- metadata.contentCoverage = 'ocr_unavailable';
888
+ metadata.contentCoverage = 'image_decode_failed';
930
889
  metadata.parseError = e.message;
931
- return { text: '', metadata, warnings: [`内置 OCR 不可用:${e.message}`] };
932
- }
933
- // 子进程用 createRequire 加载 tesseract.js(兼容打包 Server vendor 目录)
934
- const result = spawnSync(process.execPath, [
935
- '--input-type=module',
936
- '-e',
937
- `import { createRequire } from 'node:module';
938
- import { fileURLToPath } from 'node:url';
939
- import path from 'node:path';
940
-
941
- const localRequire = createRequire(import.meta.url);
942
- const tesseractMod = localRequire(${JSON.stringify(tesseractPath)});
943
- const { createWorker } = tesseractMod;
944
-
945
- const worker = await createWorker('chi_sim+eng');
946
- try {
947
- const result = await worker.recognize(process.argv[1]);
948
- const lines = (result.data.lines || []).map((line, index) => ({
949
- index: index + 1,
950
- text: line.text || '',
951
- bbox: line.bbox || line.baseline || null,
952
- })).filter(line => line.text.trim());
953
- process.stdout.write(JSON.stringify({ text: result.data.text || '', lines }));
954
- } finally {
955
- await worker.terminate();
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
- return { text: '', metadata, warnings: [`内置 OCR 解析失败:${message},未入库`] };
964
- }
965
- const parsed = this.parseOcrJson(result.stdout);
966
- const text = parsed.text.trim();
967
- const regions = this.formatOcrRegions(parsed.lines);
968
- metadata.contentCoverage = text ? 'ocr_text_bounding_boxes' : 'metadata_filename';
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
- const markdownText = this.toMarkdownDocument(text);
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 = ocr.metadata.ocrReason ?? 'pdf_text_stream_empty_or_unavailable';
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 正文暂未提取到文本,已索引文件名、路径和类型元数据', ...ocr.warnings],
1003
+ warnings: [...warnings, 'PDF 正文暂未提取到文本,已索引文件名、路径和类型元数据'],
1096
1004
  };
1097
1005
  }
1098
- shouldAugmentPdfWithOcr(text) {
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: 'pdf_page_ocr_embedded',
1008
+ extractionMode: 'pdf_hybrid_pages',
1112
1009
  vectorizable: true,
1113
- pdfPageOcrSupported: true,
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
- // 解析模块路径 —— 确保在打包 Server 等上下文中子进程也能正确加载
1120
- let canvasPath;
1121
- let pdfjsPath;
1122
- let tesseractPath;
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
- canvasPath = resolvePackage('@napi-rs/canvas');
1125
- pdfjsPath = resolvePackage('pdfjs-dist/legacy/build/pdf.mjs');
1126
- tesseractPath = resolvePackage('tesseract.js');
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 = `OCR 依赖解析失败: ${e.message}`;
1131
- return { text: '', metadata, warnings: [`内置扫描 PDF OCR 不可用:${metadata.ocrReason}`] };
1132
- }
1133
- // NODE_PATH 确保子进程能解析 tesseract.js 的依赖
1134
- const childEnv = { ...process.env };
1135
- const nmRoot = getNodeModulesRoot();
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 = result.error?.message ?? result.stderr.trim() ?? `pdf_page_ocr_exit_${result.status ?? 'unknown'}`;
1178
- return { text: '', metadata, warnings: [`内置扫描 PDF OCR 失败:${metadata.ocrReason}`] };
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
- const parsed = JSON.parse(result.stdout);
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
- metadata.ocrRecommended = true;
1191
- metadata.ocrReason = 'pdf_page_ocr_output_parse_failed';
1192
- return { text: '', metadata, warnings: ['内置扫描 PDF OCR 输出解析失败'] };
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),与 pdfjs 结果互补,避免单一解析器漏字
1200
+ // 第二层:pdf-parse(兼容旧版 PDF),适配 v1.x 函数导出 和 v2.x 类导出
1222
1201
  try {
1223
1202
  const mod = await resolveAndImport('pdf-parse');
1224
- const pdfParse = typeof mod === 'function'
1225
- ? mod
1226
- : mod.default;
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.text.trim();
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)