@customize-agent/knowledge 4.0.14 → 4.0.16

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.
@@ -48,13 +48,18 @@ export declare class ContentExtractor {
48
48
  private extractOfficeZip;
49
49
  private extractRasterImage;
50
50
  private tryPaddleOcrLayout;
51
- private parseOcrJson;
52
- private formatOcrRegions;
53
- private classifyOcrRegion;
54
51
  private formatBoundingBox;
55
52
  private validateRasterImage;
56
53
  private extractPdf;
57
54
  private extractPdfHybridPages;
55
+ /** PyMuPDF 渲染(300 DPI 原生提取,质量远高于 pdfjs-dist) */
56
+ private tryRenderWithPyMuPDF;
57
+ /** pdfjs-dist + canvas 渲染(降级方案) */
58
+ private tryRenderWithPdfJs;
59
+ private scoreOcrText;
60
+ private cleanOcrText;
61
+ /** 加载图片像素数据(依赖 sharp) */
62
+ private loadImagePixels;
58
63
  private extractPdfText;
59
64
  private normalizedTextLength;
60
65
  private toPdfTextItem;
@@ -2,8 +2,9 @@ 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 { 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';
7
8
  const CAD_INTERNAL_TOKEN_RE = /\b(?:TDbPipe|TDbPipeValve|TDbPipeFitting|TDbWellh|AcDb\w+|Dwg\w+|ObjectId|Handle|ByLayer|Continuous|Model|Layout\d*)\b/giu;
8
9
  const CAD_INTERNAL_LINE_RE = /^(?:TDb\w+|AcDb\w+|[A-F0-9]{8,}|\d+|Model|Layout\d*|ByLayer|Continuous)$/iu;
9
10
  /** 文件内容提取器,支持文档、表格、图片、CAD 等多种文件格式的内容抽取 */
@@ -857,7 +858,8 @@ export class ContentExtractor {
857
858
  }
858
859
  }
859
860
  async extractRasterImage(file) {
860
- const metadata = { extractionMode: 'builtin_tesseract_ocr_isolated', vectorizable: true };
861
+ const metadata = { extractionMode: 'ocr_provider', vectorizable: true };
862
+ const warnings = [];
861
863
  if (process.env.CUSTOMIZE_AGENT_DISABLE_OCR === '1') {
862
864
  metadata.extractionMode = 'raster_image_metadata';
863
865
  metadata.contentCoverage = 'metadata_filename';
@@ -869,67 +871,56 @@ export class ContentExtractor {
869
871
  metadata.parseError = validationError;
870
872
  return { text: '', metadata, warnings: [`图片文件无效或不完整:${validationError},未入库`] };
871
873
  }
872
- const paddle = await this.tryPaddleOcrLayout(file.absolutePath);
873
- if (paddle) {
874
+ // 1. 尝试外部 PaddleOCR 命令(CUSTOMIZE_PADDLE_OCR_CMD)
875
+ const paddleExternal = await this.tryPaddleOcrLayout(file.absolutePath);
876
+ if (paddleExternal) {
874
877
  metadata.contentCoverage = 'paddleocr_layout_regions';
875
- metadata.ocrProvider = 'paddleocr';
876
- metadata.ocrRegionCount = paddle.regionCount;
877
- 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 };
878
881
  }
879
- let tesseractPath;
882
+ // 2. 加载图片像素数据
883
+ let imageData;
880
884
  try {
881
- tesseractPath = resolvePackage('tesseract.js');
885
+ imageData = await this.loadImagePixels(file.absolutePath);
882
886
  }
883
887
  catch (e) {
884
- metadata.contentCoverage = 'ocr_unavailable';
888
+ metadata.contentCoverage = 'image_decode_failed';
885
889
  metadata.parseError = e.message;
886
- return { text: '', metadata, warnings: [`内置 OCR 不可用:${e.message}`] };
887
- }
888
- // 子进程用 createRequire 加载 tesseract.js(兼容打包 Server vendor 目录)
889
- const result = spawnSync(process.execPath, [
890
- '--input-type=module',
891
- '-e',
892
- `import { createRequire } from 'node:module';
893
- import { fileURLToPath } from 'node:url';
894
- import path from 'node:path';
895
-
896
- const localRequire = createRequire(import.meta.url);
897
- const tesseractMod = localRequire(${JSON.stringify(tesseractPath)});
898
- const { createWorker } = tesseractMod;
899
-
900
- const worker = await createWorker('chi_sim+eng');
901
- try {
902
- const result = await worker.recognize(process.argv[1]);
903
- const lines = (result.data.lines || []).map((line, index) => ({
904
- index: index + 1,
905
- text: line.text || '',
906
- bbox: line.bbox || line.baseline || null,
907
- })).filter(line => line.text.trim());
908
- process.stdout.write(JSON.stringify({ text: result.data.text || '', lines }));
909
- } finally {
910
- await worker.terminate();
911
- }`,
912
- file.absolutePath,
913
- ], { encoding: 'utf8', timeout: 120_000, maxBuffer: 20 * 1024 * 1024 });
914
- if (result.status !== 0 || result.error) {
915
- 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) {
916
916
  metadata.contentCoverage = 'ocr_failed';
917
- metadata.parseError = message;
918
- return { text: '', metadata, warnings: [`内置 OCR 解析失败:${message},未入库`] };
919
- }
920
- const parsed = this.parseOcrJson(result.stdout);
921
- const text = parsed.text.trim();
922
- const regions = this.formatOcrRegions(parsed.lines);
923
- metadata.contentCoverage = text ? 'ocr_text_bounding_boxes' : 'metadata_filename';
924
- metadata.ocrProvider = 'tesseract.js';
925
- metadata.ocrLanguages = 'chi_sim+eng';
926
- metadata.ocrTextLength = text.length;
927
- metadata.ocrLineCount = parsed.lines.length;
928
- return {
929
- text: text ? [this.metadataOnlyText(file), 'OCR 区域文本:', ...regions, `OCR 完整文本:\n${text}`].join('\n') : '',
930
- metadata,
931
- warnings: text ? [] : ['内置 OCR 未识别到文字,未入库'],
932
- };
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
+ }
933
924
  }
934
925
  async tryPaddleOcrLayout(filePath) {
935
926
  const command = process.env.CUSTOMIZE_PADDLE_OCR_CMD || process.env.PADDLE_OCR_CMD;
@@ -948,32 +939,6 @@ try {
948
939
  return { text: ['OCR 版面分析区域:', ...lines].join('\n'), regionCount: lines.length };
949
940
  }
950
941
  }
951
- parseOcrJson(raw) {
952
- try {
953
- const parsed = JSON.parse(raw);
954
- return {
955
- text: parsed.text ?? raw,
956
- lines: (parsed.lines ?? []).map((line, index) => ({ index: line.index ?? index + 1, text: line.text ?? '', bbox: line.bbox })).filter(line => line.text.trim()),
957
- };
958
- }
959
- catch {
960
- return { text: raw, lines: raw.split(/\r?\n/u).map((text, index) => ({ index: index + 1, text })).filter(line => line.text.trim()) };
961
- }
962
- }
963
- formatOcrRegions(lines) {
964
- return lines.map(line => {
965
- const bbox = this.formatBoundingBox(line.bbox);
966
- const type = this.classifyOcrRegion(line.text);
967
- return `区域 ${line.index} [${type}]${bbox ? ` ${bbox}` : ''}: ${line.text}`;
968
- });
969
- }
970
- classifyOcrRegion(text) {
971
- if (/\|/.test(text) || /\s{2,}/u.test(text) || /表\s*\d|合计|小计/u.test(text))
972
- return 'table';
973
- if (/图\s*\d|figure|image|示意图/iu.test(text))
974
- return 'image-caption';
975
- return 'text';
976
- }
977
942
  formatBoundingBox(value) {
978
943
  if (!value || typeof value !== 'object')
979
944
  return '';
@@ -1043,166 +1008,182 @@ try {
1043
1008
  extractionMode: 'pdf_hybrid_pages',
1044
1009
  vectorizable: true,
1045
1010
  contentCoverage: 'pdf_page_text_plus_selective_ocr',
1046
- pdfExtractor: 'pdfjs-dist',
1047
1011
  };
1048
1012
  const warnings = [];
1049
- let canvasPath;
1050
- let pdfjsPath;
1051
- let tesseractPath;
1052
- let sharpPath;
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-'));
1053
1028
  try {
1054
- canvasPath = resolvePackage('@napi-rs/canvas');
1055
- pdfjsPath = resolvePackage('pdfjs-dist/legacy/build/pdf.mjs');
1056
- tesseractPath = resolvePackage('tesseract.js');
1057
- sharpPath = resolvePackage('sharp');
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
+ }
1058
1044
  }
1059
1045
  catch (e) {
1060
1046
  metadata.ocrRecommended = true;
1061
- metadata.ocrReason = `PDF 混合解析依赖解析失败: ${e.message}`;
1062
- return { text: '', metadata, warnings: [`内置 PDF 混合解析不可用:${metadata.ocrReason}`] };
1063
- }
1064
- const childEnv = { ...process.env };
1065
- const nmRoot = getNodeModulesRoot();
1066
- if (nmRoot)
1067
- childEnv.NODE_PATH = nmRoot;
1068
- const result = spawnSync(process.execPath, [
1069
- '--input-type=module',
1070
- '-e',
1071
- `import fs from 'node:fs';
1072
- import os from 'node:os';
1073
- import path from 'node:path';
1074
- import { createCanvas } from ${JSON.stringify(canvasPath)};
1075
- import * as pdfjs from ${JSON.stringify(pdfjsPath)};
1076
- import { createWorker } from ${JSON.stringify(tesseractPath)};
1077
- import sharp from ${JSON.stringify(sharpPath)};
1078
- const filePath = process.argv[1];
1079
- const minTextLength = Number(process.argv[2]);
1080
- const bytes = new Uint8Array(fs.readFileSync(filePath));
1081
- const doc = await pdfjs.getDocument({ data: bytes, verbosity: 0 }).promise;
1082
- const pageCount = doc.numPages;
1083
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-pdf-hybrid-'));
1084
- const pageTexts = [];
1085
- const ocrPages = [];
1086
- const failedPages = [];
1087
- let worker;
1088
- function normalizeTextLength(value) { return String(value || '').replace(/\\s+/gu, '').length; }
1089
- function isLowQualityText(value) {
1090
- const text = String(value || '');
1091
- const normalizedLength = normalizeTextLength(text);
1092
- if (normalizedLength < minTextLength) return true;
1093
- const lines = text.split(/\\r?\\n/u).map(line => line.trim()).filter(Boolean);
1094
- if (lines.length === 0) return true;
1095
- const shortLineRatio = lines.filter(line => line.length <= 12).length / lines.length;
1096
- const cjkCount = (text.match(/[\\p{Script=Han}]/gu) || []).length;
1097
- const replacementCount = (text.match(/[�□]/gu) || []).length;
1098
- return replacementCount > 5 || (shortLineRatio > 0.65 && cjkCount < 1200);
1099
- }
1100
- function itemsToMarkdown(items) {
1101
- const rows = [];
1102
- for (const item of items) {
1103
- if (!item || typeof item.str !== 'string' || !item.str.trim()) continue;
1104
- const transform = item.transform || [1, 0, 0, 1, 0, 0];
1105
- rows.push({ text: item.str.trim(), x: transform[4] || 0, y: transform[5] || 0 });
1106
- }
1107
- rows.sort((a, b) => Math.abs(b.y - a.y) > 3 ? b.y - a.y : a.x - b.x);
1108
- const lines = [];
1109
- let currentY = null;
1110
- let current = [];
1111
- for (const row of rows) {
1112
- if (currentY === null || Math.abs(row.y - currentY) <= 3) {
1113
- current.push(row);
1114
- currentY = currentY === null ? row.y : currentY;
1115
- } else {
1116
- lines.push(current.sort((a, b) => a.x - b.x).map(x => x.text).join(' '));
1117
- current = [row];
1118
- currentY = row.y;
1119
- }
1120
- }
1121
- if (current.length > 0) lines.push(current.sort((a, b) => a.x - b.x).map(x => x.text).join(' '));
1122
- return lines.join('\\n').trim();
1123
- }
1124
- async function recognizePage(page, pageNumber) {
1125
- if (!worker) worker = await createWorker('chi_sim+eng');
1126
- const viewport = page.getViewport({ scale: 2.5 });
1127
- const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
1128
- const context = canvas.getContext('2d');
1129
- await page.render({ canvasContext: context, viewport }).promise;
1130
- const rawPath = path.join(tmpDir, 'page-' + pageNumber + '-raw.png');
1131
- const processedPath = path.join(tmpDir, 'page-' + pageNumber + '-processed.png');
1132
- fs.writeFileSync(rawPath, canvas.toBuffer('image/png'));
1133
- await sharp(rawPath)
1134
- .resize({ width: Math.min(canvas.width, 4200), withoutEnlargement: true })
1135
- .grayscale()
1136
- .normalize()
1137
- .sharpen()
1138
- .threshold(170)
1139
- .png()
1140
- .toFile(processedPath);
1141
- const recognized = await worker.recognize(processedPath);
1142
- return (recognized.data.text || '').trim();
1143
- }
1144
- try {
1145
- for (let i = 1; i <= pageCount; i += 1) {
1146
- try {
1147
- const page = await doc.getPage(i);
1148
- const content = await page.getTextContent();
1149
- const text = itemsToMarkdown(content.items || []);
1150
- if (!isLowQualityText(text)) {
1151
- pageTexts.push('## PDF 第 ' + i + ' 页\\n\\n' + text);
1152
- continue;
1153
- }
1154
- const ocrText = await recognizePage(page, i);
1155
- if (ocrText) {
1156
- ocrPages.push(i);
1157
- pageTexts.push('## PDF 第 ' + i + ' 页(OCR)\\n\\n' + ocrText);
1158
- } else if (text) {
1159
- pageTexts.push('## PDF 第 ' + i + ' 页(低质量文本流)\\n\\n' + text);
1160
- } else {
1161
- failedPages.push({ page: i, reason: 'empty_text_and_ocr' });
1162
- }
1163
- } catch (error) {
1164
- failedPages.push({ page: i, reason: error instanceof Error ? error.message : String(error) });
1165
- }
1166
- }
1167
- } finally {
1168
- if (worker) await worker.terminate();
1169
- await doc.destroy();
1170
- fs.rmSync(tmpDir, { recursive: true, force: true });
1171
- }
1172
- process.stdout.write(JSON.stringify({ pageCount, text: pageTexts.join('\\n\\n'), ocrPages, failedPages }));`,
1173
- file.absolutePath,
1174
- '80',
1175
- ], { encoding: 'utf8', timeout: 0, maxBuffer: 80 * 1024 * 1024, env: childEnv });
1176
- 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) {
1177
1053
  metadata.ocrRecommended = true;
1178
- metadata.ocrReason = result.error ? result.error.message : (result.stderr || 'PDF 混合解析子进程失败').trim();
1179
- return { text: '', metadata, warnings: [`内置 PDF 混合解析失败: ${metadata.ocrReason}`] };
1054
+ metadata.ocrReason = '无法渲染PDF页面';
1055
+ return { text: '', metadata, warnings: ['无法渲染PDF页面'] };
1180
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 = this.cleanOcrText(ocrResult.text);
1069
+ if (ocrResult.warnings?.length)
1070
+ warnings.push(...ocrResult.warnings.map(item => `OCR 警告: ${item}`));
1071
+ if (ocrText) {
1072
+ ocrPages.push(i + 1);
1073
+ ocrStrategies.push({ page: i + 1, strategy: renderer, score: this.scoreOcrText(ocrText) });
1074
+ pageTexts.push(`## PDF 第 ${i + 1} 页(OCR)\n\n${ocrText}`);
1075
+ }
1076
+ else {
1077
+ failedPages.push({ page: i + 1, reason: 'empty_ocr' });
1078
+ }
1079
+ }
1080
+ catch (error) {
1081
+ failedPages.push({ page: i + 1, reason: error instanceof Error ? error.message : String(error) });
1082
+ }
1083
+ }
1084
+ if (ocrProvider)
1085
+ await ocrProvider.dispose();
1086
+ // 清理临时文件
1087
+ try {
1088
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1089
+ }
1090
+ catch {
1091
+ warnings.push('PDF OCR 临时文件清理失败');
1092
+ }
1093
+ metadata.ocrAugmented = ocrPages.length > 0;
1094
+ metadata.ocrPages = ocrPages;
1095
+ metadata.ocrStrategies = ocrStrategies;
1096
+ metadata.failedPages = failedPages;
1097
+ metadata.ocrProvider = ocrProvider?.id ?? 'unknown';
1098
+ if (failedPages.length > 0) {
1099
+ warnings.push(`PDF 部分页解析失败: ${failedPages.map((p) => `${p.page}:${p.reason}`).join('; ')}`);
1100
+ }
1101
+ const combined = pageTexts.join('\n\n').trim();
1102
+ return {
1103
+ text: combined ? [this.metadataOnlyText(file), combined].join('\n\n') : '',
1104
+ metadata,
1105
+ warnings,
1106
+ };
1107
+ }
1108
+ /** PyMuPDF 渲染(300 DPI 原生提取,质量远高于 pdfjs-dist) */
1109
+ tryRenderWithPyMuPDF(pdfPath, outputDir) {
1181
1110
  try {
1182
- const parsed = JSON.parse(result.stdout || '{}');
1183
- const ocrPages = parsed.ocrPages ?? [];
1184
- const failedPages = parsed.failedPages ?? [];
1185
- metadata.pdfPageCount = parsed.pageCount ?? 0;
1186
- metadata.ocrAugmented = ocrPages.length > 0;
1187
- metadata.ocrPages = ocrPages;
1188
- metadata.textPages = Math.max(metadata.pdfPageCount - ocrPages.length - failedPages.length, 0);
1189
- metadata.failedPages = failedPages;
1190
- if (ocrPages.length > 0) {
1191
- metadata.pdfRenderer = 'pdfjs-dist + @napi-rs/canvas';
1192
- metadata.ocrProvider = 'tesseract.js';
1193
- metadata.ocrLanguages = 'chi_sim+eng';
1194
- metadata.imagePreprocessor = 'sharp';
1111
+ const workerScript = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts', 'render_pdf_pages.py');
1112
+ spawnSync('python3', [workerScript, pdfPath, outputDir, '300'], {
1113
+ encoding: 'utf-8', timeout: 60_000, maxBuffer: 1024 * 1024,
1114
+ });
1115
+ // 检查输出文件(即使 Python 非零退出码也可能已渲染部分页面)
1116
+ const images = [];
1117
+ for (let i = 1; i <= 999; i++) {
1118
+ const p = path.join(outputDir, `page-${i}.png`);
1119
+ if (fs.existsSync(p) && fs.statSync(p).size > 100)
1120
+ images.push(p);
1121
+ else
1122
+ break;
1195
1123
  }
1196
- if (failedPages.length > 0)
1197
- warnings.push(`PDF 部分页解析失败: ${failedPages.map(p => `${p.page}:${p.reason}`).join('; ')}`);
1198
- return { text: parsed.text?.trim() ? [this.metadataOnlyText(file), parsed.text.trim()].join('\n\n') : '', metadata, warnings };
1124
+ return images.length > 0 ? images : null;
1199
1125
  }
1200
- catch (error) {
1201
- metadata.ocrRecommended = true;
1202
- metadata.ocrReason = `PDF 混合解析结果解析失败: ${error instanceof Error ? error.message : String(error)}`;
1203
- return { text: '', metadata, warnings: [`内置 PDF 混合解析输出异常:${metadata.ocrReason}`] };
1126
+ catch {
1127
+ return null;
1204
1128
  }
1205
1129
  }
1130
+ /** pdfjs-dist + canvas 渲染(降级方案) */
1131
+ async tryRenderWithPdfJs(file, outputDir) {
1132
+ try {
1133
+ const canvasMod = await resolveAndImport('@napi-rs/canvas');
1134
+ const pdfjsLib = await resolveAndImport('pdfjs-dist/legacy/build/pdf.mjs');
1135
+ const sharpMod = await resolveAndImport('sharp');
1136
+ const createCanvas = canvasMod.createCanvas ?? canvasMod.default?.createCanvas;
1137
+ const sharpFn = sharpMod.default ?? sharpMod;
1138
+ if (!createCanvas || !sharpFn)
1139
+ return null;
1140
+ const raw = fs.readFileSync(file.absolutePath);
1141
+ const doc = await pdfjsLib.getDocument({ data: new Uint8Array(raw), verbosity: 0 }).promise;
1142
+ const pageCount = doc.numPages;
1143
+ const images = [];
1144
+ for (let i = 1; i <= pageCount; i++) {
1145
+ const page = await doc.getPage(i);
1146
+ const viewport = page.getViewport({ scale: 4 });
1147
+ const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
1148
+ const ctx = canvas.getContext('2d');
1149
+ await page.render({ canvasContext: ctx, viewport }).promise;
1150
+ const pngPath = path.join(outputDir, `page-${i}.png`);
1151
+ await sharpFn(canvas.toBuffer('image/png'))
1152
+ .removeAlpha().normalize().linear(3.0, -150)
1153
+ .withMetadata({ density: 288 }).png().toFile(pngPath);
1154
+ images.push(pngPath);
1155
+ }
1156
+ await doc.destroy();
1157
+ return images.length > 0 ? images : null;
1158
+ }
1159
+ catch {
1160
+ return null;
1161
+ }
1162
+ }
1163
+ scoreOcrText(value) {
1164
+ const text = String(value ?? '').trim();
1165
+ const normalizedLength = this.normalizedTextLength(text);
1166
+ const cjkCount = (text.match(/[\p{Script=Han}]/gu) ?? []).length;
1167
+ const latinCount = (text.match(/[A-Za-z]/g) ?? []).length;
1168
+ const replacementCount = (text.match(/[�□]/gu) ?? []).length;
1169
+ return cjkCount * 8 + normalizedLength - latinCount * 0.8 - replacementCount * 10;
1170
+ }
1171
+ cleanOcrText(value) {
1172
+ return String(value ?? '')
1173
+ .replace(/[ \t]+/gu, ' ')
1174
+ .replace(/([\p{Script=Han}])\s+([\p{Script=Han}])/gu, '$1$2')
1175
+ .replace(/([\p{Script=Han}])\s+([,。;:!?、)】》])/gu, '$1$2')
1176
+ .replace(/([(【《])\s+([\p{Script=Han}])/gu, '$1$2')
1177
+ .replace(/\n{3,}/gu, '\n\n')
1178
+ .trim();
1179
+ }
1180
+ /** 加载图片像素数据(依赖 sharp) */
1181
+ async loadImagePixels(filePath) {
1182
+ const sharpMod = await resolveAndImport('sharp');
1183
+ const sharpFn = sharpMod.default ?? sharpMod;
1184
+ const { data, info } = await sharpFn(filePath).raw().toBuffer({ resolveWithObject: true });
1185
+ return { data: new Uint8Array(data), width: info.width, height: info.height };
1186
+ }
1206
1187
  async extractPdfText(buffer) {
1207
1188
  let pdfjsText = '';
1208
1189
  // 第一层:pdfjs-dist 文本提取(处理压缩内容流、CJK 字体、现代 PDF)
@@ -1229,15 +1210,26 @@ process.stdout.write(JSON.stringify({ pageCount, text: pageTexts.join('\\n\\n'),
1229
1210
  if (process.env.KB_DEBUG === '1')
1230
1211
  console.warn('[kb] pdfjs-dist extraction failed:', e.message);
1231
1212
  }
1232
- // 第二层:pdf-parse(兼容旧版 PDF),与 pdfjs 结果互补,避免单一解析器漏字
1213
+ // 第二层:pdf-parse(兼容旧版 PDF),适配 v1.x 函数导出 和 v2.x 类导出
1233
1214
  try {
1234
1215
  const mod = await resolveAndImport('pdf-parse');
1235
- const pdfParse = typeof mod === 'function'
1236
- ? mod
1237
- : mod.default;
1216
+ let pdfParse;
1217
+ // v1.x: module.exports = function(buffer) { ... }
1218
+ if (typeof mod === 'function') {
1219
+ pdfParse = mod;
1220
+ }
1221
+ // v1.x ESM: { default: function(buffer) { ... } }
1222
+ else if (mod && typeof mod.default === 'function') {
1223
+ pdfParse = mod.default;
1224
+ }
1225
+ // v2.x: { PDFParse: class { parse(buffer) { ... } } }
1226
+ else if (mod && typeof mod.PDFParse === 'function') {
1227
+ const PDFParse = mod.PDFParse;
1228
+ pdfParse = (buf) => new PDFParse().parse(buf);
1229
+ }
1238
1230
  if (pdfParse) {
1239
1231
  const result = await pdfParse(buffer);
1240
- const parseText = result.text.trim();
1232
+ const parseText = (result?.text ?? '').trim();
1241
1233
  if (pdfjsText && parseText && this.normalizedTextLength(parseText) > this.normalizedTextLength(pdfjsText) * 1.08)
1242
1234
  return [pdfjsText, '## PDF 备用解析文本', parseText].join('\n\n');
1243
1235
  if (parseText && !pdfjsText)
@@ -0,0 +1,55 @@
1
+ /**
2
+ * OCR 提供者 — tesseract.js 跨平台 WASM
3
+ *
4
+ * 使用 tesseract.js v7(WASM),bundled traineddata,
5
+ * 真正跨平台(Windows/macOS/Linux),无需系统依赖。
6
+ */
7
+ export interface OcrRegion {
8
+ text: string;
9
+ confidence: number;
10
+ box: {
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ };
16
+ }
17
+ export interface OcrResult {
18
+ text: string;
19
+ confidence: number;
20
+ regions: OcrRegion[];
21
+ warnings?: string[];
22
+ }
23
+ export interface OcrProvider {
24
+ readonly id: string;
25
+ readonly available: boolean;
26
+ recognize(input: {
27
+ data: Uint8Array;
28
+ width: number;
29
+ height: number;
30
+ channels?: number;
31
+ filePath?: string;
32
+ }): Promise<OcrResult>;
33
+ getWarnings?(): string[];
34
+ dispose(): Promise<void>;
35
+ }
36
+ export declare class TesseractJsProvider implements OcrProvider {
37
+ readonly id = "tesseract.js";
38
+ private _available;
39
+ private worker;
40
+ private workerPromise;
41
+ private warnings;
42
+ get available(): boolean;
43
+ recognize(input: {
44
+ data: Uint8Array;
45
+ width: number;
46
+ height: number;
47
+ channels?: number;
48
+ filePath?: string;
49
+ }): Promise<OcrResult>;
50
+ getWarnings(): string[];
51
+ private getWorker;
52
+ private createReusableWorker;
53
+ dispose(): Promise<void>;
54
+ }
55
+ export declare function createOcrProvider(): Promise<OcrProvider>;