@bill_nee/dwg-reader 1.3.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 +40 -39
- package/lib/index.js +429 -2
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
# @bill_nee/dwg-reader
|
|
2
2
|
|
|
3
|
-
DSH(DeepSeek Harness
|
|
3
|
+
DSH(DeepSeek Harness)插件:直接读取 DWG/DXF 图纸、PDF 与图片/截屏,无需安装 AutoCAD、Tesseract 等外部软件。
|
|
4
4
|
|
|
5
|
-
基于 [libredwg-web](https://github.com/mlightcad/libredwg-web) 0.8.2 WASM
|
|
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.
|
|
12
|
+
dsh plugin --profile desktop add @bill_nee/dwg-reader@1.5.0
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
安装后在 DSH 工作台插件管理中启用插件,即可使用 `read_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
|
-
|
|
|
25
|
-
|
|
|
48
|
+
| INSERT 明细 | 返回块名、插入点、旋转、缩放及块属性(tag/text/value) |
|
|
49
|
+
| 图纸版本 | 返回 ACADVER、版本号、版本描述(如 r2007/AC1021) |
|
|
26
50
|
| 图幅信息 | 返回 EXTMIN/EXTMAX 边界框 |
|
|
27
51
|
| 导出 SVG | 可将 DWG 内容导出为 SVG 预览文件到指定路径 |
|
|
28
|
-
|
|
|
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
|
-
-
|
|
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
|
|
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.
|
|
98
|
-
- 安装命令:`dsh plugin --profile desktop add @bill_nee/dwg-reader@1.
|
|
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
|
|
|
@@ -9,6 +12,7 @@ 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
14
|
const MAX_PDF_BYTES = 100 * 1024 * 1024; // 100 MB PDF 上限
|
|
15
|
+
const OCR_TIMEOUT_MS = 180000; // OCR 单张图片 180 秒上限
|
|
12
16
|
|
|
13
17
|
let libreDwgPromise;
|
|
14
18
|
|
|
@@ -571,10 +575,428 @@ export function apply(ctx, config) {
|
|
|
571
575
|
const disposeDwg = ctx.tools.register(buildReadDwgTool(ctx));
|
|
572
576
|
const disposeDxf = ctx.tools.register(buildReadDxfTool(ctx));
|
|
573
577
|
const disposePdf = ctx.tools.register(buildReadPdfTool(ctx));
|
|
578
|
+
const disposeImage = ctx.tools.register(buildReadImageTool(ctx));
|
|
574
579
|
return () => {
|
|
575
580
|
disposeDwg();
|
|
576
581
|
disposeDxf();
|
|
577
582
|
disposePdf();
|
|
583
|
+
disposeImage();
|
|
584
|
+
};
|
|
585
|
+
}
|
|
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
|
+
|
|
835
|
+
function buildReadImageTool(ctx) {
|
|
836
|
+
return {
|
|
837
|
+
name: "ocr_image",
|
|
838
|
+
description:
|
|
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
|
+
"未提供时自动探测常见目录,仍无则联网下载并缓存。",
|
|
843
|
+
parameters: {
|
|
844
|
+
type: "object",
|
|
845
|
+
additionalProperties: true,
|
|
846
|
+
properties: {
|
|
847
|
+
file_path: {
|
|
848
|
+
type: "string",
|
|
849
|
+
description: "图片文件路径(绝对路径或相对当前工作目录)。",
|
|
850
|
+
},
|
|
851
|
+
ocr: {
|
|
852
|
+
type: "boolean",
|
|
853
|
+
description: "是否执行 OCR 文字提取(默认 true)。",
|
|
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
|
+
},
|
|
860
|
+
tessdata_prefix: {
|
|
861
|
+
type: "string",
|
|
862
|
+
description:
|
|
863
|
+
"本地 traineddata 目录(可选;提供后只读本地语言包、不联网。目录内需有 <语言码>.traineddata 或 .traineddata.gz;缺包会报错并列出缺项)。",
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
required: ["file_path"],
|
|
867
|
+
},
|
|
868
|
+
output: {
|
|
869
|
+
schema: {
|
|
870
|
+
type: "object",
|
|
871
|
+
additionalProperties: true,
|
|
872
|
+
properties: {
|
|
873
|
+
path: { type: "string", description: "解析后的文件路径。" },
|
|
874
|
+
fileName: { type: "string", description: "文件名。" },
|
|
875
|
+
fileSizeBytes: { type: "integer", description: "文件字节数。" },
|
|
876
|
+
format: { type: "string", description: "图片格式(如 PNG/JPEG)。" },
|
|
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)。" },
|
|
882
|
+
ocrText: { type: "string", description: "OCR 提取的文字(可能为空)。" },
|
|
883
|
+
ocrError: { oneOf: [{ type: "string" }, { type: "null" }], description: "OCR 错误信息;成功时为 null。" },
|
|
884
|
+
ocrElapsedMs: { type: "integer", description: "OCR 耗时(毫秒)。" },
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
render: (args, value) => {
|
|
888
|
+
const lines = [];
|
|
889
|
+
lines.push(`<path>${value.path}</path>`);
|
|
890
|
+
lines.push(`<type>image</type>`);
|
|
891
|
+
const sizeKb = (value.fileSizeBytes / 1024).toFixed(1);
|
|
892
|
+
lines.push(`<content>已解析图片:${value.fileName}(${sizeKb} KB)`);
|
|
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
|
+
}
|
|
901
|
+
if (value.ocrError) lines.push(`OCR 错误:${value.ocrError}`);
|
|
902
|
+
if (value.ocrText) {
|
|
903
|
+
const preview = value.ocrText.slice(0, 300);
|
|
904
|
+
lines.push(`OCR 文字(${value.ocrText.length} 字符):${preview}${value.ocrText.length > 300 ? "…" : ""}`);
|
|
905
|
+
} else if (!value.ocrError) {
|
|
906
|
+
lines.push("OCR 文字:无(未识别出文字)");
|
|
907
|
+
}
|
|
908
|
+
lines.push(`</content>`);
|
|
909
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
910
|
+
},
|
|
911
|
+
presentationMeta: () => null,
|
|
912
|
+
},
|
|
913
|
+
isConcurrencySafe: () => false,
|
|
914
|
+
presentCall: (args) => ({
|
|
915
|
+
card: "generic",
|
|
916
|
+
title: `读取图片 ${args.file_path || "(未指定路径)"}`,
|
|
917
|
+
kind: "read",
|
|
918
|
+
locations: [{ path: args.file_path || "" }],
|
|
919
|
+
}),
|
|
920
|
+
presentResult: (args, result) => {
|
|
921
|
+
if (result && result.isError) return undefined;
|
|
922
|
+
return {
|
|
923
|
+
card: "generic",
|
|
924
|
+
title: `图片解析完成:${args.file_path || ""}`,
|
|
925
|
+
kind: "read",
|
|
926
|
+
locations: [{ path: args.file_path || "" }],
|
|
927
|
+
};
|
|
928
|
+
},
|
|
929
|
+
async execute(args, exec) {
|
|
930
|
+
if (typeof args !== "object" || args === null) {
|
|
931
|
+
throw new Error('invalid arguments: expected an object with a "file_path" string');
|
|
932
|
+
}
|
|
933
|
+
if (typeof args.file_path !== "string" || !args.file_path.trim()) {
|
|
934
|
+
throw new Error('invalid arguments: "file_path" must be a non-empty string');
|
|
935
|
+
}
|
|
936
|
+
if (args.ocr !== undefined && typeof args.ocr !== "boolean") {
|
|
937
|
+
throw new Error('invalid arguments: "ocr" must be a boolean');
|
|
938
|
+
}
|
|
939
|
+
if (args.lang !== undefined && typeof args.lang !== "string") {
|
|
940
|
+
throw new Error('invalid arguments: "lang" must be a string');
|
|
941
|
+
}
|
|
942
|
+
if (args.tessdata_prefix !== undefined && typeof args.tessdata_prefix !== "string") {
|
|
943
|
+
throw new Error('invalid arguments: "tessdata_prefix" must be a string');
|
|
944
|
+
}
|
|
945
|
+
const langCodes = splitLangs(args.lang);
|
|
946
|
+
|
|
947
|
+
const resolveOptions = await sessionResolveOptions(ctx, exec);
|
|
948
|
+
const target = await ctx.fs.resolve(args.file_path.trim(), resolveOptions);
|
|
949
|
+
const info = await ctx.fs.stat(target, exec.signal);
|
|
950
|
+
const displayPath = ctx.fs.processPath(target);
|
|
951
|
+
if (!info) {
|
|
952
|
+
ctx.emit("fs/observed", target, { kind: "absent" }, exec);
|
|
953
|
+
throw new Error(`cannot read "${displayPath}": not found`);
|
|
954
|
+
}
|
|
955
|
+
if (info.type !== "file") {
|
|
956
|
+
throw new Error(`cannot read "${displayPath}": not a regular file`);
|
|
957
|
+
}
|
|
958
|
+
|
|
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
|
+
}
|
|
965
|
+
|
|
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
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
ctx.emit("fs/observed", target, { kind: "present", version: info.version }, exec);
|
|
984
|
+
|
|
985
|
+
return toJson({
|
|
986
|
+
path: displayPath,
|
|
987
|
+
fileName: displayPath.split(/[\\/]/).pop(),
|
|
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,
|
|
998
|
+
});
|
|
999
|
+
},
|
|
578
1000
|
};
|
|
579
1001
|
}
|
|
580
1002
|
|
|
@@ -711,8 +1133,13 @@ function buildReadPdfTool(ctx) {
|
|
|
711
1133
|
let metadata = {};
|
|
712
1134
|
try {
|
|
713
1135
|
const infoResult = await parser.getInfo();
|
|
714
|
-
pageCount =
|
|
715
|
-
|
|
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 ?? {};
|
|
716
1143
|
if (args.extract_text !== false) {
|
|
717
1144
|
const textResult = await parser.getText();
|
|
718
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
|
-
"description": "直接读取 AutoCAD DWG/DXF
|
|
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
|
}
|