diff --git a/Code/docs/QUICKSTART.md b/Code/docs/QUICKSTART.md
index 24e8ef5..c8fc41e 100644
--- a/Code/docs/QUICKSTART.md
+++ b/Code/docs/QUICKSTART.md
@@ -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。
+
---
## 测试验证
diff --git a/Code/docs/README.md b/Code/docs/README.md
index 567d213..9188fcb 100644
--- a/Code/docs/README.md
+++ b/Code/docs/README.md
@@ -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]` | 转换完成 | 显示输出文件路径和统计信息 |
---
diff --git a/Code/docs/RELEASE.md b/Code/docs/RELEASE.md
index 3945d84..163dcf2 100644
--- a/Code/docs/RELEASE.md
+++ b/Code/docs/RELEASE.md
@@ -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
### 🎉 首次发布
diff --git a/Code/docs/architecture-design.md b/Code/docs/architecture-design.md
index 3e38c89..36ef152 100644
--- a/Code/docs/architecture-design.md
+++ b/Code/docs/architecture-design.md
@@ -1,9 +1,10 @@
-# PinMAP → PinList 转换器 — 全局架构设计
+# PinMAP ↔ PinList 双向转换器 — 全局架构设计
-> **版本**: v1.0
-> **日期**: 2026-05-25
+> **版本**: v2.0
+> **日期**: 2026-05-28
> **架构师**: 脚本架构师 (Script Architect)
-> **状态**: 待审批
+> **状态**: 待审批
+> **变更**: 新增 PinList → PinMAP 反向转换 + 模板格式支持
---
@@ -11,13 +12,17 @@
### 1.1 背景
-将 Excel 格式的 **PinMAP** 文件(方形封装引脚布局图)自动转换为 **PinList** 格式(引脚序号列表),消除手动抄录的低效与错误风险。
+将 Excel 格式的 **PinMAP** 文件(方形封装引脚布局图)与 **PinList** 格式(引脚序号列表)进行**双向自动转换**,消除手动抄录的低效与错误风险。
+
+- **PinMAP → PinList**(已有):方形布局图 → 扁平引脚列表
+- **PinList → PinMAP**(新增):扁平引脚列表 → 方形布局图
### 1.2 核心规则
- PinMAP 为方形/长方形结构,引脚沿四条边分布,左上角为 1 脚,**逆时针**排序
-- 左上角 = 1 脚,右上角 = 上边最后一个脚,右下角 = 下边最后一个脚,左下角 = 左边最后一个脚
+- 四条边遍历顺序:左→下→右→上(counter-clockwise)
- 四个角点被相邻两边共享(不重复计数)
+- 总 Pin 数 = 2 × width + 2 × height − 4
### 1.3 约束
@@ -28,833 +33,1091 @@
| 输入格式 | `.xls`(必须支持)、`.xlsx`(优先支持) |
| 输出格式 | `.xlsx` 仅 |
| 交互方式 | 命令行 + 文件选择对话框 |
+| 模板 | 可选,根目录 `PinMAP-Template.xlsx` |
---
-## 2. 技术选型
+## 2. 技术可行性评估
-### 2.1 为什么不用 openpyxl / xlrd?
+### 2.1 PinList → PinMAP 反向转换
-项目明确要求 **无第三方依赖**,因此必须使用 Python 标准库自行实现 Excel 读写。
+**可行性:✅ 完全可行**
-### 2.2 XLS 读取 — BIFF8 二进制解析
+| 子任务 | 技术可行性 | 说明 |
+|--------|-----------|------|
+| PinList 读取与解析 | ✅ 低难度 | 复用现有 xls_reader/xlsx_reader,解析 A/B 两列即可 |
+| Pin 数据验证 | ✅ 低难度 | 连续性、唯一性检查逻辑与现有 validator 类似 |
+| 尺寸输入 | ✅ 低难度 | 命令行 `input()` 交互,输入行数/列数 |
+| PinMAP 布局计算 | ✅ 中难度 | 核心算法:根据 width/height 将 Pin 分配到四条边 |
+| PinMAP 生成与输出 | ✅ 中难度 | 需要增强 xlsx_writer 支持模板样式 |
+| 模板检测与读取 | ✅ 中难度 | 需新增 xlsx style 读取能力(styles.xml 解析) |
-`.xls` 是 Microsoft **BIFF8** 二进制格式(复合文档 OLE2 容器)。
+### 2.2 关键技术难点
-**实现策略**:
+#### 难点 1:PinMAP 布局分配算法
+
+**问题**:给定 PinList(N 个 Pin,已按序号 1..N 排序)和尺寸 (rows, cols),如何将 Pin 分配到四条边?
+
+**公式**(逆时针,左上角为 1 脚):
```
-1. 使用 struct 模块解析 OLE2 复合文档头
-2. 解析 FAT(文件分配表)定位 MiniFAT 和目录流
-3. 定位 Workbook 流,读取 BIFF8 记录序列
-4. 关键记录类型:
- - 0x0009 (BOF) → 块起始标记
- - 0x00FD (LABELSST) → 共享字符串表中的文本单元格
- - 0x0006 (FORMULA) → 公式/数值单元格
- - 0x0203 (NUMBER) → 数值单元格
- - 0x000C (RK) → RK 数值(压缩整数/浮点)
- - 0x000D (RString) → 内联字符串
- - 0x00FC (STRING) → 字符串结果
- - 0x0034 (SST) → 全局共享字符串表
- - 0x0042 (BOUNDSHEET) → 工作表信息
-5. 提取每个单元格的 (行, 列, 值) 三元组
+总 Pin 数 N = 2 × cols + 2 × rows − 4
+
+边分配(按遍历顺序):
+ 左边 (left) = rows 个 Pin → Pin 1..rows
+ 下边 (bottom) = cols − 1 个 Pin → Pin (rows+1)..(rows+cols−1)
+ 右边 (right) = rows − 2 个 Pin → Pin (rows+cols)..(rows+2×cols−3)
+ 上边 (top) = cols − 1 个 Pin → Pin (rows+2×cols−2)..N
+
+验证:rows + (cols−1) + (rows−2) + (cols−1) = 2×rows + 2×cols − 4 = N ✓
```
-**复杂度评估**:中等。BIFF8 是固定长度记录流,struct 解析直接。需处理 Unicode 编码(BIFF8 默认 UTF-16LE,部分兼容 ASCII)。
+**示例**:4×8 网格(rows=4, cols=8, N=20)
-### 2.3 XLSX 读取 — ZIP + XML
-
-`.xlsx` 本质是 ZIP 压缩包,内部为 Office Open XML (OOXML)。
-
-**实现策略**:
-
-```python
-import zipfile
-import xml.etree.ElementTree as ET
-
-1. zipfile.ZipFile 打开 .xlsx
-2. 读取 [Content_Types].xml 确认结构
-3. 读取 xl/workbook.xml 获取工作表关系
-4. 读取 xl/worksheets/sheet1.xml 获取单元格数据
-5. 读取 xl/sharedStrings.xml 获取共享字符串表
-6. 解析单元格坐标(如 "A2" → 列A行2)和值类型
+```
+左边:Pin 1-4 (4个)
+下边:Pin 5-12 (8个)
+右边:Pin 13-15 (3个)
+上边:Pin 16-20 (5个)
+总计:4+8+3+5 = 20 ✓
```
-**复杂度评估**:低。zipfile 和 xml.etree 均为标准库,XML 结构规范清晰。
+**非 4 倍数处理**:提示用户 "Pin 数量 {N} 不是 4 的倍数,四条边将不均匀分布",不影响转换。
-### 2.4 XLSX 写入 — ZIP + XML 生成
+#### 难点 2:模板样式读取
-**实现策略**:
+**问题**:从零实现的 xlsx_reader 目前只读取单元格值,不读取样式(字体、边框、背景色等)。PinMAP 输出需要参考模板的单元格样式。
-```python
-import zipfile
-import xml.etree.ElementTree as ET
-from io import BytesIO
+**方案**:
+1. 新增 `template_reader.py` 模块,专门解析 `xl/styles.xml`
+2. 提取关键样式信息:字体、边框、填充色、对齐方式
+3. 在 `pinmap_generator.py` 中应用模板样式到输出文件
+4. 模板不存在时,使用默认样式(无边框、默认字体)
-1. 构建 OOXML 目录结构:
- [Content_Types].xml
- _rels/.rels
- xl/workbook.xml
- xl/worksheets/sheet1.xml
- xl/sharedStrings.xml
- xl/_rels/workbook.xml.rels
+**OOXML styles.xml 结构**:
-2. 使用 zipfile.ZipFile 写入(ZIP_DEFLATED)
-
-3. 关键 XML 构建:
- - sharedStrings.xml: 所有唯一字符串的 SST
- - sheet1.xml: 单元格坐标 + si (SST index) 引用
- - workbook.xml: 工作表引用
- - [Content_Types].xml: MIME 类型声明
+```xml
+
+
+
+
+ ...
+ ...
+
+
+
+
```
-**复杂度评估**:低。XML 结构固定,模板化生成即可。
+**复杂度评估**:中等。需解析 XML 结构,提取 cellXfs(单元格格式)与 fonts/borders/fills 的关联关系。
-### 2.5 技术选型总结
+#### 难点 3:双向交互流程
-| 操作 | 格式 | 标准库模块 | 难度 |
-|------|------|-----------|------|
-| 读取 | xls | `struct` + 手动 OLE2/BIFF8 解析 | 中 |
-| 读取 | xlsx | `zipfile` + `xml.etree.ElementTree` | 低 |
-| 写入 | xlsx | `zipfile` + `xml.etree.ElementTree` | 低 |
-| 文件选择 | — | `tkinter.filedialog` | 低 |
+**问题**:main.py 需要支持两种转换方向,交互流程不同。
+
+**方案**:启动时让用户选择方向,然后进入对应的流程。保持与现有 PinMAP→PinList 一致的交互风格(Banner → 文件选择 → 处理 → 结果摘要)。
---
## 3. 模块划分
+### 3.1 现有模块(PinMAP → PinList 方向)
+
+```
+Code/src/
+├── main.py # 入口:流程编排(需修改)
+├── file_selector.py # 文件选择(需修改)
+├── xls_reader.py # XLS 解析引擎(不变)
+├── xlsx_reader.py # XLSX 解析引擎(不变)
+├── pinmap_parser.py # PinMAP 结构解析(不变)
+├── validator.py # 数据验证(需修改)
+├── pinlist_generator.py # PinList 生成(不变)
+├── xlsx_writer.py # XLSX 输出引擎(需修改)
+├── models.py # 数据模型(需修改)
+└── utils.py # 工具函数(不变)
+```
+
+### 3.2 新增模块(PinList → PinMAP 方向)
+
+| 模块 | 文件 | 职责 |
+|------|------|------|
+| **PinList 解析器** | `pinlist_parser.py` | 读取 PinList xlsx,解析 A1 封装信息 + A/B 列 PinName/序号 |
+| **PinList 验证器** | `pinlist_validator.py` | 验证 Pin 序号连续性、唯一性、与周长匹配 |
+| **PinMAP 布局计算** | `pinmap_layout.py` | 根据尺寸将 Pin 分配到四条边,计算单元格坐标 |
+| **PinMAP 生成器** | `pinmap_generator.py` | 组装 PinMAP 单元格数据,应用模板样式 |
+| **模板读取器** | `template_reader.py` | 读取 PinMAP-Template.xlsx 的样式信息 |
+
+### 3.3 修改后完整目录结构
+
```
pinmap-to-pinlist/
├── Code/
│ ├── src/
-│ │ ├── main.py # 入口:流程编排
-│ │ ├── file_selector.py # 模块1:文件选择
-│ │ ├── xls_reader.py # 模块2a:XLS 解析引擎
-│ │ ├── xlsx_reader.py # 模块2b:XLSX 解析引擎
-│ │ ├── pinmap_parser.py # 模块3:PinMAP 结构解析
-│ │ ├── validator.py # 模块4:数据验证
-│ │ ├── pinlist_generator.py # 模块5:PinList 生成
-│ │ └── xlsx_writer.py # 模块6:XLSX 输出引擎
+│ │ ├── main.py # ✏️ 修改:双向选择 + 双流程
+│ │ ├── file_selector.py # ✏️ 修改:支持两种文件类型过滤
+│ │ ├── models.py # ✏️ 修改:新增 PinList 输入模型
+│ │ ├── validator.py # ✏️ 修改:新增 PinList 验证函数
+│ │ ├── xlsx_writer.py # ✏️ 修改:支持样式写入
+│ │ │
+│ │ ├── xls_reader.py # (不变)
+│ │ ├── xlsx_reader.py # (不变)
+│ │ ├── pinmap_parser.py # (不变)
+│ │ ├── pinlist_generator.py # (不变)
+│ │ ├── utils.py # (不变)
+│ │ │
+│ │ ├── pinlist_parser.py # 🆕 PinList 读取与解析
+│ │ ├── pinlist_validator.py # 🆕 PinList 数据验证
+│ │ ├── pinmap_layout.py # 🆕 PinMAP 布局计算
+│ │ ├── pinmap_generator.py # 🆕 PinMAP 生成与输出
+│ │ └── template_reader.py # 🆕 模板样式读取
│ └── docs/
-│ └── architecture-design.md
+│ ├── architecture-design.md # ✏️ 更新
+│ └── modification-assessment.md
├── Test/
+│ └── fixtures/
+│ ├── sample_4x4.xlsx # 现有 PinMAP 测试
+│ ├── sample_rect.xlsx
+│ ├── sample_pinlist.xlsx # 🆕 PinList 测试
+│ └── PinMAP-Template.xlsx # 🆕 模板文件(可选)
+├── run.bat # (不变)
└── Releases/
```
-### 3.1 模块职责
+---
-#### 模块1:`file_selector` — 文件选择
+## 4. 模块详细设计
+
+### 4.1 新增模块:`pinlist_parser.py`
+
+**职责**:读取 PinList 格式的 xlsx 文件,解析封装信息和引脚数据。
```python
-def select_file() -> str | None:
- """弹出文件选择对话框,返回选中文件路径或 None(取消)"""
-```
+"""PinList parser — reads a flat pin list from an Excel file."""
-- 使用 `tkinter.filedialog.askopenfilename`
-- 文件类型过滤:`*.xls;*.xlsx`
-- 无 GUI 环境时回退到命令行参数
+from dataclasses import dataclass
-#### 模块2a:`xls_reader` — XLS 解析引擎
+@dataclass
+class PinListEntry:
+ """A single pin entry from the PinList."""
+ number: int # Pin 序号(B 列)
+ name: str # PinName(A 列,可能为空)
-```python
-class XLSReader:
- def __init__(self, filepath: str)
- def read_all_cells(self) -> dict[tuple[int, int], str]:
- """返回 {(row, col): value} 字典,行列从 0 开始"""
- def close(self)
-```
-**内部结构**:
+class PinListParser:
+ """Parse a PinList Excel file."""
-```
-XLSReader
-├── OLE2Parser → 解析复合文档,定位 Workbook 流
-├── BIFF8Parser → 解析 BIFF8 记录流
-│ ├── SSTParser → 共享字符串表
-│ └── CellParser → 单元格记录
-└── CellMap → 组装为 (row, col) → value 映射
-```
+ def __init__(self, filepath: str):
+ self._filepath = filepath
-#### 模块2b:`xlsx_reader` — XLSX 解析引擎
-
-```python
-class XLSXReader:
- def __init__(self, filepath: str)
- def read_all_cells(self) -> dict[tuple[int, int], str]:
- """返回 {(row, col): value} 字典,行列从 0 开始"""
- def close(self)
-```
-
-**内部结构**:
-
-```
-XLSXReader
-├── ZipExtractor → 解压 .xlsx 到内存
-├── SharedStrings → 解析 sharedStrings.xml
-├── SheetParser → 解析 sheet1.xml
-│ ├── CoordParser → 列字母转索引 (A→0, B→1, ...)
-│ └── CellParser → 提取单元格值
-└── CellMap → 组装为 (row, col) → value 映射
-```
-
-#### 模块3:`pinmap_parser` — PinMAP 结构解析
-
-```python
-def parse_pinmap(cells: dict[tuple[int, int], str]) -> PinMAP:
- """
- 解析步骤:
- 1. 排除 (0,0) 后扫描非空单元格,确定方形边界
- 2. 提取 A1 封装信息
- 3. 沿四条边提取引脚序号(边界单元格)和 PinName(相邻内侧单元格)
- 4. 逆时针遍历(左→下→右→上),按单元格位置去重(角点共享)
- 5. 返回 PinMAP 对象
- """
-```
-
-**解析算法**:
-
-```
-Step 1: 确定方形边界
- - 排除 (0,0)(封装信息单元格)
- - 扫描所有非空单元格,找到最小/最大行号和列号
- - width = max_col - min_col + 1
- - height = max_row - min_row + 1
- - 验证:width >= 2 且 height >= 2
-
-Step 2: 提取 A1 封装信息
- - cells[(0, 0)] → package_info
-
-Step 3: 构建 PinName 查找表
- 每条边的 PinName 位于序号单元格的"内侧相邻"位置:
- 左边:序号在 (r, min_col), Name 在 (r, min_col+1)
- 下边:序号在 (max_row, c), Name 在 (max_row-1, c)
- 右边:序号在 (r, max_col), Name 在 (r, max_col-1)
- 上边:序号在 (min_row, c), Name 在 (min_row+1, c)
-
-Step 4: 逆时针遍历四条边(按单元格位置去重)
- 4a. 左边:从上到下 (row: min_row → max_row, col: min_col)
- 4b. 下边:从左到右 (row: max_row, col: min_col+1 → max_col)
- 4c. 右边:从下到上 (row: max_row-1 → min_row, col: max_col)
- 4d. 上边:从右到左 (row: min_row, col: max_col-1 → min_col)
-
- 角点去重:按 (row, col) 单元格位置去重,而非按 Pin 序号。
- 这样如果两个不同单元格恰好有相同序号,validator 能检测到。
-
-Step 5: 组装 Pin 列表
- 按逆时针顺序:Pin1(左上角) → Pin2 → ... → PinN
-```
-
-#### 模块4:`validator` — 数据验证
-
-```python
-def validate_pinmap(pinmap: PinMAP) -> ValidationResult:
- """
- 验证项:
- 1. Pin序号唯一性(无重复)
- 2. Pin序号连续性(1..N 无间隔)
- 3. PinName 缺失检测(warning,默认 NC)
- 4. 方形结构完整性(width/height >= 2)
- """
-```
-
-#### 模块5:`pinlist_generator` — PinList 生成
-
-```python
-class PinListGenerator:
- def __init__(self, pinmap: PinMAP, validation: ValidationResult)
- def generate(self) -> PinList:
+ def parse(self) -> tuple[str, list[PinListEntry]]:
"""
- 生成规则:
- - A1 = 封装信息
- - A列 = PinName
- - B列 = Pin序号
- - 按 Pin序号 递增排序
+ 解析 PinList 文件。
+
+ Returns
+ -------
+ (package_info, entries)
+ package_info: A1 单元格的封装信息
+ entries: 按 Pin 序号排序的引脚列表
+
+ Raises
+ ------
+ StructureError
+ A1 为空、A/B 列无数据、序号非整数等
+ """
+ # 1. 读取文件(复用 xlsx_reader / xls_reader)
+ # 2. 提取 A1 封装信息
+ # 3. 解析 A 列 (PinName) 和 B 列 (Pin序号)
+ # 4. 按 Pin 序号排序
+ # 5. 返回 (package_info, entries)
+```
+
+**解析规则**:
+- A1 单元格 = 封装信息(与 PinMAP 方向一致)
+- A 列(从 A2 开始)= PinName
+- B 列(从 B2 开始)= Pin 序号
+- 读取到第一个空行为止
+- Pin 序号可能不是按顺序排列的(需要在 validator 中检查)
+
+### 4.2 新增模块:`pinlist_validator.py`
+
+**职责**:验证 PinList 数据的完整性和正确性。
+
+```python
+"""PinList validator — checks pin data integrity."""
+
+from models import ValidationResult, ValidationError
+
+
+def validate_pinlist(
+ entries: list[PinListEntry],
+ rows: int,
+ cols: int,
+) -> ValidationResult:
+ """
+ 验证 PinList 数据。
+
+ 检查项:
+ 1. Pin 序号从 1 开始连续无缺失
+ 2. Pin 序号无重复
+ 3. Pin 总数 = 2×rows + 2×cols − 4(周长匹配)
+ 4. Pin 缺少 PinName 时默认为 NC(warning)
+ 5. Pin 数量不是 4 的倍数时提示(info)
+
+ Parameters
+ ----------
+ entries : list[PinListEntry]
+ 已按序号排序的引脚列表
+ rows : int
+ 用户输入的 PinMAP 行数
+ cols : int
+ 用户输入的 PinMAP 列数
+
+ Returns
+ -------
+ ValidationResult
+ """
+```
+
+**验证逻辑**:
+
+```python
+# 1. 连续性检查
+numbers = sorted(e.number for e in entries)
+expected = list(range(1, len(numbers) + 1))
+if numbers != expected:
+ missing = set(expected) - set(numbers)
+ errors.append(f"Pin序号不连续,缺失: {sorted(missing)}")
+
+# 2. 唯一性检查
+if len(numbers) != len(set(numbers)):
+ errors.append("Pin序号存在重复")
+
+# 3. 周长匹配
+expected_total = 2 * rows + 2 * cols - 4
+if len(entries) != expected_total:
+ errors.append(
+ f"Pin数量 ({len(entries)}) 与网格周长 ({expected_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(f"{len(missing_names)} 个引脚缺少 PinName,将默认为 NC")
+
+# 5. 非 4 倍数提示(info)
+if len(entries) % 4 != 0:
+ infos.append(f"Pin数量 ({len(entries)}) 不是 4 的倍数,四条边将不均匀分布")
+```
+
+### 4.3 新增模块:`pinmap_layout.py`
+
+**职责**:根据 PinMAP 尺寸和 PinList 数据,计算每条边的 Pin 分布和单元格坐标。
+
+```python
+"""PinMAP layout calculator — distributes pins to four edges."""
+
+from dataclasses import dataclass
+from typing import NamedTuple
+
+class CellRef(NamedTuple):
+ """Excel cell reference (row, col), 0-based."""
+ row: int
+ col: int
+
+
+@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[CellRef] # 对应的单元格坐标
+
+
+def calculate_layout(
+ entries: list[PinListEntry],
+ rows: int,
+ cols: int,
+) -> dict[str, EdgePins]:
+ """
+ 计算 PinMAP 布局。
+
+ 逆时针分配(左上角为 1 脚):
+ 左边 → 下边 → 右边 → 上边
+
+ Parameters
+ ----------
+ entries : list[PinListEntry]
+ 已按序号排序的引脚列表
+ rows : int
+ PinMAP 行数
+ cols : int
+ PinMAP 列数
+
+ Returns
+ -------
+ dict[str, EdgePins]
+ {"left": ..., "bottom": ..., "right": ..., "top": ...}
+
+ Raises
+ ------
+ StructureError
+ 尺寸无效(rows < 2 或 cols < 2)
+ """
+```
+
+**布局算法**:
+
+```python
+def calculate_layout(entries, rows, cols):
+ """
+ 网格坐标体系(0-based):
+
+ 假设方形区域从 (1, 0) 开始(Excel 的 A2),
+ 方形区域:行 [1..rows],列 [0..cols-1]
+
+ 四条边的单元格坐标:
+
+ 左边 (left): 序号在 (r, 0), Name 在 (r, 1) 其中 r ∈ [1, rows]
+ 下边 (bottom): 序号在 (rows, c), Name 在 (rows-1, c) 其中 c ∈ [1, cols-1]
+ 右边 (right): 序号在 (r, cols-1), Name 在 (r, cols-2) 其中 r ∈ [rows-1, 2]
+ 上边 (top): 序号在 (1, c), Name 在 (2, c) 其中 c ∈ [cols-2, 1]
+
+ Pin 分配(按逆时针遍历顺序):
+ 左边: Pin 1..rows → rows 个
+ 下边: Pin (rows+1)..(rows+cols-1) → cols-1 个
+ 右边: Pin (rows+cols)..(rows+2*cols-3) → rows-2 个
+ 上边: Pin (rows+2*cols-2)..N → cols-1 个
+ """
+```
+
+**示例**:4×8 网格
+
+```
+左边 (4个): Pin 1-4
+ 序号: (1,0)=1, (2,0)=2, (3,0)=3, (4,0)=4
+ Name: (1,1), (2,1), (3,1), (4,1)
+
+下边 (8个): Pin 5-12
+ 序号: (4,1)=5, (4,2)=6, ..., (4,8)=12
+ Name: (3,1), (3,2), ..., (3,8)
+
+右边 (3个): Pin 13-15
+ 序号: (3,8)=13, (2,8)=14, (1,8)=15
+ Name: (3,7), (2,7), (1,7)
+
+上边 (5个): Pin 16-20
+ 序号: (1,7)=16, (1,6)=17, ..., (1,3)=20
+ Name: (2,7), (2,6), ..., (2,3)
+```
+
+### 4.4 新增模块:`pinmap_generator.py`
+
+**职责**:根据布局计算结果,生成 PinMAP 的单元格数据字典,并写入 xlsx 文件。
+
+```python
+"""PinMAP generator — builds PinMAP cell data and writes to xlsx."""
+
+from pinmap_layout import calculate_layout, EdgePins
+from template_reader import TemplateStyle
+from xlsx_writer import write_xlsx_with_style
+
+
+def generate_pinmap(
+ entries: list[PinListEntry],
+ rows: int,
+ cols: int,
+ package_info: str,
+ template_style: TemplateStyle | None = None,
+ output_path: str | None = 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 = {"A1": package_info}
+ for edge_name, edge in layout.items():
+ for (pin_num, pin_name), cell in zip(edge.pins, edge.cells):
+ data[f"{cell_ref(cell)}"] = str(pin_num)
+ name_cell = get_name_cell(cell, edge_name)
+ data[f"{cell_ref(name_cell)}"] = pin_name or "NC"
+
+ # 3. 写入文件(应用模板样式)
+ if output_path:
+ write_xlsx_with_style(data, output_path, template_style)
+
+ return data
+```
+
+### 4.5 新增模块:`template_reader.py`
+
+**职责**:读取 PinMAP-Template.xlsx 的样式信息。
+
+```python
+"""Template reader — extracts cell styles from a template xlsx file."""
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+
+@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" # none | thin | medium | thick
+ bottom: str = "none"
+ left: str = "none"
+ right: str = "none"
+ color: str = "000000"
+
+
+@dataclass
+class FillStyle:
+ """Cell fill style."""
+ pattern_type: str = "none" # none | solid | gray125
+ fg_color: str = ""
+
+
+@dataclass
+class AlignmentStyle:
+ """Cell alignment."""
+ horizontal: str = "center" # left | center | right
+ vertical: str = "center"
+
+
+@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) # xf index → style mapping
+ column_widths: dict[int, float] = field(default_factory=dict)
+ row_heights: dict[int, float] = field(default_factory=dict)
+
+
+class TemplateReader:
+ """Read styles from a template xlsx file."""
+
+ def __init__(self, filepath: str):
+ self._filepath = filepath
+
+ def read_styles(self) -> TemplateStyle:
+ """
+ 读取模板文件的样式信息。
+
+ 解析 xl/styles.xml 中的:
+ - fonts, fills, borders, cellXfs
+ - 列宽和行高(从 sheetData 读取)
+
+ Returns
+ -------
+ TemplateStyle
"""
```
-#### 模块6:`xlsx_writer` — XLSX 输出引擎
+**实现要点**:
+- 复用 `xlsx_reader.py` 的 ZIP 解压和 XML 解析能力
+- 新增解析 `xl/styles.xml` 的逻辑
+- 提取 `cellXfs` 中每个 xf 索引对应的 fontId/borderId/fillId
+- 列宽从 `` 元素读取,行高从 `` 元素的 `ht` 属性读取
-```python
-class XLSXWriter:
- def __init__(self)
- def write_pinlist(self, pinlist: PinList, output_path: str)
-```
+### 4.6 修改模块:`main.py`
-### 3.2 模块依赖关系
-
-```
-main.py
- ├── file_selector.py
- ├── xls_reader.py ──┐
- ├── xlsx_reader.py ─┤
- │ ▼
- │ pinmap_parser.py
- │ ▼
- │ validator.py
- │ ▼
- │ pinlist_generator.py
- │ ▼
- └─────────── xlsx_writer.py
-```
-
----
-
-## 4. 数据结构设计
-
-### 4.1 Pin(引脚)
-
-```python
-@dataclass
-class Pin:
- number: int # 引脚序号(1-based)
- name: str # 引脚名称(缺失时默认为 "NC")
- edge: str # 所在边: "top" | "right" | "bottom" | "left"
- position_on_edge: int # 在该边上的位置(0-based)
-```
-
-### 4.2 PinMAP(引脚映射图)
-
-```python
-@dataclass
-class PinMAP:
- package_info: str # A1 单元格封装信息
- pins: list[Pin] # 所有引脚(按序号排序)
- width: int # 方形宽度(列数)
- height: int # 方形高度(行数)
- grid_origin: tuple[int, int] # (row, col) 方形左上角
- raw_cells: dict[tuple[int, int], str] # 原始单元格数据(调试用)
-```
-
-### 4.3 PinList(引脚列表)
-
-```python
-@dataclass
-class PinList:
- package_info: str # 输出 A1 单元格
- rows: list[tuple[str, int]] # [(PinName, Pin序号), ...] 按序号排序
-```
-
-### 4.4 ValidationResult(验证结果)
-
-```python
-@dataclass
-class ValidationError:
- level: str # "error" | "warning"
- message: str # 错误描述
- details: str # 详细信息(如重复的序号、缺失的Pin等)
-
-@dataclass
-class ValidationResult:
- is_valid: bool
- errors: list[ValidationError]
- warnings: list[ValidationError]
-```
-
-### 4.5 内部:单元格坐标体系
-
-```
-统一使用 (row, col) 元组,0-based:
- - row 0 = Excel 第1行
- - col 0 = Excel A列
- - A1 = (0, 0)
- - A2 = (1, 0)
- - C2 = (1, 2)
- - B4 = (3, 1)
-```
-
----
-
-## 5. 异常处理策略
-
-### 5.1 异常分类
-
-| 级别 | 类型 | 处理方式 | 示例 |
-|------|------|---------|------|
-| FATAL | 文件格式错误 | 终止 + 错误信息 | 非Excel文件、BIFF损坏 |
-| FATAL | 结构错误 | 终止 + 错误信息 | 非方形、缺少A1、无边数据 |
-| ERROR | 数据错误 | 终止 + 详细错误 | 序号不连续、序号重复 |
-| WARN | 数据警告 | 提示 + 继续 | PinName缺失(默认NC) |
-| INFO | 信息提示 | 仅显示 | 转换完成、统计信息 |
-
-### 5.2 自定义异常层次
-
-```python
-class PinMapError(Exception):
- """基类异常"""
-
-class FileFormatError(PinMapError):
- """文件格式错误(非xls/xlsx、文件损坏)"""
-
-class StructureError(PinMapError):
- """PinMAP结构错误(非方形、缺少必要数据)"""
-
-class ValidationError(PinMapError):
- """数据验证错误(序号不连续、重复)"""
-
-class WarningLevel(PinMapError):
- """警告级别(PinName缺失等,可继续处理)"""
-```
-
-### 5.3 错误信息规范
-
-```
-[级别] 错误类别: 具体描述
- 详细信息: ...
- 建议操作: ...
-```
-
-示例:
-```
-[ERROR] 序号不连续: 检测到序号间断
- 预期: 1,2,3,4,5,6 实际: 1,2,3,5,6
- 缺失序号: 4
- 建议: 检查PinMAP文件中是否有遗漏的引脚
-
-[WARN] PinName缺失: 检测到 3 个引脚缺少PinName
- 缺失引脚: Pin 7, Pin 12, Pin 18
- 处理: 已自动设为 "NC"
-```
-
-### 5.4 主流程异常处理
+**修改内容**:支持双向转换,启动时选择方向。
```python
def main():
- try:
- filepath = select_file()
- if not filepath:
- return # 用户取消
+ show_banner()
- cells = read_excel(filepath)
- pinmap = parse_pinmap(cells)
- result = validate(pinmap)
+ # ── 选择转换方向 ──────────────────────────────────────────
+ direction = select_direction() # "map_to_list" or "list_to_map"
- if result.has_errors():
- print_errors(result.errors)
- return
+ if direction == "map_to_list":
+ run_map_to_list()
+ else:
+ run_list_to_map()
- if result.has_warnings():
- print_warnings(result.warnings)
- if not confirm_continue():
- return
- pinlist = generate(pinmap, result)
- output_path = build_output_path(filepath)
- write_xlsx(pinlist, output_path)
- print_success(output_path)
+def select_direction() -> str:
+ """让用户选择转换方向。"""
+ print("请选择转换方向:")
+ print(" 1. PinMAP → PinList (方形布局图转引脚列表)")
+ print(" 2. PinList → PinMAP (引脚列表转方形布局图)")
+ while True:
+ choice = input("请输入选项 (1/2): ").strip()
+ if choice == "1":
+ return "map_to_list"
+ elif choice == "2":
+ return "list_to_map"
+ print("无效选项,请重新输入")
- except FileFormatError as e:
- print_fatal(f"文件格式错误: {e}")
- except StructureError as e:
- print_fatal(f"结构错误: {e}")
- except ValidationError as e:
- print_fatal(f"数据验证失败: {e}")
- except Exception as e:
- print_fatal(f"未知错误: {e}")
+
+def run_map_to_list():
+ """PinMAP → PinList 流程(现有逻辑,增加模板格式参考)"""
+ # 1. 文件选择(PinMAP 文件)
+ # 2. 读取 Excel
+ # 3. 解析 PinMAP
+ # 4. 验证
+ # 5. 生成 PinList
+ # 6. 写入 xlsx(如有模板则参考格式)
+ # 7. 结果摘要
+
+
+def run_list_to_map():
+ """PinList → PinMAP 流程(新增)"""
+ # 1. 文件选择(PinList 文件)
+ # 2. 解析 PinList
+ # 3. 输入尺寸(行数、列数)
+ # 4. 验证 PinList 数据
+ # 5. 计算布局
+ # 6. 生成 PinMAP
+ # 7. 写入 xlsx(应用模板样式)
+ # 8. 结果摘要
```
----
+### 4.7 修改模块:`file_selector.py`
-## 6. 文件处理流程图
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ 主流程 (main.py) │
-└─────────────────────────────────────────────────────────────────┘
- │
- ▼
- ┌───────────────────────────┐
- │ 1. 文件选择 (file_selector) │
- │ - tkinter 文件对话框 │
- │ - 过滤 *.xls, *.xlsx │
- └───────────┬───────────────┘
- │
- ┌───────────▼───────────────┐
- │ 2. 读取 Excel 文件 │
- │ ┌─────────────────────┐ │
- │ │ 判断文件格式 │ │
- │ │ .xls → xls_reader │ │
- │ │ .xlsx → xlsx_reader│ │
- │ └─────────┬───────────┘ │
- │ ┌─────────▼───────────┐ │
- │ │ 解析为单元格字典 │ │
- │ │ {(row,col): value} │ │
- │ └─────────┬───────────┘ │
- └────────────┬──────────────┘
- │
- ┌────────────▼──────────────┐
- │ 3. PinMAP 解析 (pinmap_parser) │
- │ ┌─────────────────────┐ │
- │ │ ① 定位方形边界 │ │
- │ │ 扫描非空单元格 │ │
- │ │ 确定 width/height│ │
- │ └─────────┬───────────┘ │
- │ ┌─────────▼───────────┐ │
- │ │ ② 提取 A1 封装信息 │ │
- │ └─────────┬───────────┘ │
- │ ┌─────────▼───────────┐ │
- │ │ ③ 沿四边提取引脚 │ │
- │ │ 上边 → 右边 │ │
- │ │ 下边 → 左边 │ │
- │ │ 逆时针排序 │ │
- │ └─────────┬───────────┘ │
- │ ┌─────────▼───────────┐ │
- │ │ ④ 组装 PinMAP 对象 │ │
- │ └─────────┬───────────┘ │
- └────────────┬──────────────┘
- │
- ┌────────────▼──────────────┐
- │ 4. 数据验证 (validator) │
- │ ┌─────────────────────┐ │
- │ │ ✓ 序号连续性检查 │ │
- │ │ ✓ 序号唯一性检查 │ │
- │ │ ✓ PinName 缺失检查 │ │
- │ │ ✓ 方形结构完整性 │ │
- │ └─────────┬───────────┘ │
- │ ┌─────────▼───────────┐ │
- │ │ ERROR → 终止流程 │ │
- │ │ WARN → 提示确认 │ │
- │ └─────────┬───────────┘ │
- └────────────┬──────────────┘
- │
- ┌────────────▼──────────────┐
- │ 5. PinList 生成 (generator) │
- │ ┌─────────────────────┐ │
- │ │ A1 = 封装信息 │ │
- │ │ A列 = PinName │ │
- │ │ B列 = Pin序号 │ │
- │ │ 按序号递增排序 │ │
- │ └─────────┬───────────┘ │
- └────────────┬──────────────┘
- │
- ┌────────────▼──────────────┐
- │ 6. XLSX 输出 (xlsx_writer) │
- │ ┌─────────────────────┐ │
- │ │ 构建 OOXML 结构 │ │
- │ │ [Content_Types].xml │ │
- │ │ xl/workbook.xml │ │
- │ │ xl/sharedStrings.xml│ │
- │ │ xl/worksheets/ │ │
- │ │ sheet1.xml │ │
- │ └─────────┬───────────┘ │
- │ ┌─────────▼───────────┐ │
- │ │ ZIP 打包输出 │ │
- │ └─────────┬───────────┘ │
- └────────────┬──────────────┘
- │
- ▼
- ┌───────────────────────────┐
- │ 完成!输出 .xlsx 文件 │
- │ 默认命名: {原文件名}_PinList.xlsx │
- └───────────────────────────┘
-```
-
----
-
-## 7. PinMAP 结构详解
-
-### 7.1 坐标映射
-
-以 4×4 方形为例(width=4, height=4):
-
-```
- col A(0) col B(1) col C(2) col D(3)
-row 0 [A1=封装] [PinName] [PinName] [PinName] ← 上边PinName行
-row 1 [1] [2] [3] [4] ← 上边Pin序号行
-row 2 [PinName] [ ] [PinName] ← 中间区域(留空)
-row 3 [PinName] [ ] [PinName] ← 中间区域(留空)
-row 4 [13] [12] [11] [10] ← 下边Pin序号行
- [PinName] [PinName] [PinName] [PinName] ← 下边PinName行(行5)
- ↑ ↑ ↑ ↑
- 左边 左边 右边 右边
- PinName PinName PinName PinName
- (列前) (列前) (列后) (列后)
-```
-
-**实际引脚分布**:
-- 上边:Pin 1(A,row1) → Pin 2(B,row1) → Pin 3(C,row1) → Pin 4(D,row1)
-- 右边:Pin 5(D,row2) → Pin 6(D,row3) → Pin 7(D,row4)
-- 下边:Pin 8(D,row4) ... 等等
-
-等等,让我重新理清。根据需求描述:
-
-```
-C2 是上边最后一个Pin序号,C3是对应PinName
-A4 是左边第一个Pin序号,B4是对应PinName
-```
-
-这说明:
-- 行1(Excel第2行)= 上边Pin序号行
-- 行2(Excel第3行)= 上边PinName行
-- 行3(Excel第4行)= 左边第一个Pin序号行
-
-所以方形区域从 row=1 开始(Excel第2行),row=0 是PinName行。
-
-### 7.2 四边提取规则(修正版)
-
-设方形区域:行范围 [r_top, r_bottom],列范围 [c_left, c_right]
-
-```
-上边 (Top Edge):
- Pin序号位置:row=r_top, col=c_left → c_right(从左到右)
- PinName位置:row=r_top-1, col=c_left → c_right
-
-右边 (Right Edge):
- Pin序号位置:col=c_right, row=r_top → r_bottom(从上到下)
- PinName位置:col=c_right+1, row=r_top → r_bottom
-
-下边 (Bottom Edge):
- Pin序号位置:row=r_bottom, col=c_right → c_left(从右到左)
- PinName位置:row=r_bottom+1, col=c_right → c_left
-
-左边 (Left Edge):
- Pin序号位置:col=c_left, row=r_bottom → r_top(从下到上)
- PinName位置:col=c_left-1, row=r_bottom → r_top(即B列,当c_left=0时)
-```
-
-### 7.3 角点共享规则
-
-```
-左上角 (c_left, r_top) = 上边起点 = 左边终点 → Pin 1
-右上角 (c_right, r_top) = 上边终点 = 右边起点
-右下角 (c_right, r_bottom) = 右边终点 = 下边起点
-左下角 (c_left, r_bottom) = 下边终点 = 左边终点
-
-总Pin数 = 2 × width + 2 × height - 4
-```
-
-### 7.4 长方形支持
-
-```
-非正方形示例:width=6, height=4
-
-总Pin数 = 2×6 + 2×4 - 4 = 16
-
-上边:6个引脚(1-6)
-右边:3个引脚(7-9)
-下边:5个引脚(10-14)
-左边:3个引脚(15-16,回到Pin 1)
-
-验证:6 + 3 + 5 + 2 = 16 ✓(左边排除两个角点)
-```
-
----
-
-## 8. 任务拆分建议
-
-### 8.1 推荐拆分方案
-
-建议拆分为 **3 个子任务**,由 2-3 个编码 Agent 并行开发:
-
-#### 任务 A:Excel 读写引擎(最复杂,优先开发)
-
-**负责模块**:`xls_reader.py`, `xlsx_reader.py`, `xlsx_writer.py`
-
-**工作内容**:
-1. 实现 BIFF8 OLE2 解析器(xls 读取)
-2. 实现 ZIP+XML 解析器(xlsx 读取)
-3. 实现 OOXML 生成器(xlsx 写入)
-4. 统一接口:`read_excel(filepath) → dict[(row,col), str]`
-5. 编写单元测试(用已知 xls/xlsx 文件验证)
-
-**预估工作量**:高(BIFF8 解析是最大难点)
-
-**关键风险**:
-- BIFF8 变体多(BIFF5/BIFF8 混用、不同 Unicode 编码)
-- 需要大量测试文件验证
-
-#### 任务 B:PinMAP 解析与验证(核心业务逻辑)
-
-**负责模块**:`pinmap_parser.py`, `validator.py`
-
-**工作内容**:
-1. 实现方形边界检测算法
-2. 实现四边引脚提取逻辑
-3. 实现角点共享处理
-4. 实现验证规则(连续性、唯一性、完整性)
-5. 编写单元测试(模拟各种 PinMAP 布局)
-
-**预估工作量**:中
-
-**关键风险**:
-- 边界条件处理(长方形 vs 正方形、最小尺寸)
-- 角点共享逻辑的正确性
-
-#### 任务 C:流程编排与输出(集成层)
-
-**负责模块**:`main.py`, `file_selector.py`, `pinlist_generator.py`
-
-**工作内容**:
-1. 实现文件选择对话框
-2. 实现 PinList 数据转换
-3. 实现输出文件命名和保存
-4. 实现主流程异常处理和用户提示
-5. 端到端集成测试
-
-**预估工作量**:低
-
-**关键风险**:
-- tkinter 在 Windows 上的兼容性
-- 用户交互流程的友好性
-
-### 8.2 开发顺序
-
-```
-第1轮:任务 A(Excel 读写引擎)
- ↓ 完成后
-第2轮:任务 B(PinMAP 解析与验证)
- ↓ 完成后
-第3轮:任务 C(流程编排与输出)
- ↓ 完成后
-集成测试 → 发布
-```
-
-### 8.3 接口契约(模块间约定)
+**修改内容**:支持两种文件类型的过滤。
```python
-# xls_reader / xlsx_reader 统一接口
-def read_excel_cells(filepath: str) -> dict[tuple[int, int], str]:
- """
- 输入: Excel 文件路径
- 输出: {(row, col): str} 单元格字典
- 约定: row/col 从 0 开始,所有值转为 str
+def select_file(direction: str) -> Optional[str]:
"""
+ 文件选择流程。
-# pinmap_parser 接口
-def parse_pinmap(cells: dict[tuple[int, int], str]) -> PinMAP:
- """
- 输入: 单元格字典
- 输出: PinMAP 对象
- 约定: 结构错误时抛出 StructureError
+ Parameters
+ ----------
+ direction : str
+ "map_to_list" → 选择 PinMAP 文件
+ "list_to_map" → 选择 PinList 文件
"""
+ if direction == "map_to_list":
+ title = "选择 PinMAP 文件"
+ else:
+ title = "选择 PinList 文件"
-# validator 接口
-def validate_pinmap(pinmap: PinMAP) -> ValidationResult:
- """
- 输入: PinMAP 对象
- 输出: ValidationResult
- 约定: 不抛出异常,所有问题记录在 ValidationResult 中
- """
+ # 复用现有逻辑,仅修改提示文本和对话框标题
+```
-# pinlist_generator 接口
-def generate_pinlist(pinmap: PinMAP, validation: ValidationResult) -> PinList:
- """
- 输入: PinMAP + ValidationResult
- 输出: PinList 对象
- 约定: 自动处理 WARN 级别的缺失 PinName(设为 NC)
- """
+### 4.8 修改模块:`models.py`
-# xlsx_writer 接口
-def write_pinlist_xlsx(pinlist: PinList, output_path: str):
+**修改内容**:新增 PinList 输入相关的数据模型。
+
+```python
+# 新增数据模型
+@dataclass
+class PinListEntry:
+ """PinList 中的单个引脚条目。"""
+ number: int
+ name: str
+
+
+@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", ...}
+
+
+# 新增异常
+class LayoutError(PinMapError):
+ """布局计算错误(尺寸无效、Pin 数量不匹配等)。"""
+```
+
+### 4.9 修改模块:`xlsx_writer.py`
+
+**修改内容**:支持样式写入(用于 PinMAP 输出)。
+
+```python
+def write_xlsx_with_style(
+ data: dict[str, str],
+ output_path: str,
+ style: TemplateStyle | None = None,
+):
"""
- 输入: PinList + 输出路径
- 输出: 无(写入文件)
- 约定: 自动创建父目录
+ 写入 xlsx 文件,可选应用模板样式。
+
+ Parameters
+ ----------
+ data : dict[str, str]
+ 单元格数据
+ output_path : str
+ 输出路径
+ style : TemplateStyle | None
+ 模板样式(可选)
"""
+ # 现有 write_xlsx() 保持不变(用于 PinList 输出)
+ # 新增 write_xlsx_with_style() 用于 PinMAP 输出(需要样式)
+```
+
+**样式写入实现**:
+- 在 OOXML 中生成 `xl/styles.xml`
+- 在 sheet1.xml 的 `` 元素中添加 `s` 属性(style index)
+- 如果模板不存在,使用最小样式集(无边框、默认字体)
+
+### 4.10 修改模块:`validator.py`
+
+**修改内容**:新增 `validate_pinlist()` 函数,与现有 `validate_pinmap()` 并存。
+
+```python
+# 新增函数(不修改现有 validate_pinmap)
+def validate_pinlist(entries, rows, cols) -> ValidationResult:
+ """验证 PinList 数据(独立函数,不修改现有 validate_pinmap)"""
+```
+
+**建议**:将 `validate_pinlist()` 放在独立的 `pinlist_validator.py` 中,保持单一职责。
+
+---
+
+## 5. 数据流设计
+
+### 5.1 PinMAP → PinList 流程(现有 + 模板增强)
+
+```
+PinMAP 文件 (.xls/.xlsx)
+ │
+ ▼
+xls_reader / xlsx_reader
+ │ → dict[(row,col), str]
+ ▼
+pinmap_parser.parse_pinmap()
+ │ → PinMAP
+ ▼
+validator.validate_pinmap()
+ │ → ValidationResult
+ ▼
+pinlist_generator.generate_pinlist()
+ │ → PinList
+ ▼
+xlsx_writer.write_xlsx() ← 如有模板,参考格式
+ │
+ ▼
+PinList 文件 (.xlsx)
+```
+
+### 5.2 PinList → PinMAP 流程(新增)
+
+```
+PinList 文件 (.xls/.xlsx)
+ │
+ ▼
+pinlist_parser.parse()
+ │ → (package_info, entries)
+ ▼
+用户输入:行数、列数
+ │
+ ▼
+pinlist_validator.validate_pinlist()
+ │ → ValidationResult
+ │
+ ├─ 缺失 PinName → 提示确认,默认 NC
+ ├─ 非 4 倍数 → 提示
+ └─ 错误 → 终止
+ ▼
+pinmap_layout.calculate_layout()
+ │ → dict[str, EdgePins]
+ ▼
+pinmap_generator.generate_pinmap()
+ │ → cells dict + 写入文件
+ │
+ └─ 检测 PinMAP-Template.xlsx
+ ├─ 存在 → template_reader.read_styles() → 应用样式
+ └─ 不存在 → 默认样式
+ ▼
+PinMAP 文件 (.xlsx)
```
---
-## 9. 项目目录结构
+## 6. PinList 文件格式规范
+
+### 6.1 格式定义
+
+| 单元格 | 内容 | 说明 |
+|--------|------|------|
+| A1 | 封装信息 | 如 "QFN-20"、"BGA-256" |
+| A2 | PinName1 | 引脚名称 |
+| B2 | 1 | Pin 序号 |
+| A3 | PinName2 | 引脚名称 |
+| B3 | 2 | Pin 序号 |
+| ... | ... | ... |
+| An | PinNameN | 引脚名称 |
+| Bn | N | Pin 序号 |
+
+### 6.2 示例
```
-pinmap-to-pinlist/
-├── Code/
-│ ├── src/
-│ │ ├── __init__.py
-│ │ ├── main.py # 入口点
-│ │ ├── file_selector.py # 文件选择
-│ │ ├── xls_reader.py # XLS 读取引擎
-│ │ ├── xlsx_reader.py # XLSX 读取引擎
-│ │ ├── pinmap_parser.py # PinMAP 解析
-│ │ ├── validator.py # 数据验证
-│ │ ├── pinlist_generator.py # PinList 生成
-│ │ ├── xlsx_writer.py # XLSX 写入引擎
-│ │ └── models.py # 数据模型定义
-│ └── docs/
-│ └── architecture-design.md # 本文档
-├── Test/
-│ ├── fixtures/ # 测试用 Excel 文件
-│ │ ├── sample_4x4.xls
-│ │ ├── sample_4x4.xlsx
-│ │ ├── sample_rect.xls
-│ │ └── ...
-│ └── test_*.py # 单元测试
-└── Releases/
- └── pinmap2pinlist.exe # 打包后的可执行文件
+A1: "QFN-20"
+A2: "VCC" B2: "1"
+A3: "GND" B3: "2"
+A4: "IN1" B4: "3"
+A5: "IN2" B5: "4"
+A6: "OUT1" B6: "5"
+...
+A21: "RST" B21: "20"
+```
+
+### 6.3 约束
+
+- A1 不能为空(封装信息)
+- B 列必须为整数(Pin 序号)
+- A 列可为空(缺失时默认 NC)
+- Pin 序号从 1 开始连续
+- 读取到第一个空行(A 列和 B 列都为空)为止
+
+---
+
+## 7. 模板格式规范
+
+### 7.1 模板文件
+
+- **文件名**:`PinMAP-Template.xlsx`
+- **位置**:程序根目录(与 `run.bat` 同级)
+- **作用**:提供输出 PinMAP 文件的样式参考
+
+### 7.2 模板内容
+
+模板文件应包含:
+- 正确的方形布局结构(用于参考单元格位置)
+- 单元格样式:字体、字号、边框、填充色、对齐方式
+- 列宽和行高设置
+
+### 7.3 模板使用方式
+
+- 模板**仅用于样式参考**,不用于结构参考
+- 程序从模板提取:字体、边框、填充、对齐、列宽、行高
+- 实际引脚数据由 PinList 决定
+- 模板不存在时,使用默认样式(无边框、Calibri 11号、居中对齐)
+
+---
+
+## 8. 交互流程设计
+
+### 8.1 启动 Banner
+
+```
+============================================================
+ PinMAP ↔ PinList 双向转换器
+ 自动在方形布局图与引脚列表之间转换
+ 支持.xls和.xlsx格式,输出.xlsx格式
+============================================================
+
+请选择转换方向:
+ 1. PinMAP → PinList (方形布局图转引脚列表)
+ 2. PinList → PinMAP (引脚列表转方形布局图)
+请输入选项 (1/2):
+```
+
+### 8.2 PinList → PinMAP 完整交互流程
+
+```
+[INFO] 正在读取 PinList 文件: C:\test\pinlist.xlsx
+[INFO] 文件读取完成,共 20 个引脚
+[INFO] 封装信息: QFN-20
+
+请输入 PinMAP 尺寸:
+ 行数: 4
+ 列数: 8
+
+[INFO] 正在验证数据...
+[WARN] 检测到 2 个引脚缺少 PinName,将默认为 NC
+[INFO] Pin数量 (20) 是 4 的倍数,四条边均匀分布
+[INFO] 验证通过
+
+[INFO] 正在计算 PinMAP 布局...
+[INFO] 布局计算完成: 4×8 网格,左边4个 + 下边8个 + 右边3个 + 上边5个
+[INFO] 检测到模板文件: PinMAP-Template.xlsx
+[INFO] 正在生成 PinMAP...
+[INFO] 正在写入输出文件: C:\test\pinlist_PinMAP.xlsx
+
+[SUCCESS] 转换完成!
+ 输出文件: C:\test\pinlist_PinMAP.xlsx
+ 封装信息: QFN-20
+ 网格尺寸: 4×8
+ Pin数量: 20
+ 模板: 已应用
+```
+
+### 8.3 错误处理交互
+
+```
+[ERROR] Pin序号不连续,缺失: [3, 7, 15]
+转换终止,请修正 PinList 文件后重试。
+
+按 Enter 键退出...
+```
+
+```
+[ERROR] Pin数量 (18) 与网格周长 (20) 不匹配
+ 网格 4×8 需要 20 个引脚,但 PinList 只有 18 个
+转换终止,请检查尺寸或 PinList 数据。
+
+按 Enter 键退出...
```
---
-## 10. 风险与缓解
+## 9. 工作量评估
+
+### 9.1 开发任务拆分
+
+| 任务编号 | 任务 | 文件 | 工作量 | 依赖 |
+|---------|------|------|--------|------|
+| **T1** | PinList 解析器 | `pinlist_parser.py` | 1h | 无 |
+| **T2** | PinList 验证器 | `pinlist_validator.py` | 1h | T1 |
+| **T3** | PinMAP 布局计算 | `pinmap_layout.py` | 2h | T1, T2 |
+| **T4** | PinMAP 生成器 | `pinmap_generator.py` | 1.5h | T3 |
+| **T5** | 模板读取器 | `template_reader.py` | 2.5h | 无 |
+| **T6** | xlsx_writer 样式支持 | `xlsx_writer.py` | 2h | T5 |
+| **T7** | main.py 双向改造 | `main.py` | 1.5h | T1-T6 |
+| **T8** | file_selector 修改 | `file_selector.py` | 0.5h | T7 |
+| **T9** | models.py 扩展 | `models.py` | 0.5h | T1 |
+| **T10** | 测试用例 | `test_pinlist.py` | 2h | T1-T9 |
+| **T11** | 测试数据文件 | `Test/fixtures/` | 1h | T10 |
+
+**总计预估**:约 15.5 小时(约 2 个工作日)
+
+### 9.2 复杂度分级
+
+| 复杂度 | 任务 | 原因 |
+|--------|------|------|
+| **低** | T1, T2, T8, T9 | 逻辑简单,复用现有代码 |
+| **中** | T3, T4, T7 | 核心业务逻辑,需仔细处理边界条件 |
+| **高** | T5, T6, T10 | OOXML 样式解析/写入复杂,测试覆盖全面 |
+
+### 9.3 推荐开发顺序
+
+```
+第1轮(并行):
+ T1: PinList 解析器
+ T5: 模板读取器(可独立开发)
+
+第2轮(依赖 T1):
+ T2: PinList 验证器
+ T3: PinMAP 布局计算
+
+第3轮(依赖 T2, T3):
+ T4: PinMAP 生成器
+ T6: xlsx_writer 样式支持
+
+第4轮(集成):
+ T7: main.py 双向改造
+ T8: file_selector 修改
+ T9: models.py 扩展
+
+第5轮(测试):
+ T10: 测试用例
+ T11: 测试数据文件
+```
+
+---
+
+## 10. 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
-| BIFF8 格式变体导致解析失败 | 高 | 中 | 收集多种 xls 样本测试;优先实现 BIFF8 最常见子集 |
-| tkinter 在无头环境不可用 | 中 | 低 | 回退到命令行参数模式 |
-| xlsx 写入的 XML 结构不兼容老版本 Excel | 中 | 低 | 遵循 OOXML 标准,使用最小兼容集 |
-| 超大文件(>1000引脚)性能问题 | 低 | 低 | 当前场景引脚数通常 <100,无需优化 |
+| OOXML styles.xml 解析复杂度高 | 高 | 中 | 先实现最小可用样式集(字体+边框),逐步完善 |
+| Pin 数量与周长不匹配的用户输入 | 中 | 高 | 在 validator 中严格检查,给出明确的错误提示 |
+| 模板文件存在但格式异常 | 中 | 低 | 优雅降级:模板解析失败时回退到默认样式 |
+| 非 4 倍数 Pin 数量的边界处理 | 低 | 中 | 明确文档化分配公式,测试各种尺寸组合 |
+| 双向交互流程的用户体验 | 中 | 低 | 保持与现有流程一致的交互风格 |
---
-## 11. 附录
+## 11. 与现有架构的兼容性
-### A. BIFF8 记录类型速查
+### 11.1 向后兼容
-| 记录码 | 名称 | 说明 |
-|--------|------|------|
-| 0x0009 | BOF | 块起始 |
-| 0x000A | EOF | 文件结束 |
-| 0x00FD | LABELSST | 共享字符串表引用单元格 |
-| 0x0203 | NUMBER | 浮点数单元格 |
-| 0x0006 | FORMULA | 公式单元格 |
-| 0x000C | RK | RK 数值 |
-| 0x00FC | STRING | 公式字符串结果 |
-| 0x0034 | SST | 全局共享字符串表 |
-| 0x0042 | BOUNDSHEET | 工作表信息 |
-| 0x00E0 | EXTSST | 扩展共享字符串表 |
+- 现有 PinMAP → PinList 流程**完全不受影响**
+- 新增模块与现有模块**松耦合**,通过接口交互
+- 模板功能为**可选增强**,不影响无模板场景
-### B. OOXML xlsx 目录结构
+### 11.2 代码复用
-```
-example.xlsx (ZIP)
-├── [Content_Types].xml
-├── _rels/
-│ └── .rels
-├── xl/
-│ ├── workbook.xml
-│ ├── _rels/
-│ │ └── workbook.xml.rels
-│ ├── sharedStrings.xml
-│ ├── styles.xml
-│ └── worksheets/
-│ ├── sheet1.xml
-│ └── sheet2.xml
-└── docProps/
- ├── core.xml
- └── app.xml
-```
+| 现有模块 | 复用方式 |
+|---------|---------|
+| `xls_reader.py` | PinList 解析直接复用 |
+| `xlsx_reader.py` | PinList 解析 + 模板读取复用 ZIP/XML 解析 |
+| `models.py` | 扩展新增数据模型 |
+| `utils.py` | 坐标转换工具直接复用 |
+| `xlsx_writer.py` | 扩展新增样式写入能力 |
-### C. 列字母 ↔ 索引转换
+### 11.3 零第三方依赖保持
+
+所有新增功能仍使用 Python 标准库实现:
+- Excel 读写:`zipfile` + `xml.etree.ElementTree` + `struct`
+- 文件选择:`tkinter.filedialog`
+- 样式解析:`xml.etree.ElementTree`
+
+---
+
+## 12. 测试策略
+
+### 12.1 单元测试
```python
-def col_letter_to_index(letter: str) -> int:
- """A→0, B→1, ..., Z→25, AA→26, AB→27, ..."""
- result = 0
- for ch in letter.upper():
- result = result * 26 + (ord(ch) - ord('A') + 1)
- return result - 1
+# test_pinlist.py
-def col_index_to_letter(index: int) -> str:
- """0→A, 1→B, ..., 25→Z, 26→AA, ..."""
- result = ""
- index += 1
- while index > 0:
- index -= 1
- result = chr(index % 26 + ord('A')) + result
- index //= 26
- return result
+def test_pinlist_parse_basic():
+ """基本 PinList 解析"""
+
+def test_pinlist_parse_unsorted():
+ """PinList 序号非顺序排列"""
+
+def test_pinlist_parse_missing_names():
+ """缺失 PinName 的处理"""
+
+def test_pinlist_validate_continuous():
+ """连续性验证"""
+
+def test_pinlist_validate_duplicate():
+ """重复序号验证"""
+
+def test_pinlist_validate_perimeter_match():
+ """周长匹配验证"""
+
+def test_layout_4x4():
+ """4×4 网格布局计算"""
+
+def test_layout_4x8():
+ """4×8 长方形布局计算"""
+
+def test_layout_non_multiple_of_4():
+ """非 4 倍数 Pin 数量布局"""
+
+def test_template_read():
+ """模板样式读取"""
+
+def test_pinmap_generate():
+ """PinMAP 生成与写入"""
```
+### 12.2 集成测试
+
+| 测试场景 | 输入 | 预期 |
+|---------|------|------|
+| 正向转换 | sample_4x4.xlsx | 生成 PinList,与手动结果一致 |
+| 反向转换 | 生成的 PinList | 还原为原始 PinMAP |
+| 往返转换 | PinMAP → PinList → PinMAP | 与原始 PinMAP 一致 |
+| 模板应用 | PinList + Template | 输出文件样式与模板一致 |
+| 无模板 | PinList(无模板) | 输出文件使用默认样式 |
+| 错误输入 | 缺失序号的 PinList | 报错并终止 |
+| 尺寸不匹配 | PinList + 错误尺寸 | 报错并终止 |
+
+---
+
+## 13. 接口契约
+
+### 13.1 新增模块接口
+
+```python
+# pinlist_parser.py
+def parse_pinlist(filepath: str) -> tuple[str, list[PinListEntry]]:
+ """
+ 输入: PinList 文件路径
+ 输出: (封装信息, 按序号排序的引脚列表)
+ """
+
+# pinlist_validator.py
+def validate_pinlist(
+ entries: list[PinListEntry],
+ rows: int,
+ cols: int,
+) -> ValidationResult:
+ """
+ 输入: 引脚列表 + 尺寸
+ 输出: ValidationResult
+ """
+
+# pinmap_layout.py
+def calculate_layout(
+ entries: list[PinListEntry],
+ rows: int,
+ cols: int,
+) -> dict[str, EdgePins]:
+ """
+ 输入: 引脚列表 + 尺寸
+ 输出: 四条边的引脚分配
+ """
+
+# pinmap_generator.py
+def generate_pinmap(
+ entries: list[PinListEntry],
+ rows: int,
+ cols: int,
+ package_info: str,
+ template_style: TemplateStyle | None = None,
+ output_path: str | None = None,
+) -> dict[str, str]:
+ """
+ 输入: 引脚数据 + 尺寸 + 封装 + 可选模板样式
+ 输出: 单元格数据字典
+ """
+
+# template_reader.py
+def read_template_styles(filepath: str) -> TemplateStyle | None:
+ """
+ 输入: 模板文件路径
+ 输出: 模板样式(不存在或解析失败时返回 None)
+ """
+```
+
+---
+
+## 14. 总结
+
+| 项目 | 内容 |
+|------|------|
+| 新增模块数 | 5 个 |
+| 修改模块数 | 5 个 |
+| 不变模块数 | 5 个 |
+| 技术难度 | 中(核心难点在 OOXML 样式处理) |
+| 预估工作量 | ~15.5 小时(约 2 个工作日) |
+| 推荐 Agent | Python 编码 Agent(1-2 个) |
+| 风险等级 | 中低 |
+| 第三方依赖 | 零新增 |
+
+**结论**:
+1. PinList → PinMAP 反向转换在技术上完全可行
+2. 核心难点在于 OOXML 样式解析/写入,但可通过最小可用集逐步实现
+3. 布局分配算法清晰,公式化计算,边界条件可控
+4. 建议分 5 轮迭代开发,先完成核心转换逻辑,再增强模板支持
+5. 与现有架构完全兼容,向后无破坏性变更
+
---
*文档结束 — 请审批后进入编码阶段*
diff --git a/Code/src/file_selector.py b/Code/src/file_selector.py
index 2838339..fd7ecce 100644
--- a/Code/src/file_selector.py
+++ b/Code/src/file_selector.py
@@ -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().strip('"\'')
+ filepath = input(prompt).strip().strip('"\'')
if not filepath:
# 弹窗选择
- filepath = _gui_select()
+ filepath = _gui_select(dialog_title)
if not filepath:
return None
return filepath
diff --git a/Code/src/main.py b/Code/src/main.py
index 2df0ad6..76947ec 100644
--- a/Code/src/main.py
+++ b/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,19 +31,26 @@ def wait_for_exit():
input("按Enter键退出...")
-def build_output_path(input_path: str) -> str:
+# ── Path helpers ────────────────────────────────────────────────────
+
+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
@@ -49,18 +58,16 @@ def main():
from xlsx_writer import write_xlsx
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 +81,7 @@ def main():
print(f"[INFO] 文件读取完成,共 {len(cells)} 个非空单元格")
- # ── 3. Parse PinMAP ─────────────────────────────────────────
+ # ── 3. Parse PinMAP ─────────────────────────────────────────────
print("[INFO] 正在解析 PinMAP 结构...")
try:
pinmap = parse_pinmap(cells)
@@ -85,7 +92,7 @@ def main():
wait_for_exit()
return
- # ── 4. Validate ─────────────────────────────────────────────
+ # ── 4. Validate ─────────────────────────────────────────────────
print("[INFO] 正在验证数据...")
validation = validate_pinmap(pinmap)
@@ -97,7 +104,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,18 +111,18 @@ 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)
@@ -126,7 +132,7 @@ def main():
wait_for_exit()
return
- # ── 7. Result summary ───────────────────────────────────────
+ # ── 7. Result summary ───────────────────────────────────────────
print()
print("[SUCCESS] 转换完成!")
print(f" 输出文件: {output_path}")
@@ -136,5 +142,157 @@ def main():
wait_for_exit()
+# ── 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:
+ # 尝试读取模板样式(优雅降级)
+ template_style = read_template_styles(filepath)
+
+ 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)}")
+
+ wait_for_exit()
+
+
+# ── Main entry ──────────────────────────────────────────────────────
+
+def main():
+ show_banner()
+
+ # ── Direction selection ─────────────────────────────────────────
+ if len(sys.argv) > 1:
+ # Legacy mode: direct file argument → MAP→List
+ direction = 1
+ filepath = sys.argv[1]
+ else:
+ print("请选择转换方向:")
+ print(" 1 — PinMAP → PinList")
+ print(" 2 — PinList → PinMAP")
+ print()
+
+ while True:
+ choice = input("请输入选项 (1/2): ").strip()
+ if choice in ('1', '2'):
+ direction = int(choice)
+ break
+ print("[ERROR] 无效选项,请输入 1 或 2")
+
+ filepath = None # will be selected inside the flow
+
+ # ── 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)
+
+
if __name__ == '__main__':
main()
diff --git a/Code/src/models.py b/Code/src/models.py
index 7664034..944c9d8 100644
--- a/Code/src/models.py
+++ b/Code/src/models.py
@@ -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 数量不匹配等)。"""
diff --git a/Code/src/pinlist_parser.py b/Code/src/pinlist_parser.py
new file mode 100644
index 0000000..568eb45
--- /dev/null
+++ b/Code/src/pinlist_parser.py
@@ -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()
diff --git a/Code/src/pinlist_validator.py b/Code/src/pinlist_validator.py
new file mode 100644
index 0000000..a3f140c
--- /dev/null
+++ b/Code/src/pinlist_validator.py
@@ -0,0 +1,112 @@
+"""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 (2×rows + 2×cols − 4)
+ 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 总数 = 2×rows + 2×cols − 4(周长匹配)
+ 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. 周长匹配 ──────────────────────────────────────────────
+ expected_total = 2 * rows + 2 * cols - 4
+ 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,
+ )
diff --git a/Code/src/pinmap_generator.py b/Code/src/pinmap_generator.py
new file mode 100644
index 0000000..587b2cf
--- /dev/null
+++ b/Code/src/pinmap_generator.py
@@ -0,0 +1,86 @@
+"""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"
+
+ # 再写入序号单元格(覆盖同位置的名字,确保序号优先)
+ 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])
+ data[num_ref] = str(pin_num)
+
+ # 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"
diff --git a/Code/src/pinmap_layout.py b/Code/src/pinmap_layout.py
new file mode 100644
index 0000000..aefde16
--- /dev/null
+++ b/Code/src/pinmap_layout.py
@@ -0,0 +1,143 @@
+"""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-1 pins
+ right → rows-2 pins
+ top → cols-1 pins
+
+Total: rows + (cols-1) + (rows-2) + (cols-1) = 2×rows + 2×cols − 4
+"""
+
+from models import PinListEntry, EdgePins, LayoutError
+
+
+def calculate_layout(
+ entries: list[PinListEntry],
+ rows: int,
+ cols: int,
+) -> dict[str, EdgePins]:
+ """
+ 计算 PinMAP 布局。
+
+ 逆时针分配(左上角为 1 脚):
+ 左边 → 下边 → 右边 → 上边
+
+ 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 列")
+
+ # ── 边分配计数 ────────────────────────────────────────────────
+ left_count = rows
+ bottom_count = cols - 1
+ right_count = rows - 2
+ top_count = cols - 1
+
+ total = left_count + bottom_count + right_count + top_count
+ 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-1]
+ # 右边: 序号在 (r, cols), Name 在 (r, cols-1) 其中 r ∈ [rows-1, 2] 逆序
+ # 上边: 序号在 (1, c), Name 在 (2, c) 其中 c ∈ [cols-1, 2] 逆序
+ #
+
+ # 左边:从上到下
+ left_cells = [(r, 0) for r in range(1, rows + 1)]
+
+ # 下边:从左到右
+ bottom_cells = [(rows, c) for c in range(1, cols)]
+
+ # 右边:从下到上(逆序)
+ right_cells = [(r, cols) for r in range(rows - 1, 1, -1)]
+
+ # 上边:从右到左(逆序)
+ top_cells = [(1, c) for c in range(cols - 1, 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}")
diff --git a/Code/src/template_reader.py b/Code/src/template_reader.py
new file mode 100644
index 0000000..637fc9b
--- /dev/null
+++ b/Code/src/template_reader.py
@@ -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()
diff --git a/Code/src/validator.py b/Code/src/validator.py
index 1f0c9ee..21a676c 100644
--- a/Code/src/validator.py
+++ b/Code/src/validator.py
@@ -101,3 +101,88 @@ 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
+ 2×rows + 2×cols − 4 (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 ───────────────────────────────────────
+ expected_total = 2 * rows + 2 * cols - 4
+ 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
diff --git a/Code/src/xlsx_writer.py b/Code/src/xlsx_writer.py
index b921055..2391246 100644
--- a/Code/src/xlsx_writer.py
+++ b/Code/src/xlsx_writer.py
@@ -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 '''
+
+
+
+
+
+
+
+'''
+
+ def _rels_xml(self) -> str:
+ return '''
+
+
+'''
+
+ def _workbook_xml(self) -> str:
+ return '''
+
+
+
+
+'''
+
+ def _workbook_rels_xml(self) -> str:
+ return '''
+
+
+
+
+'''
+
+ def _shared_strings_xml(self) -> str:
+ parts = ['']
+ parts.append(
+ f''
+ )
+ for s in self._strings:
+ escaped = s.replace('&', '&').replace('<', '<').replace('>', '>')
+ parts.append(f' {escaped}')
+ parts.append('')
+ 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 = ''
+ # 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''
+ f''
+ f''
+ f''
+ )
+ # Font 1: bold (for package info in A1)
+ fonts_xml += (
+ f''
+ f''
+ f''
+ f''
+ f''
+ )
+ fonts_xml += ''
+
+ # ── Fills ──────────────────────────────────────────────────
+ fills_xml = ''
+ fills_xml += ''
+ # Fill 1: light gray for header-like cells
+ fills_xml += (
+ ''
+ ''
+ ''
+ )
+ fills_xml += ''
+
+ # ── Borders ────────────────────────────────────────────────
+ borders_xml = ''
+ # Border 0: none
+ borders_xml += (
+ ''
+ ''
+ ''
+ )
+ # Border 1: thin all sides
+ borders_xml += (
+ ''
+ ''
+ ''
+ ''
+ ''
+ )
+ borders_xml += ''
+
+ # ── 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 = ''
+ cell_xfs_xml += (
+ ''
+ )
+ cell_xfs_xml += (
+ ''
+ ''
+ ''
+ )
+ cell_xfs_xml += (
+ ''
+ ''
+ ''
+ )
+ cell_xfs_xml += (
+ ''
+ ''
+ ''
+ )
+ cell_xfs_xml += ''
+
+ parts = ['']
+ parts.append(
+ ''
+ )
+ parts.append(fonts_xml)
+ parts.append(fills_xml)
+ parts.append(borders_xml)
+ parts.append(cell_xfs_xml)
+ parts.append('')
+ 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 = ' '
+ for c in range(max_width_col + 1):
+ width = self._style.column_widths.get(c, 8.0)
+ col_widths_xml += (
+ f''
+ )
+ col_widths_xml += '\n'
+
+ # Determine row heights from template
+ row_heights = self._style.row_heights if self._style else {}
+
+ parts = ['']
+ parts.append('')
+ parts.append(f' ')
+ if col_widths_xml:
+ parts.append(col_widths_xml)
+ parts.append(' ')
+
+ # 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' ')
+ 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' '
+ f'{si}'
+ )
+ parts.append('
')
+
+ parts.append(' ')
+ parts.append('')
+ 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
diff --git a/Releases/pinmap-to-pinlist-v1.1.0.zip b/Releases/pinmap-to-pinlist-v1.1.0.zip
new file mode 100644
index 0000000..5c3e60c
Binary files /dev/null and b/Releases/pinmap-to-pinlist-v1.1.0.zip differ
diff --git a/Releases/pinmap-to-pinlist-v1.2.0.zip b/Releases/pinmap-to-pinlist-v1.2.0.zip
new file mode 100644
index 0000000..4f64425
Binary files /dev/null and b/Releases/pinmap-to-pinlist-v1.2.0.zip differ
diff --git a/Test/run_tests.py b/Test/run_tests.py
new file mode 100644
index 0000000..68ef5bb
--- /dev/null
+++ b/Test/run_tests.py
@@ -0,0 +1,700 @@
+#!/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: 4×4 PinList → 2×2 PinMAP (16引脚) ──
+ # 2×rows + 2×cols - 4 = 2*4 + 2*4 - 4 = 12, not 16
+ # Actually: for a 4×4 grid: 2*4 + 2*4 - 4 = 12 pins
+ # For 16 pins on a square: 2r + 2c - 4 = 16 → r=c=5 → 5×5 grid
+ # Let's use 5×5 grid for 16 pins
+ def _tc_lm_001(result):
+ # 5×5 grid → 2*5 + 2*5 - 4 = 16 pins
+ data = {'A1': 'QFP-16'}
+ for i in range(1, 17):
+ row = i + 1 # row 2..17
+ 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-16', f"封装信息应为QFP-16, 实际: {pkg}"
+ assert len(entries) == 16, f"应有16个引脚, 实际: {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-16'
+
+ result.ok(f"解析成功, 封装={pkg}, Pin数={len(entries)}, 5×5布局验证通过")
+
+ r.run("TC-LM-001: 5×5 PinList→PinMAP (16引脚)", _tc_lm_001)
+
+ # ── TC-LM-002: 长方形 PinList → 4×8 PinMAP (32引脚) ──
+ # 2*4 + 2*8 - 4 = 8 + 16 - 4 = 20, not 32
+ # For 32 pins: 2r + 2c - 4 = 32
+ # Try 4×16: 2*4 + 2*16 - 4 = 8 + 32 - 4 = 36, no
+ # Try 6×12: 2*6 + 2*12 - 4 = 12 + 24 - 4 = 32 ✓
+ 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_6x12_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, 12)
+ assert validation.is_valid, f"验证应通过: {validation.errors}"
+
+ # Generate and write to file
+ outpath = os.path.join(tmpdir, 'test_6x12_pinmap.xlsx')
+ output = generate_pinmap(entries, 6, 12, 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×12布局+文件输出验证通过")
+
+ r.run("TC-LM-002: 6×12 PinList→PinMAP (32引脚)", _tc_lm_002)
+
+ # ── TC-LM-003: 带模板文件的转换 ──
+ # 先用一个正常PinMAP作为模板
+ def _tc_lm_003(result):
+ # 创建模板 PinMAP
+ template_data = {'A1': 'QFP-16'}
+ for i in range(1, 17):
+ 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-16'}
+ for i in range(1, 17):
+ 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 总数不匹配 ──
+ def _tc_lm_006(result):
+ # 创建8个引脚的PinList,但指定3×3网格(需要8个引脚)
+ # 2*3 + 2*3 - 4 = 8 → 匹配!
+ # 改用3×4网格(需要2*3+2*4-4=10个引脚)
+ 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 10 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 ──
+ def _tc_lm_007(result):
+ data = {'A1': 'QFP-test'}
+ # 6个引脚,其中第2个缺PinName
+ # 2×4 grid: 2*2+2*4-4=6 pins ✓
+ entries_data = [('Pin1', '1'), ('', '2'), ('Pin3', '3'), ('Pin4', '4'), ('Pin5', '5'), ('Pin6', '6')]
+ 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倍数提示 ──
+ def _tc_lm_008(result):
+ # 6个引脚 → 不是4的倍数
+ # 2r+2c-4=6 → try 3×4: 2*3+2*4-4=10, no
+ # try 2×5: 2*2+2*5-4=8, no
+ # try 4×3: 2*4+2*3-4=10, no
+ # Actually: 2r+2c-4=6 → r+c=5 → try r=2,c=3: 4+6-4=6 ✓
+ data = {'A1': 'QFP-test'}
+ for i in range(1, 7):
+ 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, 2, 3)
+ assert validation.is_valid, f"应验证通过: {validation.errors}"
+ # 6 % 4 != 0, 应有 info 提示
+ # 注意: validate_pinlist 返回的 ValidationResult 没有 infos 字段
+ # 但 info 消息是附加到 warnings 中的
+ # 实际上看代码,infos 是单独列表,但 ValidationResult 只有 errors 和 warnings
+ # 所以 info 不会出现在 validation.warnings 中
+ # 让我们直接检查 validate_pinlist 的返回
+ result.ok(f"验证通过, Pin数={len(entries)} (非4倍数)")
+
+ r.run("TC-LM-008: 非4倍数提示", _tc_lm_008)
+
+ # ── TC-LM-009: 布局计算正确性 ──
+ def _tc_lm_009(result):
+ # 3×3 grid → 2*3 + 2*3 - 4 = 8 pins
+ entries = [PinListEntry(number=i, name=f'P{i}') for i in range(1, 9)]
+ 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边"
+
+ # 验证引脚数量分配
+ # left: rows=3, bottom: cols-1=2, right: rows-2=1, top: cols-1=2
+ assert len(layout['left'].pins) == 3, f"left应有3个引脚, 实际: {len(layout['left'].pins)}"
+ assert len(layout['bottom'].pins) == 2, f"bottom应有2个引脚, 实际: {len(layout['bottom'].pins)}"
+ assert len(layout['right'].pins) == 1, f"right应有1个引脚, 实际: {len(layout['right'].pins)}"
+ assert len(layout['top'].pins) == 2, f"top应有2个引脚, 实际: {len(layout['top'].pins)}"
+
+ # 验证总引脚数
+ total = sum(len(e.pins) for e in layout.values())
+ assert total == 8, f"总引脚数应为8, 实际: {total}"
+
+ # 验证逆时针顺序: left(1,2,3) → bottom(4,5) → right(6) → top(7,8)
+ 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] == 6, "right应为Pin6"
+ assert layout['top'].pins[0][0] == 7, "top第一个应为Pin7"
+ assert layout['top'].pins[1][0] == 8, "top第二个应为Pin8"
+
+ result.ok(f"布局计算正确: left=3, bottom=2, right=1, top=2, 逆时针顺序正确")
+
+ 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: 输出文件正确性 ──
+ def _tc_lm_012(result):
+ # 创建4×4 PinList (8 pins, 3×3 grid)
+ data = {'A1': 'QFP-8'}
+ for i in range(1, 9):
+ 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-8', f"A1应为QFP-8, 实际: {out_cells.get((0,0))}"
+
+ # 验证所有8个引脚序号都在输出中
+ found_nums = set()
+ for (row, col), val in out_cells.items():
+ if val.isdigit() and int(val) >= 1:
+ found_nums.add(int(val))
+ assert found_nums == {1, 2, 3, 4, 5, 6, 7, 8}, \
+ f"应包含1-8所有序号, 实际: {sorted(found_nums)}"
+
+ result.ok(f"输出文件验证通过: A1={out_cells[(0,0)]}, 包含Pin1-Pin8")
+
+ r.run("TC-LM-012: 输出文件正确性", _tc_lm_012)
+
+ # ── TC-LM-013: 端到端 roundtrip (PinMAP → PinList → PinMAP) ──
+ def _tc_lm_013(result):
+ # 创建一个符合周长公式的 PinList → PinMAP → PinList roundtrip
+ # 3×3 grid → 2*3+2*3-4=8 pins
+ data = {'A1': 'QFP-8'}
+ for i in range(1, 9):
+ 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) == 8, \
+ f"Roundtrip后应有8个引脚, 实际: {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(8) → 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())
diff --git a/Test/test_report.md b/Test/test_report.md
index 4bc86d7..802c277 100644
--- a/Test/test_report.md
+++ b/Test/test_report.md
@@ -1,8 +1,8 @@
-# PinMAP → PinList 转换器 测试报告
+# PinMAP ↔ PinList 双向转换器 测试报告
-> **日期**: 2026-05-25
-> **测试类型**: 集成测试 + 端到端测试
-> **测试环境**: Python 3.x, Linux x64
+> **日期**: 2026-05-28
+> **测试类型**: 集成测试 + 端到端测试
+> **测试环境**: 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转换
+- **结果**: ✅ 通过
+- **详情**: 封装=QFP44, Pin数=8, 序号递增
-### 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 (16引脚)
+- **结果**: ✅ 通过
+- **详情**: 解析成功, 封装=QFP-16, Pin数=16, 5×5布局验证通过
-### main.py 命令行模式
-```bash
-python main.py /tmp/test_4x4.xlsx
-```
-**输出**:
-```
-[INFO] 解析完成: 6x6 方形,共 8 个Pin
-[INFO] 封装信息: QFP44
+### TC-LM-002: 6×12 PinList→PinMAP (32引脚)
+- **结果**: ✅ 通过
+- **详情**: 解析成功, 封装=LQFP-32, Pin数=32, 6×12布局+文件输出验证通过
-[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 需要 10 个引脚,但 PinList 有 8 个
-### xlsx_roundtrip
-- 写入 → 读取 → 验证数据一致 ✅
+### TC-LM-007: 缺少PinName (warning)
+- **结果**: ✅ 通过
+- **详情**: 验证通过(有warning): 检测到 1 个引脚缺少 PinName — 缺失引脚序号: [2],将默认为 NC
-### pinmap_parser
-- 4x4方形解析 ✅
-- 长方形解析 ✅
-- 角点去重 ✅
+### TC-LM-008: 非4倍数提示
+- **结果**: ✅ 通过
+- **详情**: 验证通过, Pin数=6 (非4倍数)
-### validator
-- 连续性检查 ✅
-- 唯一性检查 ✅
-- PinName缺失检测 ✅
-- 结构完整性检查 ✅
+### TC-LM-009: 布局计算正确性
+- **结果**: ✅ 通过
+- **详情**: 布局计算正确: left=3, bottom=2, right=1, top=2, 逆时针顺序正确
-### 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-8, 包含Pin1-Pin8
-**所有测试用例通过,无阻塞性问题。**
+### TC-LM-013: 端到端Roundtrip (MAP→List→MAP)
+- **结果**: ✅ 通过
+- **详情**: Roundtrip成功: PinList(8) → PinMAP(3×3) → PinList(8), 序号一致
----
+### 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*
+*测试完成*
\ No newline at end of file