@customize-agent/knowledge 4.0.14 → 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/extraction/content-extractor.d.ts +7 -3
- package/dist/extraction/content-extractor.js +214 -235
- 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 +4 -1
- package/scripts/download-paddleocr-models.sh +63 -0
- package/scripts/render_pdf_pages.py +29 -0
|
@@ -48,13 +48,17 @@ 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
|
+
/** 加载图片像素数据(依赖 sharp) */
|
|
61
|
+
private loadImagePixels;
|
|
58
62
|
private extractPdfText;
|
|
59
63
|
private normalizedTextLength;
|
|
60
64
|
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,165 +1008,168 @@ 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 = 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
|
+
// 清理临时文件
|
|
1083
|
+
try {
|
|
1084
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
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) {
|
|
1181
1106
|
try {
|
|
1182
|
-
const
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
metadata.imagePreprocessor = 'sharp';
|
|
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;
|
|
1195
1119
|
}
|
|
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 };
|
|
1120
|
+
return images.length > 0 ? images : null;
|
|
1199
1121
|
}
|
|
1200
|
-
catch
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1122
|
+
catch {
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
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;
|
|
1204
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 };
|
|
1205
1173
|
}
|
|
1206
1174
|
async extractPdfText(buffer) {
|
|
1207
1175
|
let pdfjsText = '';
|
|
@@ -1229,15 +1197,26 @@ process.stdout.write(JSON.stringify({ pageCount, text: pageTexts.join('\\n\\n'),
|
|
|
1229
1197
|
if (process.env.KB_DEBUG === '1')
|
|
1230
1198
|
console.warn('[kb] pdfjs-dist extraction failed:', e.message);
|
|
1231
1199
|
}
|
|
1232
|
-
// 第二层:pdf-parse(兼容旧版 PDF
|
|
1200
|
+
// 第二层:pdf-parse(兼容旧版 PDF),适配 v1.x 函数导出 和 v2.x 类导出
|
|
1233
1201
|
try {
|
|
1234
1202
|
const mod = await resolveAndImport('pdf-parse');
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
+
}
|
|
1238
1217
|
if (pdfParse) {
|
|
1239
1218
|
const result = await pdfParse(buffer);
|
|
1240
|
-
const parseText = result
|
|
1219
|
+
const parseText = (result?.text ?? '').trim();
|
|
1241
1220
|
if (pdfjsText && parseText && this.normalizedTextLength(parseText) > this.normalizedTextLength(pdfjsText) * 1.08)
|
|
1242
1221
|
return [pdfjsText, '## PDF 备用解析文本', parseText].join('\n\n');
|
|
1243
1222
|
if (parseText && !pdfjsText)
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
}
|
|
22
|
+
export interface OcrProvider {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly available: boolean;
|
|
25
|
+
recognize(input: {
|
|
26
|
+
data: Uint8Array;
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
channels?: number;
|
|
30
|
+
filePath?: string;
|
|
31
|
+
}): Promise<OcrResult>;
|
|
32
|
+
dispose(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export declare class TesseractJsProvider implements OcrProvider {
|
|
35
|
+
readonly id = "tesseract.js";
|
|
36
|
+
private _available;
|
|
37
|
+
get available(): boolean;
|
|
38
|
+
recognize(input: {
|
|
39
|
+
data: Uint8Array;
|
|
40
|
+
width: number;
|
|
41
|
+
height: number;
|
|
42
|
+
channels?: number;
|
|
43
|
+
filePath?: string;
|
|
44
|
+
}): Promise<OcrResult>;
|
|
45
|
+
dispose(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export declare function createOcrProvider(): Promise<OcrProvider>;
|