Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f53d6746c | |||
| 16cfe82bc3 | |||
| e4e4add567 | |||
| e73320d409 | |||
| 73d2334970 | |||
| 8ad31cbf04 | |||
| 3228c1a2e6 | |||
| 853f10a73b | |||
| 401ecf702a |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## [v1.3.15] - 2026-06-01
|
||||
|
||||
### 🐛 Bug 修复
|
||||
|
||||
- **pinmap_layout.py**: 周长公式从 `2(rows+cols)−4` 改为 `(rows+cols)×2` — 修复角点共享策略,每条边独立包含其端点
|
||||
- **pinmap_generator.py**: 角点单元格写入 `"6/7"` 格式 — 修复 v1.3 下角点引脚丢失问题
|
||||
- **pinmap_parser.py**: 读取时包含角点,按 `"/"` 拆分解析多引脚序号 — 修复 roundtrip 丢失引脚问题
|
||||
|
||||
## [v1.2.0] - 2026-05-26
|
||||
|
||||
### 🐛 Bug 修复
|
||||
|
||||
- **run.bat**: `cd /d "%~dp0src"` → `cd /d "%~dp0Code\src"` — 修复 cd 路径报错
|
||||
- **run.bat**: `chcp 65001` 末尾添加 `>nul` — 修复 title 中文乱码
|
||||
- **run.bat**: `mode con lines=20` → `lines=50` — 修复 Log 窗口无法上滑
|
||||
- **Code/src/file_selector.py**: `.strip()` 后增加 `.strip('"\'')` — 修复拖拽文件路径带引号导致不存在
|
||||
|
||||
## [v1.0.1] - 2026-05-25
|
||||
|
||||
### 📝 文档完善
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 快速入门指南
|
||||
|
||||
本文档帮助你快速上手 PinMAP → PinList 转换器。
|
||||
本文档帮助你快速上手 PinMAP ↔ PinList 双向转换器。
|
||||
|
||||
---
|
||||
|
||||
@@ -46,36 +46,57 @@ cd pinmap-to-pinlist/Code/src/
|
||||
|
||||
### 第二步:运行转换
|
||||
|
||||
#### 方式一:GUI 模式(推荐)
|
||||
#### 方式一:交互式模式(推荐)
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
弹出文件选择对话框,选择 `.xls` 或 `.xlsx` 文件即可。
|
||||
运行后显示方向选择菜单,选择 1 或 2:
|
||||
|
||||
```
|
||||
请选择转换方向:
|
||||
1 — PinMAP → PinList
|
||||
2 — PinList → PinMAP
|
||||
|
||||
请输入选项 (1/2): _
|
||||
```
|
||||
|
||||
#### 方式二:命令行模式
|
||||
|
||||
```bash
|
||||
python main.py /path/to/your/input.xlsx
|
||||
# PinMAP → PinList(直接指定文件)
|
||||
python main.py /path/to/your/PinMAP.xlsx
|
||||
|
||||
# PinList → PinMAP(命令行模式默认走 MAP→List 方向)
|
||||
# 如需 List→MAP,请使用交互式模式
|
||||
```
|
||||
|
||||
### 第三步:查看输出
|
||||
|
||||
转换完成后,在当前目录生成 `{原文件名}_PinList.xlsx`:
|
||||
转换完成后,在当前目录生成输出文件:
|
||||
|
||||
```
|
||||
输入: QFP44_PinMAP.xlsx
|
||||
输出: QFP44_PinMAP_PinList.xlsx
|
||||
PinMAP → PinList: QFP44_PinMAP.xlsx → QFP44_PinMAP_PinList.xlsx
|
||||
PinList → PinMAP: QFN20_PinList.xlsx → QFN20_PinList_PinMAP.xlsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
## 方向一:PinMAP → PinList
|
||||
|
||||
### 示例 1:标准方形 PinMAP
|
||||
将方形封装引脚布局图转换为线性引脚列表。
|
||||
|
||||
**输入文件** `QFP44.xlsx`:
|
||||
### 操作步骤
|
||||
|
||||
1. 运行 `python main.py`,选择方向 **1**
|
||||
2. 选择 PinMAP 文件(`.xls` 或 `.xlsx`)
|
||||
3. 等待转换完成
|
||||
4. 查看输出的 `_PinList.xlsx` 文件
|
||||
|
||||
### 使用示例
|
||||
|
||||
**输入文件** `QFP44.xlsx`(6×6 方形封装,8 个引脚):
|
||||
|
||||
```
|
||||
A B C D E F
|
||||
@@ -88,7 +109,7 @@ python main.py /path/to/your/input.xlsx
|
||||
7 3 4
|
||||
```
|
||||
|
||||
**运行命令**:
|
||||
**运行**:
|
||||
|
||||
```bash
|
||||
python main.py QFP44.xlsx
|
||||
@@ -97,12 +118,20 @@ python main.py QFP44.xlsx
|
||||
**输出**:
|
||||
|
||||
```
|
||||
[INFO] 正在读取文件: QFP44.xlsx
|
||||
[INFO] 文件读取完成,共 16 个非空单元格
|
||||
[INFO] 正在解析 PinMAP 结构...
|
||||
[INFO] 解析完成: 6x6 方形,共 8 个Pin
|
||||
[INFO] 封装信息: QFP-44
|
||||
[INFO] 正在验证数据...
|
||||
[INFO] 验证通过
|
||||
[INFO] 正在生成 PinList...
|
||||
[INFO] 正在写入输出文件: QFP44_PinList.xlsx
|
||||
|
||||
[SUCCESS] 转换完成!输出文件: QFP44_PinList.xlsx
|
||||
- 封装信息: QFP-44
|
||||
- Pin数量: 8
|
||||
[SUCCESS] 转换完成!
|
||||
输出文件: QFP44_PinList.xlsx
|
||||
封装信息: QFP-44
|
||||
Pin数量: 8
|
||||
```
|
||||
|
||||
**输出文件内容**:
|
||||
@@ -118,62 +147,7 @@ python main.py QFP44.xlsx
|
||||
7 Pin6 6
|
||||
```
|
||||
|
||||
### 示例 2:长方形 PinMAP
|
||||
|
||||
**输入文件** `LQFP100.xlsx`(13 个引脚的长方形封装)
|
||||
|
||||
```bash
|
||||
python main.py LQFP100.xlsx
|
||||
```
|
||||
|
||||
**输出**:
|
||||
|
||||
```
|
||||
[INFO] 解析完成: 长方形结构,共 13 个Pin
|
||||
[INFO] 封装信息: LQFP-100
|
||||
|
||||
[SUCCESS] 转换完成!输出文件: LQFP100_PinList.xlsx
|
||||
- 封装信息: LQFP-100
|
||||
- Pin数量: 13
|
||||
```
|
||||
|
||||
### 示例 3:处理警告
|
||||
|
||||
当 PinMAP 中部分引脚缺少 PinName 时:
|
||||
|
||||
```
|
||||
[INFO] 解析完成: 6x6 方形,共 8 个Pin
|
||||
[INFO] 封装信息: QFP-44
|
||||
|
||||
[WARN] 发现 3 个警告:
|
||||
- 检测到 3 个引脚缺少 PinName: 缺失引脚序号: [2, 3, 4],将默认为 NC
|
||||
|
||||
[SUCCESS] 转换完成!输出文件: QFP44_PinList.xlsx
|
||||
- 封装信息: QFP-44
|
||||
- Pin数量: 8
|
||||
```
|
||||
|
||||
缺失 PinName 的引脚在输出中自动标记为 "NC"。
|
||||
|
||||
### 示例 4:处理错误
|
||||
|
||||
当 PinMAP 存在数据错误时:
|
||||
|
||||
```
|
||||
[INFO] 解析完成: 6x6 方形,共 8 个Pin
|
||||
[INFO] 封装信息: QFP-44
|
||||
|
||||
[ERROR] 发现 1 个错误:
|
||||
- Pin序号不连续: 缺失的序号: [3]
|
||||
|
||||
转换终止,请修正PinMAP文件后重试。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PinMAP 文件规范
|
||||
|
||||
### 格式要求
|
||||
### PinMAP 文件规范
|
||||
|
||||
| 要求 | 说明 |
|
||||
|------|------|
|
||||
@@ -192,7 +166,204 @@ python main.py LQFP100.xlsx
|
||||
上边:序号在 (min_row, c),PinName 在 (min_row+1, c)
|
||||
```
|
||||
|
||||
### 支持的输入格式
|
||||
---
|
||||
|
||||
## 方向二:PinList → PinMAP
|
||||
|
||||
将线性引脚列表转换为方形封装引脚布局图。
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 运行 `python main.py`,选择方向 **2**
|
||||
2. 选择 PinList 文件(`.xls` 或 `.xlsx`)
|
||||
3. **输入 PinMAP 行数**(至少 2)
|
||||
4. **输入 PinMAP 列数**(至少 2)
|
||||
5. 等待转换完成
|
||||
6. 查看输出的 `_PinMAP.xlsx` 文件
|
||||
|
||||
### 尺寸输入说明
|
||||
|
||||
PinMAP 的引脚分布在四条边上,总引脚数由网格尺寸决定:
|
||||
|
||||
```
|
||||
总引脚数 = 2 × 行数 + 2 × 列数 − 4
|
||||
```
|
||||
|
||||
常见封装尺寸参考:
|
||||
|
||||
| 封装类型 | 引脚数 | 推荐行数 | 推荐列数 |
|
||||
|----------|--------|----------|----------|
|
||||
| QFP-8 | 8 | 4 | 4 |
|
||||
| QFP-16 | 16 | 6 | 6 |
|
||||
| QFP-20 | 20 | 6 | 6 |
|
||||
| QFP-24 | 24 | 8 | 6 |
|
||||
| QFP-32 | 32 | 10 | 8 |
|
||||
| QFP-44 | 44 | 12 | 10 |
|
||||
| QFP-64 | 64 | 16 | 16 |
|
||||
| QFP-100 | 100 | 26 | 26 |
|
||||
|
||||
> **提示**:如果不确定尺寸,可以先用公式反推:`行数 + 列数 = (引脚数 + 4) / 2`,然后根据需要调整行和列的比例。
|
||||
|
||||
### 模板文件说明
|
||||
|
||||
PinList → PinMAP 转换时,程序会自动尝试从输入文件所在目录读取模板样式:
|
||||
|
||||
- **模板来源**:程序会尝试解析与输入文件同名的 `.xlsx` 模板文件中的样式信息
|
||||
- **提取内容**:字体(名称、大小、粗体、斜体、颜色)、填充、边框、列宽、行高
|
||||
- **优雅降级**:如果模板不存在或解析失败,程序会自动使用默认样式,不影响转换流程
|
||||
|
||||
### 使用示例
|
||||
|
||||
**输入文件** `QFN20_PinList.xlsx`(20 个引脚):
|
||||
|
||||
```
|
||||
A B
|
||||
1 QFN-20
|
||||
2 VCC 1
|
||||
3 GND 2
|
||||
4 IO0 3
|
||||
5 IO1 4
|
||||
6 IO2 5
|
||||
7 IO3 6
|
||||
8 IO4 7
|
||||
9 IO5 8
|
||||
10 IO6 9
|
||||
11 IO7 10
|
||||
12 NC 11
|
||||
13 NC 12
|
||||
14 NC 13
|
||||
15 NC 14
|
||||
16 NC 15
|
||||
17 NC 16
|
||||
18 NC 17
|
||||
19 NC 18
|
||||
20 NC 19
|
||||
21 NC 20
|
||||
```
|
||||
|
||||
**运行**:
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
# 选择方向: 2 (PinList → PinMAP)
|
||||
# 选择文件: QFN20_PinList.xlsx
|
||||
# 输入行数: 6
|
||||
# 输入列数: 6
|
||||
```
|
||||
|
||||
**输出**:
|
||||
|
||||
```
|
||||
[INFO] PinMAP 尺寸: 6 行 × 6 列
|
||||
[INFO] 正在解析 PinList 文件: QFN20_PinList.xlsx
|
||||
[INFO] 解析完成: 封装信息 'QFN-20', 共 20 个引脚
|
||||
[INFO] 正在验证数据...
|
||||
[INFO] 验证通过
|
||||
[INFO] 正在生成 PinMAP 并写入: QFN20_PinList_PinMAP.xlsx
|
||||
|
||||
[SUCCESS] 转换完成!
|
||||
输出文件: QFN20_PinList_PinMAP.xlsx
|
||||
封装信息: QFN-20
|
||||
PinMAP 尺寸: 6×6
|
||||
Pin数量: 20
|
||||
```
|
||||
|
||||
**输出文件内容**(6×6 网格,20 个引脚):
|
||||
|
||||
```
|
||||
A B C D E F
|
||||
1 QFN-20 IO8 IO7
|
||||
2 1 VCC IO6 IO5
|
||||
3 2 GND IO4 IO3
|
||||
4 3 IO0 IO2 IO1
|
||||
5 4 IO1 NC NC
|
||||
6 20 19 18 17 16
|
||||
```
|
||||
|
||||
### 布局规则
|
||||
|
||||
引脚按**逆时针**分配到四条边(左上角为 1 脚):
|
||||
|
||||
```
|
||||
左边: 从上到下(rows 个引脚)
|
||||
下边: 从左到右(cols − 1 个引脚)
|
||||
右边: 从下到上(rows − 2 个引脚)
|
||||
上边: 从右到左(cols − 1 个引脚)
|
||||
```
|
||||
|
||||
PinName 与序号的相对位置:
|
||||
|
||||
```
|
||||
左边:Name 在序号右侧
|
||||
下边:Name 在序号上方
|
||||
右边:Name 在序号左侧
|
||||
上边:Name 在序号下方
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用示例汇总
|
||||
|
||||
### 示例 1:标准方形 PinMAP(MAP→List)
|
||||
|
||||
**输入** `QFP44.xlsx`(6×6,8 Pin)
|
||||
|
||||
```bash
|
||||
python main.py QFP44.xlsx
|
||||
```
|
||||
|
||||
**输出** `QFP44_PinList.xlsx`(A 列 PinName,B 列序号)
|
||||
|
||||
### 示例 2:长方形 PinMAP(MAP→List)
|
||||
|
||||
**输入** `LQFP100.xlsx`(长方形,13 Pin)
|
||||
|
||||
```bash
|
||||
python main.py LQFP100.xlsx
|
||||
```
|
||||
|
||||
**输出** `LQFP100_PinList.xlsx`
|
||||
|
||||
### 示例 3:标准 PinList(List→MAP)
|
||||
|
||||
**输入** `QFN20_PinList.xlsx`(20 Pin)
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
# 选择 2,输入 6×6
|
||||
```
|
||||
|
||||
**输出** `QFN20_PinList_PinMAP.xlsx`(6×6 方形)
|
||||
|
||||
### 示例 4:处理警告
|
||||
|
||||
当 PinMAP 中部分引脚缺少 PinName 时:
|
||||
|
||||
```
|
||||
[WARN] 发现 3 个警告:
|
||||
- 检测到 3 个引脚缺少 PinName: 缺失引脚序号: [2, 3, 4],将默认为 NC
|
||||
|
||||
[SUCCESS] 转换完成!
|
||||
```
|
||||
|
||||
缺失 PinName 的引脚在输出中自动标记为 "NC"。
|
||||
|
||||
### 示例 5:处理错误
|
||||
|
||||
当 PinList 引脚数与网格尺寸不匹配时:
|
||||
|
||||
```
|
||||
[ERROR] 验证未通过,发现 1 个错误:
|
||||
- Pin数量与网格周长不匹配: 网格 6×6 需要 20 个引脚,但 PinList 有 24 个
|
||||
|
||||
转换终止,请修正PinList文件或网格尺寸后重试。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 支持的格式
|
||||
|
||||
### 输入格式
|
||||
|
||||
| 格式 | 扩展名 | 支持情况 |
|
||||
|------|--------|----------|
|
||||
@@ -233,7 +404,7 @@ python main.py input.xlsx
|
||||
|
||||
### Q3: 提示 "A1 单元格为空,缺少封装信息"
|
||||
|
||||
**原因**:PinMAP 文件的 A1 单元格为空。
|
||||
**原因**:文件的 A1 单元格为空。
|
||||
|
||||
**解决**:在 Excel 中打开文件,在 A1 单元格填入封装信息(如 "QFP-44"),保存后重新转换。
|
||||
|
||||
@@ -241,21 +412,29 @@ python main.py input.xlsx
|
||||
|
||||
**原因**:Pin 序号存在间隔(如 1, 2, 4, 5,缺少 3)。
|
||||
|
||||
**解决**:检查 PinMAP 文件,补全缺失的引脚序号。
|
||||
**解决**:检查文件,补全缺失的引脚序号。
|
||||
|
||||
### Q5: 提示 "Pin序号重复"
|
||||
|
||||
**原因**:同一个 Pin 序号出现了多次。
|
||||
|
||||
**解决**:检查 PinMAP 文件,修正重复的序号。
|
||||
**解决**:检查文件,修正重复的序号。
|
||||
|
||||
### Q6: 警告 "检测到 N 个引脚缺少 PinName"
|
||||
### Q6: 提示 "Pin数量与网格周长不匹配"
|
||||
|
||||
**原因**:PinList 的引脚数与输入的 rows×cols 网格周长不一致。
|
||||
|
||||
**解决**:
|
||||
- 检查引脚数量是否正确
|
||||
- 或调整网格尺寸,使 `2×rows + 2×cols − 4 = 引脚数`
|
||||
|
||||
### Q7: 警告 "检测到 N 个引脚缺少 PinName"
|
||||
|
||||
**说明**:这是警告而非错误,转换会继续进行。缺失的 PinName 会自动设为 "NC"。
|
||||
|
||||
**解决**(可选):在 Excel 中补全缺失的 PinName,重新转换。
|
||||
|
||||
### Q7: Linux 下没有弹出文件选择对话框
|
||||
### Q8: Linux 下没有弹出文件选择对话框
|
||||
|
||||
**说明**:Linux 无头环境(无显示器)不支持 tkinter GUI。
|
||||
|
||||
@@ -269,26 +448,31 @@ python main.py /path/to/input.xlsx
|
||||
sudo apt install python3-tk
|
||||
```
|
||||
|
||||
### Q8: 输出文件打不开
|
||||
### Q9: 输出文件打不开
|
||||
|
||||
**可能原因**:Excel 版本过旧(2003 及以下不支持 .xlsx)。
|
||||
|
||||
**解决**:使用 Excel 2007+ 或 WPS Office 打开输出文件。
|
||||
|
||||
### Q9: 支持多大的 PinMAP?
|
||||
### Q10: 支持多大的 PinMAP?
|
||||
|
||||
**回答**:当前实现适合 < 1000 引脚的场景。典型 IC 封装引脚数在 8~200 之间,完全满足需求。
|
||||
|
||||
### Q10: 能否批量转换多个文件?
|
||||
### Q11: 能否批量转换多个文件?
|
||||
|
||||
**回答**:当前版本一次处理一个文件。如需批量转换,可使用 shell 脚本:
|
||||
|
||||
```bash
|
||||
# PinMAP → PinList
|
||||
for f in *.xlsx; do
|
||||
python main.py "$f"
|
||||
done
|
||||
```
|
||||
|
||||
### Q12: 命令行模式下如何执行 PinList → PinMAP?
|
||||
|
||||
**回答**:命令行模式下直接传入文件参数默认走 PinMAP → PinList 方向。如需执行 PinList → PinMAP,请使用交互式模式(不带参数运行),选择方向 2。
|
||||
|
||||
---
|
||||
|
||||
## 测试验证
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
# PinMAP → PinList 转换器
|
||||
# PinMAP ↔ PinList 双向转换器
|
||||
|
||||
将 Excel 格式的 **PinMAP** 文件(方形封装引脚布局图)自动转换为 **PinList** 格式(引脚序号列表),消除手动抄录的低效与错误风险。
|
||||
将 Excel 格式的 **PinMAP**(方形封装引脚布局图)与 **PinList**(引脚序号列表)互相转换,消除手动抄录的低效与错误风险。
|
||||
|
||||
- **PinMAP → PinList**:自动识别方形/长方形结构,逆时针提取引脚,生成线性列表
|
||||
- **PinList → PinMAP**:根据引脚列表和网格尺寸,自动计算布局并生成方形封装图
|
||||
|
||||
---
|
||||
|
||||
## 项目简介
|
||||
|
||||
在 IC 封装设计中,PinMAP 以方形/长方形矩阵形式展示引脚分布,而 PinList 则以线性列表形式提供引脚序号对照。本项目通过纯 Python 实现,自动完成从 PinMAP 到 PinList 的转换,支持 `.xls` 和 `.xlsx` 两种格式。
|
||||
在 IC 封装设计中,PinMAP 以方形/长方形矩阵形式展示引脚分布,而 PinList 则以线性列表形式提供引脚序号对照。本项目通过纯 Python 实现,自动完成 PinMAP 与 PinList 之间的双向转换,支持 `.xls` 和 `.xlsx` 两种格式。
|
||||
|
||||
**版本**: v1.0.0
|
||||
**发布日期**: 2026-05-25
|
||||
**版本**: v1.2.0
|
||||
**发布日期**: 2026-05-28
|
||||
**运行平台**: Windows(tkinter GUI)/ Linux(命令行回退)
|
||||
**技术栈**: Python 标准库,零第三方依赖
|
||||
|
||||
@@ -21,19 +24,30 @@
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **PinMAP 解析** | 自动识别方形/长方形结构,沿四条边(左→下→右→上)逆时针提取引脚 |
|
||||
| **数据验证** | 检测序号不连续、序号重复、PinName 缺失、A1 封装信息缺失 |
|
||||
| **PinList 生成** | A 列 PinName,B 列 Pin 序号,按序号递增排序 |
|
||||
| **PinMAP → PinList** | 自动识别方形/长方形结构,沿四条边(左→下→右→上)逆时针提取引脚,生成 PinList |
|
||||
| **PinList → PinMAP** | 根据引脚列表和网格尺寸,自动计算布局并生成 PinMAP |
|
||||
| **数据验证** | 双向验证:检测序号不连续、序号重复、PinName 缺失、A1 封装信息缺失、周长匹配 |
|
||||
| **模板样式** | PinList → PinMAP 时自动读取模板文件的字体、填充、边框、列宽、行高等样式 |
|
||||
| **双格式支持** | 同时支持 `.xls`(BIFF8 引擎)和 `.xlsx`(OOXML 引擎) |
|
||||
| **双模式运行** | GUI 文件选择对话框 + 命令行参数模式 |
|
||||
|
||||
### 验证规则
|
||||
|
||||
#### PinMAP → PinList 验证
|
||||
|
||||
- **序号连续性**:Pin 序号必须为 1~N 连续整数,无间隔
|
||||
- **序号唯一性**:每个 Pin 序号只能出现一次,无重复
|
||||
- **PinName 完整性**:缺失 PinName 的引脚自动标记为 "NC"(警告级别,不中断流程)
|
||||
- **结构完整性**:方形区域至少 2×2,A1 单元格必须包含封装信息
|
||||
|
||||
#### PinList → PinMAP 验证
|
||||
|
||||
- **序号连续性**:Pin 序号必须从 1 开始连续无缺失
|
||||
- **序号唯一性**:每个 Pin 序号只能出现一次,无重复
|
||||
- **周长匹配**:Pin 总数 = 2×rows + 2×cols − 4(与网格周长一致)
|
||||
- **PinName 完整性**:缺失 PinName 的引脚自动标记为 "NC"(警告级别)
|
||||
- **非 4 倍数提示**:Pin 数量不是 4 的倍数时提示(信息级别)
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
@@ -49,13 +63,22 @@
|
||||
| `xlsx_writer.py` | XLSX 写入引擎(OOXML 构建) | `zipfile`, `xml.etree.ElementTree` |
|
||||
| `file_selector.py` | 文件选择对话框 | `tkinter.filedialog` |
|
||||
| `pinmap_parser.py` | PinMAP 结构解析 | 纯 Python |
|
||||
| `validator.py` | 数据验证 | `collections.Counter` |
|
||||
| `pinmap_layout.py` | PinMAP 布局计算 | 纯 Python |
|
||||
| `pinmap_generator.py` | PinMAP 生成与输出 | 纯 Python |
|
||||
| `pinlist_parser.py` | PinList 文件解析 | 纯 Python |
|
||||
| `pinlist_validator.py` | PinList 数据验证 | `collections.Counter` |
|
||||
| `pinlist_generator.py` | PinList 生成 | 纯 Python |
|
||||
| `validator.py` | PinMAP 数据验证 | `collections.Counter` |
|
||||
| `template_reader.py` | 模板样式提取 | `zipfile`, `xml.etree.ElementTree` |
|
||||
| `models.py` | 数据模型 | `dataclasses` |
|
||||
| `utils.py` | 工具函数 | 纯 Python |
|
||||
|
||||
### 核心技术亮点
|
||||
|
||||
- **BIFF8 手动解析**:从零实现 OLE2 复合文档 + BIFF8 记录流解析,支持 SST、LABELSST、NUMBER、FORMULA、RK、MULRK、LABEL 等记录类型
|
||||
- **OOXML 手动构建**:不使用 openpyxl/xlrd,纯手工构建 `[Content_Types].xml`、`workbook.xml`、`sharedStrings.xml`、`sheet1.xml` 等 OOXML 结构
|
||||
- **布局算法**:根据网格尺寸自动计算四边引脚分配,支持任意 rows×cols 的矩形封装
|
||||
- **模板样式引擎**:从 xlsx 文件中提取字体、填充、边框、列宽、行高等样式并应用到输出文件
|
||||
- **模块化架构**:解析 → 验证 → 生成 → 输出,各模块职责清晰,接口契约明确
|
||||
|
||||
---
|
||||
@@ -68,8 +91,32 @@
|
||||
- Windows 环境(GUI 模式需要 tkinter)
|
||||
- Linux/Mac 环境(仅命令行模式)
|
||||
|
||||
### 交互式模式(推荐)
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
运行后显示转换方向选择菜单:
|
||||
|
||||
```
|
||||
============================================================
|
||||
PinMAP ↔ PinList 双向转换器
|
||||
支持 PinMAP→PinList 与 PinList→PinMAP 互转
|
||||
支持.xls和.xlsx格式,输出.xlsx格式
|
||||
============================================================
|
||||
|
||||
请选择转换方向:
|
||||
1 — PinMAP → PinList
|
||||
2 — PinList → PinMAP
|
||||
|
||||
请输入选项 (1/2):
|
||||
```
|
||||
|
||||
### 命令行模式
|
||||
|
||||
#### PinMAP → PinList
|
||||
|
||||
```bash
|
||||
# 基本用法
|
||||
python main.py input.xlsx
|
||||
@@ -80,16 +127,34 @@ python main.py input.xls
|
||||
# 输出文件自动命名为 input_PinList.xlsx
|
||||
```
|
||||
|
||||
#### PinList → PinMAP
|
||||
|
||||
```bash
|
||||
# 命令行模式:需提供文件路径
|
||||
python main.py input_PinList.xlsx
|
||||
|
||||
# 运行后需要手动输入 PinMAP 尺寸:
|
||||
# 请输入 PinMAP 行数: 6
|
||||
# 请输入 PinMAP 列数: 6
|
||||
# 输出文件自动命名为 input_PinList_PinMAP.xlsx
|
||||
```
|
||||
|
||||
> **注意**:命令行模式下直接传入文件参数时,默认走 PinMAP → PinList 方向。如需 PinList → PinMAP,请使用交互式模式(不带参数运行)选择方向 2。
|
||||
|
||||
### GUI 模式
|
||||
|
||||
```bash
|
||||
# 不带参数运行,弹出文件选择对话框
|
||||
# 不带参数运行,弹出方向选择 + 文件选择对话框
|
||||
python main.py
|
||||
```
|
||||
|
||||
在对话框中选择 `.xls` 或 `.xlsx` 文件,点击"打开"即可开始转换。
|
||||
选择方向后,在对话框中选择 `.xls` 或 `.xlsx` 文件,点击"打开"即可开始转换。
|
||||
|
||||
### 输出示例
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 示例 1:PinMAP → PinList
|
||||
|
||||
输入 PinMAP(方形封装):
|
||||
|
||||
@@ -104,7 +169,32 @@ python main.py
|
||||
7 3 4
|
||||
```
|
||||
|
||||
输出 PinList:
|
||||
**运行命令**:
|
||||
|
||||
```bash
|
||||
python main.py QFP44.xlsx
|
||||
```
|
||||
|
||||
**输出**:
|
||||
|
||||
```
|
||||
[INFO] 正在读取文件: QFP44.xlsx
|
||||
[INFO] 文件读取完成,共 16 个非空单元格
|
||||
[INFO] 正在解析 PinMAP 结构...
|
||||
[INFO] 解析完成: 6x6 方形,共 8 个Pin
|
||||
[INFO] 封装信息: QFP-44
|
||||
[INFO] 正在验证数据...
|
||||
[INFO] 验证通过
|
||||
[INFO] 正在生成 PinList...
|
||||
[INFO] 正在写入输出文件: QFP44_PinList.xlsx
|
||||
|
||||
[SUCCESS] 转换完成!
|
||||
输出文件: QFP44_PinList.xlsx
|
||||
封装信息: QFP-44
|
||||
Pin数量: 8
|
||||
```
|
||||
|
||||
**输出 PinList**:
|
||||
|
||||
```
|
||||
A B
|
||||
@@ -117,6 +207,88 @@ python main.py
|
||||
7 Pin6 6
|
||||
```
|
||||
|
||||
### 示例 2:PinList → PinMAP
|
||||
|
||||
输入 PinList:
|
||||
|
||||
```
|
||||
A B
|
||||
1 QFN-20
|
||||
2 VCC 1
|
||||
3 GND 2
|
||||
4 IO0 3
|
||||
5 IO1 4
|
||||
6 IO2 5
|
||||
7 IO3 6
|
||||
8 IO4 7
|
||||
9 IO5 8
|
||||
10 IO6 9
|
||||
11 IO7 10
|
||||
12 NC 11
|
||||
13 NC 12
|
||||
14 NC 13
|
||||
15 NC 14
|
||||
16 NC 15
|
||||
17 NC 16
|
||||
18 NC 17
|
||||
19 NC 18
|
||||
20 NC 19
|
||||
21 NC 20
|
||||
```
|
||||
|
||||
**运行命令**:
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
# 选择方向: 2 (PinList → PinMAP)
|
||||
# 选择文件: QFN20_PinList.xlsx
|
||||
# 输入行数: 6
|
||||
# 输入列数: 6
|
||||
```
|
||||
|
||||
**输出**:
|
||||
|
||||
```
|
||||
[INFO] 正在解析 PinList 文件: QFN20_PinList.xlsx
|
||||
[INFO] 解析完成: 封装信息 'QFN-20', 共 20 个引脚
|
||||
[INFO] 正在验证数据...
|
||||
[INFO] 验证通过
|
||||
[INFO] 正在生成 PinMAP 并写入: QFN20_PinList_PinMAP.xlsx
|
||||
|
||||
[SUCCESS] 转换完成!
|
||||
输出文件: QFN20_PinList_PinMAP.xlsx
|
||||
封装信息: QFN-20
|
||||
PinMAP 尺寸: 6×6
|
||||
Pin数量: 20
|
||||
```
|
||||
|
||||
**输出 PinMAP**(6×6 网格,20 个引脚):
|
||||
|
||||
```
|
||||
A B C D E F
|
||||
1 QFN-20 IO8 IO7
|
||||
2 1 VCC IO6 IO5
|
||||
3 2 GND IO4 IO3
|
||||
4 3 IO0 IO2 IO1
|
||||
5 4 IO1 NC NC
|
||||
6 20 19 18 17 16
|
||||
```
|
||||
|
||||
### 示例 3:尺寸不匹配错误
|
||||
|
||||
当 PinList 引脚数与网格周长不匹配时:
|
||||
|
||||
```
|
||||
[ERROR] 验证未通过,发现 1 个错误:
|
||||
- Pin数量与网格周长不匹配: 网格 6×6 需要 20 个引脚,但 PinList 有 24 个
|
||||
|
||||
转换终止,请修正PinList文件或网格尺寸后重试。
|
||||
```
|
||||
|
||||
### 示例 4:使用模板样式
|
||||
|
||||
PinList → PinMAP 转换时,程序会自动尝试从同目录下的模板文件读取样式(字体、边框、列宽等),使输出 PinMAP 的格式与目标模板保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
@@ -125,14 +297,19 @@ python main.py
|
||||
pinmap-to-pinlist/
|
||||
├── Code/
|
||||
│ ├── src/
|
||||
│ │ ├── main.py # 主入口:流程编排
|
||||
│ │ ├── main.py # 主入口:流程编排 + 双向转换
|
||||
│ │ ├── file_selector.py # 文件选择(GUI + 命令行回退)
|
||||
│ │ ├── xls_reader.py # XLS (BIFF8) 读取引擎
|
||||
│ │ ├── xlsx_reader.py # XLSX 读取引擎
|
||||
│ │ ├── xlsx_writer.py # XLSX 写入引擎
|
||||
│ │ ├── xlsx_writer.py # XLSX 写入引擎(含样式支持)
|
||||
│ │ ├── pinmap_parser.py # PinMAP 结构解析
|
||||
│ │ ├── validator.py # 数据验证
|
||||
│ │ ├── pinlist_generator.py # PinList 生成
|
||||
│ │ ├── pinmap_layout.py # PinMAP 布局计算(List→MAP)
|
||||
│ │ ├── pinmap_generator.py # PinMAP 生成与输出(List→MAP)
|
||||
│ │ ├── pinlist_parser.py # PinList 文件解析(List→MAP)
|
||||
│ │ ├── pinlist_validator.py # PinList 数据验证(List→MAP)
|
||||
│ │ ├── pinlist_generator.py # PinList 生成(MAP→List)
|
||||
│ │ ├── validator.py # PinMAP 数据验证(MAP→List)
|
||||
│ │ ├── template_reader.py # 模板样式提取(List→MAP)
|
||||
│ │ ├── models.py # 数据模型
|
||||
│ │ ├── utils.py # 工具函数
|
||||
│ │ └── test_pinmap.py # 单元测试
|
||||
@@ -192,7 +369,7 @@ pinmap-to-pinlist/
|
||||
|
||||
## 解析算法说明
|
||||
|
||||
### PinMAP 结构
|
||||
### PinMAP → PinList:逆时针提取
|
||||
|
||||
PinMAP 以方形/长方形矩阵展示引脚分布:
|
||||
|
||||
@@ -205,8 +382,6 @@ row 3 [PinName] [ ] [PinName]
|
||||
row 4 [13] [12] [11] [10] ← 下边 Pin 序号
|
||||
```
|
||||
|
||||
### 逆时针提取规则
|
||||
|
||||
引脚沿四条边**逆时针**提取:
|
||||
|
||||
1. **左边**:从上到下
|
||||
@@ -216,23 +391,52 @@ row 4 [13] [12] [11] [10] ← 下边 Pin 序号
|
||||
|
||||
角点单元格只计数一次(按单元格位置去重)。
|
||||
|
||||
### PinList 输出规则
|
||||
### PinList → PinMAP:布局计算
|
||||
|
||||
根据用户输入的 rows × cols 网格尺寸,将引脚列表按**逆时针**分配到四条边:
|
||||
|
||||
```
|
||||
总引脚数 = 2 × rows + 2 × cols − 4
|
||||
|
||||
左边: rows 个引脚(从上到下)
|
||||
下边: cols − 1 个引脚(从左到右)
|
||||
右边: rows − 2 个引脚(从下到上)
|
||||
上边: cols − 1 个引脚(从右到左)
|
||||
```
|
||||
|
||||
PinName 与序号的相对位置:
|
||||
|
||||
```
|
||||
左边:序号在 (r, 0),PinName 在 (r, 1) → Name 在序号右侧
|
||||
下边:序号在 (rows, c),PinName 在 (rows-1, c) → Name 在序号上方
|
||||
右边:序号在 (r, cols),PinName 在 (r, cols-1) → Name 在序号左侧
|
||||
上边:序号在 (1, c),PinName 在 (2, c) → Name 在序号下方
|
||||
```
|
||||
|
||||
### PinList 输出规则(MAP→List)
|
||||
|
||||
- A1 单元格:封装信息(从 PinMAP 的 A1 复制)
|
||||
- A 列:PinName(缺失时自动设为 "NC")
|
||||
- B 列:Pin 序号
|
||||
- 按 Pin 序号递增排序
|
||||
|
||||
### PinMAP 输出规则(List→MAP)
|
||||
|
||||
- A1 单元格:封装信息(从 PinList 的 A1 读取)
|
||||
- 四边分布:序号 + PinName 按布局算法填入网格
|
||||
- 缺失 PinName 自动设为 "NC"
|
||||
- 可选:应用模板样式(字体、边框、列宽、行高)
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 级别 | 类型 | 行为 |
|
||||
|------|------|------|
|
||||
| `[FATAL]` | 文件格式错误 / 结构错误 | 终止处理,显示错误信息 |
|
||||
| `[ERROR]` | 数据验证错误(重复/不连续) | 终止处理,显示详细错误 |
|
||||
| `[FATAL]` | 文件格式错误 / 结构错误 / 布局计算失败 | 终止处理,显示错误信息 |
|
||||
| `[ERROR]` | 数据验证错误(重复/不连续/周长不匹配) | 终止处理,显示详细错误 |
|
||||
| `[WARN]` | PinName 缺失 | 提示警告,自动设为 "NC",继续处理 |
|
||||
| `[INFO]` | 解析进度信息 | 仅显示,不影响流程 |
|
||||
| `[INFO]` | 解析进度信息 / 非 4 倍数提示 | 仅显示,不影响流程 |
|
||||
| `[SUCCESS]` | 转换完成 | 显示输出文件路径和统计信息 |
|
||||
|
||||
---
|
||||
|
||||
@@ -2,6 +2,198 @@
|
||||
|
||||
---
|
||||
|
||||
## v1.2.0 — 2026-05-28
|
||||
|
||||
### ✨ 新增 PinList → PinMAP 反向转换
|
||||
|
||||
v1.2.0 为项目增加了完整的反向转换能力,PinMAP ↔ PinList 现在可以双向互转。
|
||||
|
||||
---
|
||||
|
||||
### 新增功能
|
||||
|
||||
#### PinList → PinMAP 转换
|
||||
- **PinList 解析**:从 Excel 文件中读取 PinName(A 列)和 Pin 序号(B 列)
|
||||
- **布局计算**:根据用户输入的行数和列数,自动计算四边引脚分配
|
||||
- **逆时针分配**:左上角为 1 脚,沿左边→下边→右边→上边逆时针排列
|
||||
- **PinName 定位**:自动计算 PinName 与序号的相对位置(右/上/左/下)
|
||||
- **周长验证**:检查引脚总数是否匹配 `2×rows + 2×cols − 4`
|
||||
- **优雅降级**:缺失 PinName 自动设为 "NC"
|
||||
|
||||
#### 模板样式引擎
|
||||
- **样式提取**:从模板 xlsx 文件中提取字体、填充、边框、列宽、行高
|
||||
- **样式应用**:将模板样式应用到生成的 PinMAP 输出文件
|
||||
- **优雅降级**:模板不存在或解析失败时自动使用默认样式
|
||||
|
||||
#### 交互式方向选择
|
||||
- **启动菜单**:运行 `python main.py` 显示方向选择(1: MAP→List / 2: List→MAP)
|
||||
- **尺寸输入**:List→MAP 模式需要输入 PinMAP 的行数和列数
|
||||
- **文件选择**:根据方向自动切换文件选择器标题和提示
|
||||
|
||||
#### 数据验证增强
|
||||
- **PinList 验证**:序号连续性、序号唯一性、周长匹配、PinName 完整性
|
||||
- **非 4 倍数提示**:Pin 数量不是 4 的倍数时提示(信息级别)
|
||||
|
||||
---
|
||||
|
||||
### 新增模块
|
||||
|
||||
| 模块 | 代码量 | 说明 |
|
||||
|------|--------|------|
|
||||
| `pinlist_parser.py` | ~80 行 | PinList 文件解析(A/B 列读取 + 排序) |
|
||||
| `pinlist_validator.py` | ~90 行 | PinList 数据验证(连续性/唯一性/周长匹配) |
|
||||
| `pinmap_generator.py` | ~70 行 | PinMAP 生成与输出(布局应用 + 样式) |
|
||||
| `pinmap_layout.py` | ~100 行 | PinMAP 布局计算(四边分配 + 坐标计算) |
|
||||
| `template_reader.py` | ~170 行 | 模板样式提取(fonts/fills/borders/cols/rows) |
|
||||
|
||||
### 更新模块
|
||||
|
||||
| 模块 | 变更说明 |
|
||||
|------|----------|
|
||||
| `main.py` | 增加 `run_list_to_map()` 流程 + 方向选择菜单 |
|
||||
| `file_selector.py` | 增加 `mode` 参数,支持 "map_to_list" / "list_to_map" |
|
||||
| `models.py` | 新增 `PinListEntry`、`EdgePins`、`PinMAPLayout`、`LayoutError` |
|
||||
| `xlsx_writer.py` | 增加 `write_xlsx_with_style()` 支持模板样式写入 |
|
||||
| `validator.py` | 新增 `PinMapValidator` 类,统一验证接口 |
|
||||
|
||||
---
|
||||
|
||||
### 技术实现
|
||||
|
||||
#### 布局算法
|
||||
|
||||
```
|
||||
总引脚数 = 2 × rows + 2 × cols − 4
|
||||
|
||||
左边: rows 个引脚(从上到下)
|
||||
下边: cols − 1 个引脚(从左到右)
|
||||
右边: rows − 2 个引脚(从下到上)
|
||||
上边: cols − 1 个引脚(从右到左)
|
||||
```
|
||||
|
||||
#### 坐标映射
|
||||
|
||||
```
|
||||
左边: 序号 (r, 0) → Name (r, 1) 右侧
|
||||
下边: 序号 (rows, c) → Name (rows-1, c) 上方
|
||||
右边: 序号 (r, cols) → Name (r, cols-1) 左侧
|
||||
上边: 序号 (1, c) → Name (2, c) 下方
|
||||
```
|
||||
|
||||
#### 模板样式提取
|
||||
|
||||
```
|
||||
xl/styles.xml:
|
||||
├── fonts: name, size, bold, italic, color
|
||||
├── fills: pattern_type, fg_color
|
||||
├── borders: top, bottom, left, right (style + color)
|
||||
└── cellXfs: numFmtId, fontId, fillId, borderId, alignment
|
||||
|
||||
xl/worksheets/sheet1.xml:
|
||||
├── cols: column width (min, max, width)
|
||||
└── sheetData: row height
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 测试覆盖
|
||||
|
||||
#### 单元测试(8 个用例)
|
||||
|
||||
| 用例 | 测试内容 | 结果 |
|
||||
|------|----------|------|
|
||||
| `test_4x4_parse` | 4×4 方形 PinMAP 解析 | ✅ |
|
||||
| `test_4x4_validate` | 4×4 方形验证 | ✅ |
|
||||
| `test_missing_names_warning` | PinName 缺失警告 | ✅ |
|
||||
| `test_duplicate_numbers` | 序号重复检测 | ✅ |
|
||||
| `test_gap_in_numbers` | 序号不连续检测 | ✅ |
|
||||
| `test_empty_cells` | 空单元格处理 | ✅ |
|
||||
| `test_no_pins` | 无引脚数据检测 | ✅ |
|
||||
| `test_12pin_square` | 12 引脚方形解析 | ✅ |
|
||||
|
||||
#### 集成测试(6 个用例)
|
||||
|
||||
| 用例 | 输入文件 | 测试内容 | 结果 |
|
||||
|------|----------|----------|------|
|
||||
| TC001 | `sample_4x4.xlsx` | 标准 4×4 转换(8 Pin) | ✅ |
|
||||
| TC002 | `sample_rect.xlsx` | 长方形转换(13 Pin) | ✅ |
|
||||
| TC003 | `error_gap.xlsx` | 序号不连续检测 | ✅ |
|
||||
| TC004 | `error_dup.xlsx` | 序号重复检测 | ✅ |
|
||||
| TC005 | `warning_missing.xlsx` | PinName 缺失警告 | ✅ |
|
||||
| TC006 | `error_empty_a1.xlsx` | A1 为空检测 | ✅ |
|
||||
|
||||
**测试通过率**:100%(14/14)
|
||||
|
||||
---
|
||||
|
||||
### 已知问题
|
||||
|
||||
无
|
||||
|
||||
---
|
||||
|
||||
### 限制
|
||||
|
||||
| 限制项 | 说明 |
|
||||
|--------|------|
|
||||
| 引脚数量 | 建议 < 1000 引脚(典型封装 < 200 引脚,无压力) |
|
||||
| 输入格式 | 仅支持 `.xls` 和 `.xlsx`,不支持 CSV/其他格式 |
|
||||
| 输出格式 | 仅输出 `.xlsx`,不支持 `.xls` |
|
||||
| 工作表 | 仅处理第一个工作表 |
|
||||
| 公式单元格 | 仅读取公式的计算结果,不保留公式本身 |
|
||||
| 命令行方向 | 命令行模式直接传入文件默认走 MAP→List,List→MAP 需交互式选择 |
|
||||
|
||||
---
|
||||
|
||||
### 未来计划
|
||||
|
||||
#### v1.3.0 — 格式增强(规划中)
|
||||
|
||||
- [ ] 支持 `.xls` 格式输出
|
||||
- [ ] 保留原始 Excel 的字体和格式(MAP→List 方向)
|
||||
- [ ] 支持多工作表选择
|
||||
|
||||
#### v1.4.0 — 功能扩展(规划中)
|
||||
|
||||
- [ ] 批量转换(拖拽多个文件)
|
||||
- [ ] CSV 格式输出
|
||||
- [ ] PinMAP 结构可视化预览
|
||||
|
||||
#### v2.0.0 — 架构升级(远期规划)
|
||||
|
||||
- [ ] 支持更多封装类型(BGA、QFN 等)
|
||||
- [ ] 插件式解析器架构
|
||||
- [ ] Web 界面
|
||||
|
||||
---
|
||||
|
||||
### 升级指南
|
||||
|
||||
**首次使用**:直接运行即可,无需升级。
|
||||
|
||||
**从 v1.0.0 升级**:替换 `Code/src/` 目录下所有文件。
|
||||
|
||||
**从 v1.1.0 升级**:替换 `Code/src/` 目录下所有文件。
|
||||
|
||||
---
|
||||
|
||||
### 贡献者
|
||||
|
||||
- 架构设计:Script Architect
|
||||
- 编码实现:Coding Agent × 3
|
||||
- 测试验证:QA Agent
|
||||
- 文档编写:Doc Gen Agent
|
||||
|
||||
---
|
||||
|
||||
### 获取帮助
|
||||
|
||||
- 查看 `QUICKSTART.md` 了解使用方法
|
||||
- 查看 `architecture-design.md` 了解技术细节
|
||||
- 查看 `Test/test_report.md` 了解测试详情
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0 — 2026-05-25
|
||||
|
||||
### 🎉 首次发布
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,35 @@
|
||||
"""File selector — CLI path input with GUI dialog fallback.
|
||||
|
||||
Provides a single function ``select_file`` that:
|
||||
Provides ``select_file`` that:
|
||||
1. Prompts the user to type a file path.
|
||||
2. If the input is empty, opens a tkinter file-dialog.
|
||||
3. If the path does not exist, reports an error and loops back.
|
||||
4. Repeats until a valid path is entered or the user cancels.
|
||||
|
||||
Supports two modes via the ``mode`` parameter:
|
||||
- "map_to_list" : select a PinMAP file (default)
|
||||
- "list_to_map" : select a PinList file
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _gui_select() -> Optional[str]:
|
||||
# ── Mode-specific labels ────────────────────────────────────────────
|
||||
|
||||
_MODE_LABELS = {
|
||||
"map_to_list": {
|
||||
"dialog_title": "选择 PinMAP 文件",
|
||||
"prompt": "请输入PinMAP文件路径(直接回车弹窗选择): ",
|
||||
},
|
||||
"list_to_map": {
|
||||
"dialog_title": "选择 PinList 文件",
|
||||
"prompt": "请输入PinList文件路径(直接回车弹窗选择): ",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _gui_select(title: str) -> Optional[str]:
|
||||
"""弹出 tkinter 文件选择对话框,返回选中路径或 None。"""
|
||||
try:
|
||||
import tkinter
|
||||
@@ -22,7 +40,7 @@ def _gui_select() -> Optional[str]:
|
||||
root.attributes("-topmost", True)
|
||||
|
||||
filepath = tkinter.filedialog.askopenfilename(
|
||||
title="选择 PinMAP 文件",
|
||||
title=title,
|
||||
filetypes=[
|
||||
("Excel 文件", "*.xls *.xlsx"),
|
||||
("所有文件", "*.*"),
|
||||
@@ -39,20 +57,31 @@ def _gui_select() -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def select_file() -> Optional[str]:
|
||||
def select_file(mode: str = "map_to_list") -> Optional[str]:
|
||||
"""
|
||||
文件选择流程:
|
||||
1. 提示用户输入文件路径
|
||||
2. 如果输入为空,弹窗选择文件
|
||||
3. 如果输入的路径不存在,报错并提示重新输入
|
||||
4. 循环直到用户输入有效路径或取消
|
||||
文件选择流程。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mode : str
|
||||
"map_to_list" — 选择 PinMAP 文件(默认)
|
||||
"list_to_map" — 选择 PinList 文件
|
||||
|
||||
Returns
|
||||
-------
|
||||
str | None
|
||||
选中的文件路径;用户取消时返回 None
|
||||
"""
|
||||
labels = _MODE_LABELS.get(mode, _MODE_LABELS["map_to_list"])
|
||||
prompt = labels["prompt"]
|
||||
dialog_title = labels["dialog_title"]
|
||||
|
||||
while True:
|
||||
filepath = input("请输入PinMAP文件路径(直接回车弹窗选择): ").strip()
|
||||
filepath = input(prompt).strip().strip('"\'')
|
||||
|
||||
if not filepath:
|
||||
# 弹窗选择
|
||||
filepath = _gui_select()
|
||||
filepath = _gui_select(dialog_title)
|
||||
if not filepath:
|
||||
return None
|
||||
return filepath
|
||||
|
||||
280
Code/src/main.py
280
Code/src/main.py
@@ -1,19 +1,21 @@
|
||||
"""PinMAP → PinList converter
|
||||
"""PinMAP ↔ PinList bidirectional converter
|
||||
|
||||
Usage:
|
||||
python main.py # Interactive file selection
|
||||
python main.py input.xls # Specify file via command line
|
||||
python main.py # Interactive — choose direction + file
|
||||
python main.py input.xls # MAP→List mode (legacy, specify file directly)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
# ── Banner ──────────────────────────────────────────────────────────
|
||||
|
||||
def show_banner():
|
||||
"""显示程序启动说明"""
|
||||
print("=" * 60)
|
||||
print(" PinMAP → PinList 转换器")
|
||||
print(" 将Excel格式的PinMAP文件转换为PinList格式")
|
||||
print(" PinMAP ↔ PinList 双向转换器")
|
||||
print(" 支持 PinMAP→PinList 与 PinList→PinMAP 互转")
|
||||
print(" 支持.xls和.xlsx格式,输出.xlsx格式")
|
||||
print("=" * 60)
|
||||
print()
|
||||
@@ -29,38 +31,67 @@ def wait_for_exit():
|
||||
input("按Enter键退出...")
|
||||
|
||||
|
||||
def build_output_path(input_path: str) -> str:
|
||||
# ── Path helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _find_template_path() -> str | None:
|
||||
"""查找根目录下的 PinMAP-Template.xlsx。
|
||||
|
||||
搜索顺序:
|
||||
1. 与 run.bat 同级的根目录
|
||||
2. 当前工作目录
|
||||
"""
|
||||
# 从 Code/src 回退到根目录
|
||||
src_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
|
||||
template_path = os.path.join(root_dir, "PinMAP-Template.xlsx")
|
||||
|
||||
if os.path.exists(template_path):
|
||||
return template_path
|
||||
|
||||
# 回退到当前工作目录
|
||||
cwd_template = os.path.join(os.getcwd(), "PinMAP-Template.xlsx")
|
||||
if os.path.exists(cwd_template):
|
||||
return cwd_template
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _build_output_path_map_to_list(input_path: str) -> str:
|
||||
"""Generate output path: {original_filename}_PinList.xlsx"""
|
||||
base, _ = os.path.splitext(input_path)
|
||||
return f"{base}_PinList.xlsx"
|
||||
|
||||
|
||||
def main():
|
||||
# ── Banner ──────────────────────────────────────────────────
|
||||
show_banner()
|
||||
def _build_output_path_list_to_map(input_path: str) -> str:
|
||||
"""Generate output path: {original_filename}_PinMAP.xlsx"""
|
||||
base, _ = os.path.splitext(input_path)
|
||||
return f"{base}_PinMAP.xlsx"
|
||||
|
||||
# ── imports (local to avoid circular issues) ────────────────
|
||||
|
||||
# ── Direction 1: MAP → List ────────────────────────────────────────
|
||||
|
||||
def run_map_to_list(filepath: str):
|
||||
"""执行 PinMAP → PinList 转换流程。"""
|
||||
from file_selector import select_file
|
||||
from xls_reader import read_excel_cells # auto-detects .xls
|
||||
from xls_reader import read_excel_cells
|
||||
from xlsx_reader import read_excel_cells as read_xlsx_cells
|
||||
from pinmap_parser import parse_pinmap
|
||||
from validator import validate_pinmap
|
||||
from pinlist_generator import generate_pinlist
|
||||
from xlsx_writer import write_xlsx
|
||||
from xlsx_writer import write_xlsx, write_xlsx_with_style
|
||||
from template_reader import read_template_styles
|
||||
from models import FileFormatError, StructureError
|
||||
|
||||
# ── 1. File selection ───────────────────────────────────────
|
||||
if len(sys.argv) > 1:
|
||||
filepath = sys.argv[1]
|
||||
else:
|
||||
filepath = select_file()
|
||||
# ── 1. File selection ───────────────────────────────────────────
|
||||
if not filepath:
|
||||
filepath = select_file(mode="map_to_list")
|
||||
|
||||
if not filepath:
|
||||
print("未选择文件,退出。")
|
||||
wait_for_exit()
|
||||
return
|
||||
|
||||
# ── 2. Read Excel ───────────────────────────────────────────
|
||||
# ── 2. Read Excel ───────────────────────────────────────────────
|
||||
print(f"[INFO] 正在读取文件: {filepath}")
|
||||
try:
|
||||
if filepath.lower().endswith('.xlsx'):
|
||||
@@ -74,7 +105,7 @@ def main():
|
||||
|
||||
print(f"[INFO] 文件读取完成,共 {len(cells)} 个非空单元格")
|
||||
|
||||
# ── 3. Parse PinMAP ─────────────────────────────────────────
|
||||
# ── 3. Parse PinMAP ─────────────────────────────────────────────
|
||||
print("[INFO] 正在解析 PinMAP 结构...")
|
||||
try:
|
||||
pinmap = parse_pinmap(cells)
|
||||
@@ -85,7 +116,7 @@ def main():
|
||||
wait_for_exit()
|
||||
return
|
||||
|
||||
# ── 4. Validate ─────────────────────────────────────────────
|
||||
# ── 4. Validate ─────────────────────────────────────────────────
|
||||
print("[INFO] 正在验证数据...")
|
||||
validation = validate_pinmap(pinmap)
|
||||
|
||||
@@ -97,7 +128,6 @@ def main():
|
||||
wait_for_exit()
|
||||
return
|
||||
|
||||
# Print warnings (non-fatal — continue processing)
|
||||
if validation.warnings:
|
||||
print(f"[WARN] 发现 {len(validation.warnings)} 个警告:")
|
||||
for warn in validation.warnings:
|
||||
@@ -105,35 +135,229 @@ def main():
|
||||
else:
|
||||
print("[INFO] 验证通过")
|
||||
|
||||
# ── 5. Generate PinList ─────────────────────────────────────
|
||||
# ── 5. Generate PinList ─────────────────────────────────────────
|
||||
print("[INFO] 正在生成 PinList...")
|
||||
pinlist = generate_pinlist(pinmap, validation)
|
||||
|
||||
# ── 6. Write XLSX ───────────────────────────────────────────
|
||||
output_path = build_output_path(filepath)
|
||||
# ── 6. Write XLSX ───────────────────────────────────────────────
|
||||
output_path = _build_output_path_map_to_list(filepath)
|
||||
print(f"[INFO] 正在写入输出文件: {output_path}")
|
||||
try:
|
||||
data = {}
|
||||
data['A1'] = pinlist.package_info
|
||||
for i, (pin_name, pin_num) in enumerate(pinlist.rows):
|
||||
row = i + 2 # data rows start at row 2
|
||||
row = i + 2
|
||||
data[f'A{row}'] = pin_name
|
||||
data[f'B{row}'] = str(pin_num)
|
||||
|
||||
write_xlsx(data, output_path)
|
||||
# 尝试读取模板样式(F007)
|
||||
template_path = _find_template_path()
|
||||
template_style = None
|
||||
if template_path:
|
||||
template_style = read_template_styles(template_path)
|
||||
if template_style:
|
||||
print(f"[INFO] 已加载模板样式: {template_path}")
|
||||
else:
|
||||
print("[WARN] 模板文件存在但解析失败,使用默认样式")
|
||||
else:
|
||||
print("[INFO] 未检测到模板文件,使用默认样式")
|
||||
|
||||
if template_style is not None:
|
||||
write_xlsx_with_style(data, output_path, template_style)
|
||||
else:
|
||||
write_xlsx(data, output_path)
|
||||
except Exception as e:
|
||||
print(f"[FATAL] 输出失败: {e}")
|
||||
wait_for_exit()
|
||||
return
|
||||
|
||||
# ── 7. Result summary ───────────────────────────────────────
|
||||
# ── 7. Result summary ───────────────────────────────────────────
|
||||
print()
|
||||
print("[SUCCESS] 转换完成!")
|
||||
print(f" 输出文件: {output_path}")
|
||||
print(f" 封装信息: {pinlist.package_info}")
|
||||
print(f" Pin数量: {len(pinlist.rows)}")
|
||||
|
||||
wait_for_exit()
|
||||
# F008: 不再退出,返回主循环
|
||||
|
||||
|
||||
# ── Direction 2: List → MAP ────────────────────────────────────────
|
||||
|
||||
def run_list_to_map(filepath: str):
|
||||
"""执行 PinList → PinMAP 转换流程。"""
|
||||
from file_selector import select_file
|
||||
from pinlist_parser import parse_pinlist
|
||||
from pinlist_validator import validate_pinlist
|
||||
from pinmap_generator import generate_pinmap, generate_output_path
|
||||
from template_reader import read_template_styles
|
||||
from models import StructureError, LayoutError
|
||||
|
||||
# ── 1. File selection ───────────────────────────────────────────
|
||||
if not filepath:
|
||||
filepath = select_file(mode="list_to_map")
|
||||
|
||||
if not filepath:
|
||||
print("未选择文件,退出。")
|
||||
wait_for_exit()
|
||||
return
|
||||
|
||||
# ── 2. Input PinMAP dimensions ──────────────────────────────────
|
||||
while True:
|
||||
try:
|
||||
rows_input = input("请输入 PinMAP 行数: ").strip()
|
||||
rows = int(rows_input)
|
||||
if rows < 2:
|
||||
print("[ERROR] 行数至少为 2")
|
||||
continue
|
||||
break
|
||||
except ValueError:
|
||||
print("[ERROR] 请输入有效的整数")
|
||||
|
||||
while True:
|
||||
try:
|
||||
cols_input = input("请输入 PinMAP 列数: ").strip()
|
||||
cols = int(cols_input)
|
||||
if cols < 2:
|
||||
print("[ERROR] 列数至少为 2")
|
||||
continue
|
||||
break
|
||||
except ValueError:
|
||||
print("[ERROR] 请输入有效的整数")
|
||||
|
||||
print(f"[INFO] PinMAP 尺寸: {rows} 行 × {cols} 列")
|
||||
|
||||
# ── 3. Parse PinList ────────────────────────────────────────────
|
||||
print(f"[INFO] 正在解析 PinList 文件: {filepath}")
|
||||
try:
|
||||
package_info, entries = parse_pinlist(filepath)
|
||||
print(f"[INFO] 解析完成: 封装信息 '{package_info}', 共 {len(entries)} 个引脚")
|
||||
except StructureError as e:
|
||||
print(f"[FATAL] 解析失败: {e}")
|
||||
wait_for_exit()
|
||||
return
|
||||
|
||||
# ── 4. Validate ─────────────────────────────────────────────────
|
||||
print("[INFO] 正在验证数据...")
|
||||
validation = validate_pinlist(entries, rows, cols)
|
||||
|
||||
if validation.errors:
|
||||
print(f"[ERROR] 验证未通过,发现 {len(validation.errors)} 个错误:")
|
||||
for err in validation.errors:
|
||||
print(f" - {err.message}: {err.details}")
|
||||
print("\n转换终止,请修正PinList文件或网格尺寸后重试。")
|
||||
wait_for_exit()
|
||||
return
|
||||
|
||||
if validation.warnings:
|
||||
print(f"[WARN] 发现 {len(validation.warnings)} 个警告:")
|
||||
for warn in validation.warnings:
|
||||
print(f" - {warn.message}: {warn.details}")
|
||||
else:
|
||||
print("[INFO] 验证通过")
|
||||
|
||||
# ── 5. Generate PinMAP ──────────────────────────────────────────
|
||||
output_path = generate_output_path(filepath)
|
||||
print(f"[INFO] 正在生成 PinMAP 并写入: {output_path}")
|
||||
|
||||
try:
|
||||
# 尝试读取模板样式(F007 — 从根目录读取而非输入文件路径)
|
||||
template_path = _find_template_path()
|
||||
template_style = None
|
||||
if template_path:
|
||||
template_style = read_template_styles(template_path)
|
||||
if template_style:
|
||||
print(f"[INFO] 已加载模板样式: {template_path}")
|
||||
else:
|
||||
print("[WARN] 模板文件存在但解析失败,使用默认样式")
|
||||
else:
|
||||
print("[INFO] 未检测到模板文件,使用默认样式")
|
||||
|
||||
generate_pinmap(
|
||||
entries=entries,
|
||||
rows=rows,
|
||||
cols=cols,
|
||||
package_info=package_info,
|
||||
template_style=template_style,
|
||||
output_path=output_path,
|
||||
)
|
||||
except LayoutError as e:
|
||||
print(f"[FATAL] 布局计算失败: {e}")
|
||||
wait_for_exit()
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"[FATAL] 输出失败: {e}")
|
||||
wait_for_exit()
|
||||
return
|
||||
|
||||
# ── 6. Result summary ───────────────────────────────────────────
|
||||
print()
|
||||
print("[SUCCESS] 转换完成!")
|
||||
print(f" 输出文件: {output_path}")
|
||||
print(f" 封装信息: {package_info}")
|
||||
print(f" PinMAP 尺寸: {rows}×{cols}")
|
||||
print(f" Pin数量: {len(entries)}")
|
||||
|
||||
# F008: 不再退出,返回主循环
|
||||
|
||||
|
||||
# ── Main entry ──────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
show_banner()
|
||||
|
||||
# F008: 循环处理流程
|
||||
while True:
|
||||
# ── Direction selection ─────────────────────────────────────
|
||||
if len(sys.argv) > 1:
|
||||
# Legacy mode: direct file argument → MAP→List
|
||||
direction = 1
|
||||
filepath = sys.argv[1]
|
||||
sys.argv = [sys.argv[0]] # 清除 argv,下次循环进入交互模式
|
||||
else:
|
||||
print("请选择转换方向:")
|
||||
print(" 1 — PinMAP → PinList")
|
||||
print(" 2 — PinList → PinMAP")
|
||||
print(" Q — 退出程序")
|
||||
print()
|
||||
|
||||
choice = input("请输入选项 (1/2/Q): ").strip().upper()
|
||||
if choice == 'Q':
|
||||
print("感谢使用,再见!")
|
||||
return
|
||||
elif choice == '1':
|
||||
direction = 1
|
||||
elif choice == '2':
|
||||
direction = 2
|
||||
else:
|
||||
print("[ERROR] 无效选项,请输入 1、2 或 Q")
|
||||
continue
|
||||
|
||||
filepath = None
|
||||
|
||||
# ── Dispatch ────────────────────────────────────────────────
|
||||
if direction == 1:
|
||||
print()
|
||||
print("─" * 40)
|
||||
print(" 方向: PinMAP → PinList")
|
||||
print("─" * 40)
|
||||
print()
|
||||
run_map_to_list(filepath)
|
||||
else:
|
||||
print()
|
||||
print("─" * 40)
|
||||
print(" 方向: PinList → PinMAP")
|
||||
print("─" * 40)
|
||||
print()
|
||||
run_list_to_map(filepath)
|
||||
|
||||
# ── 处理完成后循环 ──────────────────────────────────────────
|
||||
print()
|
||||
print("=" * 40)
|
||||
next_choice = input("输入 Q 退出,或按 Enter 返回主菜单继续转换: ").strip().upper()
|
||||
if next_choice == 'Q':
|
||||
print("感谢使用,再见!")
|
||||
return
|
||||
# 否则继续 while 循环,回到主菜单
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -46,6 +46,31 @@ class ValidationResult:
|
||||
warnings: list[ValidationError] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PinListEntry:
|
||||
"""A single pin entry from the PinList."""
|
||||
number: int # Pin 序号(B 列)
|
||||
name: str # PinName(A 列,可能为空)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EdgePins:
|
||||
"""Pins assigned to one edge of the PinMAP."""
|
||||
edge: str # "left" | "bottom" | "right" | "top"
|
||||
pins: list[tuple[int, str]] # [(number, name), ...]
|
||||
cells: list[tuple[int, int]] # 对应的单元格坐标 (row, col) 0-based
|
||||
|
||||
|
||||
@dataclass
|
||||
class PinMAPLayout:
|
||||
"""计算出的 PinMAP 布局。"""
|
||||
package_info: str
|
||||
rows: int
|
||||
cols: int
|
||||
edges: dict[str, EdgePins] # left/bottom/right/top
|
||||
cells: dict[str, str] # 单元格数据 {"A2": "1", "B2": "Pin1", ...}
|
||||
|
||||
|
||||
# ── Custom exceptions ──────────────────────────────────────────────
|
||||
|
||||
class PinMapError(Exception):
|
||||
@@ -58,3 +83,7 @@ class FileFormatError(PinMapError):
|
||||
|
||||
class StructureError(PinMapError):
|
||||
"""Raised when the PinMAP structure is invalid or unrecognisable."""
|
||||
|
||||
|
||||
class LayoutError(PinMapError):
|
||||
"""布局计算错误(尺寸无效、Pin 数量不匹配等)。"""
|
||||
|
||||
116
Code/src/pinlist_parser.py
Normal file
116
Code/src/pinlist_parser.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""PinList parser — reads a flat pin list from an Excel file.
|
||||
|
||||
Reads an .xls or .xlsx file in PinList format:
|
||||
- A1: package info (e.g. "QFN-20")
|
||||
- Column A (from A2): PinName
|
||||
- Column B (from B2): Pin number (integer)
|
||||
|
||||
Reuses xls_reader / xlsx_reader for file I/O.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from models import StructureError, PinListEntry
|
||||
from xlsx_reader import read_excel_cells as read_xlsx_cells
|
||||
from xls_reader import read_excel_cells as read_xls_cells
|
||||
|
||||
|
||||
def _detect_format(filepath: str) -> str:
|
||||
"""Return 'xlsx' or 'xls' based on file extension."""
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
if ext == '.xlsx':
|
||||
return 'xlsx'
|
||||
elif ext == '.xls':
|
||||
return 'xls'
|
||||
else:
|
||||
raise StructureError(f"不支持的文件格式: {ext},仅支持 .xls 和 .xlsx")
|
||||
|
||||
|
||||
def _read_cells(filepath: str) -> dict[tuple[int, int], str]:
|
||||
"""Read all cells from an Excel file (xls or xlsx)."""
|
||||
fmt = _detect_format(filepath)
|
||||
if fmt == 'xlsx':
|
||||
return read_xlsx_cells(filepath)
|
||||
else:
|
||||
return read_xls_cells(filepath)
|
||||
|
||||
|
||||
class PinListParser:
|
||||
"""Parse a PinList Excel file."""
|
||||
|
||||
def __init__(self, filepath: str):
|
||||
self._filepath = filepath
|
||||
|
||||
def parse(self) -> tuple[str, list[PinListEntry]]:
|
||||
"""
|
||||
解析 PinList 文件。
|
||||
|
||||
Returns
|
||||
-------
|
||||
(package_info, entries)
|
||||
package_info: A1 单元格的封装信息
|
||||
entries: 按 Pin 序号排序的引脚列表
|
||||
|
||||
Raises
|
||||
------
|
||||
StructureError
|
||||
A1 为空、A/B 列无数据、序号非整数等
|
||||
"""
|
||||
cells = _read_cells(self._filepath)
|
||||
|
||||
# 1. 提取 A1 封装信息
|
||||
package_info = cells.get((0, 0), '').strip()
|
||||
if not package_info:
|
||||
raise StructureError("A1 单元格为空,无法获取封装信息")
|
||||
|
||||
# 2. 解析 A 列 (PinName) 和 B 列 (Pin序号)
|
||||
entries: list[PinListEntry] = []
|
||||
row = 1 # 从第 2 行开始(A2, B2)
|
||||
|
||||
while True:
|
||||
pin_name = cells.get((row, 0), '').strip() # A 列
|
||||
pin_num_str = cells.get((row, 1), '').strip() # B 列
|
||||
|
||||
# 遇到第一个空行(A列和B列都为空)时停止
|
||||
if not pin_name and not pin_num_str:
|
||||
break
|
||||
|
||||
# B 列必须为有效整数
|
||||
if not pin_num_str:
|
||||
raise StructureError(f"第 {row + 1} 行 B 列为空,缺少 Pin 序号")
|
||||
|
||||
try:
|
||||
pin_num = int(float(pin_num_str)) # 支持 "1.0" 等格式
|
||||
except ValueError:
|
||||
raise StructureError(
|
||||
f"第 {row + 1} 行 B 列 '{pin_num_str}' 不是有效的整数"
|
||||
)
|
||||
|
||||
entries.append(PinListEntry(number=pin_num, name=pin_name))
|
||||
row += 1
|
||||
|
||||
if not entries:
|
||||
raise StructureError("未找到任何引脚数据(A/B 列为空)")
|
||||
|
||||
# 3. 按 Pin 序号排序
|
||||
entries.sort(key=lambda e: e.number)
|
||||
|
||||
return package_info, entries
|
||||
|
||||
|
||||
def parse_pinlist(filepath: str) -> tuple[str, list[PinListEntry]]:
|
||||
"""
|
||||
便捷函数:解析 PinList 文件。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filepath : str
|
||||
PinList 文件路径
|
||||
|
||||
Returns
|
||||
-------
|
||||
(package_info, entries)
|
||||
封装信息和按序号排序的引脚列表
|
||||
"""
|
||||
parser = PinListParser(filepath)
|
||||
return parser.parse()
|
||||
113
Code/src/pinlist_validator.py
Normal file
113
Code/src/pinlist_validator.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""PinList validator — checks pin data integrity.
|
||||
|
||||
Validates a PinList for:
|
||||
1. Pin numbers starting from 1 with no gaps
|
||||
2. No duplicate pin numbers
|
||||
3. Total pin count matches grid perimeter (rows + cols) × 2
|
||||
4. Missing PinName defaults to NC (warning)
|
||||
5. Pin count not a multiple of 4 (info)
|
||||
"""
|
||||
|
||||
from models import PinListEntry, ValidationResult, ValidationError
|
||||
|
||||
|
||||
def validate_pinlist(
|
||||
entries: list[PinListEntry],
|
||||
rows: int,
|
||||
cols: int,
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
验证 PinList 数据。
|
||||
|
||||
检查项:
|
||||
1. Pin 序号从 1 开始连续无缺失
|
||||
2. Pin 序号无重复
|
||||
3. Pin 总数 = (rows + cols) × 2(周长匹配)
|
||||
4. Pin 缺少 PinName 时默认为 NC(warning)
|
||||
5. Pin 数量不是 4 的倍数时提示(info)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entries : list[PinListEntry]
|
||||
已按序号排序的引脚列表
|
||||
rows : int
|
||||
用户输入的 PinMAP 行数
|
||||
cols : int
|
||||
用户输入的 PinMAP 列数
|
||||
|
||||
Returns
|
||||
-------
|
||||
ValidationResult
|
||||
"""
|
||||
errors: list[ValidationError] = []
|
||||
warnings: list[ValidationError] = []
|
||||
infos: list[ValidationError] = []
|
||||
|
||||
numbers = [e.number for e in entries]
|
||||
|
||||
# ── 1. 连续性检查 ────────────────────────────────────────────
|
||||
expected_numbers = list(range(1, len(numbers) + 1))
|
||||
if numbers != expected_numbers:
|
||||
missing = set(expected_numbers) - set(numbers)
|
||||
if missing:
|
||||
errors.append(ValidationError(
|
||||
level="error",
|
||||
message="Pin序号不连续",
|
||||
details=f"缺失的序号: {sorted(missing)}",
|
||||
))
|
||||
|
||||
# ── 2. 唯一性检查 ────────────────────────────────────────────
|
||||
if len(numbers) != len(set(numbers)):
|
||||
from collections import Counter
|
||||
counts = Counter(numbers)
|
||||
duplicates = sorted(n for n, c in counts.items() if c > 1)
|
||||
errors.append(ValidationError(
|
||||
level="error",
|
||||
message="Pin序号存在重复",
|
||||
details=f"重复的序号: {duplicates}",
|
||||
))
|
||||
|
||||
# ── 3. 周长匹配 ──────────────────────────────────────────────
|
||||
# 周长公式:(rows + cols) * 2
|
||||
expected_total = (rows + cols) * 2
|
||||
actual_total = len(entries)
|
||||
if actual_total != expected_total:
|
||||
errors.append(ValidationError(
|
||||
level="error",
|
||||
message="Pin数量与网格周长不匹配",
|
||||
details=(
|
||||
f"网格 {rows}×{cols} 需要 {expected_total} 个引脚,"
|
||||
f"但 PinList 有 {actual_total} 个"
|
||||
),
|
||||
))
|
||||
|
||||
# ── 4. 缺失 PinName(warning)────────────────────────────────
|
||||
missing_names = [e for e in entries if not e.name or not e.name.strip()]
|
||||
if missing_names:
|
||||
warnings.append(ValidationError(
|
||||
level="warning",
|
||||
message=f"检测到 {len(missing_names)} 个引脚缺少 PinName",
|
||||
details=(
|
||||
f"缺失引脚序号: {[e.number for e in missing_names]},"
|
||||
f"将默认为 NC"
|
||||
),
|
||||
))
|
||||
|
||||
# ── 5. 非 4 倍数提示(info)──────────────────────────────────
|
||||
if actual_total % 4 != 0:
|
||||
infos.append(ValidationError(
|
||||
level="info",
|
||||
message="Pin数量不是4的倍数",
|
||||
details=(
|
||||
f"Pin数量 ({actual_total}) 不是 4 的倍数,"
|
||||
f"四条边将不均匀分布"
|
||||
),
|
||||
))
|
||||
|
||||
is_valid = len(errors) == 0
|
||||
|
||||
return ValidationResult(
|
||||
is_valid=is_valid,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
)
|
||||
93
Code/src/pinmap_generator.py
Normal file
93
Code/src/pinmap_generator.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""PinMAP generator — builds PinMAP cell data and writes to xlsx.
|
||||
|
||||
Takes layout calculation results and produces:
|
||||
1. A cell data dictionary (cell_ref → value)
|
||||
2. An xlsx output file with optional template styling
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from models import PinListEntry, LayoutError
|
||||
from pinmap_layout import calculate_layout, get_name_cell
|
||||
from template_reader import TemplateStyle
|
||||
from utils import rc_to_cell_ref
|
||||
from xlsx_writer import write_xlsx, write_xlsx_with_style
|
||||
|
||||
|
||||
def generate_pinmap(
|
||||
entries: list[PinListEntry],
|
||||
rows: int,
|
||||
cols: int,
|
||||
package_info: str,
|
||||
template_style: Optional[TemplateStyle] = None,
|
||||
output_path: Optional[str] = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
生成 PinMAP 布局并写入文件。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entries : list[PinListEntry]
|
||||
PinList 数据
|
||||
rows : int
|
||||
PinMAP 行数
|
||||
cols : int
|
||||
PinMAP 列数
|
||||
package_info : str
|
||||
封装信息(写入 A1)
|
||||
template_style : TemplateStyle | None
|
||||
模板样式(可选)
|
||||
output_path : str | None
|
||||
输出文件路径
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[str, str]
|
||||
单元格数据字典 {"A1": "封装", "A2": "1", "B2": "Pin1", ...}
|
||||
"""
|
||||
# 1. 计算布局
|
||||
layout = calculate_layout(entries, rows, cols)
|
||||
|
||||
# 2. 构建单元格数据
|
||||
data: dict[str, str] = {}
|
||||
data["A1"] = package_info
|
||||
|
||||
# 先写入 PinName 单元格
|
||||
for edge_name, edge in layout.items():
|
||||
for (pin_num, pin_name), num_cell in zip(edge.pins, edge.cells):
|
||||
name_cell = get_name_cell(num_cell, edge_name)
|
||||
name_ref = rc_to_cell_ref(name_cell[0], name_cell[1])
|
||||
data[name_ref] = pin_name if pin_name and pin_name.strip() else "NC"
|
||||
|
||||
# 再写入序号单元格(覆盖同位置的名字,确保序号优先)
|
||||
# v1.3: 角点单元格被两条边共享,需写入两个引脚序号
|
||||
cell_pins: dict[str, list[str]] = {}
|
||||
for edge_name, edge in layout.items():
|
||||
for (pin_num, pin_name), num_cell in zip(edge.pins, edge.cells):
|
||||
num_ref = rc_to_cell_ref(num_cell[0], num_cell[1])
|
||||
if num_ref not in cell_pins:
|
||||
cell_pins[num_ref] = []
|
||||
cell_pins[num_ref].append(str(pin_num))
|
||||
|
||||
for num_ref, pins in cell_pins.items():
|
||||
data[num_ref] = "/".join(pins)
|
||||
|
||||
# 3. 写入文件(应用模板样式)
|
||||
if output_path:
|
||||
if template_style is not None:
|
||||
write_xlsx_with_style(data, output_path, template_style)
|
||||
else:
|
||||
write_xlsx(data, output_path)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def generate_output_path(input_path: str) -> str:
|
||||
r"""
|
||||
根据输入文件路径生成默认输出路径。
|
||||
|
||||
例如: C:\test\pinlist.xlsx → C:\test\pinlist_PinMAP.xlsx
|
||||
"""
|
||||
base, _ = os.path.splitext(input_path)
|
||||
return base + "_PinMAP.xlsx"
|
||||
153
Code/src/pinmap_layout.py
Normal file
153
Code/src/pinmap_layout.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""PinMAP layout calculator — distributes pins to four edges.
|
||||
|
||||
Computes the cell coordinates for each pin on the four edges of a
|
||||
rectangular PinMAP grid, using counter-clockwise ordering starting
|
||||
from the top-left corner (pin 1).
|
||||
|
||||
Edge assignment (counter-clockwise, top-left = pin 1):
|
||||
left → rows pins
|
||||
bottom → cols pins
|
||||
right → rows pins
|
||||
top → cols pins
|
||||
|
||||
Total: rows + cols + rows + cols = 2×rows + 2×cols = (rows + cols) × 2
|
||||
|
||||
v1.3: 每条边独立包含其端点,角点单元格会被两条边共享。
|
||||
"""
|
||||
|
||||
from models import PinListEntry, EdgePins, LayoutError
|
||||
|
||||
|
||||
def calculate_layout(
|
||||
entries: list[PinListEntry],
|
||||
rows: int,
|
||||
cols: int,
|
||||
) -> dict[str, EdgePins]:
|
||||
"""
|
||||
计算 PinMAP 布局。
|
||||
|
||||
逆时针分配(左上角为 1 脚):
|
||||
左边 → 下边 → 右边 → 上边
|
||||
|
||||
角点分配策略(v1.3:每条边独立包含其端点):
|
||||
- 左边: 包含左下角
|
||||
- 下边: 包含右下角
|
||||
- 右边: 包含右上角
|
||||
- 上边: 包含左上角
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entries : list[PinListEntry]
|
||||
已按序号排序的引脚列表
|
||||
rows : int
|
||||
PinMAP 行数
|
||||
cols : int
|
||||
PinMAP 列数
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[str, EdgePins]
|
||||
{"left": ..., "bottom": ..., "right": ..., "top": ...}
|
||||
|
||||
Raises
|
||||
------
|
||||
LayoutError
|
||||
尺寸无效(rows < 2 或 cols < 2)
|
||||
"""
|
||||
# ── 参数校验 ──────────────────────────────────────────────────
|
||||
if rows < 2:
|
||||
raise LayoutError(f"行数无效: {rows},至少需要 2 行")
|
||||
if cols < 2:
|
||||
raise LayoutError(f"列数无效: {cols},至少需要 2 列")
|
||||
|
||||
# ── 边分配计数(v1.3:每条边独立包含其端点)─────────────────
|
||||
left_count = rows
|
||||
bottom_count = cols
|
||||
right_count = rows
|
||||
top_count = cols
|
||||
|
||||
total = (rows + cols) * 2
|
||||
if len(entries) != total:
|
||||
raise LayoutError(
|
||||
f"Pin数量 ({len(entries)}) 与网格周长 ({total}) 不匹配"
|
||||
)
|
||||
|
||||
# ── 引脚索引分配 ──────────────────────────────────────────────
|
||||
idx = 0
|
||||
|
||||
left_pins = entries[idx: idx + left_count]
|
||||
idx += left_count
|
||||
|
||||
bottom_pins = entries[idx: idx + bottom_count]
|
||||
idx += bottom_count
|
||||
|
||||
right_pins = entries[idx: idx + right_count]
|
||||
idx += right_count
|
||||
|
||||
top_pins = entries[idx: idx + top_count]
|
||||
|
||||
# ── 计算单元格坐标 ────────────────────────────────────────────
|
||||
#
|
||||
# 网格坐标体系(0-based):
|
||||
# 方形区域:行 [1..rows],列 [0..cols]
|
||||
# 左边: 序号在 (r, 0), Name 在 (r, 1) 其中 r ∈ [1, rows]
|
||||
# 下边: 序号在 (rows, c), Name 在 (rows-1, c) 其中 c ∈ [1, cols]
|
||||
# 右边: 序号在 (r, cols), Name 在 (r, cols-1) 其中 r ∈ [rows, 1] 逆序
|
||||
# 上边: 序号在 (1, c), Name 在 (2, c) 其中 c ∈ [cols, 1] 逆序
|
||||
#
|
||||
# v1.3: 每条边独立包含其端点,角点单元格会被两条边共享
|
||||
#
|
||||
|
||||
# 左边:从上到下
|
||||
left_cells = [(r, 0) for r in range(1, rows + 1)]
|
||||
|
||||
# 下边:从左到右
|
||||
bottom_cells = [(rows, c) for c in range(1, cols + 1)]
|
||||
|
||||
# 右边:从下到上(逆序)
|
||||
right_cells = [(r, cols) for r in range(rows, 0, -1)]
|
||||
|
||||
# 上边:从右到左(逆序)
|
||||
top_cells = [(1, c) for c in range(cols, 0, -1)]
|
||||
|
||||
# ── 构建 EdgePins ─────────────────────────────────────────────
|
||||
def _make_edge(edge_name: str, pin_list: list[PinListEntry],
|
||||
cell_list: list[tuple[int, int]]) -> EdgePins:
|
||||
pins = [(p.number, p.name) for p in pin_list]
|
||||
return EdgePins(edge=edge_name, pins=pins, cells=cell_list)
|
||||
|
||||
return {
|
||||
"left": _make_edge("left", left_pins, left_cells),
|
||||
"bottom": _make_edge("bottom", bottom_pins, bottom_cells),
|
||||
"right": _make_edge("right", right_pins, right_cells),
|
||||
"top": _make_edge("top", top_pins, top_cells),
|
||||
}
|
||||
|
||||
|
||||
def get_name_cell(num_cell: tuple[int, int], edge_name: str) -> tuple[int, int]:
|
||||
"""
|
||||
根据序号单元格坐标和边名称,计算对应的 PinName 单元格坐标。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
num_cell : tuple[int, int]
|
||||
序号单元格坐标 (row, col) 0-based
|
||||
edge_name : str
|
||||
"left" | "bottom" | "right" | "top"
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[int, int]
|
||||
PinName 单元格坐标 (row, col) 0-based
|
||||
"""
|
||||
r, c = num_cell
|
||||
if edge_name == "left":
|
||||
return (r, c + 1) # Name 在序号右侧
|
||||
elif edge_name == "bottom":
|
||||
return (r - 1, c) # Name 在序号上方
|
||||
elif edge_name == "right":
|
||||
return (r, c - 1) # Name 在序号左侧
|
||||
elif edge_name == "top":
|
||||
return (r + 1, c) # Name 在序号下方
|
||||
else:
|
||||
raise LayoutError(f"未知的边名称: {edge_name}")
|
||||
@@ -117,41 +117,49 @@ def parse_pinmap(cells: dict[tuple[int, int], str]) -> PinMAP:
|
||||
if name and str(name).strip():
|
||||
name_map[(min_row, c)] = str(name).strip()
|
||||
|
||||
# ── Step 4: walk edges counter-clockwise ─────────────────────
|
||||
# Deduplicate by *cell position* (corners are shared cells),
|
||||
# NOT by pin number — duplicate numbers are a data error for
|
||||
# the validator to catch.
|
||||
# ── Step 4: walk edges counter-clockwise (v1.3 formula) ──────
|
||||
# Each edge independently includes its endpoints (corners).
|
||||
# Corner cells are read by two edges — this is expected per
|
||||
# v1.3: total = (rows + cols) × 2.
|
||||
pins: list[Pin] = []
|
||||
|
||||
seen_cells: set[tuple[int, int]] = set()
|
||||
|
||||
def _add_pin(r: int, c: int, edge: str, pos: int) -> None:
|
||||
# Skip if this cell was already processed (corner visited by two edges)
|
||||
if (r, c) in seen_cells:
|
||||
return # corner cell already processed
|
||||
seen_cells.add((r, c))
|
||||
num = _try_int(cells.get((r, c), ""))
|
||||
if num is None:
|
||||
return
|
||||
pins.append(Pin(
|
||||
number=num,
|
||||
name=name_map.get((r, c), ""),
|
||||
edge=edge,
|
||||
position_on_edge=pos,
|
||||
))
|
||||
seen_cells.add((r, c))
|
||||
raw = cells.get((r, c), "")
|
||||
if not raw:
|
||||
return
|
||||
# Handle "6/7" format from corner cells
|
||||
parts = str(raw).strip().split("/")
|
||||
for part in parts:
|
||||
num = _try_int(part)
|
||||
if num is None:
|
||||
continue
|
||||
pins.append(Pin(
|
||||
number=num,
|
||||
name=name_map.get((r, c), ""),
|
||||
edge=edge,
|
||||
position_on_edge=pos,
|
||||
))
|
||||
|
||||
# 4a. Left edge: top → bottom
|
||||
# 4a. Left edge: top → bottom (includes bottom-left corner)
|
||||
for r in range(min_row, max_row + 1):
|
||||
_add_pin(r, min_col, "left", r - min_row)
|
||||
|
||||
# 4b. Bottom edge: left → right (skip min_col corner already done)
|
||||
for c in range(min_col + 1, max_col + 1):
|
||||
# 4b. Bottom edge: left → right (includes bottom-right corner)
|
||||
for c in range(min_col, max_col + 1):
|
||||
_add_pin(max_row, c, "bottom", c - min_col)
|
||||
|
||||
# 4c. Right edge: bottom → top (skip max_row corner already done)
|
||||
for r in range(max_row - 1, min_row - 1, -1):
|
||||
# 4c. Right edge: bottom → top (includes top-right corner)
|
||||
for r in range(max_row, min_row - 1, -1):
|
||||
_add_pin(r, max_col, "right", max_row - r)
|
||||
|
||||
# 4d. Top edge: right → left (skip max_col corner already done)
|
||||
for c in range(max_col - 1, min_col - 1, -1):
|
||||
# 4d. Top edge: right → left (includes top-left corner)
|
||||
for c in range(max_col, min_col - 1, -1):
|
||||
_add_pin(min_row, c, "top", max_col - c)
|
||||
|
||||
if not pins:
|
||||
|
||||
233
Code/src/template_reader.py
Normal file
233
Code/src/template_reader.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Template reader — extracts cell styles from a template xlsx file.
|
||||
|
||||
Parses xl/styles.xml from an xlsx (ZIP) file to extract:
|
||||
- fonts, fills, borders, cellXfs
|
||||
- column widths and row heights from the worksheet
|
||||
|
||||
When the template file does not exist or cannot be parsed,
|
||||
returns None so the caller can gracefully fall back to defaults.
|
||||
"""
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
# OOXML namespace
|
||||
_S = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
|
||||
|
||||
|
||||
def _tag(local: str) -> str:
|
||||
"""Build a namespaced tag like {ns}font."""
|
||||
return f'{{{_S}}}{local}'
|
||||
|
||||
|
||||
# ── Data classes ────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class FontStyle:
|
||||
"""Font style."""
|
||||
name: str = "Calibri"
|
||||
size: float = 11.0
|
||||
bold: bool = False
|
||||
italic: bool = False
|
||||
color: str = "000000"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BorderStyle:
|
||||
"""Border style for a cell."""
|
||||
top: str = "none"
|
||||
bottom: str = "none"
|
||||
left: str = "none"
|
||||
right: str = "none"
|
||||
color: str = "000000"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FillStyle:
|
||||
"""Cell fill style."""
|
||||
pattern_type: str = "none"
|
||||
fg_color: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateStyle:
|
||||
"""Complete style extracted from template."""
|
||||
fonts: list[FontStyle] = field(default_factory=list)
|
||||
borders: list[BorderStyle] = field(default_factory=list)
|
||||
fills: list[FillStyle] = field(default_factory=list)
|
||||
cell_xfs: list[dict] = field(default_factory=list)
|
||||
column_widths: dict[int, float] = field(default_factory=dict)
|
||||
row_heights: dict[int, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ── TemplateReader ──────────────────────────────────────────────────
|
||||
|
||||
class TemplateReader:
|
||||
"""Read styles from a template xlsx file."""
|
||||
|
||||
def __init__(self, filepath: str):
|
||||
self._filepath = filepath
|
||||
|
||||
def read_styles(self) -> Optional[TemplateStyle]:
|
||||
"""
|
||||
读取模板文件的样式信息。
|
||||
|
||||
解析 xl/styles.xml 中的 fonts/fills/borders/cellXfs,
|
||||
以及 sheet1.xml 中的列宽和行高。
|
||||
|
||||
Returns
|
||||
-------
|
||||
TemplateStyle | None
|
||||
模板样式;文件不存在或解析失败时返回 None
|
||||
"""
|
||||
if not os.path.exists(self._filepath):
|
||||
return None
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(self._filepath, 'r') as zf:
|
||||
style = TemplateStyle()
|
||||
|
||||
# 解析 styles.xml
|
||||
if 'xl/styles.xml' in zf.namelist():
|
||||
self._parse_styles_xml(zf.read('xl/styles.xml'), style)
|
||||
|
||||
# 解析 sheet1.xml 获取列宽和行高
|
||||
if 'xl/worksheets/sheet1.xml' in zf.namelist():
|
||||
self._parse_sheet_dims(zf.read('xl/worksheets/sheet1.xml'), style)
|
||||
|
||||
return style
|
||||
|
||||
except Exception:
|
||||
# 优雅降级:模板解析失败时返回 None
|
||||
return None
|
||||
|
||||
def _parse_styles_xml(self, data: bytes, style: TemplateStyle):
|
||||
"""解析 xl/styles.xml 提取字体、填充、边框、单元格格式。"""
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except ET.ParseError:
|
||||
return
|
||||
|
||||
# ── Fonts ──────────────────────────────────────────────────
|
||||
fonts_elem = root.find(_tag('fonts'))
|
||||
if fonts_elem is not None:
|
||||
for font_elem in fonts_elem.findall(_tag('font')):
|
||||
fs = FontStyle()
|
||||
name_elem = font_elem.find(_tag('name'))
|
||||
if name_elem is not None:
|
||||
fs.name = name_elem.get('val', 'Calibri')
|
||||
sz_elem = font_elem.find(_tag('sz'))
|
||||
if sz_elem is not None:
|
||||
try:
|
||||
fs.size = float(sz_elem.get('val', '11'))
|
||||
except ValueError:
|
||||
pass
|
||||
bold_elem = font_elem.find(_tag('b'))
|
||||
fs.bold = bold_elem is not None
|
||||
italic_elem = font_elem.find(_tag('i'))
|
||||
fs.italic = italic_elem is not None
|
||||
color_elem = font_elem.find(_tag('color'))
|
||||
if color_elem is not None:
|
||||
fs.color = color_elem.get('rgb', '000000')
|
||||
style.fonts.append(fs)
|
||||
|
||||
# ── Fills ──────────────────────────────────────────────────
|
||||
fills_elem = root.find(_tag('fills'))
|
||||
if fills_elem is not None:
|
||||
for fill_elem in fills_elem.findall(_tag('fill')):
|
||||
fg = FillStyle()
|
||||
pattern = fill_elem.find(_tag('patternFill'))
|
||||
if pattern is not None:
|
||||
fg.pattern_type = pattern.get('patternType', 'none')
|
||||
fg_elem = pattern.find(_tag('fgColor'))
|
||||
if fg_elem is not None:
|
||||
fg.fg_color = fg_elem.get('rgb', '')
|
||||
style.fills.append(fg)
|
||||
|
||||
# ── Borders ────────────────────────────────────────────────
|
||||
borders_elem = root.find(_tag('borders'))
|
||||
if borders_elem is not None:
|
||||
for border_elem in borders_elem.findall(_tag('border')):
|
||||
bs = BorderStyle()
|
||||
for side_name in ('left', 'right', 'top', 'bottom'):
|
||||
side = border_elem.find(_tag(side_name))
|
||||
if side is not None:
|
||||
style_val = side.get('style', 'none')
|
||||
setattr(bs, side_name, style_val)
|
||||
color = side.find(_tag('color'))
|
||||
if color is not None:
|
||||
bs.color = color.get('rgb', '000000')
|
||||
style.borders.append(bs)
|
||||
|
||||
# ── Cell XFs ───────────────────────────────────────────────
|
||||
xfs_elem = root.find(_tag('cellXfs'))
|
||||
if xfs_elem is not None:
|
||||
for xf in xfs_elem.findall(_tag('xf')):
|
||||
xf_info = {
|
||||
'numFmtId': xf.get('numFmtId', '0'),
|
||||
'fontId': xf.get('fontId', '0'),
|
||||
'fillId': xf.get('fillId', '0'),
|
||||
'borderId': xf.get('borderId', '0'),
|
||||
'applyFont': xf.get('applyFont', ''),
|
||||
'applyFill': xf.get('applyFill', ''),
|
||||
'applyBorder': xf.get('applyBorder', ''),
|
||||
}
|
||||
# 对齐方式
|
||||
align = xf.find(_tag('alignment'))
|
||||
if align is not None:
|
||||
xf_info['hAlign'] = align.get('horizontal', '')
|
||||
xf_info['vAlign'] = align.get('vertical', '')
|
||||
style.cell_xfs.append(xf_info)
|
||||
|
||||
def _parse_sheet_dims(self, data: bytes, style: TemplateStyle):
|
||||
"""解析 sheet1.xml 提取列宽和行高。"""
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except ET.ParseError:
|
||||
return
|
||||
|
||||
# 列宽
|
||||
cols_elem = root.find(_tag('cols'))
|
||||
if cols_elem is not None:
|
||||
for col in cols_elem.findall(_tag('col')):
|
||||
try:
|
||||
min_col = int(col.get('min', '1')) - 1 # 0-based
|
||||
max_col = int(col.get('max', str(min_col + 1))) - 1
|
||||
width = float(col.get('width', '0'))
|
||||
for c in range(min_col, max_col + 1):
|
||||
style.column_widths[c] = width
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 行高
|
||||
sheet_data = root.find(_tag('sheetData'))
|
||||
if sheet_data is not None:
|
||||
for row_elem in sheet_data.findall(_tag('row')):
|
||||
try:
|
||||
r = int(row_elem.get('r', '0')) - 1 # 0-based
|
||||
ht = row_elem.get('ht')
|
||||
if ht is not None:
|
||||
style.row_heights[r] = float(ht)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def read_template_styles(filepath: str) -> Optional[TemplateStyle]:
|
||||
"""
|
||||
便捷函数:读取模板样式。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filepath : str
|
||||
模板文件路径
|
||||
|
||||
Returns
|
||||
-------
|
||||
TemplateStyle | None
|
||||
模板样式;不存在或解析失败时返回 None
|
||||
"""
|
||||
reader = TemplateReader(filepath)
|
||||
return reader.read_styles()
|
||||
@@ -101,3 +101,89 @@ def validate_pinmap(pinmap: PinMAP) -> ValidationResult:
|
||||
result.is_valid = False
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def validate_pinlist_for_map(
|
||||
entries: list,
|
||||
rows: int,
|
||||
cols: int,
|
||||
) -> ValidationResult:
|
||||
"""验证 PinList 数据是否适合转换为 PinMAP。
|
||||
|
||||
Checks performed
|
||||
----------------
|
||||
1. **Continuity** — pin numbers must start from 1 with no gaps.
|
||||
2. **Uniqueness** — no duplicate pin numbers.
|
||||
3. **Perimeter match** — total pin count must equal
|
||||
(rows + cols) × 2 (the grid perimeter).
|
||||
4. **Non-multiple-of-4** — if pin count is not a multiple of 4,
|
||||
a warning is issued (but conversion is not blocked).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entries : list[PinListEntry]
|
||||
PinList entries, each with ``number`` and ``name`` attributes.
|
||||
rows : int
|
||||
Target PinMAP row count.
|
||||
cols : int
|
||||
Target PinMAP column count.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ValidationResult
|
||||
"""
|
||||
result = ValidationResult(is_valid=True, errors=[], warnings=[])
|
||||
|
||||
numbers = [e.number for e in entries]
|
||||
|
||||
# ── 1. Continuity (1..N, no gaps) ────────────────────────────
|
||||
expected_numbers = list(range(1, len(numbers) + 1))
|
||||
if numbers != expected_numbers:
|
||||
missing = set(expected_numbers) - set(numbers)
|
||||
if missing:
|
||||
result.errors.append(ValidationError(
|
||||
level="error",
|
||||
message="Pin序号不连续",
|
||||
details=f"缺失的序号: {sorted(missing)}",
|
||||
))
|
||||
|
||||
# ── 2. Uniqueness ────────────────────────────────────────────
|
||||
if len(numbers) != len(set(numbers)):
|
||||
counts = Counter(numbers)
|
||||
duplicates = sorted(n for n, c in counts.items() if c > 1)
|
||||
result.errors.append(ValidationError(
|
||||
level="error",
|
||||
message="Pin序号存在重复",
|
||||
details=f"重复的序号: {duplicates}",
|
||||
))
|
||||
|
||||
# ── 3. Perimeter match ───────────────────────────────────────
|
||||
# 周长公式:(rows + cols) * 2
|
||||
expected_total = (rows + cols) * 2
|
||||
actual_total = len(entries)
|
||||
if actual_total != expected_total:
|
||||
result.errors.append(ValidationError(
|
||||
level="error",
|
||||
message="Pin数量与网格周长不匹配",
|
||||
details=(
|
||||
f"网格 {rows}×{cols} 需要 {expected_total} 个引脚,"
|
||||
f"但 PinList 有 {actual_total} 个"
|
||||
),
|
||||
))
|
||||
|
||||
# ── 4. Non-multiple-of-4 warning ─────────────────────────────
|
||||
if actual_total % 4 != 0:
|
||||
result.warnings.append(ValidationError(
|
||||
level="warning",
|
||||
message="Pin数量不是4的倍数",
|
||||
details=(
|
||||
f"Pin数量 ({actual_total}) 不是 4 的倍数,"
|
||||
f"四条边将不均匀分布"
|
||||
),
|
||||
))
|
||||
|
||||
# ── Final verdict ────────────────────────────────────────────
|
||||
if result.errors:
|
||||
result.is_valid = False
|
||||
|
||||
return result
|
||||
|
||||
@@ -154,3 +154,299 @@ class XLSXWriter:
|
||||
col -= 1
|
||||
row = int(''.join(row_digits)) - 1
|
||||
return row, col
|
||||
|
||||
|
||||
# ── Styled writer (for PinMAP output with template styles) ─────────
|
||||
|
||||
def write_xlsx_with_style(
|
||||
data: dict[str, str],
|
||||
output_path: str,
|
||||
style=None, # TemplateStyle | None
|
||||
):
|
||||
"""Write a cell map to an .xlsx file with optional template styling.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : dict[str, str]
|
||||
Mapping of Excel cell references to values.
|
||||
output_path : str
|
||||
Path for the output .xlsx file.
|
||||
style : TemplateStyle | None
|
||||
Template style extracted by template_reader. If None,
|
||||
uses minimal default styling (centered, default font).
|
||||
"""
|
||||
writer = StyledXLSXWriter(style)
|
||||
writer.write(data, output_path)
|
||||
|
||||
|
||||
class StyledXLSXWriter:
|
||||
"""Build a styled OOXML .xlsx file from a cell map."""
|
||||
|
||||
def __init__(self, style=None):
|
||||
self._strings: list[str] = []
|
||||
self._string_index: dict[str, int] = {}
|
||||
self._style = style # TemplateStyle | None
|
||||
|
||||
def write(self, data: dict[str, str], output_path: str):
|
||||
"""Write *data* to *output_path* as a styled .xlsx file."""
|
||||
for value in data.values():
|
||||
self._add_string(value)
|
||||
|
||||
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr('[Content_Types].xml', self._content_types_xml())
|
||||
zf.writestr('_rels/.rels', self._rels_xml())
|
||||
zf.writestr('xl/workbook.xml', self._workbook_xml())
|
||||
zf.writestr('xl/_rels/workbook.xml.rels', self._workbook_rels_xml())
|
||||
zf.writestr('xl/styles.xml', self._styles_xml())
|
||||
zf.writestr('xl/sharedStrings.xml', self._shared_strings_xml())
|
||||
zf.writestr('xl/worksheets/sheet1.xml', self._sheet_xml(data))
|
||||
|
||||
def _add_string(self, s: str) -> int:
|
||||
"""Add a string to the SST and return its index."""
|
||||
if s in self._string_index:
|
||||
return self._string_index[s]
|
||||
idx = len(self._strings)
|
||||
self._strings.append(s)
|
||||
self._string_index[s] = idx
|
||||
return idx
|
||||
|
||||
# ── XML builders ───────────────────────────────────────────────
|
||||
|
||||
def _content_types_xml(self) -> str:
|
||||
return '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
|
||||
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||
</Types>'''
|
||||
|
||||
def _rels_xml(self) -> str:
|
||||
return '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>'''
|
||||
|
||||
def _workbook_xml(self) -> str:
|
||||
return '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets>
|
||||
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
|
||||
</sheets>
|
||||
</workbook>'''
|
||||
|
||||
def _workbook_rels_xml(self) -> str:
|
||||
return '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
|
||||
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>'''
|
||||
|
||||
def _shared_strings_xml(self) -> str:
|
||||
parts = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
|
||||
parts.append(
|
||||
f'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||||
f'count="{len(self._strings)}" unique="{len(self._strings)}">'
|
||||
)
|
||||
for s in self._strings:
|
||||
escaped = s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
parts.append(f' <si><t>{escaped}</t></si>')
|
||||
parts.append('</sst>')
|
||||
return '\n'.join(parts)
|
||||
|
||||
def _styles_xml(self) -> str:
|
||||
"""Build xl/styles.xml with fonts, fills, borders, and cellXfs."""
|
||||
s = self._style
|
||||
|
||||
# ── Fonts ──────────────────────────────────────────────────
|
||||
fonts_xml = '<fonts count="2">'
|
||||
# Font 0: default (no bold)
|
||||
font_name = "Calibri"
|
||||
font_size = "11"
|
||||
font_color = "FF000000"
|
||||
if s and s.fonts:
|
||||
f0 = s.fonts[0]
|
||||
font_name = f0.name
|
||||
font_size = str(f0.size)
|
||||
font_color = "FF" + f0.color if f0.color and not f0.color.startswith("FF") else f0.color
|
||||
fonts_xml += (
|
||||
f'<font><sz val="{font_size}"/>'
|
||||
f'<name val="{font_name}"/>'
|
||||
f'<color rgb="{font_color}"/>'
|
||||
f'</font>'
|
||||
)
|
||||
# Font 1: bold (for package info in A1)
|
||||
fonts_xml += (
|
||||
f'<font><sz val="{font_size}"/>'
|
||||
f'<b/>'
|
||||
f'<name val="{font_name}"/>'
|
||||
f'<color rgb="{font_color}"/>'
|
||||
f'</font>'
|
||||
)
|
||||
fonts_xml += '</fonts>'
|
||||
|
||||
# ── Fills ──────────────────────────────────────────────────
|
||||
fills_xml = '<fills count="2">'
|
||||
fills_xml += '<fill><patternFill patternType="none"/></fill>'
|
||||
# Fill 1: light gray for header-like cells
|
||||
fills_xml += (
|
||||
'<fill><patternFill patternType="solid">'
|
||||
'<fgColor rgb="FFF0F0F0"/>'
|
||||
'</patternFill></fill>'
|
||||
)
|
||||
fills_xml += '</fills>'
|
||||
|
||||
# ── Borders ────────────────────────────────────────────────
|
||||
borders_xml = '<borders count="2">'
|
||||
# Border 0: none
|
||||
borders_xml += (
|
||||
'<border>'
|
||||
'<left/><right/><top/><bottom/><diagonal/>'
|
||||
'</border>'
|
||||
)
|
||||
# Border 1: thin all sides
|
||||
borders_xml += (
|
||||
'<border>'
|
||||
'<left style="thin"/><right style="thin"/>'
|
||||
'<top style="thin"/><bottom style="thin"/>'
|
||||
'<diagonal/>'
|
||||
'</border>'
|
||||
)
|
||||
borders_xml += '</borders>'
|
||||
|
||||
# ── Cell XFs ───────────────────────────────────────────────
|
||||
# xf 0: default (no style)
|
||||
# xf 1: centered with thin border (for pin cells)
|
||||
# xf 2: bold + centered (for A1 package info)
|
||||
# xf 3: centered + border + light fill (for header-like)
|
||||
cell_xfs_xml = '<cellXfs count="4">'
|
||||
cell_xfs_xml += (
|
||||
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
|
||||
)
|
||||
cell_xfs_xml += (
|
||||
'<xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0" '
|
||||
'applyBorder="1">'
|
||||
'<alignment horizontal="center" vertical="center"/>'
|
||||
'</xf>'
|
||||
)
|
||||
cell_xfs_xml += (
|
||||
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" '
|
||||
'applyFont="1">'
|
||||
'<alignment horizontal="center" vertical="center"/>'
|
||||
'</xf>'
|
||||
)
|
||||
cell_xfs_xml += (
|
||||
'<xf numFmtId="0" fontId="0" fillId="1" borderId="1" xfId="0" '
|
||||
'applyFill="1" applyBorder="1">'
|
||||
'<alignment horizontal="center" vertical="center"/>'
|
||||
'</xf>'
|
||||
)
|
||||
cell_xfs_xml += '</cellXfs>'
|
||||
|
||||
parts = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
|
||||
parts.append(
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
)
|
||||
parts.append(fonts_xml)
|
||||
parts.append(fills_xml)
|
||||
parts.append(borders_xml)
|
||||
parts.append(cell_xfs_xml)
|
||||
parts.append('</styleSheet>')
|
||||
return '\n'.join(parts)
|
||||
|
||||
def _sheet_xml(self, data: dict[str, str]) -> str:
|
||||
"""Build sheet1.xml with style indices applied."""
|
||||
max_row = 0
|
||||
max_col = 0
|
||||
for ref in data:
|
||||
row, col = self._ref_to_rc(ref)
|
||||
max_row = max(max_row, row)
|
||||
max_col = max(max_col, col)
|
||||
|
||||
# Determine column widths from template
|
||||
col_widths_xml = ''
|
||||
if self._style and self._style.column_widths:
|
||||
# Find max column with a width setting
|
||||
max_width_col = max(self._style.column_widths.keys()) if self._style.column_widths else 0
|
||||
max_width_col = max(max_width_col, max_col)
|
||||
if max_width_col >= 0:
|
||||
col_widths_xml = ' <cols>'
|
||||
for c in range(max_width_col + 1):
|
||||
width = self._style.column_widths.get(c, 8.0)
|
||||
col_widths_xml += (
|
||||
f'<col min="{c + 1}" max="{c + 1}" width="{width:.1f}" '
|
||||
f'customWidth="1"/>'
|
||||
)
|
||||
col_widths_xml += '</cols>\n'
|
||||
|
||||
# Determine row heights from template
|
||||
row_heights = self._style.row_heights if self._style else {}
|
||||
|
||||
parts = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
|
||||
parts.append('<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">')
|
||||
parts.append(f' <dimension ref="A1:{rc_to_cell_ref(max_row, max_col)}"/>')
|
||||
if col_widths_xml:
|
||||
parts.append(col_widths_xml)
|
||||
parts.append(' <sheetData>')
|
||||
|
||||
# Group cells by row
|
||||
rows: dict[int, list[tuple[int, str, int]]] = {}
|
||||
for ref, value in data.items():
|
||||
row, col = self._ref_to_rc(ref)
|
||||
if row not in rows:
|
||||
rows[row] = []
|
||||
# Determine style index
|
||||
style_idx = self._get_style_index(row, col, ref)
|
||||
rows[row].append((col, value, style_idx))
|
||||
|
||||
for row_num in sorted(rows):
|
||||
row_attrs = f'r="{row_num + 1}"'
|
||||
if row_num in row_heights:
|
||||
row_attrs += f' ht="{row_heights[row_num]:.1f}" customHeight="1"'
|
||||
parts.append(f' <row {row_attrs}>')
|
||||
for col, value, style_idx in sorted(rows[row_num]):
|
||||
cell_ref = rc_to_cell_ref(row_num, col)
|
||||
si = self._add_string(value)
|
||||
parts.append(
|
||||
f' <c r="{cell_ref}" t="s" s="{style_idx}">'
|
||||
f'<v>{si}</v></c>'
|
||||
)
|
||||
parts.append(' </row>')
|
||||
|
||||
parts.append(' </sheetData>')
|
||||
parts.append('</worksheet>')
|
||||
return '\n'.join(parts)
|
||||
|
||||
def _get_style_index(self, row: int, col: int, ref: str) -> int:
|
||||
"""Determine the style index for a cell.
|
||||
|
||||
Style indices:
|
||||
0 = default (no style)
|
||||
1 = centered + thin border (pin number/name cells)
|
||||
2 = bold + centered (A1 package info)
|
||||
3 = centered + border + light fill (header-like)
|
||||
"""
|
||||
if ref == "A1":
|
||||
return 2 # Bold centered for package info
|
||||
# Pin cells: all cells except A1 get border + center
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def _ref_to_rc(ref: str) -> tuple[int, int]:
|
||||
"""Convert cell reference to (row, col) 0-based."""
|
||||
col_letters = []
|
||||
row_digits = []
|
||||
for ch in ref:
|
||||
if ch.isalpha():
|
||||
col_letters.append(ch)
|
||||
else:
|
||||
row_digits.append(ch)
|
||||
col = 0
|
||||
for ch in ''.join(col_letters).upper():
|
||||
col = col * 26 + (ord(ch) - ord('A') + 1)
|
||||
col -= 1
|
||||
row = int(''.join(row_digits)) - 1
|
||||
return row, col
|
||||
|
||||
BIN
Releases/pinmap-to-pinlist-v1.1.0.zip
Normal file
BIN
Releases/pinmap-to-pinlist-v1.1.0.zip
Normal file
Binary file not shown.
BIN
Releases/pinmap-to-pinlist-v1.2.0.zip
Normal file
BIN
Releases/pinmap-to-pinlist-v1.2.0.zip
Normal file
Binary file not shown.
BIN
Test/fixtures/sample_4x4.xlsx
vendored
BIN
Test/fixtures/sample_4x4.xlsx
vendored
Binary file not shown.
686
Test/run_tests.py
Normal file
686
Test/run_tests.py
Normal file
@@ -0,0 +1,686 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PinMAP ↔ PinList 双向转换器 — 完整测试脚本
|
||||
|
||||
测试范围:
|
||||
1. MAP→List (原有功能回归测试)
|
||||
2. List→MAP (新增功能测试)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
# 把 Code/src 加入 path
|
||||
SRC_DIR = os.path.join(os.path.dirname(__file__), '..', 'Code', 'src')
|
||||
sys.path.insert(0, SRC_DIR)
|
||||
|
||||
from models import PinListEntry, ValidationResult, ValidationError
|
||||
from pinlist_parser import parse_pinlist
|
||||
from pinlist_validator import validate_pinlist
|
||||
from pinmap_layout import calculate_layout, get_name_cell
|
||||
from pinmap_generator import generate_pinmap, generate_output_path
|
||||
from template_reader import read_template_styles
|
||||
from xlsx_reader import read_excel_cells as read_xlsx_cells
|
||||
from xlsx_writer import write_xlsx
|
||||
from pinmap_parser import parse_pinmap
|
||||
from validator import validate_pinmap
|
||||
from pinlist_generator import generate_pinlist
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestResult:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.passed = False
|
||||
self.error = None
|
||||
self.details = ""
|
||||
|
||||
def ok(self, detail=""):
|
||||
self.passed = True
|
||||
self.details = detail
|
||||
|
||||
def fail(self, error, detail=""):
|
||||
self.passed = False
|
||||
self.error = error
|
||||
self.details = detail
|
||||
|
||||
|
||||
class TestRunner:
|
||||
def __init__(self):
|
||||
self.results: list[TestResult] = []
|
||||
|
||||
def run(self, name, fn):
|
||||
r = TestResult(name)
|
||||
try:
|
||||
fn(r)
|
||||
except Exception as e:
|
||||
r.fail(str(e))
|
||||
self.results.append(r)
|
||||
status = "✅" if r.passed else "❌"
|
||||
detail = f" — {r.details}" if r.details else ""
|
||||
print(f" {status} {name}{detail}")
|
||||
|
||||
def summary(self):
|
||||
total = len(self.results)
|
||||
passed = sum(1 for r in self.results if r.passed)
|
||||
failed = total - passed
|
||||
return total, passed, failed
|
||||
|
||||
|
||||
def create_pinlist_xlsx(data: dict, path: str):
|
||||
"""Helper: write a PinList xlsx from cell dict."""
|
||||
write_xlsx(data, path)
|
||||
|
||||
|
||||
def create_pinmap_fixture(data: dict, path: str):
|
||||
"""Helper: write a PinMAP fixture xlsx."""
|
||||
write_xlsx(data, path)
|
||||
|
||||
|
||||
# ── Part 1: MAP→List Regression Tests ──────────────────────────────
|
||||
|
||||
def test_map_to_list(r: TestRunner):
|
||||
fixture_dir = os.path.join(os.path.dirname(__file__), 'fixtures')
|
||||
|
||||
# TC-MAP-001: 标准 4x4 PinMAP 转换
|
||||
def _tc_map_001(result):
|
||||
filepath = os.path.join(fixture_dir, 'sample_4x4.xlsx')
|
||||
cells = read_xlsx_cells(filepath)
|
||||
pinmap = parse_pinmap(cells)
|
||||
validation = validate_pinmap(pinmap)
|
||||
pinlist = generate_pinlist(pinmap, validation)
|
||||
assert pinlist.package_info, "package_info 不应为空"
|
||||
assert len(pinlist.rows) > 0, "应有引脚数据"
|
||||
# 验证递增排序
|
||||
nums = [num for _, num in pinlist.rows]
|
||||
assert nums == sorted(nums), f"序号应递增,实际: {nums}"
|
||||
result.ok(f"封装={pinlist.package_info}, Pin数={len(pinlist.rows)}, 序号递增")
|
||||
|
||||
r.run("TC-MAP-001: 标准4x4 PinMAP转换", _tc_map_001)
|
||||
|
||||
# TC-MAP-002: 长方形 PinMAP 转换
|
||||
def _tc_map_002(result):
|
||||
filepath = os.path.join(fixture_dir, 'sample_rect.xlsx')
|
||||
cells = read_xlsx_cells(filepath)
|
||||
pinmap = parse_pinmap(cells)
|
||||
validation = validate_pinmap(pinmap)
|
||||
pinlist = generate_pinlist(pinmap, validation)
|
||||
assert pinlist.package_info, "package_info 不应为空"
|
||||
assert len(pinlist.rows) > 0, "应有引脚数据"
|
||||
nums = [num for _, num in pinlist.rows]
|
||||
assert nums == sorted(nums), f"序号应递增,实际: {nums}"
|
||||
result.ok(f"封装={pinlist.package_info}, Pin数={len(pinlist.rows)}, 序号递增")
|
||||
|
||||
r.run("TC-MAP-002: 长方形PinMAP转换", _tc_map_002)
|
||||
|
||||
# TC-MAP-003: 序号不连续检测
|
||||
def _tc_map_003(result):
|
||||
filepath = os.path.join(fixture_dir, 'error_gap.xlsx')
|
||||
cells = read_xlsx_cells(filepath)
|
||||
pinmap = parse_pinmap(cells)
|
||||
validation = validate_pinmap(pinmap)
|
||||
assert not validation.is_valid, "应验证失败"
|
||||
assert any("不连续" in e.message for e in validation.errors), \
|
||||
"应有'不连续'错误"
|
||||
result.ok(f"错误: {validation.errors[0].message} — {validation.errors[0].details}")
|
||||
|
||||
r.run("TC-MAP-003: 序号不连续检测", _tc_map_003)
|
||||
|
||||
# TC-MAP-004: 序号重复检测
|
||||
def _tc_map_004(result):
|
||||
filepath = os.path.join(fixture_dir, 'error_dup.xlsx')
|
||||
cells = read_xlsx_cells(filepath)
|
||||
pinmap = parse_pinmap(cells)
|
||||
validation = validate_pinmap(pinmap)
|
||||
assert not validation.is_valid, "应验证失败"
|
||||
assert any("重复" in e.message for e in validation.errors), \
|
||||
"应有'重复'错误"
|
||||
result.ok(f"错误: {validation.errors[0].message} — {validation.errors[0].details}")
|
||||
|
||||
r.run("TC-MAP-004: 序号重复检测", _tc_map_004)
|
||||
|
||||
# TC-MAP-005: PinName缺失警告
|
||||
def _tc_map_005(result):
|
||||
filepath = os.path.join(fixture_dir, 'warning_missing.xlsx')
|
||||
cells = read_xlsx_cells(filepath)
|
||||
pinmap = parse_pinmap(cells)
|
||||
validation = validate_pinmap(pinmap)
|
||||
assert validation.is_valid, "应验证通过(缺失Name是warning)"
|
||||
assert len(validation.warnings) > 0, "应有警告"
|
||||
assert any("缺少" in w.message or "NC" in w.details for w in validation.warnings), \
|
||||
"应有PinName缺失警告"
|
||||
result.ok(f"警告: {validation.warnings[0].message} — {validation.warnings[0].details}")
|
||||
|
||||
r.run("TC-MAP-005: PinName缺失警告", _tc_map_005)
|
||||
|
||||
# TC-MAP-006: A1为空检测
|
||||
def _tc_map_006(result):
|
||||
filepath = os.path.join(fixture_dir, 'error_empty_a1.xlsx')
|
||||
cells = read_xlsx_cells(filepath)
|
||||
try:
|
||||
parse_pinmap(cells)
|
||||
result.fail("应抛出StructureError")
|
||||
except Exception as e:
|
||||
assert "A1" in str(e) or "空" in str(e), f"错误信息应提及A1或空: {e}"
|
||||
result.ok(f"正确报错: {e}")
|
||||
|
||||
r.run("TC-MAP-006: A1为空检测", _tc_map_006)
|
||||
|
||||
|
||||
# ── Part 2: List→MAP Tests ─────────────────────────────────────────
|
||||
|
||||
def test_list_to_map(r: TestRunner):
|
||||
tmpdir = tempfile.mkdtemp(prefix="pinmap_test_")
|
||||
|
||||
try:
|
||||
# ── TC-LM-001: 5×5 PinList → PinMAP (20引脚) ──
|
||||
# v1.3: (r+c)*2 = (5+5)*2 = 20 pins
|
||||
def _tc_lm_001(result):
|
||||
# 5×5 grid → (5+5)*2 = 20 pins
|
||||
data = {'A1': 'QFP-20'}
|
||||
for i in range(1, 21):
|
||||
row = i + 1 # row 2..21
|
||||
data[f'A{row}'] = f'Pin{i}'
|
||||
data[f'B{row}'] = str(i)
|
||||
filepath = os.path.join(tmpdir, 'test_5x5_pinlist.xlsx')
|
||||
create_pinlist_xlsx(data, filepath)
|
||||
|
||||
# Parse
|
||||
pkg, entries = parse_pinlist(filepath)
|
||||
assert pkg == 'QFP-20', f"封装信息应为QFP-20, 实际: {pkg}"
|
||||
assert len(entries) == 20, f"应有20个引脚, 实际: {len(entries)}"
|
||||
|
||||
# Validate with 5×5 grid
|
||||
validation = validate_pinlist(entries, 5, 5)
|
||||
assert validation.is_valid, f"验证应通过: {validation.errors}"
|
||||
assert len(validation.errors) == 0
|
||||
|
||||
# Generate PinMAP
|
||||
output = generate_pinmap(entries, 5, 5, pkg, output_path=None)
|
||||
assert 'A1' in output, "A1应有封装信息"
|
||||
assert output['A1'] == 'QFP-20'
|
||||
|
||||
result.ok(f"解析成功, 封装={pkg}, Pin数={len(entries)}, 5×5布局验证通过")
|
||||
|
||||
r.run("TC-LM-001: 5×5 PinList→PinMAP (20引脚)", _tc_lm_001)
|
||||
|
||||
# ── TC-LM-002: 长方形 PinList → 6×10 PinMAP (32引脚) ──
|
||||
# v1.3: (r+c)*2 = (6+10)*2 = 32 pins
|
||||
def _tc_lm_002(result):
|
||||
data = {'A1': 'LQFP-32'}
|
||||
for i in range(1, 33):
|
||||
row = i + 1
|
||||
data[f'A{row}'] = f'PIN_{i:02d}'
|
||||
data[f'B{row}'] = str(i)
|
||||
filepath = os.path.join(tmpdir, 'test_6x10_pinlist.xlsx')
|
||||
create_pinlist_xlsx(data, filepath)
|
||||
|
||||
pkg, entries = parse_pinlist(filepath)
|
||||
assert len(entries) == 32, f"应有32个引脚, 实际: {len(entries)}"
|
||||
|
||||
validation = validate_pinlist(entries, 6, 10)
|
||||
assert validation.is_valid, f"验证应通过: {validation.errors}"
|
||||
|
||||
# Generate and write to file
|
||||
outpath = os.path.join(tmpdir, 'test_6x10_pinmap.xlsx')
|
||||
output = generate_pinmap(entries, 6, 10, pkg, output_path=outpath)
|
||||
assert os.path.exists(outpath), "输出文件应存在"
|
||||
|
||||
# Verify output can be read back
|
||||
out_cells = read_xlsx_cells(outpath)
|
||||
assert (0, 0) in out_cells, "A1应有数据"
|
||||
assert out_cells[(0, 0)] == 'LQFP-32', f"A1应为LQFP-32, 实际: {out_cells.get((0,0))}"
|
||||
|
||||
result.ok(f"解析成功, 封装={pkg}, Pin数={len(entries)}, 6×10布局+文件输出验证通过")
|
||||
|
||||
r.run("TC-LM-002: 6×10 PinList→PinMAP (32引脚)", _tc_lm_002)
|
||||
|
||||
# ── TC-LM-003: 带模板文件的转换 ──
|
||||
# v1.3: 5×5 grid → (5+5)*2 = 20 pins
|
||||
def _tc_lm_003(result):
|
||||
# 创建模板 PinMAP
|
||||
template_data = {'A1': 'QFP-20'}
|
||||
for i in range(1, 21):
|
||||
row = i + 1
|
||||
template_data[f'A{row}'] = f'Pin{i}'
|
||||
template_data[f'B{row}'] = str(i)
|
||||
template_path = os.path.join(tmpdir, 'template_pinmap.xlsx')
|
||||
# 用 styled writer 创建模板
|
||||
from xlsx_writer import write_xlsx_with_style
|
||||
write_xlsx_with_style(template_data, template_path)
|
||||
|
||||
# 创建 PinList 并写入模板同目录
|
||||
data = {'A1': 'QFP-20'}
|
||||
for i in range(1, 21):
|
||||
row = i + 1
|
||||
data[f'A{row}'] = f'Pin{i}'
|
||||
data[f'B{row}'] = str(i)
|
||||
pinlist_path = os.path.join(tmpdir, 'templated_pinlist.xlsx')
|
||||
create_pinlist_xlsx(data, pinlist_path)
|
||||
|
||||
# 验证模板读取
|
||||
style = read_template_styles(template_path)
|
||||
assert style is not None, "模板样式应成功读取"
|
||||
|
||||
pkg, entries = parse_pinlist(pinlist_path)
|
||||
outpath = os.path.join(tmpdir, 'templated_output.xlsx')
|
||||
generate_pinmap(entries, 5, 5, pkg, template_style=style, output_path=outpath)
|
||||
assert os.path.exists(outpath), "带模板的输出文件应存在"
|
||||
|
||||
# 验证输出文件包含 styles.xml
|
||||
import zipfile
|
||||
with zipfile.ZipFile(outpath, 'r') as zf:
|
||||
assert 'xl/styles.xml' in zf.namelist(), "输出应包含styles.xml"
|
||||
|
||||
result.ok(f"模板样式读取成功, 带模板输出文件包含styles.xml")
|
||||
|
||||
r.run("TC-LM-003: 带模板文件的转换", _tc_lm_003)
|
||||
|
||||
# ── TC-LM-004: Pin 序号不连续 ──
|
||||
def _tc_lm_004(result):
|
||||
data = {'A1': 'QFP-test'}
|
||||
# 序号: 1, 2, 4, 5 (缺失3)
|
||||
entries_data = [('Pin1', '1'), ('Pin2', '2'), ('Pin4', '4'), ('Pin5', '5')]
|
||||
for i, (name, num) in enumerate(entries_data):
|
||||
row = i + 2
|
||||
data[f'A{row}'] = name
|
||||
data[f'B{row}'] = num
|
||||
filepath = os.path.join(tmpdir, 'gap_pinlist.xlsx')
|
||||
create_pinlist_xlsx(data, filepath)
|
||||
|
||||
pkg, entries = parse_pinlist(filepath)
|
||||
# 4 pins → grid needs 2r+2c-4=4 → try 3×3: 2*3+2*3-4=8, no
|
||||
# Actually we just test validation directly
|
||||
validation = validate_pinlist(entries, 3, 3)
|
||||
assert not validation.is_valid, "应验证失败"
|
||||
assert any("不连续" in e.message for e in validation.errors), \
|
||||
"应有'不连续'错误"
|
||||
result.ok(f"正确报错: {validation.errors[0].message} — {validation.errors[0].details}")
|
||||
|
||||
r.run("TC-LM-004: Pin序号不连续", _tc_lm_004)
|
||||
|
||||
# ── TC-LM-005: Pin 序号重复 ──
|
||||
def _tc_lm_005(result):
|
||||
data = {'A1': 'QFP-test'}
|
||||
# 序号: 1, 2, 2, 3 (2重复)
|
||||
entries_data = [('Pin1', '1'), ('Pin2', '2'), ('Pin2_dup', '2'), ('Pin3', '3')]
|
||||
for i, (name, num) in enumerate(entries_data):
|
||||
row = i + 2
|
||||
data[f'A{row}'] = name
|
||||
data[f'B{row}'] = num
|
||||
filepath = os.path.join(tmpdir, 'dup_pinlist.xlsx')
|
||||
create_pinlist_xlsx(data, filepath)
|
||||
|
||||
pkg, entries = parse_pinlist(filepath)
|
||||
validation = validate_pinlist(entries, 3, 3)
|
||||
assert not validation.is_valid, "应验证失败"
|
||||
assert any("重复" in e.message for e in validation.errors), \
|
||||
"应有'重复'错误"
|
||||
result.ok(f"正确报错: {validation.errors[0].message} — {validation.errors[0].details}")
|
||||
|
||||
r.run("TC-LM-005: Pin序号重复", _tc_lm_005)
|
||||
|
||||
# ── TC-LM-006: Pin 总数不匹配 ──
|
||||
# v1.3: 3×4 grid → (3+4)*2 = 14 pins
|
||||
def _tc_lm_006(result):
|
||||
# 创建8个引脚的PinList,但3×4网格需要14个引脚
|
||||
data = {'A1': 'QFP-test'}
|
||||
for i in range(1, 9): # 8 pins
|
||||
row = i + 1
|
||||
data[f'A{row}'] = f'Pin{i}'
|
||||
data[f'B{row}'] = str(i)
|
||||
filepath = os.path.join(tmpdir, 'mismatch_pinlist.xlsx')
|
||||
create_pinlist_xlsx(data, filepath)
|
||||
|
||||
pkg, entries = parse_pinlist(filepath)
|
||||
# 3×4 needs 14 pins, but we have 8
|
||||
validation = validate_pinlist(entries, 3, 4)
|
||||
assert not validation.is_valid, "应验证失败"
|
||||
assert any("不匹配" in e.message for e in validation.errors), \
|
||||
"应有'不匹配'错误"
|
||||
result.ok(f"正确报错: {validation.errors[0].message} — {validation.errors[0].details}")
|
||||
|
||||
r.run("TC-LM-006: Pin总数不匹配", _tc_lm_006)
|
||||
|
||||
# ── TC-LM-007: 缺少 PinName ──
|
||||
# v1.3: 2×3 grid → (2+3)*2 = 10 pins
|
||||
def _tc_lm_007(result):
|
||||
data = {'A1': 'QFP-test'}
|
||||
# 10个引脚,其中第2个缺PinName
|
||||
entries_data = [('Pin1', '1'), ('', '2'), ('Pin3', '3'), ('Pin4', '4'), ('Pin5', '5'),
|
||||
('Pin6', '6'), ('Pin7', '7'), ('Pin8', '8'), ('Pin9', '9'), ('Pin10', '10')]
|
||||
for i, (name, num) in enumerate(entries_data):
|
||||
row = i + 2
|
||||
data[f'A{row}'] = name
|
||||
data[f'B{row}'] = num
|
||||
filepath = os.path.join(tmpdir, 'missing_name_pinlist.xlsx')
|
||||
create_pinlist_xlsx(data, filepath)
|
||||
|
||||
pkg, entries = parse_pinlist(filepath)
|
||||
validation = validate_pinlist(entries, 2, 3)
|
||||
# 验证应通过(缺Name是warning,不是error)
|
||||
assert validation.is_valid, f"应验证通过: {validation.errors}"
|
||||
assert len(validation.warnings) > 0, "应有警告"
|
||||
assert any("缺少" in w.message for w in validation.warnings), \
|
||||
"应有PinName缺失警告"
|
||||
result.ok(f"验证通过(有warning): {validation.warnings[0].message} — {validation.warnings[0].details}")
|
||||
|
||||
r.run("TC-LM-007: 缺少PinName (warning)", _tc_lm_007)
|
||||
|
||||
# ── TC-LM-008: 非4倍数提示 ──
|
||||
# v1.3: (r+c)*2 is always even, but may not be multiple of 4
|
||||
# (3+4)*2 = 14, 14 % 4 = 2 → not a multiple of 4
|
||||
def _tc_lm_008(result):
|
||||
# 14个引脚 → 不是4的倍数
|
||||
data = {'A1': 'QFP-test'}
|
||||
for i in range(1, 15):
|
||||
row = i + 1
|
||||
data[f'A{row}'] = f'Pin{i}'
|
||||
data[f'B{row}'] = str(i)
|
||||
filepath = os.path.join(tmpdir, 'non4mult_pinlist.xlsx')
|
||||
create_pinlist_xlsx(data, filepath)
|
||||
|
||||
pkg, entries = parse_pinlist(filepath)
|
||||
validation = validate_pinlist(entries, 3, 4)
|
||||
assert validation.is_valid, f"应验证通过: {validation.errors}"
|
||||
# 14 % 4 != 0, 应有 info 提示
|
||||
result.ok(f"验证通过, Pin数={len(entries)} (非4倍数)")
|
||||
|
||||
r.run("TC-LM-008: 非4倍数提示", _tc_lm_008)
|
||||
|
||||
# ── TC-LM-009: 布局计算正确性 ──
|
||||
# v1.3: 3×3 grid → (3+3)*2 = 12 pins
|
||||
def _tc_lm_009(result):
|
||||
entries = [PinListEntry(number=i, name=f'P{i}') for i in range(1, 13)]
|
||||
layout = calculate_layout(entries, 3, 3)
|
||||
|
||||
# 验证四条边都有引脚
|
||||
assert 'left' in layout, "应有left边"
|
||||
assert 'bottom' in layout, "应有bottom边"
|
||||
assert 'right' in layout, "应有right边"
|
||||
assert 'top' in layout, "应有top边"
|
||||
|
||||
# 验证引脚数量分配 (v1.3: 每条边独立)
|
||||
# left: rows=3, bottom: cols=3, right: rows=3, top: cols=3
|
||||
assert len(layout['left'].pins) == 3, f"left应有3个引脚, 实际: {len(layout['left'].pins)}"
|
||||
assert len(layout['bottom'].pins) == 3, f"bottom应有3个引脚, 实际: {len(layout['bottom'].pins)}"
|
||||
assert len(layout['right'].pins) == 3, f"right应有3个引脚, 实际: {len(layout['right'].pins)}"
|
||||
assert len(layout['top'].pins) == 3, f"top应有3个引脚, 实际: {len(layout['top'].pins)}"
|
||||
|
||||
# 验证总引脚数
|
||||
total = sum(len(e.pins) for e in layout.values())
|
||||
assert total == 12, f"总引脚数应为12, 实际: {total}"
|
||||
|
||||
# 验证逆时针顺序: left(1,2,3) → bottom(4,5,6) → right(7,8,9) → top(10,11,12)
|
||||
assert layout['left'].pins[0][0] == 1, "left第一个应为Pin1"
|
||||
assert layout['left'].pins[-1][0] == 3, "left最后一个应为Pin3"
|
||||
assert layout['bottom'].pins[0][0] == 4, "bottom第一个应为Pin4"
|
||||
assert layout['right'].pins[0][0] == 7, "right应为Pin7"
|
||||
assert layout['top'].pins[0][0] == 10, "top第一个应为Pin10"
|
||||
|
||||
result.ok(f"布局计算正确: left=3, bottom=3, right=3, top=3, 逆时针顺序正确")
|
||||
|
||||
r.run("TC-LM-009: 布局计算正确性", _tc_lm_009)
|
||||
|
||||
# ── TC-LM-010: 模板文件检测(无模板) ──
|
||||
def _tc_lm_010(result):
|
||||
style = read_template_styles('/nonexistent/path.xlsx')
|
||||
assert style is None, "不存在的模板应返回None"
|
||||
result.ok("无模板文件时优雅返回None")
|
||||
|
||||
r.run("TC-LM-010: 模板文件检测(无模板)", _tc_lm_010)
|
||||
|
||||
# ── TC-LM-011: 无效尺寸输入 ──
|
||||
def _tc_lm_011(result):
|
||||
entries = [PinListEntry(number=i, name=f'P{i}') for i in range(1, 5)]
|
||||
try:
|
||||
calculate_layout(entries, 1, 5) # rows < 2
|
||||
result.fail("应抛出LayoutError")
|
||||
except Exception as e:
|
||||
assert "行" in str(e) or "无效" in str(e), f"错误应提及行数: {e}"
|
||||
result.ok(f"正确报错: {e}")
|
||||
|
||||
r.run("TC-LM-011: 无效尺寸输入(行数<2)", _tc_lm_011)
|
||||
|
||||
def _tc_lm_011b(result):
|
||||
entries = [PinListEntry(number=i, name=f'P{i}') for i in range(1, 5)]
|
||||
try:
|
||||
calculate_layout(entries, 5, 1) # cols < 2
|
||||
result.fail("应抛出LayoutError")
|
||||
except Exception as e:
|
||||
assert "列" in str(e) or "无效" in str(e), f"错误应提及列数: {e}"
|
||||
result.ok(f"正确报错: {e}")
|
||||
|
||||
r.run("TC-LM-011b: 无效尺寸输入(列数<2)", _tc_lm_011b)
|
||||
|
||||
# ── TC-LM-012: 输出文件正确性 ──
|
||||
# v1.3: 3×3 grid → (3+3)*2 = 12 pins
|
||||
def _tc_lm_012(result):
|
||||
data = {'A1': 'QFP-12'}
|
||||
for i in range(1, 13):
|
||||
row = i + 1
|
||||
data[f'A{row}'] = f'Pin{i}'
|
||||
data[f'B{row}'] = str(i)
|
||||
pinlist_path = os.path.join(tmpdir, 'verify_pinlist.xlsx')
|
||||
create_pinlist_xlsx(data, pinlist_path)
|
||||
|
||||
pkg, entries = parse_pinlist(pinlist_path)
|
||||
outpath = os.path.join(tmpdir, 'verify_pinmap.xlsx')
|
||||
generate_pinmap(entries, 3, 3, pkg, output_path=outpath)
|
||||
|
||||
# 读取输出并验证
|
||||
out_cells = read_xlsx_cells(outpath)
|
||||
assert out_cells[(0, 0)] == 'QFP-12', f"A1应为QFP-12, 实际: {out_cells.get((0,0))}"
|
||||
|
||||
# 验证所有12个引脚序号都在输出中
|
||||
found_nums = set()
|
||||
for (row, col), val in out_cells.items():
|
||||
for part in str(val).split("/"):
|
||||
if part.isdigit() and int(part) >= 1:
|
||||
found_nums.add(int(part))
|
||||
assert found_nums == {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}, \
|
||||
f"应包含1-12所有序号, 实际: {sorted(found_nums)}"
|
||||
|
||||
result.ok(f"输出文件验证通过: A1={out_cells[(0,0)]}, 包含Pin1-Pin12")
|
||||
|
||||
r.run("TC-LM-012: 输出文件正确性", _tc_lm_012)
|
||||
|
||||
# ── TC-LM-013: 端到端 roundtrip (PinMAP → PinList → PinMAP) ──
|
||||
# v1.3: 3×3 grid → (3+3)*2 = 12 pins
|
||||
def _tc_lm_013(result):
|
||||
data = {'A1': 'QFP-12'}
|
||||
for i in range(1, 13):
|
||||
row = i + 1
|
||||
data[f'A{row}'] = f'Pin{i}'
|
||||
data[f'B{row}'] = str(i)
|
||||
pinlist_path = os.path.join(tmpdir, 'rt_pinlist.xlsx')
|
||||
create_pinlist_xlsx(data, pinlist_path)
|
||||
|
||||
# List → MAP
|
||||
pkg, entries = parse_pinlist(pinlist_path)
|
||||
map_path = os.path.join(tmpdir, 'rt_pinmap.xlsx')
|
||||
generate_pinmap(entries, 3, 3, pkg, output_path=map_path)
|
||||
|
||||
# 读取生成的 PinMAP,再转回 PinList
|
||||
map_cells = read_xlsx_cells(map_path)
|
||||
rt_pinmap = parse_pinmap(map_cells)
|
||||
rt_validation = validate_pinmap(rt_pinmap)
|
||||
rt_pinlist = generate_pinlist(rt_pinmap, rt_validation)
|
||||
|
||||
assert len(rt_pinlist.rows) == 12, \
|
||||
f"Roundtrip后应有12个引脚, 实际: {len(rt_pinlist.rows)}"
|
||||
|
||||
# 验证序号一致
|
||||
orig_nums = [e.number for e in entries]
|
||||
rt_nums = [num for _, num in rt_pinlist.rows]
|
||||
assert orig_nums == rt_nums, f"序号应一致: {orig_nums} vs {rt_nums}"
|
||||
|
||||
result.ok(f"Roundtrip成功: PinList(12) → PinMAP(3×3) → PinList({len(rt_pinlist.rows)}), 序号一致")
|
||||
|
||||
r.run("TC-LM-013: 端到端Roundtrip (MAP→List→MAP)", _tc_lm_013)
|
||||
|
||||
# ── TC-LM-014: generate_output_path 路径生成 ──
|
||||
def _tc_lm_014(result):
|
||||
path = generate_output_path('/path/to/my_pinlist.xlsx')
|
||||
assert path == '/path/to/my_pinlist_PinMAP.xlsx', f"路径应为..._PinMAP.xlsx, 实际: {path}"
|
||||
result.ok(f"路径生成正确: {path}")
|
||||
|
||||
r.run("TC-LM-014: 输出路径生成", _tc_lm_014)
|
||||
|
||||
# ── TC-LM-015: 空PinList文件 ──
|
||||
def _tc_lm_015(result):
|
||||
data = {'A1': 'QFP-test'} # 只有封装信息,无引脚数据
|
||||
filepath = os.path.join(tmpdir, 'empty_pinlist.xlsx')
|
||||
create_pinlist_xlsx(data, filepath)
|
||||
try:
|
||||
parse_pinlist(filepath)
|
||||
result.fail("应抛出StructureError")
|
||||
except Exception as e:
|
||||
assert "空" in str(e) or "未找到" in str(e), f"错误应提及空/未找到: {e}"
|
||||
result.ok(f"正确报错: {e}")
|
||||
|
||||
r.run("TC-LM-015: 空PinList文件", _tc_lm_015)
|
||||
|
||||
# ── TC-LM-016: A1为空的PinList ──
|
||||
def _tc_lm_016(result):
|
||||
data = {} # 完全空的文件
|
||||
filepath = os.path.join(tmpdir, 'no_a1_pinlist.xlsx')
|
||||
create_pinlist_xlsx(data, filepath)
|
||||
try:
|
||||
parse_pinlist(filepath)
|
||||
result.fail("应抛出StructureError")
|
||||
except Exception as e:
|
||||
assert "A1" in str(e) or "空" in str(e), f"错误应提及A1/空: {e}"
|
||||
result.ok(f"正确报错: {e}")
|
||||
|
||||
r.run("TC-LM-016: A1为空的PinList", _tc_lm_016)
|
||||
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
runner = TestRunner()
|
||||
|
||||
print("=" * 60)
|
||||
print(" PinMAP ↔ PinList 双向转换器 — 测试报告")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
print("── Part 1: MAP→List 回归测试 ──")
|
||||
test_map_to_list(runner)
|
||||
print()
|
||||
|
||||
print("── Part 2: List→MAP 新增功能测试 ──")
|
||||
test_list_to_map(runner)
|
||||
print()
|
||||
|
||||
total, passed, failed = runner.summary()
|
||||
print("=" * 60)
|
||||
print(f" 总计: {total} | 通过: {passed} | 失败: {failed}")
|
||||
print("=" * 60)
|
||||
|
||||
# 生成测试报告
|
||||
generate_report(runner, total, passed, failed)
|
||||
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
def generate_report(runner: TestRunner, total: int, passed: int, failed: int):
|
||||
"""生成 Markdown 测试报告"""
|
||||
report_path = os.path.join(os.path.dirname(__file__), 'test_report.md')
|
||||
|
||||
lines = []
|
||||
lines.append("# PinMAP ↔ PinList 双向转换器 测试报告")
|
||||
lines.append("")
|
||||
lines.append(f"> **日期**: {__import__('datetime').datetime.now().strftime('%Y-%m-%d')}")
|
||||
lines.append("> **测试类型**: 集成测试 + 端到端测试")
|
||||
lines.append("> **测试环境**: Python 3.x, Linux x64")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## 测试概览")
|
||||
lines.append("")
|
||||
lines.append("| 类别 | 用例数 | 通过 | 失败 |")
|
||||
lines.append("|------|--------|------|------|")
|
||||
|
||||
# Count by category
|
||||
map_results = [r for r in runner.results if r.name.startswith("TC-MAP")]
|
||||
lm_results = [r for r in runner.results if r.name.startswith("TC-LM")]
|
||||
map_pass = sum(1 for r in map_results if r.passed)
|
||||
map_fail = len(map_results) - map_pass
|
||||
lm_pass = sum(1 for r in lm_results if r.passed)
|
||||
lm_fail = len(lm_results) - lm_pass
|
||||
|
||||
lines.append(f"| MAP→List 回归 | {len(map_results)} | {map_pass} | {map_fail} |")
|
||||
lines.append(f"| List→MAP 新增 | {len(lm_results)} | {lm_pass} | {lm_fail} |")
|
||||
lines.append(f"| **总计** | **{total}** | **{passed}** | **{failed}** |")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
# Part 1 details
|
||||
lines.append("## Part 1: MAP→List 回归测试")
|
||||
lines.append("")
|
||||
for r in map_results:
|
||||
status = "✅ 通过" if r.passed else "❌ 失败"
|
||||
lines.append(f"### {r.name}")
|
||||
lines.append(f"- **结果**: {status}")
|
||||
if r.details:
|
||||
lines.append(f"- **详情**: {r.details}")
|
||||
if r.error:
|
||||
lines.append(f"- **错误**: {r.error}")
|
||||
lines.append("")
|
||||
|
||||
# Part 2 details
|
||||
lines.append("## Part 2: List→MAP 新增功能测试")
|
||||
lines.append("")
|
||||
for r in lm_results:
|
||||
status = "✅ 通过" if r.passed else "❌ 失败"
|
||||
lines.append(f"### {r.name}")
|
||||
lines.append(f"- **结果**: {status}")
|
||||
if r.details:
|
||||
lines.append(f"- **详情**: {r.details}")
|
||||
if r.error:
|
||||
lines.append(f"- **错误**: {r.error}")
|
||||
lines.append("")
|
||||
|
||||
# Summary
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## 结论")
|
||||
lines.append("")
|
||||
if failed == 0:
|
||||
lines.append("✅ **所有测试用例通过,项目可进入发布阶段。**")
|
||||
else:
|
||||
lines.append(f"❌ **{failed} 个测试用例失败,需要修复。**")
|
||||
lines.append("")
|
||||
|
||||
# Issue summary
|
||||
failed_results = [r for r in runner.results if not r.passed]
|
||||
if failed_results:
|
||||
lines.append("## 失败用例汇总")
|
||||
lines.append("")
|
||||
lines.append("| 用例 | 错误 |")
|
||||
lines.append("|------|------|")
|
||||
for r in failed_results:
|
||||
lines.append(f"| {r.name} | {r.error} |")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("*测试完成*")
|
||||
|
||||
report = '\n'.join(lines)
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
f.write(report)
|
||||
|
||||
print(f"\n📄 测试报告已写入: {report_path}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,8 +1,8 @@
|
||||
# PinMAP → PinList 转换器 测试报告
|
||||
# PinMAP ↔ PinList 双向转换器 测试报告
|
||||
|
||||
> **日期**: 2026-05-25
|
||||
> **测试类型**: 集成测试 + 端到端测试
|
||||
> **测试环境**: Python 3.x, Linux x64
|
||||
> **日期**: 2026-06-01
|
||||
> **测试类型**: 集成测试 + 端到端测试
|
||||
> **测试环境**: Python 3.x, Linux x64
|
||||
|
||||
---
|
||||
|
||||
@@ -10,118 +10,107 @@
|
||||
|
||||
| 类别 | 用例数 | 通过 | 失败 |
|
||||
|------|--------|------|------|
|
||||
| 标准转换 | 2 | 2 | 0 |
|
||||
| 错误场景 | 3 | 3 | 0 |
|
||||
| 边界条件 | 1 | 1 | 0 |
|
||||
| **总计** | **6** | **6** | **0** |
|
||||
| MAP→List 回归 | 6 | 6 | 0 |
|
||||
| List→MAP 新增 | 17 | 17 | 0 |
|
||||
| **总计** | **23** | **23** | **0** |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例详情
|
||||
## Part 1: MAP→List 回归测试
|
||||
|
||||
### TC001: 标准4x4 PinMAP 转换
|
||||
- **输入**: `fixtures/sample_4x4.xlsx` (QFP44, 8个Pin)
|
||||
- **预期**: 正确解析8个Pin,逆时针1-8,输出PinList递增排序
|
||||
- **实际**: ✅ 解析8个Pin,Pin1→Pin8,序号递增,A1=QFP44
|
||||
- **结果**: **通过**
|
||||
### TC-MAP-001: 标准4x4 PinMAP转换
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 封装=QFP12, Pin数=12, 序号递增
|
||||
|
||||
### TC002: 长方形PinMAP转换
|
||||
- **输入**: `fixtures/sample_rect.xlsx` (LQFP100, 13个Pin)
|
||||
- **预期**: 正确解析13个Pin,逆时针排序
|
||||
- **实际**: ✅ 解析13个Pin,逆时针顺序正确
|
||||
- **结果**: **通过**
|
||||
### TC-MAP-002: 长方形PinMAP转换
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 封装=LQFP100, Pin数=11, 序号递增
|
||||
|
||||
### TC003: 序号不连续检测
|
||||
- **输入**: `fixtures/error_gap.xlsx` (缺失序号3)
|
||||
- **预期**: 报错"Pin序号不连续",给出缺失序号[3]
|
||||
- **实际**: ✅ 报错"Pin序号不连续 - 缺失的序号: [3]"
|
||||
- **结果**: **通过**
|
||||
### TC-MAP-003: 序号不连续检测
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 错误: Pin序号不连续 — 缺失的序号: [3]
|
||||
|
||||
### TC004: 序号重复检测
|
||||
- **输入**: `fixtures/error_dup.xlsx` (序号2重复)
|
||||
- **预期**: 报错"Pin序号重复",给出重复序号[2]
|
||||
- **实际**: ✅ 报错"Pin序号重复 - 重复的序号: [2]"
|
||||
- **结果**: **通过**
|
||||
### TC-MAP-004: 序号重复检测
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 错误: Pin序号重复 — 重复的序号: [2]
|
||||
|
||||
### TC005: PinName缺失警告
|
||||
- **输入**: `fixtures/warning_missing.xlsx` (部分Pin缺少PinName)
|
||||
- **预期**: 警告"检测到N个引脚缺少PinName",自动设为NC
|
||||
- **实际**: ✅ 警告"检测到3个引脚缺少PinName",缺失序号[2,3,4]
|
||||
- **结果**: **通过**
|
||||
### TC-MAP-005: PinName缺失警告
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 警告: 检测到 3 个引脚缺少 PinName — 缺失引脚序号: [2, 3, 4],将默认为 NC
|
||||
|
||||
### TC006: A1为空检测
|
||||
- **输入**: `fixtures/error_empty_a1.xlsx` (A1单元格为空)
|
||||
- **预期**: 报错"A1单元格为空,缺少封装信息"
|
||||
- **实际**: ✅ 捕获StructureError: "A1 单元格为空,缺少封装信息"
|
||||
- **结果**: **通过**
|
||||
### TC-MAP-006: A1为空检测
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 正确报错: A1 单元格为空,缺少封装信息
|
||||
|
||||
---
|
||||
## Part 2: List→MAP 新增功能测试
|
||||
|
||||
## 端到端测试
|
||||
### TC-LM-001: 5×5 PinList→PinMAP (20引脚)
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 解析成功, 封装=QFP-20, Pin数=20, 5×5布局验证通过
|
||||
|
||||
### main.py 命令行模式
|
||||
```bash
|
||||
python main.py /tmp/test_4x4.xlsx
|
||||
```
|
||||
**输出**:
|
||||
```
|
||||
[INFO] 解析完成: 6x6 方形,共 8 个Pin
|
||||
[INFO] 封装信息: QFP44
|
||||
### TC-LM-002: 6×10 PinList→PinMAP (32引脚)
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 解析成功, 封装=LQFP-32, Pin数=32, 6×10布局+文件输出验证通过
|
||||
|
||||
[SUCCESS] 转换完成!输出文件: /tmp/test_4x4_PinList.xlsx
|
||||
- 封装信息: QFP44
|
||||
- Pin数量: 8
|
||||
```
|
||||
**结果**: ✅ 通过
|
||||
### TC-LM-003: 带模板文件的转换
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 模板样式读取成功, 带模板输出文件包含styles.xml
|
||||
|
||||
### 输出文件验证
|
||||
- **输入**: `sample_4x4.xlsx` → **输出**: `sample_4x4_PinList.xlsx`
|
||||
- **A1**: QFP44 ✅
|
||||
- **A列**: Pin1, Pin2, Pin3, Pin4, Pin5, Pin6, Pin7, Pin8 ✅
|
||||
- **B列**: 1, 2, 3, 4, 5, 6, 7, 8 ✅
|
||||
- **排序**: 递增 ✅
|
||||
### TC-LM-004: Pin序号不连续
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 正确报错: Pin序号不连续 — 缺失的序号: [3]
|
||||
|
||||
---
|
||||
### TC-LM-005: Pin序号重复
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 正确报错: Pin序号不连续 — 缺失的序号: [4]
|
||||
|
||||
## 模块单元测试
|
||||
### TC-LM-006: Pin总数不匹配
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 正确报错: Pin数量与网格周长不匹配 — 网格 3×4 需要 14 个引脚,但 PinList 有 8 个
|
||||
|
||||
### xlsx_roundtrip
|
||||
- 写入 → 读取 → 验证数据一致 ✅
|
||||
### TC-LM-007: 缺少PinName (warning)
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 验证通过(有warning): 检测到 1 个引脚缺少 PinName — 缺失引脚序号: [2],将默认为 NC
|
||||
|
||||
### pinmap_parser
|
||||
- 4x4方形解析 ✅
|
||||
- 长方形解析 ✅
|
||||
- 角点去重 ✅
|
||||
### TC-LM-008: 非4倍数提示
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 验证通过, Pin数=14 (非4倍数)
|
||||
|
||||
### validator
|
||||
- 连续性检查 ✅
|
||||
- 唯一性检查 ✅
|
||||
- PinName缺失检测 ✅
|
||||
- 结构完整性检查 ✅
|
||||
### TC-LM-009: 布局计算正确性
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 布局计算正确: left=3, bottom=3, right=3, top=3, 逆时针顺序正确
|
||||
|
||||
### pinlist_generator
|
||||
- PinList生成 ✅
|
||||
- NC默认值 ✅
|
||||
- 递增排序 ✅
|
||||
### TC-LM-010: 模板文件检测(无模板)
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 无模板文件时优雅返回None
|
||||
|
||||
---
|
||||
### TC-LM-011: 无效尺寸输入(行数<2)
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 正确报错: 行数无效: 1,至少需要 2 行
|
||||
|
||||
## 问题汇总
|
||||
### TC-LM-011b: 无效尺寸输入(列数<2)
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 正确报错: 列数无效: 1,至少需要 2 列
|
||||
|
||||
| 问题 | 严重性 | 状态 |
|
||||
|------|--------|------|
|
||||
| 无 | - | - |
|
||||
### TC-LM-012: 输出文件正确性
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 输出文件验证通过: A1=QFP-12, 包含Pin1-Pin12
|
||||
|
||||
**所有测试用例通过,无阻塞性问题。**
|
||||
### TC-LM-013: 端到端Roundtrip (MAP→List→MAP)
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: Roundtrip成功: PinList(12) → PinMAP(3×3) → PinList(12), 序号一致
|
||||
|
||||
---
|
||||
### TC-LM-014: 输出路径生成
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 路径生成正确: /path/to/my_pinlist_PinMAP.xlsx
|
||||
|
||||
## 改进建议
|
||||
### TC-LM-015: 空PinList文件
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 正确报错: 未找到任何引脚数据(A/B 列为空)
|
||||
|
||||
1. **XLS读取测试**: 当前环境无.xls测试样本,建议在Windows环境用真实.xls文件验证BIFF8解析
|
||||
2. **字体格式保留**: 当前版本未实现字体格式保留(架构设计中有提及),可在后续版本添加
|
||||
3. **GUI模式**: tkinter文件选择对话框在Linux无头环境下需回退到命令行参数,已实现
|
||||
4. **性能优化**: 当前实现适合<1000引脚场景,超大文件可后续优化
|
||||
### TC-LM-016: A1为空的PinList
|
||||
- **结果**: ✅ 通过
|
||||
- **详情**: 正确报错: A1 单元格为空,无法获取封装信息
|
||||
|
||||
---
|
||||
|
||||
@@ -129,21 +118,6 @@ python main.py /tmp/test_4x4.xlsx
|
||||
|
||||
✅ **所有测试用例通过,项目可进入发布阶段。**
|
||||
|
||||
**交付物清单**:
|
||||
- `Code/src/main.py` — 主入口
|
||||
- `Code/src/file_selector.py` — 文件选择
|
||||
- `Code/src/xls_reader.py` — XLS读取引擎 (19KB)
|
||||
- `Code/src/xlsx_reader.py` — XLSX读取引擎
|
||||
- `Code/src/xlsx_writer.py` — XLSX写入引擎
|
||||
- `Code/src/pinmap_parser.py` — PinMAP解析器
|
||||
- `Code/src/validator.py` — 数据验证器
|
||||
- `Code/src/pinlist_generator.py` — PinList生成器
|
||||
- `Code/src/models.py` — 数据模型
|
||||
- `Code/src/utils.py` — 工具函数
|
||||
- `Code/docs/architecture-design.md` — 架构设计文档
|
||||
- `Test/fixtures/` — 测试夹具 (6个文件)
|
||||
- `Test/test_report.md` — 测试报告
|
||||
|
||||
---
|
||||
|
||||
*测试完成 — 2026-05-25*
|
||||
*测试完成*
|
||||
8
docs/bugs.md
Normal file
8
docs/bugs.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Bug 跟踪表
|
||||
|
||||
| Bug ID | 严重程度 | Bug 描述 | 复现步骤 | 期望行为 | 实际行为 | 状态 | 关联功能 |
|
||||
|--------|---------|---------|---------|---------|---------|------|---------|
|
||||
| BUG-001 | 中 | run.bat 换行符 + lines 设置不匹配 Windows | 在 Windows 下运行 run.bat | CRLF 换行,仅保留 `mode con cols=80` | Unix LF 换行,包含多余 `lines=50` | 已修复 | F005 |
|
||||
| BUG-002 | 高 | 周长计算公式错误 | 输入 15×15 网格 + 60 Pin | 验证通过 `(rows+cols)*2=60` | 提示不匹配 `2*rows+2*cols-4=56` | 已修复 | F006 |
|
||||
| BUG-003 | 中 | 双向转换未读取模板样式 | 使用模板文件进行 MAP↔List 转换 | 读取并应用模板样式 | 使用默认样式 | 已修复 | F007 |
|
||||
| BUG-004 | 中 | 不支持循环处理流程 | 转换完成后继续操作 | 循环等待下一个文件,输入 Q 返回主菜单 | 处理完直接退出 | 已修复 | F008 |
|
||||
31
docs/features.md
Normal file
31
docs/features.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 功能清单
|
||||
|
||||
## 核心功能
|
||||
|
||||
| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 |
|
||||
|--------|---------|------|------|------|------|--------|---------|---------|
|
||||
| F001 | PinMAP 解析 | 解析 PinMAP Excel 文件 | Excel 文件 | 解析后的 Pin 数据 | 无 | 1 | 能正确解析 PinMAP 结构 | 已通过 |
|
||||
| F002 | PinList 生成 | 从 PinMAP 生成 PinList | Pin 数据 | PinList Excel 文件 | F001 | 2 | 能正确生成 PinList | 已通过 |
|
||||
| F003 | PinList 解析 | 解析 PinList Excel 文件 | Excel 文件 | 解析后的 Pin 数据 | 无 | 1 | 能正确解析 PinList 结构 | 已通过 |
|
||||
| F004 | PinMAP 生成 | 从 PinList 生成 PinMAP | Pin 数据 | PinMAP Excel 文件 | F003 | 2 | 能正确生成 PinMAP | 已通过 |
|
||||
|
||||
## Bug 修复
|
||||
|
||||
| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 |
|
||||
|--------|---------|------|------|------|------|--------|---------|---------|
|
||||
| F005 | BAT 脚本修复 | 修复 run.bat 换行符为 CRLF,去掉 lines=50 参数 | 无 | 修复后的 run.bat | 无 | 3 | Windows 下正常运行 | 已通过 |
|
||||
| F006 | 周长公式修复 | 将周长公式从 `2*rows+2*cols-4` 改为 `(rows+cols)*2` | rows, cols | 正确的周长值 | 无 | 1 | 15×15 网格 60Pin 验证通过 | 已通过 |
|
||||
|
||||
## 功能增强
|
||||
|
||||
| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 |
|
||||
|--------|---------|------|------|------|------|--------|---------|---------|
|
||||
| F007 | 模板读取 | MAP→List 和 List→MAP 双向转换均读取并应用模板样式 | 模板文件 | 带样式的输出文件 | 无 | 2 | 双向转换均应用模板样式 | 已通过 |
|
||||
| F008 | 循环处理流程 | 处理完不退出,循环等待下一个文件,输入 Q 返回主菜单 | 用户输入 | 循环处理或返回主菜单 | 无 | 2 | 处理完不退出,Q 返回主菜单 | 已通过 |
|
||||
|
||||
## 优先级排序
|
||||
|
||||
1. **P0(必须)**:F006 周长公式修复 — 核心逻辑错误
|
||||
2. **P1(重要)**:F005 BAT 脚本修复 — 影响 Windows 用户使用
|
||||
3. **P2(建议)**:F007 模板读取 — 功能增强
|
||||
4. **P2(建议)**:F008 循环处理流程 — 体验优化
|
||||
621
docs/modification-assessment-v1.3.md
Normal file
621
docs/modification-assessment-v1.3.md
Normal file
@@ -0,0 +1,621 @@
|
||||
# PinMAP ↔ PinList 双向转换器 — 修改需求评估 v1.3
|
||||
|
||||
> **版本**: v1.3
|
||||
> **日期**: 2026-05-31
|
||||
> **评估人**: 脚本架构师 (Script Architect)
|
||||
> **状态**: 待审批
|
||||
> **变更**: 4 个 Bug 修复 + 4 个功能增强(BUG-001~004, F005~F008)
|
||||
|
||||
---
|
||||
|
||||
## 1. 修改需求总览
|
||||
|
||||
| 编号 | 类型 | 标题 | 优先级 | 复杂度 | 关联需求 |
|
||||
|------|------|------|--------|--------|---------|
|
||||
| BUG-001 | Bug | run.bat 换行符 + lines 设置不匹配 Windows | 中 | 低 | F005 |
|
||||
| BUG-002 | Bug | 周长计算公式错误 | **高** | 中 | F006 |
|
||||
| BUG-003 | Bug | 双向转换未读取模板样式 | 中 | 中 | F007 |
|
||||
| BUG-004 | Bug | 不支持循环处理流程 | 中 | 中 | F008 |
|
||||
| F005 | 功能 | BAT 脚本修复 | 3 | 低 | BUG-001 |
|
||||
| F006 | 功能 | 周长公式修复 | **1** | 中 | BUG-002 |
|
||||
| F007 | 功能 | 模板读取(双向) | 2 | 中 | BUG-003 |
|
||||
| F008 | 功能 | 循环处理流程 | 2 | 中 | BUG-004 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前代码状态分析
|
||||
|
||||
### 2.1 代码库结构(v1.2.0)
|
||||
|
||||
```
|
||||
pinmap-to-pinlist/
|
||||
├── run.bat # ✏️ 需修改(BUG-001/F005)
|
||||
├── Code/src/
|
||||
│ ├── main.py # ✏️ 需修改(BUG-003/F007, BUG-004/F008)
|
||||
│ ├── file_selector.py # (不变)
|
||||
│ ├── validator.py # ✏️ 需修改(BUG-002/F006)
|
||||
│ ├── pinlist_validator.py # ✏️ 需修改(BUG-002/F006)
|
||||
│ ├── pinmap_layout.py # ✏️ 需修改(BUG-002/F006)
|
||||
│ ├── pinlist_parser.py # (不变)
|
||||
│ ├── pinmap_generator.py # (不变)
|
||||
│ ├── template_reader.py # (不变)
|
||||
│ ├── xlsx_writer.py # (不变)
|
||||
│ ├── models.py # (不变)
|
||||
│ ├── xls_reader.py # (不变)
|
||||
│ ├── xlsx_reader.py # (不变)
|
||||
│ ├── pinlist_generator.py # (不变)
|
||||
│ └── utils.py # (不变)
|
||||
└── docs/
|
||||
├── bugs.md # ✏️ 需更新状态
|
||||
├── features.md # ✏️ 需更新状态
|
||||
└── modification-assessment-v1.3.md # 🆕 本文档
|
||||
```
|
||||
|
||||
### 2.2 各 Bug 当前代码状态
|
||||
|
||||
#### BUG-001: run.bat 问题
|
||||
|
||||
**当前 run.bat 内容**:
|
||||
```bat
|
||||
@ECHO OFF
|
||||
chcp 65001 >nul
|
||||
title PinMAP转PinList -By:LeeQwQ
|
||||
mode con cols=80 lines=50 ← 问题:含 lines=50
|
||||
color 0B
|
||||
cls
|
||||
cd /d "%~dp0Code\src"
|
||||
python main.py
|
||||
echo.
|
||||
pause
|
||||
EXIT
|
||||
```
|
||||
|
||||
**问题 1**:文件使用 Unix LF 换行符(`\n`),Windows 下应使用 CRLF(`\r\n`)。
|
||||
**问题 2**:`mode con cols=80 lines=50` 中的 `lines=50` 是多余的,需求仅保留 `cols=80`。
|
||||
|
||||
---
|
||||
|
||||
#### BUG-002: 周长公式错误
|
||||
|
||||
**当前公式**(3 处代码):
|
||||
```
|
||||
expected_total = 2 * rows + 2 * cols - 4
|
||||
```
|
||||
|
||||
**问题**:对于 15×15 网格 + 60 Pin,当前公式计算为 `2*15+2*15-4 = 56`,但用户期望 `(15+15)*2 = 60`。
|
||||
|
||||
**涉及文件**:
|
||||
1. `Code/src/pinlist_validator.py` — `validate_pinlist()` 中的周长匹配检查
|
||||
2. `Code/src/validator.py` — `validate_pinlist_for_map()` 中的周长匹配检查
|
||||
3. `Code/src/pinmap_layout.py` — `calculate_layout()` 中的 `total` 计算和 `LayoutError`
|
||||
|
||||
**数学分析**:
|
||||
|
||||
| 网格 | 当前公式 `2r+2c-4` | 新公式 `(r+c)*2` | 说明 |
|
||||
|------|-------------------|-----------------|------|
|
||||
| 4×8 | 20 | 24 | 当前公式少算 4 |
|
||||
| 15×15 | 56 | 60 | 当前公式少算 4 |
|
||||
| 2×2 | 4 | 8 | 当前公式少算 4 |
|
||||
|
||||
新公式 `(rows+cols)*2` 对所有尺寸均多 4 个 Pin。这意味着布局分配算法也需要相应调整。
|
||||
|
||||
**布局分配需同步修改**:
|
||||
|
||||
当前分配(`pinmap_layout.py`):
|
||||
```
|
||||
左边: rows 个
|
||||
下边: cols-1 个
|
||||
右边: rows-2 个
|
||||
上边: cols-1 个
|
||||
总计: 2*rows + 2*cols - 4
|
||||
```
|
||||
|
||||
新分配(`(rows+cols)*2`):
|
||||
```
|
||||
左边: rows 个
|
||||
下边: cols 个
|
||||
右边: rows 个
|
||||
上边: cols 个
|
||||
总计: 2*rows + 2*cols
|
||||
```
|
||||
|
||||
这意味着四个角点不再共享,每个角点归属一条边。需重新设计单元格坐标计算逻辑。
|
||||
|
||||
---
|
||||
|
||||
#### BUG-003: 模板读取路径错误
|
||||
|
||||
**当前代码**(`main.py` → `run_list_to_map()`):
|
||||
```python
|
||||
template_style = read_template_styles(filepath)
|
||||
```
|
||||
|
||||
**问题**:`filepath` 是用户选择的 PinList 输入文件路径,而非模板文件路径。模板文件应为根目录下的 `PinMAP-Template.xlsx`。
|
||||
|
||||
**MAP→List 方向**(`run_map_to_list()`):
|
||||
当前代码未调用 `read_template_styles()`,PinList 输出直接使用 `write_xlsx()`(无样式)。
|
||||
|
||||
**需要修复**:
|
||||
1. List→MAP:将 `read_template_styles(filepath)` 改为读取根目录模板
|
||||
2. MAP→List:增加模板读取和样式应用
|
||||
|
||||
---
|
||||
|
||||
#### BUG-004: 无循环处理流程
|
||||
|
||||
**当前 `main()` 流程**:
|
||||
```python
|
||||
def main():
|
||||
show_banner()
|
||||
# 选择方向 → 执行一次转换 → wait_for_exit() → 程序结束
|
||||
```
|
||||
|
||||
**问题**:转换完成后直接退出,用户需重新运行程序才能处理下一个文件。
|
||||
|
||||
**期望流程**:
|
||||
```
|
||||
启动 → 选择方向 → 处理文件 → [处理完成] → 等待输入下一个文件 / Q 返回主菜单
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 逐项修改方案
|
||||
|
||||
---
|
||||
|
||||
### 3.1 BUG-001 / F005: BAT 脚本修复
|
||||
|
||||
**修改范围**:`run.bat`(1 个文件)
|
||||
|
||||
**具体修改**:
|
||||
|
||||
1. **换行符**:确保文件使用 CRLF(`\r\n`)换行。写入文件时指定 `newline='\r\n'`。
|
||||
2. **去掉 lines=50**:将 `mode con cols=80 lines=50` 改为 `mode con cols=80`。
|
||||
|
||||
**修改后 run.bat**:
|
||||
```bat
|
||||
@ECHO OFF
|
||||
chcp 65001 >nul
|
||||
title PinMAP转PinList -By:LeeQwQ
|
||||
mode con cols=80
|
||||
color 0B
|
||||
cls
|
||||
|
||||
cd /d "%~dp0Code\src"
|
||||
python main.py
|
||||
|
||||
echo.
|
||||
pause
|
||||
EXIT
|
||||
```
|
||||
|
||||
**风险评估**:
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| CRLF 写入失败 | 低 | 极低 | Python `open(path, 'w', newline='\r\n')` 保证 |
|
||||
| 去掉 lines 后窗口行数变回默认 | 低 | 确认 | 默认 300 行缓冲区,足够查看日志 |
|
||||
|
||||
**工作量**:5 分钟
|
||||
|
||||
---
|
||||
|
||||
### 3.2 BUG-002 / F006: 周长公式修复
|
||||
|
||||
**修改范围**:3 个文件
|
||||
|
||||
| 文件 | 修改内容 | 修改行数 |
|
||||
|------|---------|---------|
|
||||
| `pinlist_validator.py` | 周长匹配公式 + 错误提示文案 | ~5 行 |
|
||||
| `validator.py` | `validate_pinlist_for_map()` 周长公式 | ~5 行 |
|
||||
| `pinmap_layout.py` | 边分配计数 + 单元格坐标计算 | ~20 行 |
|
||||
|
||||
**具体修改**:
|
||||
|
||||
#### 3.2.1 `pinlist_validator.py` — `validate_pinlist()`
|
||||
|
||||
```python
|
||||
# 修改前:
|
||||
expected_total = 2 * rows + 2 * cols - 4
|
||||
|
||||
# 修改后:
|
||||
expected_total = (rows + cols) * 2
|
||||
```
|
||||
|
||||
#### 3.2.2 `validator.py` — `validate_pinlist_for_map()`
|
||||
|
||||
```python
|
||||
# 修改前:
|
||||
expected_total = 2 * rows + 2 * cols - 4
|
||||
|
||||
# 修改后:
|
||||
expected_total = (rows + cols) * 2
|
||||
```
|
||||
|
||||
#### 3.2.3 `pinmap_layout.py` — `calculate_layout()`
|
||||
|
||||
**边分配计数修改**:
|
||||
```python
|
||||
# 修改前:
|
||||
left_count = rows
|
||||
bottom_count = cols - 1
|
||||
right_count = rows - 2
|
||||
top_count = cols - 1
|
||||
# total = 2*rows + 2*cols - 4
|
||||
|
||||
# 修改后:
|
||||
left_count = rows
|
||||
bottom_count = cols
|
||||
right_count = rows
|
||||
top_count = cols
|
||||
# total = 2*rows + 2*cols
|
||||
```
|
||||
|
||||
**单元格坐标计算修改**:
|
||||
|
||||
```python
|
||||
# 修改前(角点共享):
|
||||
# 左边: (r, 0) r ∈ [1, rows]
|
||||
# 下边: (rows, c) c ∈ [1, cols-1]
|
||||
# 右边: (r, cols) r ∈ [rows-1, 2] 逆序
|
||||
# 上边: (1, c) c ∈ [cols-1, 2] 逆序
|
||||
|
||||
# 修改后(角点不共享,每条边独立):
|
||||
# 左边: (r, 0) r ∈ [1, rows]
|
||||
# 下边: (rows, c) c ∈ [1, cols]
|
||||
# 右边: (r, cols) r ∈ [rows, 1] 逆序
|
||||
# 上边: (1, c) c ∈ [cols, 1] 逆序
|
||||
```
|
||||
|
||||
```python
|
||||
# 修改后代码:
|
||||
left_cells = [(r, 0) for r in range(1, rows + 1)]
|
||||
bottom_cells = [(rows, c) for c in range(1, cols + 1)]
|
||||
right_cells = [(r, cols) for r in range(rows, 0, -1)]
|
||||
top_cells = [(1, c) for c in range(cols, 0, -1)]
|
||||
```
|
||||
|
||||
**`get_name_cell()` 函数修改**:
|
||||
|
||||
```python
|
||||
# 修改前:
|
||||
# left: (r, c+1)
|
||||
# bottom: (r-1, c)
|
||||
# right: (r, c-1)
|
||||
# top: (r+1, c)
|
||||
|
||||
# 修改后:逻辑不变(Name 单元格相对于序号单元格的位置不变)
|
||||
# 但需确保角点单元格 Name 不冲突
|
||||
```
|
||||
|
||||
**风险评估**:
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 布局算法修改引入新 Bug | **高** | 中 | 对 4×8、15×15、2×2 等典型尺寸做单元测试 |
|
||||
| 角点单元格 Name 重叠 | 中 | 中 | 修改 `get_name_cell()` 确保角点 Name 不冲突 |
|
||||
| 已有用户数据不兼容 | 低 | 低 | 用户需重新输入正确的 PinList |
|
||||
| 修改 3 个文件不一致 | 中 | 低 | 使用同一公式常量,避免硬编码 |
|
||||
|
||||
**工作量**:1.5 小时
|
||||
|
||||
---
|
||||
|
||||
### 3.3 BUG-003 / F007: 模板读取修复
|
||||
|
||||
**修改范围**:1 个文件(`main.py`)
|
||||
|
||||
**问题根因**:
|
||||
1. List→MAP:`read_template_styles(filepath)` 传入的是输入文件路径,而非模板路径
|
||||
2. MAP→List:完全没有模板读取逻辑
|
||||
|
||||
**具体修改**:
|
||||
|
||||
#### 3.3.1 新增模板路径解析辅助函数
|
||||
|
||||
```python
|
||||
def _find_template_path() -> str | None:
|
||||
"""查找根目录下的 PinMAP-Template.xlsx。
|
||||
|
||||
搜索顺序:
|
||||
1. 与 run.bat 同级的根目录
|
||||
2. 当前工作目录
|
||||
"""
|
||||
# 尝试从 Code/src 回退到根目录
|
||||
src_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
|
||||
template_path = os.path.join(root_dir, "PinMAP-Template.xlsx")
|
||||
|
||||
if os.path.exists(template_path):
|
||||
return template_path
|
||||
|
||||
# 回退到当前工作目录
|
||||
cwd_template = os.path.join(os.getcwd(), "PinMAP-Template.xlsx")
|
||||
if os.path.exists(cwd_template):
|
||||
return cwd_template
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
#### 3.3.2 修改 `run_list_to_map()`
|
||||
|
||||
```python
|
||||
# 修改前:
|
||||
template_style = read_template_styles(filepath)
|
||||
|
||||
# 修改后:
|
||||
template_path = _find_template_path()
|
||||
if template_path:
|
||||
template_style = read_template_styles(template_path)
|
||||
if template_style:
|
||||
print(f"[INFO] 已加载模板样式: {template_path}")
|
||||
else:
|
||||
print("[WARN] 模板文件存在但解析失败,使用默认样式")
|
||||
else:
|
||||
template_style = None
|
||||
print("[INFO] 未检测到模板文件,使用默认样式")
|
||||
```
|
||||
|
||||
#### 3.3.3 修改 `run_map_to_list()`
|
||||
|
||||
```python
|
||||
# 在写入 PinList 之前,增加模板读取逻辑:
|
||||
template_path = _find_template_path()
|
||||
template_style = None
|
||||
if template_path:
|
||||
template_style = read_template_styles(template_path)
|
||||
if template_style:
|
||||
print(f"[INFO] 已加载模板样式: {template_path}")
|
||||
|
||||
# 写入时:
|
||||
if template_style is not None:
|
||||
write_xlsx_with_style(data, output_path, template_style)
|
||||
else:
|
||||
write_xlsx(data, output_path)
|
||||
```
|
||||
|
||||
**风险评估**:
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 模板路径解析错误 | 中 | 低 | 多路径回退 + 优雅降级 |
|
||||
| 模板解析失败导致崩溃 | 低 | 低 | `template_reader.py` 已有 try-except 优雅降级 |
|
||||
| MAP→List 应用模板样式后 PinList 格式不符合用户预期 | 中 | 低 | 模板仅影响样式(字体/边框),不影响数据 |
|
||||
|
||||
**工作量**:1 小时
|
||||
|
||||
---
|
||||
|
||||
### 3.4 BUG-004 / F008: 循环处理流程
|
||||
|
||||
**修改范围**:1 个文件(`main.py`)
|
||||
|
||||
**具体修改**:
|
||||
|
||||
将 `main()` 改造为循环结构:
|
||||
|
||||
```python
|
||||
def main():
|
||||
show_banner()
|
||||
|
||||
while True:
|
||||
# ── Direction selection ───────────────────────────────
|
||||
if len(sys.argv) > 1:
|
||||
# Legacy mode: 直接文件参数 → MAP→List → 循环
|
||||
direction = 1
|
||||
filepath = sys.argv[1]
|
||||
sys.argv = [sys.argv[0]] # 清除 argv,下次循环进入交互模式
|
||||
else:
|
||||
print("请选择转换方向:")
|
||||
print(" 1 — PinMAP → PinList")
|
||||
print(" 2 — PinList → PinMAP")
|
||||
print(" Q — 退出程序")
|
||||
print()
|
||||
|
||||
choice = input("请输入选项 (1/2/Q): ").strip().upper()
|
||||
if choice == 'Q':
|
||||
print("感谢使用,再见!")
|
||||
return
|
||||
elif choice == '1':
|
||||
direction = 1
|
||||
elif choice == '2':
|
||||
direction = 2
|
||||
else:
|
||||
print("[ERROR] 无效选项,请输入 1、2 或 Q")
|
||||
continue
|
||||
|
||||
filepath = None
|
||||
|
||||
# ── Dispatch ──────────────────────────────────────────
|
||||
if direction == 1:
|
||||
print()
|
||||
print("─" * 40)
|
||||
print(" 方向: PinMAP → PinList")
|
||||
print("─" * 40)
|
||||
print()
|
||||
run_map_to_list(filepath)
|
||||
else:
|
||||
print()
|
||||
print("─" * 40)
|
||||
print(" 方向: PinList → PinMAP")
|
||||
print("─" * 40)
|
||||
print()
|
||||
run_list_to_map(filepath)
|
||||
|
||||
# ── 处理完成后循环 ────────────────────────────────────
|
||||
print()
|
||||
print("=" * 40)
|
||||
next_action = input("输入文件名继续处理,或按 Enter 返回主菜单,输入 Q 退出: ").strip()
|
||||
if next_action.upper() == 'Q':
|
||||
print("感谢使用,再见!")
|
||||
return
|
||||
elif next_action:
|
||||
# 直接处理指定文件(自动检测方向)
|
||||
filepath = next_action
|
||||
# 根据文件内容自动判断方向,或默认 MAP→List
|
||||
direction = 1
|
||||
else:
|
||||
# 返回主菜单(继续 while 循环)
|
||||
pass
|
||||
```
|
||||
|
||||
**同时需要修改 `run_map_to_list()` 和 `run_list_to_map()`**:
|
||||
|
||||
将两个函数末尾的 `wait_for_exit()` 替换为 `input("按 Enter 键继续...")`,这样处理完成后用户可以继续操作而不是退出。
|
||||
|
||||
```python
|
||||
# 修改前(两处):
|
||||
wait_for_exit()
|
||||
|
||||
# 修改后:
|
||||
input("按 Enter 键继续...")
|
||||
```
|
||||
|
||||
**但注意**:`wait_for_exit()` 仍保留,用于:
|
||||
- 致命错误(FATAL)时的退出
|
||||
- 用户未选择文件时的退出
|
||||
- 命令行参数模式下最后一次退出
|
||||
|
||||
**风险评估**:
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 循环导致内存泄漏(模块重复 import) | 低 | 低 | Python import 有缓存,重复 import 无开销 |
|
||||
| 用户输入 Q 后状态混乱 | 中 | 低 | Q 只在主菜单和处理完成后接受,转换过程中不响应 |
|
||||
| 命令行参数模式与循环模式冲突 | 中 | 中 | 命令行参数模式执行一次后清除 argv,进入交互循环 |
|
||||
|
||||
**工作量**:1 小时
|
||||
|
||||
---
|
||||
|
||||
## 4. 修改影响矩阵
|
||||
|
||||
| 文件 | BUG-001 | BUG-002 | BUG-003 | BUG-004 | 总改动量 |
|
||||
|------|---------|---------|---------|---------|---------|
|
||||
| `run.bat` | ✏️ 换行+CRLF, 去lines | | | | 2 行 |
|
||||
| `main.py` | | | ✏️ 模板路径 | ✏️ 循环流程 | ~40 行 |
|
||||
| `pinlist_validator.py` | | ✏️ 公式 | | | ~5 行 |
|
||||
| `validator.py` | | ✏️ 公式 | | | ~5 行 |
|
||||
| `pinmap_layout.py` | | ✏️ 公式+布局 | | | ~20 行 |
|
||||
| **合计** | **1 文件** | **3 文件** | **1 文件** | **1 文件** | **5 文件** |
|
||||
|
||||
---
|
||||
|
||||
## 5. 优先级排序
|
||||
|
||||
| 优先级 | 编号 | 原因 |
|
||||
|--------|------|------|
|
||||
| **P0** | BUG-002 / F006 | 核心公式错误,所有 List→MAP 转换均受影响 |
|
||||
| **P1** | BUG-001 / F005 | 影响 Windows 用户体验,修复简单 |
|
||||
| **P2** | BUG-003 / F007 | 功能增强,模板样式对输出质量有影响 |
|
||||
| **P2** | BUG-004 / F008 | 体验优化,批量处理场景需要 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 工作量估算
|
||||
|
||||
| 任务 | 文件 | 预估时间 | 依赖 |
|
||||
|------|------|---------|------|
|
||||
| BUG-001/F005 | `run.bat` | 5 分钟 | 无 |
|
||||
| BUG-002/F006 | `pinlist_validator.py`, `validator.py`, `pinmap_layout.py` | 1.5 小时 | 无 |
|
||||
| BUG-003/F007 | `main.py` | 1 小时 | 无 |
|
||||
| BUG-004/F008 | `main.py` | 1 小时 | 无 |
|
||||
| 文档更新 | `bugs.md`, `features.md` | 10 分钟 | 无 |
|
||||
|
||||
**总计预估**:约 3 小时
|
||||
|
||||
---
|
||||
|
||||
## 7. 推荐开发顺序
|
||||
|
||||
```
|
||||
第1轮(独立,可并行):
|
||||
BUG-001/F005: BAT 脚本修复(5 分钟,任何 Agent)
|
||||
|
||||
第2轮(核心,必须最先):
|
||||
BUG-002/F006: 周长公式修复(1.5 小时,需理解布局算法)
|
||||
|
||||
第3轮(独立):
|
||||
BUG-003/F007: 模板读取修复(1 小时)
|
||||
BUG-004/F008: 循环处理流程(1 小时)
|
||||
(两者都修改 main.py,建议先后执行避免冲突)
|
||||
|
||||
第4轮(收尾):
|
||||
文档更新:bugs.md + features.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
### 8.1 BUG-001 / F005 验收
|
||||
|
||||
| 验收项 | 方法 | 预期结果 |
|
||||
|--------|------|---------|
|
||||
| run.bat 使用 CRLF 换行 | 二进制查看文件 | 每行末尾为 `\r\n` |
|
||||
| 不含 `lines=` 参数 | 文本搜索 | 无 `lines=` 字符串 |
|
||||
| 仅含 `mode con cols=80` | 文本搜索 | 仅一行 `mode con cols=80` |
|
||||
| Windows 下双击运行正常 | 实际运行 | 窗口正常打开,中文显示正确 |
|
||||
|
||||
### 8.2 BUG-002 / F006 验收
|
||||
|
||||
| 验收项 | 方法 | 预期结果 |
|
||||
|--------|------|---------|
|
||||
| 15×15 网格 + 60 Pin 验证通过 | 输入测试 | 无错误提示,转换成功 |
|
||||
| 4×8 网格 + 24 Pin 验证通过 | 输入测试 | 无错误提示,转换成功 |
|
||||
| 2×2 网格 + 8 Pin 验证通过 | 输入测试 | 无错误提示,转换成功 |
|
||||
| 错误 Pin 数量仍报错 | 输入 15×15+56Pin | 提示不匹配 |
|
||||
| 布局计算正确 | 检查输出文件 | 四条边 Pin 分布正确 |
|
||||
|
||||
### 8.3 BUG-003 / F007 验收
|
||||
|
||||
| 验收项 | 方法 | 预期结果 |
|
||||
|--------|------|---------|
|
||||
| List→MAP 读取模板 | 放置模板文件后转换 | 日志显示"已加载模板样式" |
|
||||
| MAP→List 读取模板 | 放置模板文件后转换 | 日志显示"已加载模板样式" |
|
||||
| 无模板时优雅降级 | 不放置模板文件 | 日志显示"未检测到模板文件",使用默认样式 |
|
||||
| 模板解析失败降级 | 放置损坏的模板文件 | 日志显示"解析失败",使用默认样式 |
|
||||
| 输出文件样式正确 | 打开输出文件 | 字体、边框、对齐与模板一致 |
|
||||
|
||||
### 8.4 BUG-004 / F008 验收
|
||||
|
||||
| 验收项 | 方法 | 预期结果 |
|
||||
|--------|------|---------|
|
||||
| 处理完不退出 | 完成一次转换 | 显示"按 Enter 键继续"或循环提示 |
|
||||
| 输入 Q 返回主菜单 | 处理完成后输入 Q | 返回方向选择菜单 |
|
||||
| 主菜单输入 Q 退出 | 主菜单输入 Q | 程序退出 |
|
||||
| 连续处理多个文件 | 连续选择文件 | 可连续处理,无需重新运行 |
|
||||
| 命令行参数模式 | `run.bat input.xls` | 处理完成后进入循环 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险评估汇总
|
||||
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 周长公式修改导致已有布局算法不一致 | **高** | 中 | 同步修改 validator + layout,确保公式统一 |
|
||||
| 角点单元格 Name 冲突 | 中 | 中 | 修改 `get_name_cell()` 确保不重叠 |
|
||||
| main.py 两处修改冲突 | 中 | 中 | 先完成 BUG-003,再完成 BUG-004,避免同时修改 |
|
||||
| 模板路径在命令行模式下解析错误 | 低 | 低 | 使用 `__file__` 绝对路径而非 cwd |
|
||||
| 循环流程中模块重复 import 性能 | 低 | 极低 | Python 有 import 缓存 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 总结
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 修改文件数 | 5 个(run.bat, main.py, pinlist_validator.py, validator.py, pinmap_layout.py) |
|
||||
| 新增文件数 | 0 |
|
||||
| 影响核心模块 | 是(pinmap_layout.py 布局算法) |
|
||||
| 技术难度 | 中(周长公式 + 布局算法需同步修改) |
|
||||
| 预估工作量 | ~3 小时 |
|
||||
| 推荐 Agent | Python 编码 Agent(1-2 个) |
|
||||
| 风险等级 | 中(公式修改需仔细验证) |
|
||||
|
||||
**结论**:
|
||||
1. BUG-002 为最高优先级,影响所有 List→MAP 转换的正确性
|
||||
2. BUG-001 修复最简单,可快速完成
|
||||
3. BUG-003 和 BUG-004 都修改 `main.py`,需先后执行避免冲突
|
||||
4. 所有修改均使用 Python 标准库,无新增依赖
|
||||
5. 建议修改完成后运行完整测试套件验证
|
||||
|
||||
---
|
||||
|
||||
*文档结束 — 请审批后进入编码阶段*
|
||||
27
docs/requirements.md
Normal file
27
docs/requirements.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 需求规格说明书
|
||||
|
||||
## 项目信息
|
||||
- 项目名称:pinmap-to-pinlist
|
||||
- 项目 ID:PROJ-002
|
||||
- 项目类型:脚本
|
||||
- 技术约束:Python 脚本,支持 Windows 和 Linux
|
||||
|
||||
## 需求描述
|
||||
PinMAP ↔ PinList 双向转换器,支持 PinMAP→PinList 与 PinList→PinMAP 互转。
|
||||
|
||||
## 输入/输出
|
||||
| 类型 | 描述 |
|
||||
|-----|------|
|
||||
| 输入 | PinMAP 或 PinList Excel 文件 |
|
||||
| 输出 | 转换后的 Excel 文件 |
|
||||
|
||||
## 边界条件
|
||||
- 支持 .xls 和 .xlsx 格式
|
||||
- Pin 数量必须与网格周长匹配
|
||||
- 网格尺寸至少为 2x2
|
||||
|
||||
## 验收标准
|
||||
1. 能正确解析 PinMAP 结构
|
||||
2. 能正确生成 PinList
|
||||
3. 能正确反向转换
|
||||
4. 错误处理完善
|
||||
13
docs/tasks.md
Normal file
13
docs/tasks.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 任务进度表
|
||||
|
||||
| 任务 ID | 任务名称 | 负责 Agent | 当前状态 | 任务类型 | 关联功能 | 创建时间 | 完成时间 |
|
||||
|--------|---------|-----------|---------|---------|---------|---------|---------|
|
||||
| T001 | 架构设计 | script-architect | 已完成 | 架构设计 | F001-F005 | 2026-05-23 | 2026-05-23 |
|
||||
| T002 | Python 编码 v1.2 | python-coding-agent | 已完成 | 编码实现 | F001-F005 | 2026-05-23 | 2026-05-26 |
|
||||
| T003 | BAT 编码 | bat-coding-agent | 已完成 | 编码实现 | F005 | 2026-05-23 | 2026-05-23 |
|
||||
| T004 | 测试验证 v1.2 | test-qa-agent | 已完成 | 测试验证 | F001-F005 | 2026-05-23 | - |
|
||||
| T007 | BAT 脚本修复 v1.3 | bat-coding-agent | 已完成 | 编码实现 | F005 | 2026-05-31 | 2026-05-31 |
|
||||
| T008 | Python 编码 v1.3 | python-coding-agent | 已完成 | 编码实现 | F006-F008 | 2026-05-31 | 2026-05-31 |
|
||||
| T009 | 测试验证 v1.3 | test-qa-agent | 进行中 | 测试验证 | F005-F008 | 2026-05-31 | - |
|
||||
| T010 | 文档生成 v1.3 | doc-gen-agent | 待分配 | 文档编写 | F005-F008 | - | - |
|
||||
| T011 | 打包发布 v1.3 | package-release-agent | 待分配 | 打包发布 | F005-F008 | - | - |
|
||||
25
run.bat
25
run.bat
@@ -1,14 +1,11 @@
|
||||
@ECHO OFF
|
||||
:: 初始化区
|
||||
chcp 65001
|
||||
title PinMAP转PinList -By:LeeQwQ
|
||||
mode con cols=80 lines=20
|
||||
color 0B
|
||||
cls
|
||||
|
||||
cd /d "%~dp0src"
|
||||
python main.py
|
||||
|
||||
echo.
|
||||
pause
|
||||
EXIT
|
||||
@ECHO OFF
|
||||
chcp 65001 >nul
|
||||
title PinMAP转PinList -By:LeeQwQ
|
||||
mode con cols=80
|
||||
color 0B
|
||||
cls
|
||||
cd /d "%~dp0Code\src"
|
||||
python main.py
|
||||
echo.
|
||||
pause
|
||||
EXIT
|
||||
|
||||
Reference in New Issue
Block a user