@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.
- package/dist/extraction/content-extractor.d.ts +8 -3
- package/dist/extraction/content-extractor.js +227 -235
- package/dist/extraction/ocr-providers.d.ts +55 -0
- package/dist/extraction/ocr-providers.js +134 -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 +4 -1
- package/scripts/download-paddleocr-models.sh +63 -0
- package/scripts/render_pdf_pages.py +29 -0
|
@@ -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
|
|
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: '
|
|
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
|
-
|
|
873
|
-
|
|
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 =
|
|
877
|
-
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 };
|
|
878
881
|
}
|
|
879
|
-
|
|
882
|
+
// 2. 加载图片像素数据
|
|
883
|
+
let imageData;
|
|
880
884
|
try {
|
|
881
|
-
|
|
885
|
+
imageData = await this.loadImagePixels(file.absolutePath);
|
|
882
886
|
}
|
|
883
887
|
catch (e) {
|
|
884
|
-
metadata.contentCoverage = '
|
|
888
|
+
metadata.contentCoverage = 'image_decode_failed';
|
|
885
889
|
metadata.parseError = e.message;
|
|
886
|
-
return { text: '', metadata, warnings: [
|
|
887
|
-
}
|
|
888
|
-
//
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
const
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
1050
|
-
let
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
|
1062
|
-
return { text: '', metadata, warnings: [
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
if (
|
|
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 =
|
|
1179
|
-
return { text: '', metadata, warnings: [
|
|
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
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
-
|
|
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
|
|
1201
|
-
|
|
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
|
|
1213
|
+
// 第二层:pdf-parse(兼容旧版 PDF),适配 v1.x 函数导出 和 v2.x 类导出
|
|
1233
1214
|
try {
|
|
1234
1215
|
const mod = await resolveAndImport('pdf-parse');
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
|
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>;
|