@bill_nee/dwg-reader 1.4.0 → 1.5.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/README.md CHANGED
@@ -1,18 +1,42 @@
1
1
  # @bill_nee/dwg-reader
2
2
 
3
- DSH(DeepSeek Harness)插件:可直接读取 AutoCAD DWG 格式图纸,无需安装 AutoCAD。
3
+ DSH(DeepSeek Harness)插件:直接读取 DWG/DXF 图纸、PDF 与图片/截屏,无需安装 AutoCAD、Tesseract 等外部软件。
4
4
 
5
- 基于 [libredwg-web](https://github.com/mlightcad/libredwg-web) 0.8.2 WASM 编译,在浏览器/Node 环境中解析 DWG 文件,返回图层、实体、版本、编码、图幅等信息,并可导出 SVG 预览。
5
+ 基于 [libredwg-web](https://github.com/mlightcad/libredwg-web) 0.8.2 WASM 解析 DWG;DXF 经 Python ezdxf 桥接;PDF 用 pdf-parse;图片 OCR 使用内置 **tesseract.js(WASM)**,语言包可指向本地 tessdata,也可按需联网下载,支持 Tesseract 全量语言(100+ 种)。
6
6
 
7
7
  ## 安装
8
8
 
9
9
  在 DSH 工作台或终端执行:
10
10
 
11
11
  ```powershell
12
- dsh plugin --profile desktop add @bill_nee/dwg-reader@1.1.0
12
+ dsh plugin --profile desktop add @bill_nee/dwg-reader@1.5.0
13
13
  ```
14
14
 
15
- 安装后在 DSH 工作台插件管理中启用插件,即可使用 `read_dwg` 工具读取 DWG 图纸。
15
+ 安装后在 DSH 工作台插件管理中启用插件,即可使用 `read_dwg`、`read_dxf`、`read_pdf`、`ocr_image` 四个工具(`read_image` 为 DSH 内置看图工具,本插件用 `ocr_image` 与其并存,避免同名冲突)。
16
+
17
+ ## 工具与参数
18
+
19
+ | 工具 | 说明 |
20
+ |------|------|
21
+ | `read_dwg` | 解析 DWG:版本/代码页/图层/块/实体统计与明细,可导出 SVG 预览 |
22
+ | `read_dxf` | 解析 DXF(ezdxf 桥接):版本、图层、块、实体统计与明细 |
23
+ | `read_pdf` | 解析 PDF:页数、元数据、可选全文文本 |
24
+ | `ocr_image` | 图片/截屏 OCR:格式、像素尺寸、颜色模式 + 识别文字(PNG/JPEG/WebP/BMP/GIF/TIFF) |
25
+
26
+ `ocr_image` 关键参数:
27
+
28
+ ```json
29
+ {
30
+ "file_path": "F:\\HAYNESS 工作区1\\测试用资源\\截屏.png",
31
+ "ocr": true,
32
+ "lang": "chi_sim+eng",
33
+ "tessdata_prefix": "D:\\Tools\\Tesseract-OCR\\tessdata"
34
+ }
35
+ ```
36
+
37
+ - `lang`:Tesseract 语言码,`+` 或逗号连接,默认 `chi_sim+eng`;如 `eng`、`chi_tra`、`jpn`、`kor`、`deu`、`fra`、`rus`、`spa`、`ara`、`vie`、`tha` 等。
38
+ - `tessdata_prefix`:本地 traineddata 目录。提供后只读本地语言包、不联网;目录缺包会报错并列出缺项。
39
+ - 未提供 `tessdata_prefix` 时,依次探测 `TESSDATA_PREFIX` 环境变量、`D:\Tools\Tesseract-OCR\tessdata`、`C:\Program Files\Tesseract-OCR\tessdata`、`/usr/share/tesseract-ocr/4.00/tessdata` 等常见目录;均未命中才联网下载并缓存到系统临时目录。
16
40
 
17
41
  ## 主要能力
18
42
 
@@ -21,16 +45,15 @@ dsh plugin --profile desktop add @bill_nee/dwg-reader@1.1.0
21
45
  | 读取 DWG | 支持 AutoCAD R14~R2018 等常见版本(AC1015~AC1032) |
22
46
  | 图层信息 | 返回所有图层名称、颜色、冻结/关闭/锁定状态 |
23
47
  | 实体统计 | 返回实体总数及按类型(LINE/TEXT/INSERT/HATCH/DIMENSION 等)分类统计 |
24
- | 图纸版本 | 返回 ACADVER、版本号、版本描述(如 r2018/AC1032/AutoCAD Release 2018) |
25
- | 代码页 | 返回 DWG 代码页(如 CP_ANSI_936) |
48
+ | INSERT 明细 | 返回块名、插入点、旋转、缩放及块属性(tag/text/value) |
49
+ | 图纸版本 | 返回 ACADVER、版本号、版本描述(如 r2007/AC1021) |
26
50
  | 图幅信息 | 返回 EXTMIN/EXTMAX 边界框 |
27
51
  | 导出 SVG | 可将 DWG 内容导出为 SVG 预览文件到指定路径 |
28
- | 错误处理 | 对无效/损坏/非 DWG 文件给出明确错误提示 |
52
+ | 内置 OCR | tesseract.js WASM,无需安装 Tesseract 可执行文件;多语言、可离线 |
53
+ | 错误处理 | 对无效/损坏/非目标格式文件给出明确错误提示 |
29
54
 
30
55
  ## 使用示例
31
56
 
32
- 在 DSH 工作台调用 `read_dwg` 工具:
33
-
34
57
  ```json
35
58
  {
36
59
  "file_path": "E:\\2026 工程资料\\16 天喔蜜饯工厂\\03 CAD DESIGN\\02 HVAC DESIGN PLAN.dwg",
@@ -41,41 +64,19 @@ dsh plugin --profile desktop add @bill_nee/dwg-reader@1.1.0
41
64
  }
42
65
  ```
43
66
 
44
- 返回示例:
45
-
46
- ```json
47
- {
48
- "file_path": "E:\\...\\02 HVAC DESIGN PLAN.dwg",
49
- "file_size": 123456,
50
- "version": { "type": "r2018", "hdr": "AC1032", "description": "AutoCAD Release 2018", "version": 33 },
51
- "codepage": 39,
52
- "codepageName": "CP_ANSI_936",
53
- "layer_count": 126,
54
- "entity_count": 1514,
55
- "unknown_entity_count": 0,
56
- "extents": { "min": { "x": 0, "y": 0, "z": 0 }, "max": { "x": 266000, "y": 286000, "z": 0 } },
57
- "layers": [ { "name": "HEPA BOX", "colorIndex": 5, ... }, ... ],
58
- "entities": [
59
- { "type": "INSERT", "handle": "...", "layer": "HEPA BOX", "blockName": "...", ... },
60
- ...
61
- ],
62
- "svg_path": "F:\\HAYNESS 工作区1\\hvac-plan.svg"
63
- }
64
- ```
65
-
66
67
  ## 测试实例
67
68
 
68
- 已对真实工程图纸进行验证:
69
-
70
- - **天喔蜜饯工厂暖通图纸**(r2018/AC1032):1514 个实体,126 个图层,其中高效送风口(HEPA BOX 图层)108 个,方形散流器 18 个,射流空调 5 个。
71
- - **植本清源空调设备图**(r2007/AC1021):1762 个实体,14 个图层。
69
+ - **天喔蜜饯工厂暖通图纸**(r2018/AC1032):1514 个实体、126 个图层。
70
+ - **植本清源空调设备图**(r2007/AC1021):1762 个实体、14 个图层。
71
+ - **截屏 OCR**:本地 tessdata(chi_sim+eng)约 4 秒识别 4500+ 字符;`ocr=false` 仅解析尺寸不识别。
72
72
  - **无效/损坏文件**:返回明确错误,不崩溃。
73
73
 
74
74
  ## 文件结构
75
75
 
76
76
  ```
77
77
  dwg-reader-plugin/
78
- ├── lib/index.js # 插件入口,注册 read_dwg 工具
78
+ ├── lib/index.js # 插件入口,注册 read_dwg / read_dxf / read_pdf / ocr_image
79
+ ├── scripts/read_dxf.py # DXF 解析桥接(ezdxf)
79
80
  ├── cordis.patch.yml # DSH bundle 插件清单
80
81
  ├── package.json # npm 包清单
81
82
  ├── LICENSE # GPL-3.0-only
@@ -90,10 +91,10 @@ dwg-reader-plugin/
90
91
 
91
92
  ## 许可
92
93
 
93
- GPL-3.0-only。依赖 libredwg-web 同样为 GPL,公开分发请保留 LICENSE 并提供对应源码。
94
+ GPL-3.0-only。依赖 libredwg-web 为 GPL,tesseract.js 为 Apache-2.0;公开分发请保留 LICENSE 并提供对应源码。
94
95
 
95
96
  ## 安装路径
96
97
 
97
- - npm 公开包:`@bill_nee/dwg-reader@1.1.0`
98
- - 安装命令:`dsh plugin --profile desktop add @bill_nee/dwg-reader@1.1.0`
98
+ - npm 公开包:`@bill_nee/dwg-reader@1.5.0`
99
+ - 安装命令:`dsh plugin --profile desktop add @bill_nee/dwg-reader@1.5.0`
99
100
  - GitHub 源码:https://github.com/NIDEXIANG/dwg-reader-plugin-20261029
package/lib/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
3
6
  import { PDFParse } from "pdf-parse";
4
7
  import { LibreDwg, Dwg_File_Type, DwgCodePage } from "../vendor/libredwg-web/lib/index.js";
5
8
 
@@ -8,8 +11,8 @@ const MAX_DWG_BYTES = 512 * 1024 * 1024; // 512 MB 上限,超大图纸会先
8
11
  const MAX_ENTITY_DETAIL = 2000;
9
12
  const PYTHON_EXE = "D:\\dsh-home\\dsh-runtimes\\dsh-primary-runtime\\dependencies\\python\\python.exe";
10
13
  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
14
  const MAX_PDF_BYTES = 100 * 1024 * 1024; // 100 MB PDF 上限
15
+ const OCR_TIMEOUT_MS = 180000; // OCR 单张图片 180 秒上限
13
16
 
14
17
  let libreDwgPromise;
15
18
 
@@ -581,12 +584,262 @@ export function apply(ctx, config) {
581
584
  };
582
585
  }
583
586
 
587
+ // ---------------------------------------------------------------------------
588
+ // ocr_image:图片头解析 + 内置 tesseract.js(WASM)OCR
589
+ // 不依赖外部 Tesseract 可执行文件或 Pillow;语言包可用 tessdata_prefix 指向本地目录,
590
+ // 缺省时依次探测常见 tessdata 目录,仍未命中则按需从 CDN 下载并缓存。
591
+ // ---------------------------------------------------------------------------
592
+
593
+ const COMMON_TESSDATA_DIRS = [
594
+ process.env.TESSDATA_PREFIX,
595
+ "D:\\Tools\\Tesseract-OCR\\tessdata",
596
+ "C:\\Program Files\\Tesseract-OCR\\tessdata",
597
+ "/usr/share/tesseract-ocr/4.00/tessdata",
598
+ "/usr/share/tessdata",
599
+ "/opt/homebrew/share/tessdata",
600
+ ].filter(Boolean);
601
+
602
+ function be4(bytes, offset) {
603
+ return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
604
+ }
605
+ function be2(bytes, offset) {
606
+ return (bytes[offset] << 8) | bytes[offset + 1];
607
+ }
608
+ function le4(bytes, offset) {
609
+ return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0;
610
+ }
611
+ function le2(bytes, offset) {
612
+ return bytes[offset] | (bytes[offset + 1] << 8);
613
+ }
614
+
615
+ /** 从文件头解析图片格式与像素尺寸;无法识别时返回 null。 */
616
+ function parseImageInfo(bytes) {
617
+ if (!bytes || bytes.length < 12) return null;
618
+ // PNG
619
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
620
+ const colorType = bytes[25];
621
+ const mode = { 0: "GRAY", 2: "RGB", 3: "PALETTE", 4: "GRAY+ALPHA", 6: "RGBA" }[colorType] ?? null;
622
+ return { format: "PNG", width: be4(bytes, 16) || null, height: be4(bytes, 20) || null, mode };
623
+ }
624
+ // JPEG
625
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) {
626
+ let i = 2;
627
+ while (i + 10 < bytes.length) {
628
+ if (bytes[i] !== 0xff) {
629
+ i += 1;
630
+ continue;
631
+ }
632
+ const marker = bytes[i + 1];
633
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
634
+ i += 2;
635
+ continue;
636
+ }
637
+ if (marker === 0xd9) break;
638
+ const segLen = be2(bytes, i + 2);
639
+ if (segLen < 2) break;
640
+ const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
641
+ if (isSof) {
642
+ const comps = bytes[i + 9];
643
+ const mode = comps === 1 ? "GRAY" : comps === 3 ? "RGB" : comps === 4 ? "CMYK" : null;
644
+ return { format: "JPEG", width: be2(bytes, i + 7) || null, height: be2(bytes, i + 5) || null, mode };
645
+ }
646
+ i += 2 + segLen;
647
+ }
648
+ return { format: "JPEG", width: null, height: null, mode: null };
649
+ }
650
+ // GIF
651
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {
652
+ return { format: "GIF", width: le2(bytes, 6) || null, height: le2(bytes, 8) || null, mode: null };
653
+ }
654
+ // BMP
655
+ if (bytes[0] === 0x42 && bytes[1] === 0x4d) {
656
+ return { format: "BMP", width: Math.abs(le4(bytes, 18)) || null, height: Math.abs(le4(bytes, 22)) || null, mode: null };
657
+ }
658
+ // WEBP
659
+ if (
660
+ bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
661
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
662
+ ) {
663
+ const chunk = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
664
+ if (chunk === "VP8X") {
665
+ return {
666
+ format: "WEBP",
667
+ width: (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)) + 1,
668
+ height: (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16)) + 1,
669
+ mode: null,
670
+ };
671
+ }
672
+ if (chunk === "VP8L") {
673
+ const bits = (bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24)) >>> 0;
674
+ return { format: "WEBP", width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1, mode: null };
675
+ }
676
+ if (chunk === "VP8 ") {
677
+ return { format: "WEBP", width: le2(bytes, 26) & 0x3fff, height: le2(bytes, 28) & 0x3fff, mode: null };
678
+ }
679
+ return { format: "WEBP", width: null, height: null, mode: null };
680
+ }
681
+ // TIFF(只读取第一个 IFD 的宽高标签)
682
+ if ((bytes[0] === 0x49 && bytes[1] === 0x49) || (bytes[0] === 0x4d && bytes[1] === 0x4d)) {
683
+ const little = bytes[0] === 0x49;
684
+ const u2 = (o) => (little ? le2(bytes, o) : be2(bytes, o));
685
+ const u4 = (o) => (little ? le4(bytes, o) : be4(bytes, o));
686
+ let width = null;
687
+ let height = null;
688
+ try {
689
+ const ifd = u4(4);
690
+ const count = u2(ifd);
691
+ for (let k = 0; k < Math.min(count, 256); k += 1) {
692
+ const entry = ifd + 2 + k * 12;
693
+ const tag = u2(entry);
694
+ const type = u2(entry + 2);
695
+ const valueOffset = entry + 8;
696
+ if (tag === 256) width = type === 3 ? u2(valueOffset) : u4(valueOffset);
697
+ else if (tag === 257) height = type === 3 ? u2(valueOffset) : u4(valueOffset);
698
+ }
699
+ } catch {
700
+ /* 无法解析时保留 null */
701
+ }
702
+ return { format: "TIFF", width: width || null, height: height || null, mode: null };
703
+ }
704
+ return null;
705
+ }
706
+
707
+ function checkTessdataDir(dir, codes) {
708
+ if (!dir || !existsSync(dir)) return { dir, missing: codes };
709
+ const missing = codes.filter((code) => {
710
+ try {
711
+ return !existsSync(join(dir, `${code}.traineddata`)) && !existsSync(join(dir, `${code}.traineddata.gz`));
712
+ } catch {
713
+ return true;
714
+ }
715
+ });
716
+ return { dir, missing };
717
+ }
718
+
719
+ /** 解析语言包目录:优先显式 tessdata_prefix(缺失即报错),否则探测常见目录,最后才联网。 */
720
+ function resolveTessdataDir(codes, explicitDir) {
721
+ if (explicitDir) {
722
+ if (!existsSync(explicitDir)) {
723
+ throw new Error(`tessdata_prefix 目录不存在: ${explicitDir}`);
724
+ }
725
+ const checked = checkTessdataDir(explicitDir, codes);
726
+ if (checked.missing.length) {
727
+ throw new Error(
728
+ `tessdata_prefix 缺少语言包: ${checked.missing.map((c) => `${c}.traineddata`).join("、")}(目录 ${explicitDir})`
729
+ );
730
+ }
731
+ return explicitDir;
732
+ }
733
+ for (const dir of COMMON_TESSDATA_DIRS) {
734
+ const checked = checkTessdataDir(dir, codes);
735
+ if (checked.dir && checked.missing.length === 0) return checked.dir;
736
+ }
737
+ return null;
738
+ }
739
+
740
+ let ocrWorkerPromise = null;
741
+ let ocrWorkerKey = null;
742
+ let ocrWorkerLangs = "";
743
+
744
+ async function disposeOcrWorker() {
745
+ const pending = ocrWorkerPromise;
746
+ ocrWorkerPromise = null;
747
+ ocrWorkerKey = null;
748
+ ocrWorkerLangs = "";
749
+ if (!pending) return;
750
+ try {
751
+ const worker = await pending;
752
+ await worker.terminate();
753
+ } catch {
754
+ /* 忽略清理失败 */
755
+ }
756
+ }
757
+
758
+ /** 复用单个 OCR worker;语言变化时 reinitialize。 */
759
+ async function getOcrWorker(codes, key, options) {
760
+ const joined = codes.join("+");
761
+ if (ocrWorkerPromise !== null && ocrWorkerKey === key) {
762
+ const worker = await ocrWorkerPromise;
763
+ if (ocrWorkerLangs !== joined) {
764
+ await worker.reinitialize(codes, 1);
765
+ ocrWorkerLangs = joined;
766
+ }
767
+ return worker;
768
+ }
769
+ await disposeOcrWorker();
770
+ ocrWorkerKey = key;
771
+ ocrWorkerLangs = joined;
772
+ ocrWorkerPromise = (async () => {
773
+ const { createWorker } = await import("tesseract.js");
774
+ return createWorker(codes, 1, options);
775
+ })();
776
+ return ocrWorkerPromise;
777
+ }
778
+
779
+ function raceWithAbort(promise, exec, timeoutMs) {
780
+ return new Promise((resolve, reject) => {
781
+ let settled = false;
782
+ const finish = (fn, value) => {
783
+ if (settled) return;
784
+ settled = true;
785
+ clearTimeout(timer);
786
+ if (exec.signal) exec.signal.removeEventListener("abort", onAbort);
787
+ fn(value);
788
+ };
789
+ const timer = setTimeout(
790
+ () => finish(reject, new Error(`OCR 超时(超过 ${Math.round(timeoutMs / 1000)} 秒)`)),
791
+ timeoutMs
792
+ );
793
+ const onAbort = () => finish(reject, exec.signal.reason ?? new Error("aborted"));
794
+ if (exec.signal) {
795
+ if (exec.signal.aborted) {
796
+ onAbort();
797
+ return;
798
+ }
799
+ exec.signal.addEventListener("abort", onAbort);
800
+ }
801
+ promise.then((v) => finish(resolve, v), (e) => finish(reject, e));
802
+ });
803
+ }
804
+
805
+ async function runOcr(imagePath, codes, tessdataParam, exec) {
806
+ const localDir = resolveTessdataDir(codes, tessdataParam);
807
+ const options = localDir
808
+ ? { langPath: localDir, gzip: false, cacheMethod: "none" }
809
+ : { cachePath: join(tmpdir(), "dwg-reader-ocr-cache"), gzip: true };
810
+ const key = localDir ? `local:${localDir}` : "cdn";
811
+ const worker = await getOcrWorker(codes, key, options);
812
+ const started = Date.now();
813
+ const result = await raceWithAbort(worker.recognize(imagePath), exec, OCR_TIMEOUT_MS);
814
+ const text = String(result?.data?.text ?? "").replace(/\r\n/g, "\n").trim();
815
+ return {
816
+ text,
817
+ source: localDir ? "local" : "download",
818
+ langPath: localDir ?? null,
819
+ elapsedMs: Date.now() - started,
820
+ };
821
+ }
822
+
823
+ function splitLangs(lang) {
824
+ const raw = typeof lang === "string" && lang.trim() ? lang : "chi_sim+eng";
825
+ const codes = raw.split(/[+,]/).map((s) => s.trim()).filter(Boolean);
826
+ if (!codes.length) return ["chi_sim", "eng"];
827
+ for (const code of codes) {
828
+ if (!/^[a-z0-9_]+$/i.test(code)) {
829
+ throw new Error(`invalid arguments: "lang" 含非法语言码 "${code}"(应为 chi_sim、eng、jpn 等)`);
830
+ }
831
+ }
832
+ return codes;
833
+ }
834
+
584
835
  function buildReadImageTool(ctx) {
585
836
  return {
586
- name: "read_image",
837
+ name: "ocr_image",
587
838
  description:
588
- "读取图片/截屏文件(尺寸、格式、元数据 + Tesseract OCR 文字提取,无需安装图片查看软件)。" +
589
- "支持 PNG/JPEG/WebP/BMP/TIFF;OCR 语言为简体中文+英文。",
839
+ "对图片/截屏做 OCR 文字提取(返回格式、像素尺寸、颜色模式与识别文字;不返回图片本体——看图请用内置 read_image)。" +
840
+ "无需安装 Tesseract 或图片查看软件,内置 tesseract.js WASM。支持 PNG/JPEG/WebP/BMP/GIF/TIFF;" +
841
+ "支持 Tesseract 全部语言(lang 参数,默认 chi_sim+eng),可用 tessdata_prefix 指向本地 traineddata 目录离线使用," +
842
+ "未提供时自动探测常见目录,仍无则联网下载并缓存。",
590
843
  parameters: {
591
844
  type: "object",
592
845
  additionalProperties: true,
@@ -599,9 +852,15 @@ function buildReadImageTool(ctx) {
599
852
  type: "boolean",
600
853
  description: "是否执行 OCR 文字提取(默认 true)。",
601
854
  },
855
+ lang: {
856
+ type: "string",
857
+ description:
858
+ "OCR 语言码,+ 或逗号连接多个(默认 chi_sim+eng;支持 eng、chi_tra、jpn、kor、deu、fra、rus、spa、ita、por、ara、vie、tha、hin 等 Tesseract 全量语言)。",
859
+ },
602
860
  tessdata_prefix: {
603
861
  type: "string",
604
- description: "Tesseract tessdata 目录(可选;缺省使用系统默认)。",
862
+ description:
863
+ "本地 traineddata 目录(可选;提供后只读本地语言包、不联网。目录内需有 <语言码>.traineddata 或 .traineddata.gz;缺包会报错并列出缺项)。",
605
864
  },
606
865
  },
607
866
  required: ["file_path"],
@@ -615,31 +874,36 @@ function buildReadImageTool(ctx) {
615
874
  fileName: { type: "string", description: "文件名。" },
616
875
  fileSizeBytes: { type: "integer", description: "文件字节数。" },
617
876
  format: { type: "string", description: "图片格式(如 PNG/JPEG)。" },
618
- width: { type: "integer", description: "图片宽度(像素)。" },
619
- height: { type: "integer", description: "图片高度(像素)。" },
620
- mode: { type: "string", description: "颜色模式(如 RGB/RGBA)。" },
877
+ width: { oneOf: [{ type: "integer" }, { type: "null" }], description: "图片宽度(像素)。" },
878
+ height: { oneOf: [{ type: "integer" }, { type: "null" }], description: "图片高度(像素)。" },
879
+ mode: { oneOf: [{ type: "string" }, { type: "null" }], description: "颜色模式(如 RGB/RGBA)。" },
880
+ lang: { type: "string", description: "实际使用的 OCR 语言码。" },
881
+ tessdataSource: { type: "string", description: "语言包来源:local(本地目录)/download(联网下载)/none(未启用 OCR)。" },
621
882
  ocrText: { type: "string", description: "OCR 提取的文字(可能为空)。" },
622
- ocrError: { type: "string", description: "OCR 错误信息;成功时为 null。" },
623
- error: { type: "string", description: "错误信息;成功时为 null。" },
883
+ ocrError: { oneOf: [{ type: "string" }, { type: "null" }], description: "OCR 错误信息;成功时为 null。" },
884
+ ocrElapsedMs: { type: "integer", description: "OCR 耗时(毫秒)。" },
624
885
  },
625
886
  },
626
887
  render: (args, value) => {
627
888
  const lines = [];
628
889
  lines.push(`<path>${value.path}</path>`);
629
890
  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
891
  const sizeKb = (value.fileSizeBytes / 1024).toFixed(1);
635
892
  lines.push(`<content>已解析图片:${value.fileName}(${sizeKb} KB)`);
636
- lines.push(`格式:${value.format};尺寸:${value.width}×${value.height};模式:${value.mode}`);
893
+ const dims = value.width != null && value.height != null ? `${value.width}×${value.height}` : "未知";
894
+ lines.push(`格式:${value.format};尺寸:${dims};模式:${value.mode ?? "未知"}`);
895
+ if (value.ocrElapsedMs > 0) {
896
+ const source = value.tessdataSource === "local" ? "本地语言包" : "联网下载语言包";
897
+ lines.push(`OCR 语言:${value.lang}(${source},耗时 ${(value.ocrElapsedMs / 1000).toFixed(1)} 秒)`);
898
+ } else {
899
+ lines.push("OCR:未启用");
900
+ }
637
901
  if (value.ocrError) lines.push(`OCR 错误:${value.ocrError}`);
638
902
  if (value.ocrText) {
639
903
  const preview = value.ocrText.slice(0, 300);
640
904
  lines.push(`OCR 文字(${value.ocrText.length} 字符):${preview}${value.ocrText.length > 300 ? "…" : ""}`);
641
905
  } else if (!value.ocrError) {
642
- lines.push("OCR 文字:无(未检测到可识别文字或 OCR 未启用)");
906
+ lines.push("OCR 文字:无(未识别出文字)");
643
907
  }
644
908
  lines.push(`</content>`);
645
909
  return [{ type: "text", text: lines.join("\n") }];
@@ -672,9 +936,13 @@ function buildReadImageTool(ctx) {
672
936
  if (args.ocr !== undefined && typeof args.ocr !== "boolean") {
673
937
  throw new Error('invalid arguments: "ocr" must be a boolean');
674
938
  }
939
+ if (args.lang !== undefined && typeof args.lang !== "string") {
940
+ throw new Error('invalid arguments: "lang" must be a string');
941
+ }
675
942
  if (args.tessdata_prefix !== undefined && typeof args.tessdata_prefix !== "string") {
676
943
  throw new Error('invalid arguments: "tessdata_prefix" must be a string');
677
944
  }
945
+ const langCodes = splitLangs(args.lang);
678
946
 
679
947
  const resolveOptions = await sessionResolveOptions(ctx, exec);
680
948
  const target = await ctx.fs.resolve(args.file_path.trim(), resolveOptions);
@@ -688,34 +956,28 @@ function buildReadImageTool(ctx) {
688
956
  throw new Error(`cannot read "${displayPath}": not a regular file`);
689
957
  }
690
958
 
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
- });
959
+ // 先读文件头判断格式与尺寸(最多 2 MB,足够覆盖 JPEG 的 SOF 段)
960
+ const header = await ctx.fs.readBytes(target, exec.signal, 2 * 1024 * 1024);
961
+ const parsed = parseImageInfo(header);
962
+ if (!parsed) {
963
+ throw new Error(`cannot read "${displayPath}": 无法识别的图片格式(支持 PNG/JPEG/WebP/BMP/GIF/TIFF)`);
964
+ }
716
965
 
717
- if (result.error) {
718
- throw new Error(`cannot read "${displayPath}": ${result.error}`);
966
+ let ocrText = "";
967
+ let ocrError = null;
968
+ let tessdataSource = "none";
969
+ let ocrElapsedMs = 0;
970
+ if (args.ocr !== false) {
971
+ try {
972
+ const ocr = await runOcr(args.file_path.trim(), langCodes, args.tessdata_prefix, exec);
973
+ ocrText = ocr.text;
974
+ tessdataSource = ocr.source;
975
+ ocrElapsedMs = ocr.elapsedMs;
976
+ } catch (error) {
977
+ ocrError = error?.message ?? String(error);
978
+ // OCR 失败或超时后 worker 可能仍卡在识别中,销毁以便下次重建
979
+ await disposeOcrWorker();
980
+ }
719
981
  }
720
982
 
721
983
  ctx.emit("fs/observed", target, { kind: "present", version: info.version }, exec);
@@ -723,13 +985,16 @@ function buildReadImageTool(ctx) {
723
985
  return toJson({
724
986
  path: displayPath,
725
987
  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,
988
+ fileSizeBytes: info.size !== undefined ? info.size : 0,
989
+ format: parsed.format,
990
+ width: parsed.width,
991
+ height: parsed.height,
992
+ mode: parsed.mode,
993
+ lang: langCodes.join("+"),
994
+ tessdataSource,
995
+ ocrText,
996
+ ocrError,
997
+ ocrElapsedMs,
733
998
  });
734
999
  },
735
1000
  };
@@ -868,8 +1133,13 @@ function buildReadPdfTool(ctx) {
868
1133
  let metadata = {};
869
1134
  try {
870
1135
  const infoResult = await parser.getInfo();
871
- pageCount = infoResult.info?.Pages != null ? Number(infoResult.info.Pages) : 0;
872
- metadata = infoResult.metadata ?? {};
1136
+ pageCount =
1137
+ infoResult.total != null
1138
+ ? Number(infoResult.total)
1139
+ : infoResult.info?.Pages != null
1140
+ ? Number(infoResult.info.Pages)
1141
+ : 0;
1142
+ metadata = infoResult.metadata ?? infoResult.info ?? {};
873
1143
  if (args.extract_text !== false) {
874
1144
  const textResult = await parser.getText();
875
1145
  text = textResult?.text ?? "";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bill_nee/dwg-reader",
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)。",
3
+ "version": "1.5.0",
4
+ "description": "直接读取 AutoCAD DWG/DXF、PDF 与图片/截屏的 DSH 插件(DWG 基于 libredwg-web 0.8.2 WASM,DXF 基于 Python ezdxf 桥接,PDF 基于 pdf-parse,图片基于内置 tesseract.js WASM OCR,支持本地 tessdata 多语言,GPL-3.0)。",
5
5
  "license": "GPL-3.0-only",
6
6
  "author": "MEB 工程造价项目助手 <meb@example.com>",
7
7
  "repository": {
@@ -42,6 +42,7 @@
42
42
  }
43
43
  },
44
44
  "dependencies": {
45
- "pdf-parse": "^2.4.5"
45
+ "pdf-parse": "^2.4.5",
46
+ "tesseract.js": "^7.0.0"
46
47
  }
47
48
  }
@@ -1,70 +0,0 @@
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()