@bill_nee/dwg-reader 1.2.0 → 1.4.0
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/lib/index.js +324 -0
- package/package.json +14 -3
- package/scripts/ocr_image.py +70 -0
package/lib/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { PDFParse } from "pdf-parse";
|
|
3
4
|
import { LibreDwg, Dwg_File_Type, DwgCodePage } from "../vendor/libredwg-web/lib/index.js";
|
|
4
5
|
|
|
5
6
|
const WASM_DIR = fileURLToPath(new URL("../vendor/libredwg-web/wasm", import.meta.url));
|
|
@@ -7,6 +8,8 @@ const MAX_DWG_BYTES = 512 * 1024 * 1024; // 512 MB 上限,超大图纸会先
|
|
|
7
8
|
const MAX_ENTITY_DETAIL = 2000;
|
|
8
9
|
const PYTHON_EXE = "D:\\dsh-home\\dsh-runtimes\\dsh-primary-runtime\\dependencies\\python\\python.exe";
|
|
9
10
|
const DXF_SCRIPT = fileURLToPath(new URL("../scripts/read_dxf.py", import.meta.url));
|
|
11
|
+
const OCR_SCRIPT = fileURLToPath(new URL("../scripts/ocr_image.py", import.meta.url));
|
|
12
|
+
const MAX_PDF_BYTES = 100 * 1024 * 1024; // 100 MB PDF 上限
|
|
10
13
|
|
|
11
14
|
let libreDwgPromise;
|
|
12
15
|
|
|
@@ -568,8 +571,329 @@ export const inject = ["tools", "fs", "workingDirectory"];
|
|
|
568
571
|
export function apply(ctx, config) {
|
|
569
572
|
const disposeDwg = ctx.tools.register(buildReadDwgTool(ctx));
|
|
570
573
|
const disposeDxf = ctx.tools.register(buildReadDxfTool(ctx));
|
|
574
|
+
const disposePdf = ctx.tools.register(buildReadPdfTool(ctx));
|
|
575
|
+
const disposeImage = ctx.tools.register(buildReadImageTool(ctx));
|
|
571
576
|
return () => {
|
|
572
577
|
disposeDwg();
|
|
573
578
|
disposeDxf();
|
|
579
|
+
disposePdf();
|
|
580
|
+
disposeImage();
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function buildReadImageTool(ctx) {
|
|
585
|
+
return {
|
|
586
|
+
name: "read_image",
|
|
587
|
+
description:
|
|
588
|
+
"读取图片/截屏文件(尺寸、格式、元数据 + Tesseract OCR 文字提取,无需安装图片查看软件)。" +
|
|
589
|
+
"支持 PNG/JPEG/WebP/BMP/TIFF;OCR 语言为简体中文+英文。",
|
|
590
|
+
parameters: {
|
|
591
|
+
type: "object",
|
|
592
|
+
additionalProperties: true,
|
|
593
|
+
properties: {
|
|
594
|
+
file_path: {
|
|
595
|
+
type: "string",
|
|
596
|
+
description: "图片文件路径(绝对路径或相对当前工作目录)。",
|
|
597
|
+
},
|
|
598
|
+
ocr: {
|
|
599
|
+
type: "boolean",
|
|
600
|
+
description: "是否执行 OCR 文字提取(默认 true)。",
|
|
601
|
+
},
|
|
602
|
+
tessdata_prefix: {
|
|
603
|
+
type: "string",
|
|
604
|
+
description: "Tesseract tessdata 目录(可选;缺省使用系统默认)。",
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
required: ["file_path"],
|
|
608
|
+
},
|
|
609
|
+
output: {
|
|
610
|
+
schema: {
|
|
611
|
+
type: "object",
|
|
612
|
+
additionalProperties: true,
|
|
613
|
+
properties: {
|
|
614
|
+
path: { type: "string", description: "解析后的文件路径。" },
|
|
615
|
+
fileName: { type: "string", description: "文件名。" },
|
|
616
|
+
fileSizeBytes: { type: "integer", description: "文件字节数。" },
|
|
617
|
+
format: { type: "string", description: "图片格式(如 PNG/JPEG)。" },
|
|
618
|
+
width: { type: "integer", description: "图片宽度(像素)。" },
|
|
619
|
+
height: { type: "integer", description: "图片高度(像素)。" },
|
|
620
|
+
mode: { type: "string", description: "颜色模式(如 RGB/RGBA)。" },
|
|
621
|
+
ocrText: { type: "string", description: "OCR 提取的文字(可能为空)。" },
|
|
622
|
+
ocrError: { type: "string", description: "OCR 错误信息;成功时为 null。" },
|
|
623
|
+
error: { type: "string", description: "错误信息;成功时为 null。" },
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
render: (args, value) => {
|
|
627
|
+
const lines = [];
|
|
628
|
+
lines.push(`<path>${value.path}</path>`);
|
|
629
|
+
lines.push(`<type>image</type>`);
|
|
630
|
+
if (value.error) {
|
|
631
|
+
lines.push(`<content>读取图片失败:${value.error}</content>`);
|
|
632
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
633
|
+
}
|
|
634
|
+
const sizeKb = (value.fileSizeBytes / 1024).toFixed(1);
|
|
635
|
+
lines.push(`<content>已解析图片:${value.fileName}(${sizeKb} KB)`);
|
|
636
|
+
lines.push(`格式:${value.format};尺寸:${value.width}×${value.height};模式:${value.mode}`);
|
|
637
|
+
if (value.ocrError) lines.push(`OCR 错误:${value.ocrError}`);
|
|
638
|
+
if (value.ocrText) {
|
|
639
|
+
const preview = value.ocrText.slice(0, 300);
|
|
640
|
+
lines.push(`OCR 文字(${value.ocrText.length} 字符):${preview}${value.ocrText.length > 300 ? "…" : ""}`);
|
|
641
|
+
} else if (!value.ocrError) {
|
|
642
|
+
lines.push("OCR 文字:无(未检测到可识别文字或 OCR 未启用)");
|
|
643
|
+
}
|
|
644
|
+
lines.push(`</content>`);
|
|
645
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
646
|
+
},
|
|
647
|
+
presentationMeta: () => null,
|
|
648
|
+
},
|
|
649
|
+
isConcurrencySafe: () => false,
|
|
650
|
+
presentCall: (args) => ({
|
|
651
|
+
card: "generic",
|
|
652
|
+
title: `读取图片 ${args.file_path || "(未指定路径)"}`,
|
|
653
|
+
kind: "read",
|
|
654
|
+
locations: [{ path: args.file_path || "" }],
|
|
655
|
+
}),
|
|
656
|
+
presentResult: (args, result) => {
|
|
657
|
+
if (result && result.isError) return undefined;
|
|
658
|
+
return {
|
|
659
|
+
card: "generic",
|
|
660
|
+
title: `图片解析完成:${args.file_path || ""}`,
|
|
661
|
+
kind: "read",
|
|
662
|
+
locations: [{ path: args.file_path || "" }],
|
|
663
|
+
};
|
|
664
|
+
},
|
|
665
|
+
async execute(args, exec) {
|
|
666
|
+
if (typeof args !== "object" || args === null) {
|
|
667
|
+
throw new Error('invalid arguments: expected an object with a "file_path" string');
|
|
668
|
+
}
|
|
669
|
+
if (typeof args.file_path !== "string" || !args.file_path.trim()) {
|
|
670
|
+
throw new Error('invalid arguments: "file_path" must be a non-empty string');
|
|
671
|
+
}
|
|
672
|
+
if (args.ocr !== undefined && typeof args.ocr !== "boolean") {
|
|
673
|
+
throw new Error('invalid arguments: "ocr" must be a boolean');
|
|
674
|
+
}
|
|
675
|
+
if (args.tessdata_prefix !== undefined && typeof args.tessdata_prefix !== "string") {
|
|
676
|
+
throw new Error('invalid arguments: "tessdata_prefix" must be a string');
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const resolveOptions = await sessionResolveOptions(ctx, exec);
|
|
680
|
+
const target = await ctx.fs.resolve(args.file_path.trim(), resolveOptions);
|
|
681
|
+
const info = await ctx.fs.stat(target, exec.signal);
|
|
682
|
+
const displayPath = ctx.fs.processPath(target);
|
|
683
|
+
if (!info) {
|
|
684
|
+
ctx.emit("fs/observed", target, { kind: "absent" }, exec);
|
|
685
|
+
throw new Error(`cannot read "${displayPath}": not found`);
|
|
686
|
+
}
|
|
687
|
+
if (info.type !== "file") {
|
|
688
|
+
throw new Error(`cannot read "${displayPath}": not a regular file`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const pythonArgs = [
|
|
692
|
+
OCR_SCRIPT,
|
|
693
|
+
args.file_path.trim(),
|
|
694
|
+
"D:\\Tools\\Tesseract-OCR\\tesseract.exe",
|
|
695
|
+
];
|
|
696
|
+
|
|
697
|
+
const result = await new Promise((resolve, reject) => {
|
|
698
|
+
const proc = spawn(PYTHON_EXE, pythonArgs, { stdio: ["pipe", "pipe", "pipe"] });
|
|
699
|
+
let stdout = "";
|
|
700
|
+
let stderr = "";
|
|
701
|
+
proc.stdout.on("data", (d) => (stdout += d.toString()));
|
|
702
|
+
proc.stderr.on("data", (d) => (stderr += d.toString()));
|
|
703
|
+
proc.on("close", (code) => {
|
|
704
|
+
if (code !== 0) {
|
|
705
|
+
reject(new Error(`Python 进程退出码 ${code}: ${stderr || stdout}`));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
resolve(JSON.parse(stdout));
|
|
710
|
+
} catch (err) {
|
|
711
|
+
reject(new Error(`OCR 结果解析失败: ${err.message}; stdout=${stdout.slice(0, 200)}`));
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
proc.on("error", (err) => reject(new Error(`无法启动 Python 进程: ${err.message}`)));
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
if (result.error) {
|
|
718
|
+
throw new Error(`cannot read "${displayPath}": ${result.error}`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
ctx.emit("fs/observed", target, { kind: "present", version: info.version }, exec);
|
|
722
|
+
|
|
723
|
+
return toJson({
|
|
724
|
+
path: displayPath,
|
|
725
|
+
fileName: displayPath.split(/[\\/]/).pop(),
|
|
726
|
+
fileSizeBytes: result.fileSizeBytes ?? info.size !== undefined ? info.size : 0,
|
|
727
|
+
format: result.format ?? "",
|
|
728
|
+
width: result.width ?? null,
|
|
729
|
+
height: result.height ?? null,
|
|
730
|
+
mode: result.mode ?? null,
|
|
731
|
+
ocrText: result.ocrText ?? "",
|
|
732
|
+
ocrError: result.ocrError ?? null,
|
|
733
|
+
});
|
|
734
|
+
},
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function buildReadPdfTool(ctx) {
|
|
739
|
+
return {
|
|
740
|
+
name: "read_pdf",
|
|
741
|
+
description:
|
|
742
|
+
"读取 PDF 文件(基于 pdf-parse,无需安装 Adobe Acrobat)。" +
|
|
743
|
+
"返回 PDF 页数、元数据(标题/作者/创建日期等)与可选全文文本。",
|
|
744
|
+
parameters: {
|
|
745
|
+
type: "object",
|
|
746
|
+
additionalProperties: true,
|
|
747
|
+
properties: {
|
|
748
|
+
file_path: {
|
|
749
|
+
type: "string",
|
|
750
|
+
description: "PDF 文件路径(绝对路径或相对当前工作目录)。",
|
|
751
|
+
},
|
|
752
|
+
extract_text: {
|
|
753
|
+
type: "boolean",
|
|
754
|
+
description: "是否提取全文文本(默认 true)。",
|
|
755
|
+
},
|
|
756
|
+
max_text_chars: {
|
|
757
|
+
type: "integer",
|
|
758
|
+
description: "提取文本的最大字符数(默认 50000,0 表示不限制)。",
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
required: ["file_path"],
|
|
762
|
+
},
|
|
763
|
+
output: {
|
|
764
|
+
schema: {
|
|
765
|
+
type: "object",
|
|
766
|
+
additionalProperties: true,
|
|
767
|
+
properties: {
|
|
768
|
+
path: { type: "string", description: "解析后的文件路径。" },
|
|
769
|
+
fileName: { type: "string", description: "文件名。" },
|
|
770
|
+
fileSizeBytes: { type: "integer", description: "文件字节数。" },
|
|
771
|
+
format: { type: "string", description: "文件格式(固定为 pdf)。" },
|
|
772
|
+
pageCount: { type: "integer", description: "PDF 页数。" },
|
|
773
|
+
metadata: { type: "object", additionalProperties: true, description: "PDF 元数据(标题、作者、创建日期等)。" },
|
|
774
|
+
text: { type: "string", description: "提取的全文文本(可能被截断)。" },
|
|
775
|
+
textLength: { type: "integer", description: "提取文本的字符数。" },
|
|
776
|
+
textTruncated: { type: "boolean", description: "文本是否被截断。" },
|
|
777
|
+
error: { type: "string", description: "错误信息;成功时为 null。" },
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
render: (args, value) => {
|
|
781
|
+
const lines = [];
|
|
782
|
+
lines.push(`<path>${value.path}</path>`);
|
|
783
|
+
lines.push(`<type>pdf</type>`);
|
|
784
|
+
if (value.error) {
|
|
785
|
+
lines.push(`<content>读取 PDF 失败:${value.error}</content>`);
|
|
786
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
787
|
+
}
|
|
788
|
+
const sizeMb = (value.fileSizeBytes / 1024 / 1024).toFixed(2);
|
|
789
|
+
lines.push(`<content>已解析 PDF:${value.fileName}(${sizeMb} MB)`);
|
|
790
|
+
lines.push(`页数:${value.pageCount}`);
|
|
791
|
+
if (value.metadata && Object.keys(value.metadata).length) {
|
|
792
|
+
const metaLines = Object.entries(value.metadata)
|
|
793
|
+
.filter(([, v]) => v != null && String(v).trim())
|
|
794
|
+
.map(([k, v]) => ` ${k}: ${v}`)
|
|
795
|
+
.join("\n");
|
|
796
|
+
if (metaLines) lines.push(`元数据:\n${metaLines}`);
|
|
797
|
+
}
|
|
798
|
+
lines.push(`文本长度:${value.textLength} 字符${value.textTruncated ? "(已截断)" : ""}`);
|
|
799
|
+
if (value.text) {
|
|
800
|
+
const preview = value.text.slice(0, 300);
|
|
801
|
+
lines.push(`文本预览:${preview}${value.text.length > 300 ? "…" : ""}`);
|
|
802
|
+
}
|
|
803
|
+
lines.push(`</content>`);
|
|
804
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
805
|
+
},
|
|
806
|
+
presentationMeta: () => null,
|
|
807
|
+
},
|
|
808
|
+
isConcurrencySafe: () => false,
|
|
809
|
+
presentCall: (args) => ({
|
|
810
|
+
card: "generic",
|
|
811
|
+
title: `读取 PDF ${args.file_path || "(未指定路径)"}`,
|
|
812
|
+
kind: "read",
|
|
813
|
+
locations: [{ path: args.file_path || "" }],
|
|
814
|
+
}),
|
|
815
|
+
presentResult: (args, result) => {
|
|
816
|
+
if (result && result.isError) return undefined;
|
|
817
|
+
return {
|
|
818
|
+
card: "generic",
|
|
819
|
+
title: `PDF 解析完成:${args.file_path || ""}`,
|
|
820
|
+
kind: "read",
|
|
821
|
+
locations: [{ path: args.file_path || "" }],
|
|
822
|
+
};
|
|
823
|
+
},
|
|
824
|
+
async execute(args, exec) {
|
|
825
|
+
if (typeof args !== "object" || args === null) {
|
|
826
|
+
throw new Error('invalid arguments: expected an object with a "file_path" string');
|
|
827
|
+
}
|
|
828
|
+
if (typeof args.file_path !== "string" || !args.file_path.trim()) {
|
|
829
|
+
throw new Error('invalid arguments: "file_path" must be a non-empty string');
|
|
830
|
+
}
|
|
831
|
+
if (args.extract_text !== undefined && typeof args.extract_text !== "boolean") {
|
|
832
|
+
throw new Error('invalid arguments: "extract_text" must be a boolean');
|
|
833
|
+
}
|
|
834
|
+
if (args.max_text_chars !== undefined && (typeof args.max_text_chars !== "number" || !Number.isInteger(args.max_text_chars))) {
|
|
835
|
+
throw new Error('invalid arguments: "max_text_chars" must be an integer');
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const resolveOptions = await sessionResolveOptions(ctx, exec);
|
|
839
|
+
const target = await ctx.fs.resolve(args.file_path.trim(), resolveOptions);
|
|
840
|
+
const info = await ctx.fs.stat(target, exec.signal);
|
|
841
|
+
const displayPath = ctx.fs.processPath(target);
|
|
842
|
+
if (!info) {
|
|
843
|
+
ctx.emit("fs/observed", target, { kind: "absent" }, exec);
|
|
844
|
+
throw new Error(`cannot read "${displayPath}": not found`);
|
|
845
|
+
}
|
|
846
|
+
if (info.type !== "file") {
|
|
847
|
+
throw new Error(`cannot read "${displayPath}": not a regular file`);
|
|
848
|
+
}
|
|
849
|
+
if (info.size !== undefined && info.size > MAX_PDF_BYTES) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
`cannot read "${displayPath}": file is ${(info.size / 1024 / 1024).toFixed(1)} MB, ` +
|
|
852
|
+
`超过 ${MAX_PDF_BYTES / 1024 / 1024} MB 上限`
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
const bytes = await ctx.fs.readBytes(target, exec.signal, MAX_PDF_BYTES);
|
|
856
|
+
ctx.emit("fs/observed", target, { kind: "present", version: info.version }, exec);
|
|
857
|
+
|
|
858
|
+
let parser;
|
|
859
|
+
try {
|
|
860
|
+
parser = new PDFParse({ data: bytes });
|
|
861
|
+
} catch (error) {
|
|
862
|
+
throw new Error(`cannot parse "${displayPath}": 初始化 PDF 解析器失败: ${error.message}`);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
let text = "";
|
|
866
|
+
let textTruncated = false;
|
|
867
|
+
let pageCount = 0;
|
|
868
|
+
let metadata = {};
|
|
869
|
+
try {
|
|
870
|
+
const infoResult = await parser.getInfo();
|
|
871
|
+
pageCount = infoResult.info?.Pages != null ? Number(infoResult.info.Pages) : 0;
|
|
872
|
+
metadata = infoResult.metadata ?? {};
|
|
873
|
+
if (args.extract_text !== false) {
|
|
874
|
+
const textResult = await parser.getText();
|
|
875
|
+
text = textResult?.text ?? "";
|
|
876
|
+
const maxChars = args.max_text_chars ?? 50000;
|
|
877
|
+
if (maxChars > 0 && text.length > maxChars) {
|
|
878
|
+
text = text.slice(0, maxChars);
|
|
879
|
+
textTruncated = true;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
} catch (error) {
|
|
883
|
+
throw new Error(`cannot parse "${displayPath}": PDF 内容提取失败: ${error.message}`);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return toJson({
|
|
887
|
+
path: displayPath,
|
|
888
|
+
fileName: displayPath.split(/[\\/]/).pop(),
|
|
889
|
+
fileSizeBytes: info.size !== undefined ? info.size : bytes.byteLength,
|
|
890
|
+
format: "pdf",
|
|
891
|
+
pageCount,
|
|
892
|
+
metadata,
|
|
893
|
+
text,
|
|
894
|
+
textLength: text.length,
|
|
895
|
+
textTruncated,
|
|
896
|
+
});
|
|
897
|
+
},
|
|
574
898
|
};
|
|
575
899
|
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bill_nee/dwg-reader",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "直接读取 AutoCAD DWG/DXF
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "直接读取 AutoCAD DWG/DXF、PDF 与图片/截屏的 DSH 插件(DWG 基于 libredwg-web 0.8.2 WASM,DXF 基于 Python ezdxf 桥接,PDF 基于 pdf-parse,图片基于 Pillow+Tesseract OCR,GPL-3.0)。",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"author": "MEB 工程造价项目助手 <meb@example.com>",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/NIDEXIANG/dwg-reader-plugin-20261029.git"
|
|
10
10
|
},
|
|
11
|
-
"keywords": [
|
|
11
|
+
"keywords": [
|
|
12
|
+
"dwg",
|
|
13
|
+
"dwg-reader",
|
|
14
|
+
"autocad",
|
|
15
|
+
"cad",
|
|
16
|
+
"dsh-plugin",
|
|
17
|
+
"libredwg",
|
|
18
|
+
"wasm"
|
|
19
|
+
],
|
|
12
20
|
"type": "module",
|
|
13
21
|
"engines": {
|
|
14
22
|
"node": ">=20"
|
|
@@ -32,5 +40,8 @@
|
|
|
32
40
|
"bundle": {
|
|
33
41
|
"patch": "./cordis.patch.yml"
|
|
34
42
|
}
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"pdf-parse": "^2.4.5"
|
|
35
46
|
}
|
|
36
47
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""对图片进行 OCR 文字提取(供 DSH dwg-reader 插件调用,直接调用 tesseract 命令行)。"""
|
|
3
|
+
import sys
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
IMAGE_PATH = sys.argv[1] if len(sys.argv) > 1 else None
|
|
10
|
+
TESSERACT_CMD = sys.argv[2] if len(sys.argv) > 2 else r"D:\Tools\Tesseract-OCR\tesseract.exe"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
if not IMAGE_PATH or not os.path.exists(IMAGE_PATH):
|
|
15
|
+
print(json.dumps({"error": f"文件不存在: {IMAGE_PATH}"}))
|
|
16
|
+
sys.exit(1)
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from PIL import Image
|
|
20
|
+
except ImportError as exc:
|
|
21
|
+
print(json.dumps({"error": f"Pillow 未安装: {exc}"}))
|
|
22
|
+
sys.exit(1)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
image = Image.open(IMAGE_PATH)
|
|
26
|
+
except Exception as exc:
|
|
27
|
+
print(json.dumps({"error": f"无法打开图片: {exc}"}))
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
|
|
30
|
+
width, height = image.size
|
|
31
|
+
fmt = image.format
|
|
32
|
+
mode = image.mode
|
|
33
|
+
|
|
34
|
+
# 调用 tesseract 命令行做 OCR
|
|
35
|
+
ocr_text = ""
|
|
36
|
+
ocr_error = None
|
|
37
|
+
try:
|
|
38
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
39
|
+
out_base = os.path.join(tmpdir, "ocr_out")
|
|
40
|
+
cmd = [TESSERACT_CMD, IMAGE_PATH, out_base, "-l", "chi_sim+eng"]
|
|
41
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
42
|
+
if proc.returncode != 0:
|
|
43
|
+
ocr_error = proc.stderr.strip() or f"tesseract 退出码 {proc.returncode}"
|
|
44
|
+
else:
|
|
45
|
+
out_txt = out_base + ".txt"
|
|
46
|
+
if os.path.exists(out_txt):
|
|
47
|
+
with open(out_txt, "r", encoding="utf-8", errors="replace") as f:
|
|
48
|
+
ocr_text = f.read().strip()
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
ocr_error = str(exc)
|
|
51
|
+
|
|
52
|
+
result = {
|
|
53
|
+
"path": os.path.abspath(IMAGE_PATH),
|
|
54
|
+
"fileName": os.path.basename(IMAGE_PATH),
|
|
55
|
+
"fileSizeBytes": os.path.getsize(IMAGE_PATH),
|
|
56
|
+
"format": fmt,
|
|
57
|
+
"width": width,
|
|
58
|
+
"height": height,
|
|
59
|
+
"mode": mode,
|
|
60
|
+
"ocrText": ocr_text,
|
|
61
|
+
"ocrError": ocr_error,
|
|
62
|
+
}
|
|
63
|
+
# Windows 控制台默认 GBK,改为 UTF-8 输出到 stdout.buffer 避免编码错误
|
|
64
|
+
sys.stdout.buffer.write(json.dumps(result, ensure_ascii=False).encode("utf-8"))
|
|
65
|
+
sys.stdout.buffer.write(b"\n")
|
|
66
|
+
sys.stdout.buffer.flush()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|