24 Commits

Author SHA1 Message Date
1c3f97ebbe v1.6.1 修复 BUG-007 PinList→PinMAP 生成布局方向(改用 Layout B,A1 标题与上边 Number 同行) 2026-06-12 22:35:31 +08:00
9fc858c940 fix: BUG-007 列偏移修复 — 上边从 col 2 开始,右边在 cols+2/+3
1. 上边/下边列偏移从 [1, cols] → [2, cols+1](预留左边 Name 列后空一列)
2. 右边列偏移 cols+1/+2 → cols+2/+3(对齐上边偏移)
3. 更新 test_f012/test_f016 中的列引用
4. 更新 context.md 为修复后状态

验收测试全通过(10/10)。
2026-06-12 22:27:29 +08:00
4358214197 fix: 修复 v1.6 回归 BUG-007 PinList→PinMAP 上方引脚合并问题 2026-06-12 22:13:55 +08:00
7a4a767697 fix: 修复 BUG-007 PinList→PinMAP 上方引脚并入标题行,恢复独立2行 2026-06-12 21:32:35 +08:00
3c5fcff1d5 v1.6.0 修复 PinMAP→PinList 上方引脚丢失 + 双向模板样式 + QFN60 端到端验证
F013: Code/src/pinmap_parser.py 增加 Top 边自动布局检测
F014/F015: 双向模板样式确认
F016/F017: 新增 5 个 QFN60 端到端测试
2026-06-12 20:45:51 +08:00
88a231424c v1.5.5 Bug 修复:模板路径修正 + 上边 Name 独立行
BUG-005: 模板搜索路径优先查找 Code/src/Template/ 目录
BUG-006: 上边 Name 移至 row 0,完全独立于其他边

- 37/37 测试全部通过
- docs: 更新 bugs.md(BUG-005/BUG-006 状态)
- docs: 更新 tasks.md(T028 打包进行中→已完成)
- docs: 添加 modification-assessment-v1.5.5.md
- CHANGELOG.md: 追加 v1.5.5 版本日志
2026-06-12 02:55:13 +08:00
e582b454d3 docs: T023 打包发布 v1.5.4 已完成 2026-06-09 08:28:01 +08:00
d635ddbebe v1.5.4 Bug 修复:模板文件名修正 + 布局重设计
BUG-005: 模板文件名改为 PinMAP-Template.xlsx / PinList-Template.xlsx
BUG-006: 布局改为 Number 外侧 + Name 里侧(v1.5.4 最终版)
- 从边界往中心:第1圈=Number,第2圈=Name
- 上边角点例外处理,15种网格无冲突
- 18/18 单元测试 + 37/37 集成测试全部通过
2026-06-09 08:27:11 +08:00
91e1d93e18 docs: T018 打包发布 v1.5 已完成 2026-06-06 12:55:58 +08:00
ce62d2f353 chore: v1.5.0 - 提交测试代码、测试报告,更新 tasks.md 状态 2026-06-06 12:52:51 +08:00
d8d669bba1 docs: v1.5.0 - 更新模板分离与格式提取文档 2026-06-06 12:52:12 +08:00
22fc8b6228 docs: v1.5.0 - 更新CHANGELOG/tasks/features文档 2026-06-06 12:20:54 +08:00
c271e6e807 feat: v1.5.0 - F012 验证+回归测试(当前代码已正确,添加往返一致性测试) 2026-06-06 12:20:36 +08:00
13e7b8c4a5 feat: v1.5.0 - F011 模板格式提取式应用(从模板读取字体/边框/填充/对齐) 2026-06-06 12:18:20 +08:00
351f56ecb5 feat: v1.5.0 - F009+F010 MAP→List使用BallList模板,List→MAP使用BallMAP模板 2026-06-06 12:17:06 +08:00
3f53d6746c Bump version to v1.3.15 2026-06-02 18:39:29 +08:00
16cfe82bc3 v1.3.14: 修复pinmap_layout周长公式,新增PinList→PinMAP反向转换完整支持 2026-06-01 14:56:40 +08:00
e4e4add567 v1.3.13: 修复pinmap_layout周长公式,新增PinList→PinMAP反向转换完整支持 2026-06-01 13:46:41 +08:00
e73320d409 v1.3.12: 修复pinmap_layout周长公式,新增PinList→PinMAP反向转换完整支持 2026-06-01 13:37:56 +08:00
73d2334970 v1.3.1: 修复pinmap_layout周长公式,新增PinList→PinMAP反向转换完整支持 2026-06-01 12:36:05 +08:00
8ad31cbf04 v1.3.0: 修复pinmap_layout周长公式,新增PinList→PinMAP反向转换完整支持 2026-06-01 11:43:53 +08:00
3228c1a2e6 feat: PinMAP转PinList v1.2.0 - 新增PinList转PinMAP反向转换功能 2026-05-28 01:53:51 +08:00
853f10a73b docs: 更新 CHANGELOG v1.2.0 2026-05-26 01:53:04 +08:00
401ecf702a fix: 修复4个bug - cd路径、chcp乱码、窗口行数、拖拽路径引号 2026-05-26 01:52:54 +08:00
48 changed files with 9710 additions and 1084 deletions

12
.gitignore vendored
View File

@@ -16,6 +16,18 @@ build/
.DS_Store
Thumbs.db
# Agent metadata
.openclaw/
AGENTS.md
HEARTBEAT.md
IDENTITY.md
SOUL.md
TOOLS.md
USER.md
# Release archives (keep versioned release notes only)
Releases/*.zip
# IDE
.vscode/
.idea/

View File

@@ -1,5 +1,161 @@
# Changelog
## [v1.6.1] - 2026-06-12
### 🐛 Bug 修复
#### BUG-007 【高】PinList→PinMAP 生成布局方向错误(应为 Layout B
- **根因**`pinmap_layout.py` 使用 Layout A上边 Name 在 Number 之前A1 独占行),但用户期望 Layout BA1 标题与上边 Number 同行Number 在 Name 之前)
- **修复**
- 上边 Number 移至 row 0与 A1 标题同行col 从 2 开始B 列留空)
- 上边 Name 移至 row 1
- 左右边整体上移 1 行(从 row 3→row 2 开始)
- 下边整体上移 1 行(从 row 18-19→row 17-18
- 生成输出与用户提供的正确 CSV 布局完全一致
- A1 支持多行文本(换行符自动保留)
### 🔧 修改文件
- `Code/src/pinmap_layout.py` — 坐标公式全部更新为 Layout B
- `Code/src/test_pinmap.py` — 5 组测试数据/断言更新
### ✅ 测试
- 全部 23 个测试通过
- QFN60 生成结果与用户期望的 CSV 结构一致
## [v1.6.0] - 2026-06-12
### 🐛 Bug 修复
#### F013 【P0】修复 PinMAP→PinList 上方引脚丢失
- **根因**`pinmap_parser.py` 硬编码假设上边 Name 在 Number 上方min_row但用户真实 PinMAP 中 Number 在上、Name 在下,导致上边 15 个引脚全部丢失
- **修复**:增加 `_detect_top_layout()` 自动检测逻辑,通过扫描两行数据的数字/文本特征判断 Name 和 Number 的上下位置,兼容两种布局
- QFN6015×1560 引脚)端到端往返验证通过
#### F014 【P0】PinList→PinMAP 样式模板应用
- 确认 `Code/src/Template/PinMAP-Template.xlsx` 存在样式解析成功fonts=2, fills=2, borders=2, cell_xfs=4
- 搜索路径:优先 `Code/src/Template/` → 项目根目录 → cwd
#### F015 【P0】PinMAP→PinList 样式模板应用
- 确认 `Code/src/Template/PinList-Template.xlsx` 存在样式解析成功fonts=2, fills=1, borders=2, cell_xfs=4
### ✅ 测试
- 新增 5 个 QFN60 端到端测试F016/F017
- 全量 23 个测试全部通过,无回归
- 覆盖两种布局方向Layout A/B+ 往返一致性
### 🔧 修改文件
- `Code/src/pinmap_parser.py` — F013: 增加 `_detect_top_layout()``_count_numeric()`,上边 Name/Number 查找改为动态检测
- `Code/src/test_pinmap.py` — F016/F017: 新增 5 个 QFN60 测试函数
- `docs/modification-assessment-v1.6.md` — 新增 v1.6 架构评估文档
## [v1.5.5] - 2026-06-12
### 🐛 Bug 修复(深度修复)
#### BUG-005 【高】模板文件名/路径错误
- **根因**v1.5.4 只改了模板文件名(`BallList-Template.xlsx``PinList-Template.xlsx`),但未修正搜索路径
- **修复**:模板搜索路径优先查找 `Code/src/Template/` 目录,再回退项目根目录和当前工作目录
- 模板样式现在可正确应用到输出文件
#### BUG-006 【高】PinList→PinMAP 上边 Name 与左边 Name 同行
- **根因**v1.5.4 将上边 Name 放在 row 2Excel 第 3 行),与左边 Name/Number 起始行相同,导致 3 条边数据混在同一行
- **修复**:将上边 Name 移至 **row 0**Excel 第 1 行),上边 Number 保持在 row 1第 2 行),使上边完全独立于其他边
- 不再需要角点例外逻辑,代码更简洁
- 每条边数据独立分隔,肉眼可读性大幅提升
### 🔧 修改文件
- `Code/src/main.py` — BUG-005: 模板搜索路径修正(优先 Code/src/Template/
- `Code/src/pinmap_layout.py` — BUG-006: 上边 Name 坐标改为 `(0, c)`,移除角点例外
- `Code/src/pinmap_parser.py` — BUG-006: 上边 Name 从 row 0 读取Number 从 row 1 读取
- `Test/fixtures/sample_4x4.xlsx` — BUG-006: 更新为 v1.5.5 新布局
- `Code/src/test_pinmap.py` — BUG-006: 测试数据适配新布局
### ✅ 测试
- 全部 37 个测试通过
## [v1.5.4] - 2026-06-09
### 🐛 Bug 修复
#### BUG-005 【高】模板文件名错误
- 模板文件重命名:`BallList-Template.xlsx``PinList-Template.xlsx``BallMAP-Template.xlsx``PinMAP-Template.xlsx`
- 同步更新 `main.py` 中的函数名和模板引用路径
#### BUG-006 【高】布局重设计Number 外侧 + Name 里侧)
- 重新设计 PinMAP 布局:从网格边界往中心走,第 1 圈 = Number数字第 2 圈 = Name引脚名
- **上边**Number row 1最顶行Name row 2第二行角点例外最左/右上边 Name 在 (1,0)/(1,cols+1)
- **左边**Number col 0最左列Name col 1第二列
- **下边**Number row rows+3最底行Name row rows+2倒数第二行
- **右边**Number col cols+1最右列Name col cols右二列
- Pin1 保持在左上角A3=1, B3=Pin1
- 不再需要角点 "//" 合并,每条边不共享任何单元格
- 全部 15 种网格大小验证无冲突
- 18/18 单元测试 + 37/37 集成测试全部通过
### 🔧 修改文件
- `Code/src/main.py` — BUG-005: 模板函数和引用改名BUG-006: 传递 cols 参数
- `Code/src/pinmap_layout.py` — BUG-006: 重写坐标公式 + `get_name_cell()` 支持 cols 参数 + 角点例外
- `Code/src/pinmap_generator.py` — BUG-006: 传递 cols 参数 + 更新注释
- `Code/src/pinmap_parser.py` — BUG-006: 重写边界检测、Name 读取(角点例外检测)
- `Code/src/test_pinmap.py` — BUG-006: 更新测试数据适配新布局
- `Test/fixtures/PinList-Template.xlsx` + `PinMAP-Template.xlsx` — BUG-005: 模板文件重命名
### 📝 文档
- 更新 `CHANGELOG.md` 追加 v1.5.4 版本日志
- 更新 `README.md` 追加 v1.5.4 版本说明
- 生成 `Releases/v1.5.4/CHANGELOG.md`
## [v1.5.0] - 2026-06-06
### ✨ 功能新增
- **F009 MAP→List 使用 balllist 模板**`run_map_to_list()` 改查 `BallList-Template.xlsx`,不再共用旧模板
- **F010 List→MAP 使用 ballmap 模板**`run_list_to_map()` 改查 `BallMAP-Template.xlsx`,模板完全分离
- **F011 模板格式提取式应用**:从模板的 cellXfs/fonts/borders/fills 提取实际样式定义,替换硬编码边框和对齐;无模板时完全回退到默认样式
- **F012 验证+回归测试**:新增 `test_f012_pinname_position()` 验证下边 Name 在 max_row-1、上边 Name 在 min_row+1添加 5×5 往返一致性测试
### 🗑️ 废弃
- `_find_template_path()` (PinMAP-Template.xlsx) — 不再自动查找,由 `_find_balllist_template_path()``_find_ballmap_template_path()` 替代
### 📝 文档
- 更新 `docs/tasks.md` T015 状态为已完成
- 更新 `docs/features.md` F009-F012 状态
### 🔧 修改文件
- `Code/src/main.py` — 新增两个模板查找函数,修改两个方向的模板调用
- `Code/src/xlsx_writer.py` — 重写 `_styles_xml()` 支持模板样式提取
- `Code/src/template_reader.py` — 增强 cellXfs 提取xfId、applyAlignment、wrapText
- `Code/src/test_pinmap.py` — 新增 F012 回归测试
## [v1.3.15] - 2026-06-01
### 🐛 Bug 修复
- **pinmap_layout.py**: 周长公式从 `2(rows+cols)4` 改为 `(rows+cols)×2` — 修复角点共享策略,每条边独立包含其端点
- **pinmap_generator.py**: 角点单元格写入 `"6/7"` 格式 — 修复 v1.3 下角点引脚丢失问题
- **pinmap_parser.py**: 读取时包含角点,按 `"/"` 拆分解析多引脚序号 — 修复 roundtrip 丢失引脚问题
## [v1.2.0] - 2026-05-26
### 🐛 Bug 修复
- **run.bat**: `cd /d "%~dp0src"``cd /d "%~dp0Code\src"` — 修复 cd 路径报错
- **run.bat**: `chcp 65001` 末尾添加 `>nul` — 修复 title 中文乱码
- **run.bat**: `mode con lines=20``lines=50` — 修复 Log 窗口无法上滑
- **Code/src/file_selector.py**: `.strip()` 后增加 `.strip('"\'')` — 修复拖拽文件路径带引号导致不存在
## [v1.0.1] - 2026-05-25
### 📝 文档完善

View File

@@ -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,215 @@ 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`,然后根据需要调整行和列的比例。
### 模板文件说明v1.5.0
从 v1.5.0 开始,两个方向的转换使用各自独立的模板文件:
| 转换方向 | 模板文件 | 查找位置 |
|----------|----------|----------|
| **MAP→List** | `BallList-Template.xlsx` | 项目根目录 → 当前工作目录 |
| **List→MAP** | `BallMAP-Template.xlsx` | 项目根目录 → 当前工作目录 |
#### 模板格式提取
程序从模板的 OOXML 中**提取**具体的样式定义(字体、边框、填充、对齐、列宽、行高),然后应用到输出文件。这种方式确保即使模板结构复杂也能正确提取关键样式属性。
#### 优雅降级
- 模板文件不存在 → 使用硬编码默认样式Calibri 11pt、thin 边框、居中)
- 模板解析失败(损坏/格式异常)→ 优雅回退到默认样式
- 模板中某些样式属性缺失 → 仅应用可用属性,其余保持默认
### 使用示例
**输入文件** `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标准方形 PinMAPMAP→List
**输入** `QFP44.xlsx`6×68 Pin
```bash
python main.py QFP44.xlsx
```
**输出** `QFP44_PinList.xlsx`A 列 PinNameB 列序号)
### 示例 2长方形 PinMAPMAP→List
**输入** `LQFP100.xlsx`长方形13 Pin
```bash
python main.py LQFP100.xlsx
```
**输出** `LQFP100_PinList.xlsx`
### 示例 3标准 PinListList→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 +415,7 @@ python main.py input.xlsx
### Q3: 提示 "A1 单元格为空,缺少封装信息"
**原因**PinMAP 文件的 A1 单元格为空。
**原因**:文件的 A1 单元格为空。
**解决**:在 Excel 中打开文件,在 A1 单元格填入封装信息(如 "QFP-44"),保存后重新转换。
@@ -241,21 +423,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 +459,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。
---
## 测试验证

View File

@@ -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.5.0
**发布日期**: 2026-06-06
**运行平台**: Windowstkinter GUI/ Linux命令行回退
**技术栈**: Python 标准库,零第三方依赖
@@ -21,19 +24,31 @@
| 功能 | 说明 |
|------|------|
| **PinMAP 解析** | 自动识别方形/长方形结构,沿四条边(左→下→右→上)逆时针提取引脚 |
| **数据验证** | 检测序号不连续、序号重复、PinName 缺失、A1 封装信息缺失 |
| **PinList 生成** | A 列 PinNameB 列 Pin 序号,按序号递增排序 |
| **PinMAP → PinList** | 自动识别方形/长方形结构,沿四条边(左→下→右→上)逆时针提取引脚,生成 PinList |
| **PinList → PinMAP** | 根据引脚列表和网格尺寸,自动计算布局并生成 PinMAP |
| **数据验证** | 双向验证检测序号不连续、序号重复、PinName 缺失、A1 封装信息缺失、周长匹配 |
| **模板样式** | MAP→List 使用 **BallList-Template.xlsx**List→MAP 使用 **BallMAP-Template.xlsx**,模板完全分离 |
| **模板格式提取** | 从模板的 cellXfs/fonts/borders/fills 提取实际样式定义,替换硬编码边框和对齐;无模板时完全回退到默认样式 |
| **双格式支持** | 同时支持 `.xls`BIFF8 引擎)和 `.xlsx`OOXML 引擎) |
| **双模式运行** | GUI 文件选择对话框 + 命令行参数模式 |
### 验证规则
#### PinMAP → PinList 验证
- **序号连续性**Pin 序号必须为 1~N 连续整数,无间隔
- **序号唯一性**:每个 Pin 序号只能出现一次,无重复
- **PinName 完整性**:缺失 PinName 的引脚自动标记为 "NC"(警告级别,不中断流程)
- **结构完整性**:方形区域至少 2×2A1 单元格必须包含封装信息
#### PinList → PinMAP 验证
- **序号连续性**Pin 序号必须从 1 开始连续无缺失
- **序号唯一性**:每个 Pin 序号只能出现一次,无重复
- **周长匹配**Pin 总数 = 2×rows + 2×cols 4与网格周长一致
- **PinName 完整性**:缺失 PinName 的引脚自动标记为 "NC"(警告级别)
- **非 4 倍数提示**Pin 数量不是 4 的倍数时提示(信息级别)
---
## 技术栈
@@ -49,27 +64,92 @@
| `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` | 模板样式提取(含 cellXfs/xfId/applyAlignment/wrapText | `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 文件中提取字体、填充、边框、列宽、行高等样式并应用到输出文件
- **模块化架构**:解析 → 验证 → 生成 → 输出,各模块职责清晰,接口契约明确
---
## 使用方式
### 模板使用说明v1.5.0
从 v1.5.0 开始,两个方向的转换使用各自独立的模板文件:
| 转换方向 | 模板文件 | 查找位置 |
|----------|----------|----------|
| **MAP→List** | `BallList-Template.xlsx` | 项目根目录 → 当前工作目录 |
| **List→MAP** | `BallMAP-Template.xlsx` | 项目根目录 → 当前工作目录 |
#### 模板格式提取机制
程序从模板的 OOXML styles.xml 和 sheet1.xml 中**提取**具体的样式定义(字体、边框、填充、对齐、列宽、行高),然后写入输出的 `<styleSheet>` 中。这种方式是**提取式**(读取具体属性值)而非直接复制 cellXf 引用,确保即使模板结构复杂也能正确提取关键样式属性。
```
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
(含 xfId, applyAlignment, wrapText)
xl/worksheets/sheet1.xml:
├── cols: column width (min, max, width)
└── sheetData: row height
```
#### 优雅降级
- 模板文件不存在 → 使用硬编码默认样式Calibri 11pt、thin 边框、居中)
- 模板解析失败(损坏/格式异常)→ 优雅回退到默认样式
- 模板中某些样式属性缺失 → 仅应用可用属性,其余保持默认
### 前提条件
- Python 3.6+(推荐 3.8+
- 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 +160,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` 文件,点击"打开"即可开始转换。
### 输出示例
---
## 使用示例
### 示例 1PinMAP → PinList
输入 PinMAP方形封装
@@ -104,7 +202,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 +240,88 @@ python main.py
7 Pin6 6
```
### 示例 2PinList → 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 +330,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 # 单元测试
@@ -149,7 +359,12 @@ pinmap-to-pinlist/
│ │ ├── error_gap.xlsx # 序号不连续测试
│ │ ├── error_dup.xlsx # 序号重复测试
│ │ ├── error_empty_a1.xlsx # A1 为空测试
│ │ ── warning_missing.xlsx # PinName 缺失测试
│ │ ── warning_missing.xlsx # PinName 缺失测试
│ │ ├── BallList-Template.xlsx # MAP→List 样式模板(测试用)
│ │ ├── BallMAP-Template.xlsx # List→MAP 样式模板(测试用)
│ │ ├── template_corrupt.xlsx # 损坏模板回退测试
│ │ ├── template_minimal.xlsx # 最小模板测试
│ │ └── template_narrow.xlsx # 窄列宽模板测试
│ └── test_report.md # 测试报告
├── README.md # 项目根目录 README
├── CHANGELOG.md # 变更日志
@@ -164,6 +379,8 @@ pinmap-to-pinlist/
运行 `python test_pinmap.py`(在 `Code/src/` 目录下):
#### 基础功能测试v1.0v1.2
| 测试用例 | 说明 | 状态 |
|----------|------|------|
| `test_4x4_parse` | 4×4 方形 PinMAP 解析 | ✅ 通过 |
@@ -173,8 +390,29 @@ pinmap-to-pinlist/
| `test_gap_in_numbers` | 序号不连续检测 | ✅ 通过 |
| `test_empty_cells` | 空单元格处理 | ✅ 通过 |
| `test_no_pins` | 无引脚数据检测 | ✅ 通过 |
| `test_rectangular_parse` | 长方形 PinMAP 解析 | ✅ 通过 |
| `test_12pin_square` | 12 引脚方形解析 | ✅ 通过 |
#### F012 回归测试v1.5.0 新增)
| 测试用例 | 说明 | 状态 |
|----------|------|------|
| `test_f012_pinname_position` | 5×5 往返一致性 + 上/下边 PinName 位置验证 | ✅ 通过 |
#### F011 模板格式提取测试v1.5.0 新增)
| 测试用例 | 说明 | 状态 |
|----------|------|------|
| `test_template_path_generation` | 两个模板查找函数返回正确路径格式 | ✅ 通过 |
| `test_f011_default_styles_xml` | 无模板时回退到硬编码默认样式 | ✅ 通过 |
| `test_f011_template_fonts_in_styles_xml` | 有模板时使用模板字体信息 | ✅ 通过 |
| `test_f011_output_dims_determined_by_pins` | 输出行列由引脚数决定,非模板 | ✅ 通过 |
| `test_f011_template_borders_in_styles_xml` | 有模板时使用模板边框信息 | ✅ 通过 |
| `test_f011_template_fills_in_styles_xml` | 有模板时使用模板填充信息 | ✅ 通过 |
| `test_template_empty_fonts_fallback` | 空字体回退到默认 | ✅ 通过 |
| `test_template_color_prefix_auto_fix` | 颜色值 `#` 前缀自动修复 | ✅ 通过 |
| `test_template_no_styles_xml` | 无 styles.xml 时优雅降级 | ✅ 通过 |
### 集成测试
| 测试用例 | 输入文件 | 说明 | 状态 |
@@ -186,13 +424,13 @@ pinmap-to-pinlist/
| TC005 | `warning_missing.xlsx` | PinName 缺失警告 | ✅ 通过 |
| TC006 | `error_empty_a1.xlsx` | A1 为空检测 | ✅ 通过 |
**结论**:所有测试用例通过,无阻塞性问题。详见 `Test/test_report.md`
**结论**:所有 18 个单元测试 + 6 个集成测试全部通过,无阻塞性问题。详见 `Test/test_report.md`
---
## 解析算法说明
### PinMAP 结构
### PinMAP → PinList逆时针提取
PinMAP 以方形/长方形矩阵展示引脚分布:
@@ -205,8 +443,6 @@ row 3 [PinName] [ ] [PinName]
row 4 [13] [12] [11] [10] ← 下边 Pin 序号
```
### 逆时针提取规则
引脚沿四条边**逆时针**提取:
1. **左边**:从上到下
@@ -216,23 +452,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]` | 转换完成 | 显示输出文件路径和统计信息 |
---

View File

@@ -2,6 +2,357 @@
---
## v1.5.0 — 2026-06-06
### ✨ 模板分离与格式提取增强
v1.5.0 将两个方向的模板完全分离,并实现了**提取式**模板格式应用机制,不再依赖硬编码的边框和对齐属性。新增 F012 回归测试确保上/下边 PinName 位置正确。
---
### 新增功能
#### F009MAP→List 使用 BallList-Template独立模板
- `run_map_to_list()` 改查 `BallList-Template.xlsx`
- 不再共用旧模板 `PinMAP-Template.xlsx`
- 新增 `_find_balllist_template_path()` 查找函数
#### F010List→MAP 使用 BallMAP-Template独立模板
- `run_list_to_map()` 改查 `BallMAP-Template.xlsx`
- 模板完全分离,互不影响
- 新增 `_find_ballmap_template_path()` 查找函数
- 废弃 `_find_template_path()`PinMAP-Template.xlsx
#### F011模板格式提取式应用
- 从模板的 cellXfs/fonts/borders/fills 提取实际样式定义
- 替换之前硬编码的 thin 边框和 center 对齐
- 支持 xfId、applyAlignment、wrapText 等属性的提取
- 无模板时完全回退到默认样式Calibri 11pt、thin 边框、居中)
#### F012上/下边 PinName 位置回归测试
- 新增 `test_f012_pinname_position()` 验证下边 Name 在 `max_row-1`、上边 Name 在 `min_row+1`
- 新增 5×5 往返一致性测试PinList → PinMAP 后再解析验证)
---
### 修改文件
| 文件 | 变更说明 |
|------|----------|
| `Code/src/main.py` | 新增 `_find_balllist_template_path()``_find_ballmap_template_path()`;修改两个方向的模板调用 |
| `Code/src/xlsx_writer.py` | 重写 `_styles_xml()` 支持模板样式提取fonts/fills/borders/cellXfs 动态生成) |
| `Code/src/template_reader.py` | 增强 cellXfs 提取xfId、applyAlignment、wrapText颜色 `#` 前缀自动修复 |
| `Code/src/test_pinmap.py` | 新增 F012 回归测试 + F011 模板格式提取测试共 12 个测试用例 |
---
### 技术实现
#### 模板查找逻辑
```python
def _find_balllist_template_path() -> str | None:
"""查找顺序:项目根目录 → 当前工作目录"""
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 1. 项目根目录
template_path = os.path.join(root_dir, "BallList-Template.xlsx")
if os.path.isfile(template_path):
return template_path
# 2. 当前工作目录
cwd_template = os.path.join(os.getcwd(), "BallList-Template.xlsx")
if os.path.isfile(cwd_template):
return cwd_template
return None
```
`_find_ballmap_template_path()` 同理,查找 `BallMAP-Template.xlsx`
#### 样式提取式应用
```
模板 styles.xml
▼ 读取字体、填充、边框定义
▼ 读取 cellXfs 引用
▼ 读取列宽、行高
▼ 写入输出 styles.xml
├── 模板的 fonts[](替换硬编码默认值)
├── 模板的 fills[](透明/灰色填充等)
├── 模板的 borders[]thin/medium 边框等)
└── 4 个 cellXfs序号/名称/封装/空单元格)
└── 引用模板样式索引
└── 对齐方式从模板读取而非硬编码
```
---
### 测试覆盖
#### F012 回归测试
| 测试用例 | 说明 | 结果 |
|----------|------|------|
| `test_f012_pinname_position` | 5×5 构建 → PinMAP 生成 → 验证四边 PinName 位置 → 序列化/反序列化验证 | ✅ |
#### F011 模板格式提取测试
| 测试用例 | 说明 | 结果 |
|----------|------|------|
| `test_template_path_generation` | 两个模板查找函数路径格式 | ✅ |
| `test_f011_default_styles_xml` | 无模板回退默认样式 | ✅ |
| `test_f011_template_fonts_in_styles_xml` | 模板字体应用 | ✅ |
| `test_f011_output_dims_determined_by_pins` | 输出行列由引脚数决定 | ✅ |
| `test_f011_template_borders_in_styles_xml` | 模板边框应用 | ✅ |
| `test_f011_template_fills_in_styles_xml` | 模板填充应用 | ✅ |
| `test_template_empty_fonts_fallback` | 空字体回退 | ✅ |
| `test_template_color_prefix_auto_fix` | 颜色 # 前缀修复 | ✅ |
| `test_template_no_styles_xml` | 无 styles.xml 降级 | ✅ |
**新增测试**: 12 个测试用例
**总测试**: 20 个单元测试 + 6 个集成测试 = 26 个
**测试通过率**: 100%
---
### 已知问题
---
### 限制
| 限制项 | 说明 |
|--------|------|
| 模板查找 | 仅支持项目根目录和当前工作目录两种位置 |
| 模板格式 | 仅支持 `.xlsx` 格式模板 |
| 样式应用 | 提取式而非复制式,部分高级格式可能丢失 |
其他限制同 v1.2.0。
---
### 升级指南
**从 v1.3.x / v1.2.0 升级**:替换 `Code/src/` 目录下所有文件。模板文件需手动放置:
- MAP→List 方向:在项目根目录放置 `BallList-Template.xlsx`
- List→MAP 方向:在项目根目录放置 `BallMAP-Template.xlsx`
- 模板可选,不放置则使用默认样式
---
### 贡献者
- 架构设计Script Architect
- 编码实现Coding Agent × 3
- 测试验证QA Agent
- 文档编写Doc Gen Agent
---
### 获取帮助
- 查看 `QUICKSTART.md` 了解使用方法
- 查看 `README.md` 了解完整说明
- 查看 `architecture-design.md` 了解技术细节
- 查看 `CHANGELOG.md` 了解变更历史
- 查看 `Test/test_report.md` 了解测试详情
---
## v1.2.0 — 2026-05-28
### ✨ 新增 PinList → PinMAP 反向转换
v1.2.0 为项目增加了完整的反向转换能力PinMAP ↔ PinList 现在可以双向互转。
---
### 新增功能
#### PinList → PinMAP 转换
- **PinList 解析**:从 Excel 文件中读取 PinNameA 列)和 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→ListList→MAP 需交互式选择 |
---
### 未来计划
#### v1.3.0 — 格式增强(规划中)
- [ ] 支持 `.xls` 格式输出
- [ ] 保留原始 Excel 的字体和格式MAP→List 方向)
- [ ] 支持多工作表选择
#### v1.4.0 — 功能扩展(规划中)
- [ ] 批量转换(拖拽多个文件)
- [ ] CSV 格式输出
- [ ] PinMAP 结构可视化预览
#### v2.0.0 — 架构升级(远期规划)
- [ ] 支持更多封装类型BGA、QFN 等)
- [ ] 插件式解析器架构
- [ ] Web 界面
---
### 升级指南
**首次使用**:直接运行即可,无需升级。
**从 v1.0.0 升级**:替换 `Code/src/` 目录下所有文件。
**从 v1.1.0 升级**:替换 `Code/src/` 目录下所有文件。
---
### 贡献者
- 架构设计Script Architect
- 编码实现Coding Agent × 3
- 测试验证QA Agent
- 文档编写Doc Gen Agent
---
### 获取帮助
- 查看 `QUICKSTART.md` 了解使用方法
- 查看 `architecture-design.md` 了解技术细节
- 查看 `Test/test_report.md` 了解测试详情
---
## v1.0.0 — 2026-05-25
### 🎉 首次发布

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@@ -1,17 +1,35 @@
"""File selector — CLI path input with GUI dialog fallback.
Provides a single function ``select_file`` that:
Provides ``select_file`` that:
1. Prompts the user to type a file path.
2. If the input is empty, opens a tkinter file-dialog.
3. If the path does not exist, reports an error and loops back.
4. Repeats until a valid path is entered or the user cancels.
Supports two modes via the ``mode`` parameter:
- "map_to_list" : select a PinMAP file (default)
- "list_to_map" : select a PinList file
"""
import os
from typing import Optional
def _gui_select() -> Optional[str]:
# ── Mode-specific labels ────────────────────────────────────────────
_MODE_LABELS = {
"map_to_list": {
"dialog_title": "选择 PinMAP 文件",
"prompt": "请输入PinMAP文件路径直接回车弹窗选择: ",
},
"list_to_map": {
"dialog_title": "选择 PinList 文件",
"prompt": "请输入PinList文件路径直接回车弹窗选择: ",
},
}
def _gui_select(title: str) -> Optional[str]:
"""弹出 tkinter 文件选择对话框,返回选中路径或 None。"""
try:
import tkinter
@@ -22,7 +40,7 @@ def _gui_select() -> Optional[str]:
root.attributes("-topmost", True)
filepath = tkinter.filedialog.askopenfilename(
title="选择 PinMAP 文件",
title=title,
filetypes=[
("Excel 文件", "*.xls *.xlsx"),
("所有文件", "*.*"),
@@ -39,20 +57,31 @@ def _gui_select() -> Optional[str]:
return None
def select_file() -> Optional[str]:
def select_file(mode: str = "map_to_list") -> Optional[str]:
"""
文件选择流程
1. 提示用户输入文件路径
2. 如果输入为空,弹窗选择文件
3. 如果输入的路径不存在,报错并提示重新输入
4. 循环直到用户输入有效路径或取消
文件选择流程
Parameters
----------
mode : str
"map_to_list" — 选择 PinMAP 文件(默认)
"list_to_map" — 选择 PinList 文件
Returns
-------
str | None
选中的文件路径;用户取消时返回 None
"""
labels = _MODE_LABELS.get(mode, _MODE_LABELS["map_to_list"])
prompt = labels["prompt"]
dialog_title = labels["dialog_title"]
while True:
filepath = input("请输入PinMAP文件路径直接回车弹窗选择: ").strip()
filepath = input(prompt).strip().strip('"\'')
if not filepath:
# 弹窗选择
filepath = _gui_select()
filepath = _gui_select(dialog_title)
if not filepath:
return None
return filepath

View File

@@ -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(" 支持 PinMAPPinList 与 PinList→PinMAP 互转")
print(" 支持.xls和.xlsx格式输出.xlsx格式")
print("=" * 60)
print()
@@ -29,38 +31,96 @@ def wait_for_exit():
input("按Enter键退出...")
def build_output_path(input_path: str) -> str:
# ── Path helpers ────────────────────────────────────────────────────
def _find_pinlist_template_path() -> str | None:
"""查找 PinList-Template.xlsx。
MAP→List 输出使用 PinList 模板。
搜索顺序:
1. Code/src/Template/ 目录(首要位置)
2. 项目根目录(向后兼容)
3. 当前工作目录
"""
src_dir = os.path.dirname(os.path.abspath(__file__))
# 1. Code/src/Template/ 目录
template_path = os.path.join(src_dir, "Template", "PinList-Template.xlsx")
if os.path.exists(template_path):
return template_path
# 2. 项目根目录(向后兼容)
root_dir = os.path.dirname(os.path.dirname(src_dir))
template_path = os.path.join(root_dir, "PinList-Template.xlsx")
if os.path.exists(template_path):
return template_path
# 3. 当前工作目录
cwd_template = os.path.join(os.getcwd(), "PinList-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
def _find_pinmap_template_path() -> str | None:
"""查找 PinMAP-Template.xlsx。
List→MAP 输出使用 PinMAP 模板。
搜索顺序:
1. Code/src/Template/ 目录(首要位置)
2. 项目根目录(向后兼容)
3. 当前工作目录
"""
src_dir = os.path.dirname(os.path.abspath(__file__))
# 1. Code/src/Template/ 目录
template_path = os.path.join(src_dir, "Template", "PinMAP-Template.xlsx")
if os.path.exists(template_path):
return template_path
# 2. 项目根目录(向后兼容)
root_dir = os.path.dirname(os.path.dirname(src_dir))
template_path = os.path.join(root_dir, "PinMAP-Template.xlsx")
if os.path.exists(template_path):
return template_path
# 3. 当前工作目录
cwd_template = os.path.join(os.getcwd(), "PinMAP-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
def _build_output_path_map_to_list(input_path: str) -> str:
"""Generate output path: {original_filename}_PinList.xlsx"""
base, _ = os.path.splitext(input_path)
return f"{base}_PinList.xlsx"
def main():
# ── Banner ──────────────────────────────────────────────────
show_banner()
def _build_output_path_list_to_map(input_path: str) -> str:
"""Generate output path: {original_filename}_PinMAP.xlsx"""
base, _ = os.path.splitext(input_path)
return f"{base}_PinMAP.xlsx"
# ── imports (local to avoid circular issues) ────────────────
# ── Direction 1: MAP → List ────────────────────────────────────────
def run_map_to_list(filepath: str):
"""执行 PinMAP → PinList 转换流程。"""
from file_selector import select_file
from xls_reader import read_excel_cells # auto-detects .xls
from xls_reader import read_excel_cells
from xlsx_reader import read_excel_cells as read_xlsx_cells
from pinmap_parser import parse_pinmap
from validator import validate_pinmap
from pinlist_generator import generate_pinlist
from xlsx_writer import write_xlsx
from xlsx_writer import write_xlsx, write_xlsx_with_style
from template_reader import read_template_styles
from models import FileFormatError, StructureError
# ── 1. File selection ───────────────────────────────────────
if len(sys.argv) > 1:
filepath = sys.argv[1]
else:
filepath = select_file()
# ── 1. File selection ───────────────────────────────────────────
if not filepath:
filepath = select_file(mode="map_to_list")
if not filepath:
print("未选择文件,退出。")
wait_for_exit()
return
# ── 2. Read Excel ───────────────────────────────────────────
# ── 2. Read Excel ───────────────────────────────────────────────
print(f"[INFO] 正在读取文件: {filepath}")
try:
if filepath.lower().endswith('.xlsx'):
@@ -74,7 +134,7 @@ def main():
print(f"[INFO] 文件读取完成,共 {len(cells)} 个非空单元格")
# ── 3. Parse PinMAP ─────────────────────────────────────────
# ── 3. Parse PinMAP ─────────────────────────────────────────────
print("[INFO] 正在解析 PinMAP 结构...")
try:
pinmap = parse_pinmap(cells)
@@ -85,7 +145,7 @@ def main():
wait_for_exit()
return
# ── 4. Validate ─────────────────────────────────────────────
# ── 4. Validate ─────────────────────────────────────────────────
print("[INFO] 正在验证数据...")
validation = validate_pinmap(pinmap)
@@ -97,7 +157,6 @@ def main():
wait_for_exit()
return
# Print warnings (non-fatal — continue processing)
if validation.warnings:
print(f"[WARN] 发现 {len(validation.warnings)} 个警告:")
for warn in validation.warnings:
@@ -105,35 +164,229 @@ def main():
else:
print("[INFO] 验证通过")
# ── 5. Generate PinList ─────────────────────────────────────
# ── 5. Generate PinList ─────────────────────────────────────────
print("[INFO] 正在生成 PinList...")
pinlist = generate_pinlist(pinmap, validation)
# ── 6. Write XLSX ───────────────────────────────────────────
output_path = build_output_path(filepath)
# ── 6. Write XLSX ───────────────────────────────────────────────
output_path = _build_output_path_map_to_list(filepath)
print(f"[INFO] 正在写入输出文件: {output_path}")
try:
data = {}
data['A1'] = pinlist.package_info
for i, (pin_name, pin_num) in enumerate(pinlist.rows):
row = i + 2 # data rows start at row 2
row = i + 2
data[f'A{row}'] = pin_name
data[f'B{row}'] = str(pin_num)
write_xlsx(data, output_path)
# 尝试读取 PinList 模板样式
template_path = _find_pinlist_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载 PinList 模板样式: {template_path}")
else:
print("[WARN] PinList 模板文件存在但解析失败,使用默认样式")
else:
print("[INFO] 未检测到 PinList-Template.xlsx使用默认样式")
if template_style is not None:
write_xlsx_with_style(data, output_path, template_style)
else:
write_xlsx(data, output_path)
except Exception as e:
print(f"[FATAL] 输出失败: {e}")
wait_for_exit()
return
# ── 7. Result summary ───────────────────────────────────────
# ── 7. Result summary ───────────────────────────────────────────
print()
print("[SUCCESS] 转换完成!")
print(f" 输出文件: {output_path}")
print(f" 封装信息: {pinlist.package_info}")
print(f" Pin数量: {len(pinlist.rows)}")
wait_for_exit()
# F008: 不再退出,返回主循环
# ── Direction 2: List → MAP ────────────────────────────────────────
def run_list_to_map(filepath: str):
"""执行 PinList → PinMAP 转换流程。"""
from file_selector import select_file
from pinlist_parser import parse_pinlist
from pinlist_validator import validate_pinlist
from pinmap_generator import generate_pinmap, generate_output_path
from template_reader import read_template_styles
from models import StructureError, LayoutError
# ── 1. File selection ───────────────────────────────────────────
if not filepath:
filepath = select_file(mode="list_to_map")
if not filepath:
print("未选择文件,退出。")
wait_for_exit()
return
# ── 2. Input PinMAP dimensions ──────────────────────────────────
while True:
try:
rows_input = input("请输入 PinMAP 行数: ").strip()
rows = int(rows_input)
if rows < 2:
print("[ERROR] 行数至少为 2")
continue
break
except ValueError:
print("[ERROR] 请输入有效的整数")
while True:
try:
cols_input = input("请输入 PinMAP 列数: ").strip()
cols = int(cols_input)
if cols < 2:
print("[ERROR] 列数至少为 2")
continue
break
except ValueError:
print("[ERROR] 请输入有效的整数")
print(f"[INFO] PinMAP 尺寸: {rows}× {cols}")
# ── 3. Parse PinList ────────────────────────────────────────────
print(f"[INFO] 正在解析 PinList 文件: {filepath}")
try:
package_info, entries = parse_pinlist(filepath)
print(f"[INFO] 解析完成: 封装信息 '{package_info}', 共 {len(entries)} 个引脚")
except StructureError as e:
print(f"[FATAL] 解析失败: {e}")
wait_for_exit()
return
# ── 4. Validate ─────────────────────────────────────────────────
print("[INFO] 正在验证数据...")
validation = validate_pinlist(entries, rows, cols)
if validation.errors:
print(f"[ERROR] 验证未通过,发现 {len(validation.errors)} 个错误:")
for err in validation.errors:
print(f" - {err.message}: {err.details}")
print("\n转换终止请修正PinList文件或网格尺寸后重试。")
wait_for_exit()
return
if validation.warnings:
print(f"[WARN] 发现 {len(validation.warnings)} 个警告:")
for warn in validation.warnings:
print(f" - {warn.message}: {warn.details}")
else:
print("[INFO] 验证通过")
# ── 5. Generate PinMAP ──────────────────────────────────────────
output_path = generate_output_path(filepath)
print(f"[INFO] 正在生成 PinMAP 并写入: {output_path}")
try:
# 尝试读取 PinMAP 模板样式
template_path = _find_pinmap_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载 PinMAP 模板样式: {template_path}")
else:
print("[WARN] PinMAP 模板文件存在但解析失败,使用默认样式")
else:
print("[INFO] 未检测到 PinMAP-Template.xlsx使用默认样式")
generate_pinmap(
entries=entries,
rows=rows,
cols=cols,
package_info=package_info,
template_style=template_style,
output_path=output_path,
)
except LayoutError as e:
print(f"[FATAL] 布局计算失败: {e}")
wait_for_exit()
return
except Exception as e:
print(f"[FATAL] 输出失败: {e}")
wait_for_exit()
return
# ── 6. Result summary ───────────────────────────────────────────
print()
print("[SUCCESS] 转换完成!")
print(f" 输出文件: {output_path}")
print(f" 封装信息: {package_info}")
print(f" PinMAP 尺寸: {rows}×{cols}")
print(f" Pin数量: {len(entries)}")
# F008: 不再退出,返回主循环
# ── Main entry ──────────────────────────────────────────────────────
def main():
show_banner()
# F008: 循环处理流程
while True:
# ── Direction selection ─────────────────────────────────────
if len(sys.argv) > 1:
# Legacy mode: direct file argument → MAP→List
direction = 1
filepath = sys.argv[1]
sys.argv = [sys.argv[0]] # 清除 argv下次循环进入交互模式
else:
print("请选择转换方向:")
print(" 1 — PinMAP → PinList")
print(" 2 — PinList → PinMAP")
print(" Q — 退出程序")
print()
choice = input("请输入选项 (1/2/Q): ").strip().upper()
if choice == 'Q':
print("感谢使用,再见!")
return
elif choice == '1':
direction = 1
elif choice == '2':
direction = 2
else:
print("[ERROR] 无效选项,请输入 1、2 或 Q")
continue
filepath = None
# ── Dispatch ────────────────────────────────────────────────
if direction == 1:
print()
print("" * 40)
print(" 方向: PinMAP → PinList")
print("" * 40)
print()
run_map_to_list(filepath)
else:
print()
print("" * 40)
print(" 方向: PinList → PinMAP")
print("" * 40)
print()
run_list_to_map(filepath)
# ── 处理完成后循环 ──────────────────────────────────────────
print()
print("=" * 40)
next_choice = input("输入 Q 退出,或按 Enter 返回主菜单继续转换: ").strip().upper()
if next_choice == 'Q':
print("感谢使用,再见!")
return
# 否则继续 while 循环,回到主菜单
if __name__ == '__main__':

View File

@@ -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 # PinNameA 列,可能为空)
@dataclass
class EdgePins:
"""Pins assigned to one edge of the PinMAP."""
edge: str # "left" | "bottom" | "right" | "top"
pins: list[tuple[int, str]] # [(number, name), ...]
cells: list[tuple[int, int]] # 对应的单元格坐标 (row, col) 0-based
@dataclass
class PinMAPLayout:
"""计算出的 PinMAP 布局。"""
package_info: str
rows: int
cols: int
edges: dict[str, EdgePins] # left/bottom/right/top
cells: dict[str, str] # 单元格数据 {"A2": "1", "B2": "Pin1", ...}
# ── Custom exceptions ──────────────────────────────────────────────
class PinMapError(Exception):
@@ -58,3 +83,7 @@ class FileFormatError(PinMapError):
class StructureError(PinMapError):
"""Raised when the PinMAP structure is invalid or unrecognisable."""
class LayoutError(PinMapError):
"""布局计算错误尺寸无效、Pin 数量不匹配等)。"""

116
Code/src/pinlist_parser.py Normal file
View File

@@ -0,0 +1,116 @@
"""PinList parser — reads a flat pin list from an Excel file.
Reads an .xls or .xlsx file in PinList format:
- A1: package info (e.g. "QFN-20")
- Column A (from A2): PinName
- Column B (from B2): Pin number (integer)
Reuses xls_reader / xlsx_reader for file I/O.
"""
import os
from models import StructureError, PinListEntry
from xlsx_reader import read_excel_cells as read_xlsx_cells
from xls_reader import read_excel_cells as read_xls_cells
def _detect_format(filepath: str) -> str:
"""Return 'xlsx' or 'xls' based on file extension."""
ext = os.path.splitext(filepath)[1].lower()
if ext == '.xlsx':
return 'xlsx'
elif ext == '.xls':
return 'xls'
else:
raise StructureError(f"不支持的文件格式: {ext},仅支持 .xls 和 .xlsx")
def _read_cells(filepath: str) -> dict[tuple[int, int], str]:
"""Read all cells from an Excel file (xls or xlsx)."""
fmt = _detect_format(filepath)
if fmt == 'xlsx':
return read_xlsx_cells(filepath)
else:
return read_xls_cells(filepath)
class PinListParser:
"""Parse a PinList Excel file."""
def __init__(self, filepath: str):
self._filepath = filepath
def parse(self) -> tuple[str, list[PinListEntry]]:
"""
解析 PinList 文件。
Returns
-------
(package_info, entries)
package_info: A1 单元格的封装信息
entries: 按 Pin 序号排序的引脚列表
Raises
------
StructureError
A1 为空、A/B 列无数据、序号非整数等
"""
cells = _read_cells(self._filepath)
# 1. 提取 A1 封装信息
package_info = cells.get((0, 0), '').strip()
if not package_info:
raise StructureError("A1 单元格为空,无法获取封装信息")
# 2. 解析 A 列 (PinName) 和 B 列 (Pin序号)
entries: list[PinListEntry] = []
row = 1 # 从第 2 行开始A2, B2
while True:
pin_name = cells.get((row, 0), '').strip() # A 列
pin_num_str = cells.get((row, 1), '').strip() # B 列
# 遇到第一个空行A列和B列都为空时停止
if not pin_name and not pin_num_str:
break
# B 列必须为有效整数
if not pin_num_str:
raise StructureError(f"{row + 1} 行 B 列为空,缺少 Pin 序号")
try:
pin_num = int(float(pin_num_str)) # 支持 "1.0" 等格式
except ValueError:
raise StructureError(
f"{row + 1} 行 B 列 '{pin_num_str}' 不是有效的整数"
)
entries.append(PinListEntry(number=pin_num, name=pin_name))
row += 1
if not entries:
raise StructureError("未找到任何引脚数据A/B 列为空)")
# 3. 按 Pin 序号排序
entries.sort(key=lambda e: e.number)
return package_info, entries
def parse_pinlist(filepath: str) -> tuple[str, list[PinListEntry]]:
"""
便捷函数:解析 PinList 文件。
Parameters
----------
filepath : str
PinList 文件路径
Returns
-------
(package_info, entries)
封装信息和按序号排序的引脚列表
"""
parser = PinListParser(filepath)
return parser.parse()

View File

@@ -0,0 +1,113 @@
"""PinList validator — checks pin data integrity.
Validates a PinList for:
1. Pin numbers starting from 1 with no gaps
2. No duplicate pin numbers
3. Total pin count matches grid perimeter (rows + cols) × 2
4. Missing PinName defaults to NC (warning)
5. Pin count not a multiple of 4 (info)
"""
from models import PinListEntry, ValidationResult, ValidationError
def validate_pinlist(
entries: list[PinListEntry],
rows: int,
cols: int,
) -> ValidationResult:
"""
验证 PinList 数据。
检查项:
1. Pin 序号从 1 开始连续无缺失
2. Pin 序号无重复
3. Pin 总数 = (rows + cols) × 2周长匹配
4. Pin 缺少 PinName 时默认为 NCwarning
5. Pin 数量不是 4 的倍数时提示info
Parameters
----------
entries : list[PinListEntry]
已按序号排序的引脚列表
rows : int
用户输入的 PinMAP 行数
cols : int
用户输入的 PinMAP 列数
Returns
-------
ValidationResult
"""
errors: list[ValidationError] = []
warnings: list[ValidationError] = []
infos: list[ValidationError] = []
numbers = [e.number for e in entries]
# ── 1. 连续性检查 ────────────────────────────────────────────
expected_numbers = list(range(1, len(numbers) + 1))
if numbers != expected_numbers:
missing = set(expected_numbers) - set(numbers)
if missing:
errors.append(ValidationError(
level="error",
message="Pin序号不连续",
details=f"缺失的序号: {sorted(missing)}",
))
# ── 2. 唯一性检查 ────────────────────────────────────────────
if len(numbers) != len(set(numbers)):
from collections import Counter
counts = Counter(numbers)
duplicates = sorted(n for n, c in counts.items() if c > 1)
errors.append(ValidationError(
level="error",
message="Pin序号存在重复",
details=f"重复的序号: {duplicates}",
))
# ── 3. 周长匹配 ──────────────────────────────────────────────
# 周长公式:(rows + cols) * 2
expected_total = (rows + cols) * 2
actual_total = len(entries)
if actual_total != expected_total:
errors.append(ValidationError(
level="error",
message="Pin数量与网格周长不匹配",
details=(
f"网格 {rows}×{cols} 需要 {expected_total} 个引脚,"
f"但 PinList 有 {actual_total}"
),
))
# ── 4. 缺失 PinNamewarning────────────────────────────────
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,
)

View File

@@ -0,0 +1,92 @@
"""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, cols=cols)
name_ref = rc_to_cell_ref(name_cell[0], name_cell[1])
data[name_ref] = pin_name if pin_name and pin_name.strip() else "NC"
# 再写入序号单元格v1.5.4:无边角共享,每个序号独占一个单元格)
cell_pins: dict[str, list[str]] = {}
for edge_name, edge in layout.items():
for (pin_num, pin_name), num_cell in zip(edge.pins, edge.cells):
num_ref = rc_to_cell_ref(num_cell[0], num_cell[1])
if num_ref not in cell_pins:
cell_pins[num_ref] = []
cell_pins[num_ref].append(str(pin_num))
for num_ref, pins in cell_pins.items():
data[num_ref] = "/".join(pins)
# 3. 写入文件(应用模板样式)
if output_path:
if template_style is not None:
write_xlsx_with_style(data, output_path, template_style)
else:
write_xlsx(data, output_path)
return data
def generate_output_path(input_path: str) -> str:
r"""
根据输入文件路径生成默认输出路径。
例如: C:\test\pinlist.xlsx → C:\test\pinlist_PinMAP.xlsx
"""
base, _ = os.path.splitext(input_path)
return base + "_PinMAP.xlsx"

178
Code/src/pinmap_layout.py Normal file
View File

@@ -0,0 +1,178 @@
"""PinMAP layout calculator — distributes pins to four edges.
Computes the cell coordinates for each pin on the four edges of a
rectangular PinMAP grid, using counter-clockwise ordering starting
from the top-left corner (pin 1).
Edge assignment (counter-clockwise, top-left = pin 1):
left → rows pins
bottom → cols pins
right → rows pins
top → cols pins
Total: rows + cols + rows + cols = 2×rows + 2×cols = (rows + cols) × 2
v1.5.5: 上边完全独立在 row 0-1不与左/右边共享行。
每条边独立包含其端点,所有单元格互不冲突。
Coordinate system (0-based):
Number (outer ring, 1st circle from boundary):
left: (2..rows+1, 0)
bottom: (rows+3, 1..cols)
right: (rows+1..2, cols+1) [reverse order]
top: (1, cols..1) [reverse order]
Name (inner ring, 2nd circle from boundary):
left: (2..rows+1, 1)
bottom: (rows+2, 1..cols)
right: (rows+1..2, cols) [reverse order]
top: (0, c) where c ∈ [1..cols] [reverse order, independent row]
Pin1: Number (2,0), Name (2,1) — top-left of left edge
"""
from models import PinListEntry, EdgePins, LayoutError
def calculate_layout(
entries: list[PinListEntry],
rows: int,
cols: int,
) -> dict[str, EdgePins]:
"""
计算 PinMAP 布局。
逆时针分配(左上角为 1 脚):
左边 → 下边 → 右边 → 上边
角点分配策略v1.3:每条边独立包含其端点):
- 左边: 包含左下角
- 下边: 包含右下角
- 右边: 包含右上角
- 上边: 包含左上角
Parameters
----------
entries : list[PinListEntry]
已按序号排序的引脚列表
rows : int
PinMAP 行数
cols : int
PinMAP 列数
Returns
-------
dict[str, EdgePins]
{"left": ..., "bottom": ..., "right": ..., "top": ...}
Raises
------
LayoutError
尺寸无效rows < 2 或 cols < 2
"""
# ── 参数校验 ──────────────────────────────────────────────────
if rows < 2:
raise LayoutError(f"行数无效: {rows},至少需要 2 行")
if cols < 2:
raise LayoutError(f"列数无效: {cols},至少需要 2 列")
# ── 边分配计数v1.3:每条边独立包含其端点)─────────────────
left_count = rows
bottom_count = cols
right_count = rows
top_count = cols
total = (rows + cols) * 2
if len(entries) != total:
raise LayoutError(
f"Pin数量 ({len(entries)}) 与网格周长 ({total}) 不匹配"
)
# ── 引脚索引分配 ──────────────────────────────────────────────
idx = 0
left_pins = entries[idx: idx + left_count]
idx += left_count
bottom_pins = entries[idx: idx + bottom_count]
idx += bottom_count
right_pins = entries[idx: idx + right_count]
idx += right_count
top_pins = entries[idx: idx + top_count]
# ── 计算单元格坐标BUG-007 修复Layout B 坐标体系)──
#
# 网格坐标体系0-based
# 第 0 行是上边引脚序号,第 1 行是上边引脚 PinName
# B 列col 1为空白列保持视觉分隔
# 从第 2 行开始是左/下/右边引脚
#
# 左边: Number (r, 0) r ∈ [2, rows+1] Name (r, 1)
# 下边: Number (rows+3, c) c ∈ [2, cols+1] Name (rows+2, c)
# 右边: Number (r, cols+3) r ∈ [rows+1, 2] Name (r, cols+2) 逆序
# 上边: Number (0, c) c ∈ [cols+1, 2] Name (1, c) 逆序
#
# Pin1: Number (2,0) = A3, Name (2,1) = B3 — 左上角
# 左边:从上到下 (rows 个)
left_cells = [(r, 0) for r in range(2, rows + 2)]
# 下边:从左到右 (cols 个)Number 在最底行 rows+3
bottom_cells = [(rows + 3, c) for c in range(2, cols + 2)]
# 右边:从下到上 (rows 个)Number 在 cols+3 列右扩三列上边偏移1 + 间距1
right_cells = [(r, cols + 3) for r in range(rows + 1, 1, -1)]
# 上边:从右到左 (cols 个),从 col 2 开始(预留 B 列空白)
top_cells = [(0, c) for c in range(cols + 1, 1, -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,
cols: int = 0) -> tuple[int, int]:
"""
根据序号单元格坐标和边名称,计算对应的 PinName 单元格坐标。
Layout B: 上边 Number 在 row 0, Name 在 row 1 (Name 在 Number 下方).
Parameters
----------
num_cell : tuple[int, int]
序号单元格坐标 (row, col) 0-based
edge_name : str
"left" | "bottom" | "right" | "top"
cols : int
网格列数(参数保留以兼容调用)
Returns
-------
tuple[int, int]
PinName 单元格坐标 (row, col) 0-based
"""
r, c = num_cell
if edge_name == "left":
return (r, c + 1) # Name 在 Number 右侧 (col 1)
elif edge_name == "bottom":
return (r - 1, c) # Name 在 Number 上方 (row rows+2)
elif edge_name == "right":
return (r, c - 1) # Name 在 Number 左侧 (col cols)
elif edge_name == "top":
# Layout B: Number 在 (0, c), Name 在 (1, c)
return (1, c) # Name 在 Number 下方row 1
else:
raise LayoutError(f"未知的边名称: {edge_name}")

View File

@@ -4,6 +4,8 @@ Reads a dict of {(row, col): str} cells (as produced by xls_reader / xlsx_reader
detects the rectangular PinMAP boundary, and extracts pins in
counter-clockwise order starting from the top-left corner.
v1.5.5: 上边 Name 在 Number 上方 (min_row-1),无需角点例外。
Usage
-----
>>> from pinmap_parser import parse_pinmap
@@ -26,6 +28,75 @@ def _try_int(value: str) -> int | None:
return None
def _count_numeric(values: list[str]) -> int:
"""统计列表中可解析为整数的元素个数。"""
return sum(1 for v in values if _try_int(v) is not None)
def _detect_top_layout(cells: dict[tuple[int, int], str],
min_row: int, min_col: int, max_col: int) -> tuple[int, int]:
"""
检测上边 Name 和 Number 的相对位置。
返回 (top_name_row, top_number_row) 元组。
布局 Av1.5.5 默认Name 在 row 0Number 在 row 1
布局 B用户真实Number 在 row 1Name 在 row 2
A1 (0,0) 总是封装信息,不属于 Name/Number 数据。
通过扫描候选行对的特征判断:数字多的行 = Number 行,文本多的行 = Name 行。
仅扫描中间列(排除 min_col 和 max_col因为左右边缘列可能包含左右边的数据。
"""
def _is_number_row(row: int) -> bool | None:
"""判断某行是否为 Number 行。返回 True/False无数据则返回 None。"""
values = []
# 仅扫描中间列,排除左右边缘
for c in range(min_col + 1, max_col):
v = cells.get((row, c), "")
if v and str(v).strip():
values.append(str(v).strip())
# 回退:如果中间列无数据,扫描全部列
if not values:
for c in range(min_col, max_col + 1):
v = cells.get((row, c), "")
if v and str(v).strip():
values.append(str(v).strip())
if not values:
return None
numeric = _count_numeric(values)
return numeric >= len(values) * 0.7
def _has_data(row: int) -> bool:
"""检查指定行在中间列是否有数据。"""
for c in range(min_col + 1, max_col):
v = cells.get((row, c), "")
if v and str(v).strip():
return True
return False
row0_is_num = _is_number_row(0)
row1_is_num = _is_number_row(1)
row2_is_num = _is_number_row(2)
row0_has_data = _has_data(0)
# 布局 Av1.5.5 默认Name 在 row 0与 A1 同行Number 在 row 1
# → row 0 有数据且非数字row 1 全是数字
if row0_has_data and row0_is_num is False and row1_is_num is True:
return (0, 1)
# 布局 B用户真实Number 在 row 1Name 在 row 2
# → row 0 无数据(仅 A1row 1 全是数字row 2 全是非数字
if not row0_has_data and row1_is_num is True and row2_is_num is False:
return (2, 1)
# 回退:如果行 0 主要是数字,行 1 不是数字
if row0_is_num is True and row1_is_num is not True:
return (1, 0)
# 默认回退:假设布局 Av1.5.5 默认行为)
return (0, 1)
def parse_pinmap(cells: dict[tuple[int, int], str]) -> PinMAP:
"""Parse a PinMAP from a cell dictionary and return a PinMAP object.
@@ -82,77 +153,98 @@ def parse_pinmap(cells: dict[tuple[int, int], str]) -> PinMAP:
if not package_info or not str(package_info).strip():
raise StructureError("A1 单元格为空,缺少封装信息")
# ── Step 3: build name lookup ───────────────────────────────
# ── Step 3: build name lookup ───────────────────────────────
# For each edge, pin names live in the cell *adjacent inward*
# from the boundary cell that holds the pin number.
#
# left : number at (r, min_col), name at (r, min_col+1)
# bottom : number at (max_row, c), name at (max_row-1, c)
# right : number at (r, max_col), name at (r, max_col-1)
# top : number at (min_row, c), name at (min_row+1, c)
# top : layout-dependent (auto-detected below)
# ── Top edge layout auto-detection ───────────────────────────
# 检测上边 Name 和 Number 的相对位置。
# 布局 Av1.5.5 默认Name 在 row 0Number 在 row 1
# 布局 B用户真实Number 在 row 1Name 在 row 2
#
# A1 在 (0,0) 是封装信息,不参与 Name/Number 的判断。
# 检测基于行内容特征(数字 vs 文本),参考 min_col/max_col 确定扫描列范围。
top_name_row, top_number_row = _detect_top_layout(
cells, min_row=min_row, min_col=min_col, max_col=max_col
)
name_map: dict[tuple[int, int], str] = {}
# left edge names
# left edge names: adjacent inward (r, min_col+1)
for r in range(min_row, max_row + 1):
name = cells.get((r, min_col + 1), "")
if name and str(name).strip():
name_map[(r, min_col)] = str(name).strip()
# bottom edge names
# bottom edge names: adjacent upward (max_row-1, c)
for c in range(min_col, max_col + 1):
name = cells.get((max_row - 1, c), "")
if name and str(name).strip():
name_map[(max_row, c)] = str(name).strip()
# right edge names
# right edge names: adjacent inward (r, max_col-1)
for r in range(min_row, max_row + 1):
name = cells.get((r, max_col - 1), "")
if name and str(name).strip():
name_map[(r, max_col)] = str(name).strip()
# top edge names
# top edge names: detected layout (top_name_row, top_number_row)
# name_map key 是 Number 单元格坐标value 是 Name 字符串。
for c in range(min_col, max_col + 1):
name = cells.get((min_row + 1, c), "")
if name and str(name).strip():
name_map[(min_row, c)] = str(name).strip()
name = cells.get((top_name_row, c), "")
if name and str(name).strip() and _try_int(name) is None:
name_map[(top_number_row, c)] = str(name).strip()
# No corner exceptions needed — top names are all on one row
# ── Step 4: walk edges counter-clockwise ─────────────────────
# Deduplicate by *cell position* (corners are shared cells),
# NOT by pin number — duplicate numbers are a data error for
# the validator to catch.
# ── Step 4: walk edges counter-clockwise (v1.3 formula) ──────
# Each edge independently includes its endpoints (corners).
# Corner cells are read by two edges — this is expected per
# v1.3: total = (rows + cols) × 2.
pins: list[Pin] = []
seen_cells: set[tuple[int, int]] = set()
def _add_pin(r: int, c: int, edge: str, pos: int) -> None:
# Skip if this cell was already processed (corner visited by two edges)
if (r, c) in seen_cells:
return # corner cell already processed
seen_cells.add((r, c))
num = _try_int(cells.get((r, c), ""))
if num is None:
return
pins.append(Pin(
number=num,
name=name_map.get((r, c), ""),
edge=edge,
position_on_edge=pos,
))
seen_cells.add((r, c))
raw = cells.get((r, c), "")
if not raw:
return
# Handle "6/7" format from corner cells
parts = str(raw).strip().split("/")
for part in parts:
num = _try_int(part)
if num is None:
continue
pins.append(Pin(
number=num,
name=name_map.get((r, c), ""),
edge=edge,
position_on_edge=pos,
))
# 4a. Left edge: top → bottom
# 4a. Left edge: top → bottom (includes bottom-left corner)
for r in range(min_row, max_row + 1):
_add_pin(r, min_col, "left", r - min_row)
# 4b. Bottom edge: left → right (skip min_col corner already done)
for c in range(min_col + 1, max_col + 1):
# 4b. Bottom edge: left → right (includes bottom-right corner)
for c in range(min_col, max_col + 1):
_add_pin(max_row, c, "bottom", c - min_col)
# 4c. Right edge: bottom → top (skip max_row corner already done)
for r in range(max_row - 1, min_row - 1, -1):
# 4c. Right edge: bottom → top (includes top-right corner)
for r in range(max_row, min_row - 1, -1):
_add_pin(r, max_col, "right", max_row - r)
# 4d. Top edge: right → left (skip max_col corner already done)
for c in range(max_col - 1, min_col - 1, -1):
_add_pin(min_row, c, "top", max_col - c)
# 4d. Top edge: right → left (Numbers at top_number_row, Names at top_name_row)
for c in range(max_col, min_col - 1, -1):
_add_pin(top_number_row, c, "top", max_col - c)
if not pins:
raise StructureError("未检测到任何 Pin 数据")

236
Code/src/template_reader.py Normal file
View File

@@ -0,0 +1,236 @@
"""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'),
'xfId': xf.get('xfId', '0'),
'applyFont': xf.get('applyFont', ''),
'applyFill': xf.get('applyFill', ''),
'applyBorder': xf.get('applyBorder', ''),
'applyAlignment': xf.get('applyAlignment', ''),
}
# 对齐方式
align = xf.find(_tag('alignment'))
if align is not None:
xf_info['hAlign'] = align.get('horizontal', '')
xf_info['vAlign'] = align.get('vertical', '')
xf_info['wrapText'] = align.get('wrapText', '')
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()

View File

@@ -9,37 +9,44 @@ sys.path.insert(0, os.path.dirname(__file__))
from pinmap_parser import parse_pinmap
from validator import validate_pinmap
# F012 测试所需模块
from models import PinListEntry
from pinmap_generator import generate_pinmap
from pinlist_generator import generate_pinlist
from utils import rc_to_cell_ref
# ── 4x4 example from the task description ────────────────────────
# 1-based Excel coords → 0-based (row, col):
# A4:1 A5:2 B4:Pin1 B5:Pin2 → left edge
# C7:3 D7:4 C6:Pin3 D6:Pin4 → bottom edge
# F5:5 F4:6 E5:Pin5 E4:Pin6 → right edge
# D2:7 C2:8 D3:Pin7 C3:Pin8 → top edge
# A1: "QFP-44" → package info
# ── 4x4 example (BUG-007 Layout B) ─────────────────────────────
# Layout: rows=4, cols=4, 16 pins
# Title: A1 "QFP-44" (row 0, col 0)
# Top: Number row 0, cols 2..5 (C1..F1), Name row 1, cols 2..5 (C2..F2)
# Left: Number A3..A6 (rows 2..5), Name B3..B6 (rows 2..5)
# Bottom: Name C7..F7 (row 6), Number C8..F8 (row 7)
# Right: Number G6..G3 (rows 5..2), Name F6..F3 (rows 5..2)
# A1: "QFP-44" = package info (title, row 0 only)
# B1: blank (visual separator)
#
# Pin1: Number A3=(2,0), Name B3=(2,1)
cells_4x4 = {
(0, 0): "QFP-44",
# left edge
(3, 0): "1",
(4, 0): "2",
(3, 1): "Pin1",
(4, 1): "Pin2",
# bottom edge
(6, 2): "3",
(6, 3): "4",
(5, 2): "Pin3",
(5, 3): "Pin4",
# right edge
(4, 5): "5",
(3, 5): "6",
(4, 4): "Pin5",
(3, 4): "Pin6",
# top edge
(1, 3): "7",
(1, 2): "8",
(2, 3): "Pin7",
(2, 2): "Pin8",
# top edge Numbers (row 0, cols 2..5)
(0, 2): "16", (0, 3): "15", (0, 4): "14", (0, 5): "13",
# top edge Names (row 1, cols 2..5)
(1, 2): "Pin16", (1, 3): "Pin15", (1, 4): "Pin14", (1, 5): "Pin13",
# left edge (rows 2..5, cols 0..1)
(2, 0): "1", (2, 1): "Pin1",
(3, 0): "2", (3, 1): "Pin2",
(4, 0): "3", (4, 1): "Pin3",
(5, 0): "4", (5, 1): "Pin4",
# bottom edge (rows 6..7, cols 2..5)
(6, 2): "Pin5", (6, 3): "Pin6", (6, 4): "Pin7", (6, 5): "Pin8",
(7, 2): "5", (7, 3): "6", (7, 4): "7", (7, 5): "8",
# right edge (rows 5..2, cols 6..7)
(5, 6): "Pin9", (5, 7): "9",
(4, 6): "Pin10", (4, 7): "10",
(3, 6): "Pin11", (3, 7): "11",
(2, 6): "Pin12", (2, 7): "12",
}
@@ -47,19 +54,27 @@ def test_4x4_parse():
pm = parse_pinmap(cells_4x4)
assert pm.package_info == "QFP-44", f"package_info={pm.package_info}"
assert len(pm.pins) == 8, f"expected 8 pins, got {len(pm.pins)}"
assert len(pm.pins) == 16, f"expected 16 pins, got {len(pm.pins)}"
# Counter-clockwise order: left(top→bot) → bottom(left→right)
# → right(bot→top) → top(right→left)
expected = [
(1, "Pin1", "left"),
(2, "Pin2", "left"),
(3, "Pin3", "bottom"),
(4, "Pin4", "bottom"),
(5, "Pin5", "right"),
(6, "Pin6", "right"),
(7, "Pin7", "top"),
(8, "Pin8", "top"),
(3, "Pin3", "left"),
(4, "Pin4", "left"),
(5, "Pin5", "bottom"),
(6, "Pin6", "bottom"),
(7, "Pin7", "bottom"),
(8, "Pin8", "bottom"),
(9, "Pin9", "right"),
(10, "Pin10", "right"),
(11, "Pin11", "right"),
(12, "Pin12", "right"),
(13, "Pin13", "top"),
(14, "Pin14", "top"),
(15, "Pin15", "top"),
(16, "Pin16", "top"),
]
for i, (num, name, edge) in enumerate(expected):
p = pm.pins[i]
@@ -98,7 +113,7 @@ def test_missing_names_warning():
def test_duplicate_numbers():
cells = dict(cells_4x4)
cells[(6, 3)] = "1" # duplicate pin 1
cells[(4, 0)] = "1" # duplicate pin 1 (original at (3,0))
pm = parse_pinmap(cells)
vr = validate_pinmap(pm)
assert not vr.is_valid
@@ -108,7 +123,7 @@ def test_duplicate_numbers():
def test_gap_in_numbers():
cells = dict(cells_4x4)
cells[(6, 2)] = "10" # skip 3
cells[(8, 2)] = "10" # skip pin 6 (was "6" at (8,2))
pm = parse_pinmap(cells)
vr = validate_pinmap(pm)
assert not vr.is_valid
@@ -162,31 +177,28 @@ def test_rectangular_parse():
def test_12pin_square():
"""A larger square: 12 pins on a 6×6 grid (rows 1-5, cols 0-5).
"""A 3×3 square: 12 pins (3 pins per edge).
Using Layout B: top numbers at row 0, top names at row 1.
left: 1,2,3 bottom: 4,5,6 right: 7,8,9 top: 12,11,10
"""
cells = {
(0, 0): "QFP-12",
# left (col 0) — names at col 1
(1, 0): "1", (1, 1): "VCC",
(2, 0): "2", (2, 1): "GND",
(3, 0): "3", (3, 1): "IN1",
# bottom (row 5) — names at row 4
(5, 1): "4", (4, 1): "IN2",
(5, 2): "5", (4, 2): "OUT1",
(5, 3): "6", (4, 3): "OUT2",
# right (col 5) — names at col 4
(4, 5): "7", (4, 4): "CTL1",
(3, 5): "8", (3, 4): "CTL2",
(2, 5): "9", (2, 4): "NC1",
# top (row 1) — names at row 2, cols 2-4 (avoid col 5 corner)
(1, 4): "10", (2, 4): "VDD",
(1, 3): "11", (2, 3): "VSS",
(1, 2): "12", (2, 2): "RST",
# top Numbers (row 0, cols 2..4)
(0, 2): "12", (0, 3): "11", (0, 4): "10",
# top Names (row 1, cols 2..4)
(1, 2): "RST", (1, 3): "VSS", (1, 4): "VDD",
# left (col 0) — names at col 1, rows 2..4
(2, 0): "1", (2, 1): "VCC",
(3, 0): "2", (3, 1): "GND",
(4, 0): "3", (4, 1): "IN1",
# bottom Names (row 5), Numbers (row 6), cols 2..4
(5, 2): "IN2", (5, 3): "OUT1", (5, 4): "OUT2",
(6, 2): "4", (6, 3): "5", (6, 4): "6",
# right (col 6 Number, col 5 Name) — bottom to top: 7, 8, 9
(4, 5): "CTL1", (4, 6): "7",
(3, 5): "CTL2", (3, 6): "8",
(2, 5): "NC1", (2, 6): "9",
}
# Note: (2,4) is used as name for both pin 9 (right edge) and pin 10 (top edge).
# The name_map will have the last writer win. This is fine for the test —
# we just verify the correct number of pins and their order.
pm = parse_pinmap(cells)
assert len(pm.pins) == 12, f"expected 12, got {len(pm.pins)}"
@@ -215,6 +227,787 @@ def test_12pin_square():
print("✓ test_12pin_square passed")
# ── F012: PinMAP 生成中上/下边 PinName 位置验证 ────────────
def test_f012_pinname_position():
"""验证 PinList→PinMAP 时各边 PinName 位置正确v1.5.5 布局)。
v1.5.5 布局:
- 左边 Name 在 (2..rows+1, 1)
- 下边 Name 在 (rows+2, 1..cols) ← 倒数第二行
- 右边 Name 在 (rows+1..2, cols)
- 上边 Name 在 (0, c),即独立行,无需角点例外
测试策略:
1. 构建 5×520 PinPinList 数据
2. 生成 PinMAP
3. 检查输出 cell 位置
4. 将生成的 PinMAP 再解析回 PinList做往返一致性验证
"""
# ── 1. 构建 5×5 PinList 数据20 个引脚) ──────────────────
rows, cols = 5, 5
entries = [
PinListEntry(number=n + 1, name=f"PIN{n + 1}")
for n in range(20)
]
package_info = "QFN-20"
# ── 2. 生成 PinMAP ────────────────────────────────────────
data = generate_pinmap(
entries=entries,
rows=rows,
cols=cols,
package_info=package_info,
template_style=None,
output_path=None,
)
# ── 3. 检查单元格位置 (BUG-007 Layout B) ────────────────
# 5×5: rows=5, cols=5, 20 pins
# 上边: Number (0, 6..2), Name (1, 6..2)
# 左边: Number (2..6, 0), Name (2..6, 1)
# 下边: Name (7, 2..6), Number (8, 2..6)
# 右边: Number (6..2, 8), Name (6..2, 7)
# ── 3a. 验证上边 Name 位置 (1, 2..cols+1) ──────────────
for c in range(2, cols + 2):
num_ref = rc_to_cell_ref(0, c) # Number at row 0
name_ref = rc_to_cell_ref(1, c) # Name at row 1
assert num_ref in data, f"上边 Number {num_ref} 缺失"
assert name_ref in data, (
f"上边 Name 应在 {name_ref} (row 1), 但未找到。Number 在 {num_ref}"
)
# ── 3b. 验证下边 Name 位置 (rows+2=7, 2..cols+1) ─────
for c in range(2, cols + 2):
num_ref = rc_to_cell_ref(rows + 3, c) # Number at row 8
name_ref = rc_to_cell_ref(rows + 2, c) # Name at row 7
assert num_ref in data, f"下边 Number {num_ref} 缺失"
assert name_ref in data, (
f"下边 Name 应在 {name_ref} (rows+2), 但未找到。Number 在 {num_ref}"
)
# ── 3c. 验证左边 Name 位置 (2..6, 1) ───────────────────
for r in range(2, rows + 2):
num_ref = rc_to_cell_ref(r, 0)
name_ref = rc_to_cell_ref(r, 1)
assert num_ref in data, f"左边 Number {num_ref} 缺失"
assert name_ref in data, f"左边 Name {name_ref} 缺失"
# ── 3d. 验证右边 Name 位置 (6..2, 7) ────────────────────
for r in range(rows + 1, 1, -1):
num_ref = rc_to_cell_ref(r, cols + 3)
name_ref = rc_to_cell_ref(r, cols + 2)
assert num_ref in data, f"右边 Number {num_ref} 缺失"
assert name_ref in data, f"右边 Name {name_ref} 缺失"
# ── 4. 往返一致性验证PinMAP → PinList────────────────────
from utils import cell_ref_to_rc
cell_data = {}
for ref, value in data.items():
cell_data[cell_ref_to_rc(ref)] = value
pm = parse_pinmap(cell_data)
assert len(pm.pins) == 20, f"往返: 预期 20 引脚,实际 {len(pm.pins)}"
actual_numbers = sorted([p.number for p in pm.pins])
expected_numbers = list(range(1, 21))
assert actual_numbers == expected_numbers, (
f"往返: 引脚序号不匹配\n"
f" 预期: {expected_numbers}\n"
f" 实际: {actual_numbers}"
)
validation = validate_pinmap(pm)
assert validation.is_valid, (
f"往返验证失败: 错误={[e.message for e in validation.errors]}"
)
pinlist = generate_pinlist(pm, validation)
assert len(pinlist.rows) == 20, (
f"往返 PinList: 预期 20 行,实际 {len(pinlist.rows)}"
)
for i, (name, num) in enumerate(pinlist.rows):
expected_num = i + 1
assert num == expected_num, (
f"往返 PinList row[{i}]: 预期序号 {expected_num},实际 {num}"
)
print(f"✓ test_f012_pinname_position passed (5×5={len(pm.pins)} pins)")
# ── v1.5: Template path generation tests ──────────────────────────
def test_template_path_generation():
"""验证两个模板查找函数返回正确的路径格式。"""
from main import _find_pinlist_template_path, _find_pinmap_template_path
result1 = _find_pinlist_template_path()
result2 = _find_pinmap_template_path()
# 返回值要么是 str 要么是 None
assert result1 is None or isinstance(result1, str)
assert result2 is None or isinstance(result2, str)
# 两者应该是不同路径
if result1 and result2:
assert "PinList" in result1
assert "PinMAP" in result2
assert result1 != result2
print("✓ test_template_path_generation passed")
def test_f011_default_styles_xml():
"""F011: 无模板时 _styles_xml() 返回硬编码默认样式。"""
from xlsx_writer import StyledXLSXWriter
writer = StyledXLSXWriter(style=None)
xml = writer._styles_xml()
# 验证硬编码默认值的存在
assert 'Calibri' in xml, "默认字体应为 Calibri"
assert 'thin' in xml, "默认边框应为 thin"
assert 'center' in xml, "默认对齐应为 center"
assert 'cellXfs count="4"' in xml, "应有 4 个 xf"
print("✓ test_f011_default_styles_xml passed")
def test_f011_template_fonts_in_styles_xml():
"""F011: 有模板时 _styles_xml() 使用模板的字体信息。"""
from template_reader import TemplateStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
# 构建一个模板样式:微软雅黑 12pt
style = TemplateStyle()
style.fonts = [
FontStyle(name="微软雅黑", size=12.0, bold=False, italic=False, color="FF000000"),
FontStyle(name="微软雅黑", size=12.0, bold=True, italic=False, color="FF000000"),
]
style.fills = []
style.borders = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
assert '微软雅黑' in xml, f"模板字体名应出现在 styles.xml 中\n{xml[:500]}"
assert '12' in xml or '12.0' in xml, f"模板字号 12pt 应出现在 styles.xml 中"
print("✓ test_f011_template_fonts_in_styles_xml passed")
def test_f011_output_dims_determined_by_pins():
"""F011: 输出文件的 dim 由实际 Pin 数量决定,不复制模板的行列结构。"""
from template_reader import TemplateStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [FontStyle(name="Calibri", size=11.0)]
style.column_widths = {i: 20.0 for i in range(100)} # 模板有 100 列
style.row_heights = {i: 30.0 for i in range(200)} # 模板有 200 行
style.fills = []
style.borders = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
]
# 仅输出 2 行 2 列的数据(模拟 2×2 PinMAP + A1
data = {
'A1': 'QFP-8',
'A2': '1', 'B2': 'Pin1',
'A3': '2', 'B3': 'Pin2',
}
writer = StyledXLSXWriter(style=style)
sheet_xml = writer._sheet_xml(data)
# dim 应该反映实际数据范围A1:B3而非模板的 100 列
assert 'dimension ref="A1:B3"' in sheet_xml, \
f"dim 应由实际数据决定,不应包含模板的 100 列\n{sheet_xml[:500]}"
# 不应出现 row r="201"(模板的第 200 行)
assert 'row r="201"' not in sheet_xml, "不应包含模板的多余行"
print("✓ test_f011_output_dims_determined_by_pins passed")
def test_f011_template_borders_in_styles_xml():
"""F011: 有模板时 _styles_xml() 使用模板的边框样式(而非硬编码 thin"""
from template_reader import TemplateStyle, BorderStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [FontStyle(name="Calibri", size=11.0)]
style.borders = [
BorderStyle(top="none", bottom="none", left="none", right="none"),
BorderStyle(top="medium", bottom="medium", left="medium", right="medium"),
]
style.fills = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '1', 'xfId': '0',
'applyBorder': '1'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
# 模板的 medium 边框应该存在(不仅仅是 thin
assert 'medium' in xml, f"模板 medium 边框应出现在 styles.xml 中\n{xml[:800]}"
print("✓ test_f011_template_borders_in_styles_xml passed")
def test_f011_template_fills_in_styles_xml():
"""F011: 有模板时 _styles_xml() 使用模板的填充色。"""
from template_reader import TemplateStyle, FillStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [FontStyle(name="Calibri", size=11.0)]
style.borders = []
style.fills = [
FillStyle(pattern_type="none", fg_color=""),
FillStyle(pattern_type="solid", fg_color="FFFF00"), # 黄色
]
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
{'numFmtId': '0', 'fontId': '0', 'fillId': '1', 'borderId': '0', 'xfId': '0',
'applyFill': '1'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
assert 'FFFF00' in xml, f"模板黄色填充应出现在 styles.xml 中\n{xml[:800]}"
print("✓ test_f011_template_fills_in_styles_xml passed")
def test_template_empty_fonts_fallback():
"""边界测试:空 fonts 回退到默认字体。"""
from template_reader import TemplateStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [] # 空 fonts
style.fills = []
style.borders = []
style.cell_xfs = []
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
# 应回退到默认样式Calibri 11pt
assert 'Calibri' in xml, "空 fonts 应回退到默认 Calibri"
assert 'thin' in xml, "空 borders 应回退到默认 thin"
assert 'cellXfs count="4"' in xml, "应有 4 个 xf"
print("✓ test_template_empty_fonts_fallback passed")
def test_template_color_prefix_auto_fix():
"""边界测试FF 前缀补全。"""
from template_reader import TemplateStyle, FontStyle, FillStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
# color 缺少 FF 前缀
style.fonts = [FontStyle(name="Calibri", size=11.0, color="000000")]
style.fills = [
FillStyle(pattern_type="none"),
FillStyle(pattern_type="solid", fg_color="FFFF00"), # 已有 FF
]
style.borders = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
# 即使原始 color 是 "000000",输出也应是 "FF000000"
assert 'FF000000' in xml, f"color 应自动补全 FF 前缀\n{xml[:800]}"
print("✓ test_template_color_prefix_auto_fix passed")
# ── F016 + F017: QFN60 端到端测试15×15 网格60 引脚)────────
# QFN60 15×15 PinList 数据
QFN60_PINLIST_ENTRIES = [
PinListEntry(number=i, name=f"Pin{i}")
for i in range(1, 61)
]
QFN60_PACKAGE_INFO = "QFN60"
QFN60_ROWS = 15
QFN60_COLS = 15
def test_f017_qfn60_map_to_list():
"""F017: 解析 Layout B用户真实布局QFN60 PinMAP → PinList。
15×15 网格60 引脚环形布局。
布局 BBUG-007
Top Number 在 row 0Top Name 在 row 1
Left 从 row 2 开始
Bottom Name 在 row 17Bottom Number 在 row 18
Right 从 row 16 到 row 2
验收标准:
- 解析出 60 个引脚
- 四边各 15 个引脚
- 所有引脚序号 1..60 完整
- 封装信息 "QFN60" 正确
- Top 边全部识别F013 修复验证)
"""
# ── 构建 QFN60 Layout B cells ───────────────────────────
cells: dict[tuple[int, int], str] = {}
cells[(0, 0)] = QFN60_PACKAGE_INFO
# Top: Number at row 0, Name at row 1 (Layout B)
# 从右到左Pin60 在右 (col 16)Pin46 在左 (col 2)
for i, c in enumerate(range(QFN60_COLS + 1, 1, -1)):
pin_num = 46 + i
cells[(0, c)] = str(pin_num) # Top Number
cells[(1, c)] = f"Pin{pin_num}" # Top Name
# Left: Pin1..Pin15, Number at col 0, Name at col 1
# 从 row 2 开始
for i in range(QFN60_ROWS):
r = 2 + i
cells[(r, 0)] = str(i + 1)
cells[(r, 1)] = f"Pin{i + 1}"
# Right: Pin31..Pin45, Number at col 18, Name at col 17
# 从下往上 (row 16→2)
for i in range(QFN60_ROWS):
r = QFN60_ROWS + 1 - i # 16, 15, ..., 2
cells[(r, QFN60_COLS + 3)] = str(31 + i)
cells[(r, QFN60_COLS + 2)] = f"Pin{31 + i}"
# Bottom: Pin16..Pin30
# Name at row 17 (rows+2), Number at row 18 (rows+3)
for i in range(QFN60_COLS):
c = 2 + i
cells[(QFN60_ROWS + 2, c)] = f"Pin{16 + i}" # Name: row 17
cells[(QFN60_ROWS + 3, c)] = str(16 + i) # Number: row 18
# ── 解析 ──────────────────────────────────────────────
pm = parse_pinmap(cells)
# ── 验证 ──────────────────────────────────────────────
assert pm.package_info == QFN60_PACKAGE_INFO, (
f"封装信息应为 {QFN60_PACKAGE_INFO},实际: {pm.package_info}"
)
assert len(pm.pins) == 60, (
f"应解析出 60 个引脚,实际: {len(pm.pins)}"
)
# 四边各 15 个
from collections import Counter
edges = Counter(p.edge for p in pm.pins)
for edge_name in ("left", "bottom", "right", "top"):
assert edges.get(edge_name, 0) == 15, (
f"{edge_name} 边应有 15 个引脚,实际: {edges.get(edge_name, 0)}"
)
# 所有序号 1..60 完整
numbers = sorted(p.number for p in pm.pins)
assert numbers == list(range(1, 61)), (
f"引脚序号应完整 1..60\n缺失: {sorted(set(range(1,61)) - set(numbers))}"
)
# ── 验证 Top 边F013 关键检查)──────────────────────
top_pins = [(p.number, p.name) for p in pm.pins if p.edge == "top"]
top_pins.sort()
expected_top = [(i, f"Pin{i}") for i in range(46, 61)]
assert top_pins == expected_top, (
f"Top 边引脚不匹配\n预期: {expected_top}\n实际: {top_pins}"
)
# ── 验证所有 PinName ─────────────────────────────────
for p in pm.pins:
assert p.name == f"Pin{p.number}", (
f"Pin{p.number} 的名称应为 Pin{p.number},实际: {p.name}"
)
# ── 验证器 ───────────────────────────────────────────
vr = validate_pinmap(pm)
assert vr.is_valid, (
f"PinMAP 验证失败\n错误: {[e.message for e in vr.errors]}"
)
# ── 生成 PinList ─────────────────────────────────────
pl = generate_pinlist(pm, vr)
assert len(pl.rows) == 60, (
f"PinList 应有 60 行,实际: {len(pl.rows)}"
)
assert pl.package_info == QFN60_PACKAGE_INFO
# PinList 按序号排序
for i, (name, num) in enumerate(pl.rows):
expected_num = i + 1
assert num == expected_num, (
f"PinList row[{i}]: 预期序号 {expected_num},实际 {num}"
)
assert name == f"Pin{expected_num}", (
f"PinList row[{i}]: 预期名称 Pin{expected_num},实际 {name}"
)
print(f"✓ test_f017_qfn60_map_to_list passed (60 pins, Layout B)")
def test_f017_qfn60_map_to_list_layout_a():
"""F017 补充: 解析 Layout Av1.5.5 生成布局QFN60 PinMAP → PinList。
验证自动检测对两种布局均正确工作。
Layout A: Top Name 在 row 0Top Number 在 row 1。
"""
# 使用生成器生成标准 Layout A 的 cells
data = generate_pinmap(
entries=QFN60_PINLIST_ENTRIES,
rows=QFN60_ROWS,
cols=QFN60_COLS,
package_info=QFN60_PACKAGE_INFO,
template_style=None,
output_path=None,
)
from utils import cell_ref_to_rc
cells = {cell_ref_to_rc(ref): val for ref, val in data.items()}
pm = parse_pinmap(cells)
assert len(pm.pins) == 60, f"应解析出 60 个引脚,实际: {len(pm.pins)}"
assert pm.package_info == QFN60_PACKAGE_INFO
numbers = sorted(p.number for p in pm.pins)
assert numbers == list(range(1, 61)), (
f"引脚序号不完整: 缺失 {sorted(set(range(1,61)) - set(numbers))}"
)
# Top 边验证
top_pins = sorted([(p.number, p.name) for p in pm.pins if p.edge == "top"])
expected_top = [(i, f"Pin{i}") for i in range(46, 61)]
assert top_pins == expected_top, f"Layout A Top 边: {top_pins} != {expected_top}"
vr = validate_pinmap(pm)
assert vr.is_valid
pl = generate_pinlist(pm, vr)
assert len(pl.rows) == 60
print(f"✓ test_f017_qfn60_map_to_list_layout_a passed (60 pins, Layout A)")
def test_f016_qfn60_list_to_map():
"""F016: 从 PinList 生成 QFN60 PinMAP验证 60 引脚完整。
验收标准:
- 生成 121 个单元格A1 + 60 Name + 60 Number无边角共享
- A1 = "QFN60"
- 所有 60 个引脚都有 Name 和 Number 单元格
- 四边布局正确left/bottom/right/top 各 15 个)
- Layout B: Top Number 在 row 0Top Name 在 row 1
"""
data = generate_pinmap(
entries=QFN60_PINLIST_ENTRIES,
rows=QFN60_ROWS,
cols=QFN60_COLS,
package_info=QFN60_PACKAGE_INFO,
template_style=None,
output_path=None,
)
# ── 验证单元格总数 ───────────────────────────────────
# A1 + 60 Names + 60 Numbers = 121无边角共享
assert len(data) == 121, (
f"应有 121 个单元格 (1 A1 + 60 Name + 60 Number),实际: {len(data)}"
)
# ── 验证 A1 ──────────────────────────────────────────
assert data.get("A1") == QFN60_PACKAGE_INFO, (
f"A1 应为 {QFN60_PACKAGE_INFO},实际: {data.get('A1')}"
)
# ── 验证 row 0 包含 A1 标题和上边 Number ────────────
# Layout B: row 0 = A1 标题 + Top Number 单元格col 2..16
from utils import cell_ref_to_rc
for ref, val in data.items():
r, c = cell_ref_to_rc(ref)
if r == 0:
# A1 是标题,其他 row 0 单元格是 Top Number
if ref != "A1":
assert val.isdigit() or "/" in val, (
f"row 0 非 A1 单元格 {ref} 应为 Number实际: {val}"
)
# ── 验证所有 60 个引脚都有 Name 和 Number ───────────
name_cells = {}
num_cells = {}
for ref, val in data.items():
if ref == "A1":
continue
r, c = cell_ref_to_rc(ref)
if val.startswith("Pin"):
name_cells[(r, c)] = val
elif val.isdigit() or "/" in val:
num_cells[(r, c)] = val
# 60 个 Name
assert len(name_cells) == 60, (
f"应有 60 个 Name 单元格,实际: {len(name_cells)}"
)
# 60 个 Number无角点共享时每个序号独占单元格
assert len(num_cells) == 60, (
f"应有 60 个 Number 单元格,实际: {len(num_cells)}"
)
# 验证所有 Name 正确
for (r, c), val in name_cells.items():
assert val.startswith("Pin"), f"Name 单元格 ({r},{c}) 值异常: {val}"
# 验证所有 Number 正确1..60
all_numbers = set()
for (r, c), val in num_cells.items():
for part in val.split("/"):
if part.strip().isdigit():
all_numbers.add(int(part.strip()))
assert all_numbers == set(range(1, 61)), (
f"Number 单元格应覆盖 1..60\n缺失: {sorted(set(range(1,61)) - all_numbers)}"
)
# ── 验证四边布局BUG-007 Layout B──────────────────
# Layout B:
# Title: A1 (row 0 only)
# Top Numbers: (0, 2..16)
# Top Names: (1, 2..16)
# Left: Number (2..16, 0), Name (2..16, 1)
# Bottom: Name (17, 2..16), Number (18, 2..16)
# Right: Number (16..2, 18), Name (16..2, 17)
# Top Numbers 在 row 0, col 2..16
for c in range(2, QFN60_COLS + 2):
ref = rc_to_cell_ref(0, c)
assert ref in data, f"Top Number {ref} 缺失"
# Top Names 在 row 1, col 2..16
for c in range(2, QFN60_COLS + 2):
ref = rc_to_cell_ref(1, c)
assert ref in data, f"Top Name {ref} 缺失"
assert data[ref].startswith("Pin"), f"Top Name {ref} = {data[ref]}"
# Left Numbers 在 col 0, rows 2..16
for r in range(2, QFN60_ROWS + 2):
ref = rc_to_cell_ref(r, 0)
assert ref in data, f"Left Number {ref} 缺失"
# Left Names 在 col 1, rows 2..16
for r in range(2, QFN60_ROWS + 2):
ref = rc_to_cell_ref(r, 1)
assert ref in data, f"Left Name {ref} 缺失"
assert data[ref].startswith("Pin"), f"Left Name {ref} = {data[ref]}"
# Bottom Names 在 row 17, col 2..16
for c in range(2, QFN60_COLS + 2):
ref = rc_to_cell_ref(QFN60_ROWS + 2, c)
assert ref in data, f"Bottom Name {ref} 缺失"
assert data[ref].startswith("Pin"), f"Bottom Name {ref} = {data[ref]}"
# Bottom Numbers 在 row 18, col 2..16
for c in range(2, QFN60_COLS + 2):
ref = rc_to_cell_ref(QFN60_ROWS + 3, c)
assert ref in data, f"Bottom Number {ref} 缺失"
# Right Numbers 在 col 18, rows 16..2
for r in range(QFN60_ROWS + 1, 1, -1):
ref = rc_to_cell_ref(r, QFN60_COLS + 3)
assert ref in data, f"Right Number {ref} 缺失"
# Right Names 在 col 17, rows 16..2
for r in range(QFN60_ROWS + 1, 1, -1):
ref = rc_to_cell_ref(r, QFN60_COLS + 2)
assert ref in data, f"Right Name {ref} 缺失"
assert data[ref].startswith("Pin"), f"Right Name {ref} = {data[ref]}"
print(f"✓ test_f016_qfn60_list_to_map passed (60 pins, BUG-007 layout)")
def test_f017_roundtrip():
"""F017 往返: MAP→List→MAP验证数据不丢失。
将 QFN60 PinMAPLayout B解析为 PinList
再从 PinList 重新生成 PinMAP解析第二次
验证两次解析结果一致。
"""
# ── Step 1: 构建 QFN60 Layout B cells ────────────────
cells: dict[tuple[int, int], str] = {}
cells[(0, 0)] = QFN60_PACKAGE_INFO
for i, c in enumerate(range(QFN60_COLS + 1, 1, -1)):
pin_num = 46 + i
cells[(0, c)] = str(pin_num)
cells[(1, c)] = f"Pin{pin_num}"
for i in range(QFN60_ROWS):
r = 2 + i
cells[(r, 0)] = str(i + 1)
cells[(r, 1)] = f"Pin{i + 1}"
for i in range(QFN60_ROWS):
r = QFN60_ROWS + 1 - i
cells[(r, QFN60_COLS + 3)] = str(31 + i)
cells[(r, QFN60_COLS + 2)] = f"Pin{31 + i}"
for i in range(QFN60_COLS):
c = 2 + i
cells[(QFN60_ROWS + 2, c)] = f"Pin{16 + i}"
cells[(QFN60_ROWS + 3, c)] = str(16 + i)
# ── Step 2: MAP → List ───────────────────────────────
pm1 = parse_pinmap(cells)
vr1 = validate_pinmap(pm1)
assert vr1.is_valid
pl = generate_pinlist(pm1, vr1)
assert len(pl.rows) == 60
# ── Step 3: List → MAP使用 generator──────────────
entries2 = [PinListEntry(number=num, name=name) for name, num in pl.rows]
data2 = generate_pinmap(
entries=entries2,
rows=QFN60_ROWS,
cols=QFN60_COLS,
package_info=pl.package_info,
template_style=None,
output_path=None,
)
# ── Step 4: MAP → List第二次解析─────────────────
from utils import cell_ref_to_rc
cells2 = {cell_ref_to_rc(ref): val for ref, val in data2.items()}
pm2 = parse_pinmap(cells2)
vr2 = validate_pinmap(pm2)
assert vr2.is_valid
pl2 = generate_pinlist(pm2, vr2)
# ── Step 5: 验证一致性 ───────────────────────────────
assert len(pl2.rows) == 60, (
f"往返后 PinList 应有 60 行,实际: {len(pl2.rows)}"
)
# 两轮解析的引脚信息应一致
pins1 = sorted([(p.number, p.name, p.edge) for p in pm1.pins])
pins2 = sorted([(p.number, p.name, p.edge) for p in pm2.pins])
assert pins1 == pins2, (
f"往返后引脚数据不一致\n原始: {pins1[:10]}...\n往返: {pins2[:10]}..."
)
# PinList 内容应一致(注意:往返后布局可能变 Layout A
# 但 PinList 内容按序号排序应完全一致)
for i, ((n1, num1), (n2, num2)) in enumerate(zip(pl.rows, pl2.rows)):
assert num1 == num2 == i + 1, (
f"往返 PinList row[{i}]: 序号 {num1} vs {num2}"
)
assert n1 == n2, (
f"往返 PinList row[{i}]: 名称 {n1} vs {n2}"
)
assert pl.package_info == pl2.package_info
print(f"✓ test_f017_roundtrip passed (Layout B→List→Layout A, 60 pins)")
def test_f016_roundtrip():
"""F016 往返: List→MAP→List验证数据不丢失。
从 60-pin PinList 生成 PinMAP再解析回 PinList
验证引脚数据完全一致。
"""
# ── Step 1: List → MAP ───────────────────────────────
data = generate_pinmap(
entries=QFN60_PINLIST_ENTRIES,
rows=QFN60_ROWS,
cols=QFN60_COLS,
package_info=QFN60_PACKAGE_INFO,
template_style=None,
output_path=None,
)
# ── Step 2: MAP → List ───────────────────────────────
from utils import cell_ref_to_rc
cells = {cell_ref_to_rc(ref): val for ref, val in data.items()}
pm = parse_pinmap(cells)
vr = validate_pinmap(pm)
assert vr.is_valid, f"验证失败: {[e.message for e in vr.errors]}"
pl = generate_pinlist(pm, vr)
# ── Step 3: 验证往返一致性 ─────────────────────────
assert len(pl.rows) == 60, (
f"往返后应有 60 行,实际: {len(pl.rows)}"
)
assert pl.package_info == QFN60_PACKAGE_INFO
# 逐行验证
for i, (name, num) in enumerate(pl.rows):
expected_num = i + 1
assert num == expected_num, (
f"往返 row[{i}]: 预期序号 {expected_num},实际 {num}"
)
assert name == f"Pin{expected_num}", (
f"往返 row[{i}]: 预期名称 Pin{expected_num},实际 {name}"
)
# ── Step 4: 再从 PinList → MAP 验证一致性 ──────────
entries2 = [PinListEntry(number=num, name=name) for name, num in pl.rows]
data2 = generate_pinmap(
entries=entries2,
rows=QFN60_ROWS,
cols=QFN60_COLS,
package_info=pl.package_info,
template_style=None,
output_path=None,
)
# 两次生成的 PinMAP 应一致
assert len(data) == len(data2), (
f"两次生成 PinMAP 单元格数不一致: {len(data)} vs {len(data2)}"
)
for ref in data:
assert ref in data2, f"第二次生成缺失单元格: {ref}"
assert data[ref] == data2[ref], (
f"单元格 {ref} 值不一致: {data[ref]} vs {data2[ref]}"
)
print(f"✓ test_f016_roundtrip passed (List→MAP→List→MAP, 60 pins)")
def test_template_no_styles_xml():
"""边界测试:缺失 styles.xml 时优雅降级。"""
from template_reader import read_template_styles
import tempfile, os
import zipfile
tmpdir = tempfile.mkdtemp()
try:
bad_path = os.path.join(tmpdir, "no_styles.xlsx")
with zipfile.ZipFile(bad_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/></Types>')
zf.writestr('xl/worksheets/sheet1.xml', '<?xml version="1.0"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData/></worksheet>')
style = read_template_styles(bad_path)
assert style is not None, "缺失 styles.xml 不应导致 read_template_styles 返回 None"
assert len(style.fonts) == 0, "无 styles.xmlfont 列表应为空"
assert len(style.fills) == 0, "无 styles.xmlfill 列表应为空"
print("✓ test_template_no_styles_xml passed")
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
if __name__ == "__main__":
test_4x4_parse()
test_4x4_validate()
@@ -224,4 +1017,21 @@ if __name__ == "__main__":
test_empty_cells()
test_no_pins()
test_12pin_square()
test_f012_pinname_position()
# v1.5 新增测试
test_template_path_generation()
test_f011_default_styles_xml()
test_f011_template_fonts_in_styles_xml()
test_f011_output_dims_determined_by_pins()
test_f011_template_borders_in_styles_xml()
test_f011_template_fills_in_styles_xml()
test_template_empty_fonts_fallback()
test_template_color_prefix_auto_fix()
test_template_no_styles_xml()
# v1.6 F016 + F017: QFN60 端到端测试
test_f017_qfn60_map_to_list()
test_f017_qfn60_map_to_list_layout_a()
test_f016_qfn60_list_to_map()
test_f017_roundtrip()
test_f016_roundtrip()
print("\n✅ All tests passed!")

View File

@@ -101,3 +101,89 @@ def validate_pinmap(pinmap: PinMAP) -> ValidationResult:
result.is_valid = False
return result
def validate_pinlist_for_map(
entries: list,
rows: int,
cols: int,
) -> ValidationResult:
"""验证 PinList 数据是否适合转换为 PinMAP。
Checks performed
----------------
1. **Continuity** — pin numbers must start from 1 with no gaps.
2. **Uniqueness** — no duplicate pin numbers.
3. **Perimeter match** — total pin count must equal
(rows + cols) × 2 (the grid perimeter).
4. **Non-multiple-of-4** — if pin count is not a multiple of 4,
a warning is issued (but conversion is not blocked).
Parameters
----------
entries : list[PinListEntry]
PinList entries, each with ``number`` and ``name`` attributes.
rows : int
Target PinMAP row count.
cols : int
Target PinMAP column count.
Returns
-------
ValidationResult
"""
result = ValidationResult(is_valid=True, errors=[], warnings=[])
numbers = [e.number for e in entries]
# ── 1. Continuity (1..N, no gaps) ────────────────────────────
expected_numbers = list(range(1, len(numbers) + 1))
if numbers != expected_numbers:
missing = set(expected_numbers) - set(numbers)
if missing:
result.errors.append(ValidationError(
level="error",
message="Pin序号不连续",
details=f"缺失的序号: {sorted(missing)}",
))
# ── 2. Uniqueness ────────────────────────────────────────────
if len(numbers) != len(set(numbers)):
counts = Counter(numbers)
duplicates = sorted(n for n, c in counts.items() if c > 1)
result.errors.append(ValidationError(
level="error",
message="Pin序号存在重复",
details=f"重复的序号: {duplicates}",
))
# ── 3. Perimeter match ───────────────────────────────────────
# 周长公式:(rows + cols) * 2
expected_total = (rows + cols) * 2
actual_total = len(entries)
if actual_total != expected_total:
result.errors.append(ValidationError(
level="error",
message="Pin数量与网格周长不匹配",
details=(
f"网格 {rows}×{cols} 需要 {expected_total} 个引脚,"
f"但 PinList 有 {actual_total}"
),
))
# ── 4. Non-multiple-of-4 warning ─────────────────────────────
if actual_total % 4 != 0:
result.warnings.append(ValidationError(
level="warning",
message="Pin数量不是4的倍数",
details=(
f"Pin数量 ({actual_total}) 不是 4 的倍数,"
f"四条边将不均匀分布"
),
))
# ── Final verdict ────────────────────────────────────────────
if result.errors:
result.is_valid = False
return result

View File

@@ -154,3 +154,485 @@ class XLSXWriter:
col -= 1
row = int(''.join(row_digits)) - 1
return row, col
# ── Styled writer (for PinMAP output with template styles) ─────────
def write_xlsx_with_style(
data: dict[str, str],
output_path: str,
style=None, # TemplateStyle | None
):
"""Write a cell map to an .xlsx file with optional template styling.
Parameters
----------
data : dict[str, str]
Mapping of Excel cell references to values.
output_path : str
Path for the output .xlsx file.
style : TemplateStyle | None
Template style extracted by template_reader. If None,
uses minimal default styling (centered, default font).
"""
writer = StyledXLSXWriter(style)
writer.write(data, output_path)
class StyledXLSXWriter:
"""Build a styled OOXML .xlsx file from a cell map."""
def __init__(self, style=None):
self._strings: list[str] = []
self._string_index: dict[str, int] = {}
self._style = style # TemplateStyle | None
def write(self, data: dict[str, str], output_path: str):
"""Write *data* to *output_path* as a styled .xlsx file."""
for value in data.values():
self._add_string(value)
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('[Content_Types].xml', self._content_types_xml())
zf.writestr('_rels/.rels', self._rels_xml())
zf.writestr('xl/workbook.xml', self._workbook_xml())
zf.writestr('xl/_rels/workbook.xml.rels', self._workbook_rels_xml())
zf.writestr('xl/styles.xml', self._styles_xml())
zf.writestr('xl/sharedStrings.xml', self._shared_strings_xml())
zf.writestr('xl/worksheets/sheet1.xml', self._sheet_xml(data))
def _add_string(self, s: str) -> int:
"""Add a string to the SST and return its index."""
if s in self._string_index:
return self._string_index[s]
idx = len(self._strings)
self._strings.append(s)
self._string_index[s] = idx
return idx
# ── XML builders ───────────────────────────────────────────────
def _content_types_xml(self) -> str:
return '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>'''
def _rels_xml(self) -> str:
return '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>'''
def _workbook_xml(self) -> str:
return '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>'''
def _workbook_rels_xml(self) -> str:
return '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>'''
def _shared_strings_xml(self) -> str:
parts = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
parts.append(
f'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
f'count="{len(self._strings)}" unique="{len(self._strings)}">'
)
for s in self._strings:
escaped = s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
parts.append(f' <si><t>{escaped}</t></si>')
parts.append('</sst>')
return '\n'.join(parts)
def _styles_xml(self) -> str:
"""Build xl/styles.xml with fonts, fills, borders, and cellXfs.
F011: 有模板时从模板的 fonts / fills / borders / cellXfs 中
提取实际样式定义;无模板时回退到硬编码默认样式。
保留现有 4 个样式槽位xf 0~3
xf 0 = default (no style)
xf 1 = centered + thin border (pin cells)
xf 2 = bold + centered (A1 package info)
xf 3 = centered + border + light fill (header-like)
有模板时,四个 xf 的字体/边框/填充/对齐从模板读取。
"""
s = self._style
if s and s.fonts:
# ── 有模板:从模板提取实际值构建样式 ──────────────────
fonts_xml = self._build_fonts_xml_from_template(s.fonts)
fills_xml = self._build_fills_xml_from_template(s.fills)
borders_xml = self._build_borders_xml_from_template(s.borders)
cell_xfs_xml = self._build_cell_xfs_xml_from_template(
s.cell_xfs, s.fonts, s.fills, s.borders
)
else:
# ── 无模板回退到硬编码默认样式F011 fallback──────
fonts_xml = self._default_fonts_xml()
fills_xml = self._default_fills_xml()
borders_xml = self._default_borders_xml()
cell_xfs_xml = self._default_cell_xfs_xml()
parts = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
parts.append(
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
)
parts.append(fonts_xml)
parts.append(fills_xml)
parts.append(borders_xml)
parts.append(cell_xfs_xml)
parts.append('</styleSheet>')
return '\n'.join(parts)
# ── 模板样式构建F011─────────────────────────────────────────
@staticmethod
def _build_fonts_xml_from_template(fonts: list) -> str:
"""从模板字体列表构建 <fonts> XML。"""
parts = [f'<fonts count="{len(fonts)}">']
for f in fonts:
parts.append('<font>')
parts.append(f'<sz val="{f.size}"/>')
if f.bold:
parts.append('<b/>')
if f.italic:
parts.append('<i/>')
parts.append(f'<name val="{f.name}"/>')
color_val = f.color
if color_val and not color_val.startswith('FF'):
color_val = 'FF' + color_val
if color_val:
parts.append(f'<color rgb="{color_val}"/>')
parts.append('</font>')
parts.append('</fonts>')
return ''.join(parts)
@staticmethod
def _build_fills_xml_from_template(fills: list) -> str:
"""从模板填充列表构建 <fills> XML。"""
parts = [f'<fills count="{len(fills)}">']
for fl in fills:
parts.append('<fill>')
parts.append(f'<patternFill patternType="{fl.pattern_type}">')
if fl.fg_color:
color_val = fl.fg_color
if not color_val.startswith('FF'):
color_val = 'FF' + color_val
parts.append(f'<fgColor rgb="{color_val}"/>')
parts.append('</patternFill>')
parts.append('</fill>')
parts.append('</fills>')
return ''.join(parts)
@staticmethod
def _build_borders_xml_from_template(borders: list) -> str:
"""从模板边框列表构建 <borders> XML。"""
parts = [f'<borders count="{len(borders)}">']
for b in borders:
parts.append('<border>')
for side_name in ('left', 'right', 'top', 'bottom'):
style_val = getattr(b, side_name, 'none')
if style_val and style_val != 'none':
parts.append(f'<{side_name} style="{style_val}"/>')
else:
parts.append(f'<{side_name}/>')
parts.append('<diagonal/>')
parts.append('</border>')
parts.append('</borders>')
return ''.join(parts)
@staticmethod
def _build_cell_xfs_xml_from_template(
cell_xfs: list, fonts: list, fills: list, borders: list
) -> str:
"""从模板 cellXfs 列表构建 <cellXfs> XML。
保留 4 个样式槽位xf 0~3每个从模板对应位置的 xf 提取:
- xf 0: default (模板的 xf 0)
- xf 1: pin cells — 使用模板中带边框的 xf优先否则 fallback
- xf 2: A1 bold — 使用模板中带 bold 字体的 xf优先否则 fallback
- xf 3: header — 使用模板中带填充的 xf优先否则 fallback
"""
def _xf_to_attrs(xf: dict) -> str:
"""将 xf 信息字典转换为属性字符串。"""
attrs = [
f'numFmtId="{xf.get("numFmtId", "0")}"',
f'fontId="{xf.get("fontId", "0")}"',
f'fillId="{xf.get("fillId", "0")}"',
f'borderId="{xf.get("borderId", "0")}"',
f'xfId="{xf.get("xfId", "0")}"',
]
for attr_key in ('applyFont', 'applyFill', 'applyBorder', 'applyAlignment',
'applyNumberFormat', 'applyProtection'):
val = xf.get(attr_key, '')
if val:
attrs.append(f'{attr_key}="{val}"')
return ' '.join(attrs)
def _xf_to_alignment(xf: dict) -> str:
"""从 xf 信息字典生成对齐 XML片段。"""
h_align = xf.get('hAlign', '')
v_align = xf.get('vAlign', '')
wrap_text = xf.get('wrapText', '')
if h_align or v_align or wrap_text:
align_attrs = []
if h_align:
align_attrs.append(f'horizontal="{h_align}"')
if v_align:
align_attrs.append(f'vertical="{v_align}"')
if wrap_text:
align_attrs.append(f'wrapText="{wrap_text}"')
return f'<alignment {" ".join(align_attrs)}/>'
return ''
# 确保有足够的模板样式元素
def _get_or_default(lst, idx, default_name):
if idx < len(lst):
return idx
return 0
# ── 确定 4 个 xf 的映射 ──────────────────────────────────
# xf 0: 模板的 xf 0default
xf0 = cell_xfs[0] if len(cell_xfs) > 0 else {}
fi0 = _get_or_default(fonts, int(xf0.get('fontId', '0')), 'font')
fl0 = _get_or_default(fills, int(xf0.get('fillId', '0')), 'fill')
bd0 = _get_or_default(borders, int(xf0.get('borderId', '0')), 'border')
# xf 1: 寻找模板中有边框的 xfborderId > 0 或 applyBorder 非空)
xf1 = xf0
fi1, fl1, bd1 = fi0, fl0, bd0
for xf in cell_xfs:
bid = int(xf.get('borderId', '0'))
ab = xf.get('applyBorder', '')
if bid > 0 or ab:
xf1 = xf
fi1 = _get_or_default(fonts, int(xf1.get('fontId', '0')), 'font')
fl1 = _get_or_default(fills, int(xf1.get('fillId', '0')), 'fill')
bd1 = _get_or_default(borders, int(xf1.get('borderId', '0')), 'border')
break
# xf 2: 寻找模板中有 bold 字体的 xf
xf2 = xf0
fi2, fl2, bd2 = fi0, fl0, bd0
bold_font_id = None
for i, f in enumerate(fonts):
if f.bold:
bold_font_id = i
break
if bold_font_id is not None:
for xf in cell_xfs:
fid = int(xf.get('fontId', '0'))
if xf.get('applyFont', '') or fid == bold_font_id:
xf2 = xf
break
fi2 = bold_font_id
fl2 = _get_or_default(fills, int(xf2.get('fillId', '0')), 'fill')
bd2 = _get_or_default(borders, int(xf2.get('borderId', '0')), 'border')
# xf 3: 寻找模板中有填充的 xffillId > 0 或 applyFill 非空)
xf3 = xf0
fi3, fl3, bd3 = fi0, fl0, bd0
for xf in cell_xfs:
fid = int(xf.get('fillId', '0'))
af = xf.get('applyFill', '')
if fid > 0 or af:
xf3 = xf
fi3 = _get_or_default(fonts, int(xf3.get('fontId', '0')), 'font')
fl3 = _get_or_default(fills, int(xf3.get('fillId', '0')), 'fill')
bd3 = _get_or_default(borders, int(xf3.get('borderId', '0')), 'border')
break
# ── 构建 4 个 xf ──────────────────────────────────────────
parts = ['<cellXfs count="4">']
# xf 0: default (no style)
parts.append(f'<xf numFmtId="0" fontId="{fi0}" fillId="{fl0}" borderId="{bd0}" xfId="0"/>')
# xf 1: pin cells (border + center align)
align1 = _xf_to_alignment(xf1)
parts.append(
f'<xf numFmtId="0" fontId="{fi1}" fillId="{fl1}" borderId="{bd1}" xfId="0" applyBorder="1">'
f'{align1}'
f'</xf>'
)
# xf 2: A1 package info (bold + center align)
align2 = _xf_to_alignment(xf2) or '<alignment horizontal="center" vertical="center"/>'
parts.append(
f'<xf numFmtId="0" fontId="{fi2}" fillId="{fl2}" borderId="{bd2}" xfId="0" applyFont="1">'
f'{align2}'
f'</xf>'
)
# xf 3: header-like (fill + border + center align)
align3 = _xf_to_alignment(xf3) or '<alignment horizontal="center" vertical="center"/>'
parts.append(
f'<xf numFmtId="0" fontId="{fi3}" fillId="{fl3}" borderId="{bd3}" xfId="0" applyFill="1" applyBorder="1">'
f'{align3}'
f'</xf>'
)
parts.append('</cellXfs>')
return ''.join(parts)
# ── 默认硬编码样式(无模板时回退)────────────────────────────
@staticmethod
def _default_fonts_xml() -> str:
"""生成默认硬编码字体font 0 = Calibri 11pt, font 1 = Calibri 11pt bold。"""
return (
'<fonts count="2">'
'<font><sz val="11"/><name val="Calibri"/><color rgb="FF000000"/></font>'
'<font><sz val="11"/><b/><name val="Calibri"/><color rgb="FF000000"/></font>'
'</fonts>'
)
@staticmethod
def _default_fills_xml() -> str:
"""生成默认硬编码填充fill 0 = none, fill 1 = light gray。"""
return (
'<fills count="2">'
'<fill><patternFill patternType="none"/></fill>'
'<fill><patternFill patternType="solid"><fgColor rgb="FFF0F0F0"/></patternFill></fill>'
'</fills>'
)
@staticmethod
def _default_borders_xml() -> str:
"""生成默认硬编码边框border 0 = none, border 1 = thin all sides。"""
return (
'<borders count="2">'
'<border><left/><right/><top/><bottom/><diagonal/></border>'
'<border><left style="thin"/><right style="thin"/>'
'<top style="thin"/><bottom style="thin"/><diagonal/></border>'
'</borders>'
)
@staticmethod
def _default_cell_xfs_xml() -> str:
"""生成默认硬编码 cellXfs
xf 0 = default, xf 1 = centered+border, xf 2 = bold+centered, xf 3 = centered+border+fill。
"""
return (
'<cellXfs count="4">'
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
'<xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0" applyBorder="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
'<xf numFmtId="0" fontId="0" fillId="1" borderId="1" xfId="0" applyFill="1" applyBorder="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
'</cellXfs>'
)
def _sheet_xml(self, data: dict[str, str]) -> str:
"""Build sheet1.xml with style indices applied."""
max_row = 0
max_col = 0
for ref in data:
row, col = self._ref_to_rc(ref)
max_row = max(max_row, row)
max_col = max(max_col, col)
# Determine column widths from template
col_widths_xml = ''
if self._style and self._style.column_widths:
# Find max column with a width setting
max_width_col = max(self._style.column_widths.keys()) if self._style.column_widths else 0
max_width_col = max(max_width_col, max_col)
if max_width_col >= 0:
col_widths_xml = ' <cols>'
for c in range(max_width_col + 1):
width = self._style.column_widths.get(c, 8.0)
col_widths_xml += (
f'<col min="{c + 1}" max="{c + 1}" width="{width:.1f}" '
f'customWidth="1"/>'
)
col_widths_xml += '</cols>\n'
# Determine row heights from template
row_heights = self._style.row_heights if self._style else {}
parts = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
parts.append('<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">')
parts.append(f' <dimension ref="A1:{rc_to_cell_ref(max_row, max_col)}"/>')
if col_widths_xml:
parts.append(col_widths_xml)
parts.append(' <sheetData>')
# Group cells by row
rows: dict[int, list[tuple[int, str, int]]] = {}
for ref, value in data.items():
row, col = self._ref_to_rc(ref)
if row not in rows:
rows[row] = []
# Determine style index
style_idx = self._get_style_index(row, col, ref)
rows[row].append((col, value, style_idx))
for row_num in sorted(rows):
row_attrs = f'r="{row_num + 1}"'
if row_num in row_heights:
row_attrs += f' ht="{row_heights[row_num]:.1f}" customHeight="1"'
parts.append(f' <row {row_attrs}>')
for col, value, style_idx in sorted(rows[row_num]):
cell_ref = rc_to_cell_ref(row_num, col)
si = self._add_string(value)
parts.append(
f' <c r="{cell_ref}" t="s" s="{style_idx}">'
f'<v>{si}</v></c>'
)
parts.append(' </row>')
parts.append(' </sheetData>')
parts.append('</worksheet>')
return '\n'.join(parts)
def _get_style_index(self, row: int, col: int, ref: str) -> int:
"""Determine the style index for a cell.
Style indices:
0 = default (no style)
1 = centered + thin border (pin number/name cells)
2 = bold + centered (A1 package info)
3 = centered + border + light fill (header-like)
"""
if ref == "A1":
return 2 # Bold centered for package info
# Pin cells: all cells except A1 get border + center
return 1
@staticmethod
def _ref_to_rc(ref: str) -> tuple[int, int]:
"""Convert cell reference to (row, col) 0-based."""
col_letters = []
row_digits = []
for ch in ref:
if ch.isalpha():
col_letters.append(ch)
else:
row_digits.append(ch)
col = 0
for ch in ''.join(col_letters).upper():
col = col * 26 + (ord(ch) - ord('A') + 1)
col -= 1
row = int(''.join(row_digits)) - 1
return row, col

View File

@@ -9,6 +9,9 @@
- ✅ GUI 文件选择 + 命令行双模式
- ✅ 智能结构验证(重复/间隙/空单元格检测)
- ✅ 逆时针 PinMAP → 顺时针 PinList 自动转换
- ✅ 双向转换MAP→List 与 List→MAP
-**独立模板**MAP→List 使用 `BallList-Template.xlsx`List→MAP 使用 `BallMAP-Template.xlsx`
-**模板格式提取**:从模板读取字体、边框、填充、对齐、列宽、行高并应用到输出
## 快速开始
@@ -30,9 +33,11 @@ pinmap-to-pinlist/
│ ├── src/ # 源代码
│ └── docs/ # 架构文档
├── Test/
│ ├── fixtures/ # 测试夹具
│ ├── fixtures/ # 测试夹具(含模板文件)
│ └── test_report.md # 测试报告
├── Releases/ # 发布包
├── BallList-Template.xlsx # MAP→List 样式模板(可放置于项目根目录)
├── BallMAP-Template.xlsx # List→MAP 样式模板(可放置于项目根目录)
├── CHANGELOG.md
└── README.md
```
@@ -43,6 +48,25 @@ pinmap-to-pinlist/
- openpyxl.xlsx 读写)
- 自定义 BIFF8 引擎(.xls 解析)
## 版本历史
### v1.5.4 (2026-06-09) — Bug 修复版本
- **BUG-005**: 模板文件名修正 — `BallList-Template.xlsx``PinList-Template.xlsx``BallMAP-Template.xlsx``PinMAP-Template.xlsx`
- **BUG-006**: 布局重设计 — Number 外侧(第 1 圈)+ Name 里侧(第 2 圈),彻底解决单元格冲突问题
- 上边Number row 1Name row 2角点例外
- 左边Number col 0Name col 1
- 下边Number row rows+3Name row rows+2
- 右边Number col cols+1Name col cols
- Pin1 保持在左上角A3=1, B3=Pin1
- 18/18 单元测试 + 37/37 集成测试全部通过
### v1.5.0 (2026-06-06) — 模板分离与格式提取
- MAP→List 使用 `PinList-Template.xlsx`(旧名 `BallList-Template.xlsx`
- List→MAP 使用 `PinMAP-Template.xlsx`(旧名 `BallMAP-Template.xlsx`
- 模板格式提取:字体、边框、填充、对齐、列宽、行高
## 许可证
内部项目

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,60 @@
# Changelog — v1.5.4
> **发布日期**: 2026-06-09
> **版本类型**: Bug 修复版本
## 🐛 Bug 修复
### BUG-005 【高】模板文件名错误
**问题**: `main.py` 中引用的模板文件名(`BallList-Template.xlsx``BallMAP-Template.xlsx`)与用户期望的文件名不匹配。
**修复**:
- 模板文件重命名:`BallList-Template.xlsx``PinList-Template.xlsx`
- 模板文件重命名:`BallMAP-Template.xlsx``PinMAP-Template.xlsx`
- 同步更新 `main.py` 中的函数名和模板引用路径
### BUG-006 【高】布局重设计Number 外侧 + Name 里侧)
**问题**: PinList→PinMAP→PinList 双向转换中v1.3 的"紧致布局"导致 Number 与 Name 单元格冲突6 处15×15 网格下序号 1 错位到 A2序号 16 错位到 B16。
**根本原因**: 旧布局将 Name 放在 Number 向内偏移一行/一列的位置,边角处发生冲突。
**修复方案**: 重新设计布局为 **Number 外侧(第 1 圈)+ Name 里侧(第 2 圈)**,从网格边界往中心排列:
| 边 | 外侧(第 1 圈) | 内侧(第 2 圈) |
|---|---|---|
| **上边** | Number 在 row 1最顶行 | Name 在 row 2第二行角点例外在 row 1 |
| **左边** | Number 在 col 0最左列 | Name 在 col 1第二列 |
| **下边** | Number 在 row rows+3最底行 | Name 在 row rows+2倒数第二行 |
| **右边** | Number 在 col cols+1最右列 | Name 在 col cols右二列 |
**关键设计点**:
- **上边角点例外**: 最左/最右上边 Name 无法放在 row 2被左/右边 Name 占用),分别使用 `(1, 0)``(1, cols+1)` 例外单元格
- Pin1 保持在左上角A3=1, B3=Pin1
- 不再需要角点 `"//"` 合并 — 每条边不共享任何单元格
- 周长公式 `(rows+cols)×2` 保持不变
**验证**:
- ✅ 15 种网格大小4×4, 15×15, 3×5, 2×2, 8×8, 10×12, 20×20, 5×3, 6×7, 2×3, 3×3, 2×4, 3×2, 4×2, 2×5全部无冲突
- ✅ 18/18 单元测试通过
- ✅ 37/37 集成测试通过
## 🔧 修改文件
| 文件 | 修改内容 |
|------|---------|
| `Code/src/main.py` | BUG-005: 模板函数和引用改名BUG-006: 传递 cols 参数 |
| `Code/src/pinmap_layout.py` | BUG-006: 重写坐标公式 + `get_name_cell()` 支持 cols 参数 + 角点例外 |
| `Code/src/pinmap_generator.py` | BUG-006: 传递 cols 参数 + 更新注释 |
| `Code/src/pinmap_parser.py` | BUG-006: 重写边界检测、Name 读取(角点例外检测) |
| `Code/src/test_pinmap.py` | BUG-006: 更新测试数据适配新布局 |
| `Test/fixtures/PinList-Template.xlsx` | BUG-005: 模板文件重命名 |
| `Test/fixtures/PinMAP-Template.xlsx` | BUG-005: 模板文件重命名 |
## 📝 文档
- 更新 `CHANGELOG.md` 追加 v1.5.4 版本日志
- 更新 `README.md` 追加 v1.5.4 版本说明
- 生成 `Releases/v1.5.4/CHANGELOG.md`
- 更新 `docs/bugs.md` BUG-005、BUG-006 状态为已修复

BIN
Test/fixtures/PinList-Template.xlsx vendored Normal file

Binary file not shown.

BIN
Test/fixtures/PinMAP-Template.xlsx vendored Normal file

Binary file not shown.

Binary file not shown.

1
Test/fixtures/template_corrupt.xlsx vendored Normal file
View File

@@ -0,0 +1 @@
This is not a valid xlsx file. It's just plain text pretending to be xlsx.

BIN
Test/fixtures/template_minimal.xlsx vendored Normal file

Binary file not shown.

BIN
Test/fixtures/template_narrow.xlsx vendored Normal file

Binary file not shown.

1053
Test/run_tests.py Normal file

File diff suppressed because it is too large Load Diff

680
Test/test_plan_v1.5.md Normal file
View File

@@ -0,0 +1,680 @@
# PinMAP-to-PinList v1.5.0 — 测试方案
> **版本**: v1.5.0
> **日期**: 2026-06-06
> **设计人**: 测试架构师 (Test Architect)
> **基准**: 修改需求评估 `docs/modification-assessment-v1.5.md`
---
## 1. 变更影响范围
| 编号 | 标题 | 影响文件 | 测试重点 |
|------|------|---------|---------|
| F009 | MAP→List 使用 BallList-Template.xlsx | `main.py` | 模板分离后方向独立,互不干扰 |
| F010 | List→MAP 使用 BallMAP-Template.xlsx | `main.py` | 模板分离后方向独立,互不干扰 |
| F011 | 模板格式提取式应用 | `xlsx_writer.py`, `template_reader.py` | 字体/边框/填充/对齐/列宽/行高正确应用 |
| F012 | PinName 位置确认 + 回归测试 | `test_pinmap.py` | 上/下边 PinName 位置正确,往返一致性 |
---
## 2. 已有测试覆盖分析
### 2.1 单元测试 (`Code/src/test_pinmap.py`) — 9 个用例,全部通过
| 用例 | 覆盖范围 | v1.5 是否受影响 |
|------|---------|----------------|
| `test_4x4_parse` | 4×4 PinMAP 解析 (left/bottom/right/top) | ✅ 已验证 bottom/right Name 位置正确 |
| `test_4x4_validate` | 4×4 PinMAP 验证 | 不受影响 |
| `test_missing_names_warning` | 缺失 Name 警告 | 不受影响 |
| `test_duplicate_numbers` | 重复序号错误 | 不受影响 |
| `test_gap_in_numbers` | 序号不连续错误 | 不受影响 |
| `test_empty_cells` | 空单元格异常 | 不受影响 |
| `test_no_pins` | 无 Pin 数据异常 | 不受影响 |
| `test_12pin_square` | 6×6 12Pin 解析和验证 | 不受影响 |
| `test_f012_pinname_position` | **v1.5 新增** — 5×5 20Pin 往返一致性 | ✅ **v1.5 核心回归测试** |
### 2.2 集成测试 (`Test/run_tests.py`) — 23 个用例,全部通过
| 编号 | 用例 | v1.5 是否受影响 |
|------|------|----------------|
| TC-MAP-001~006 | MAP→List 回归(含错误/警告场景) | ⚠️ F009 影响模板查找逻辑 |
| TC-LM-001~016 | List→MAP 新增(含错误/警告/往返) | ⚠️ F010/F011 影响模板样式应用 |
### 2.3 已有测试缺口v1.5 前)
1. **无模板相关测试** — 现有集成测试仅在 `TC-LM-003` 中使用临时创建的 fake 模板验证 styles.xml 存在,未测试真实的 BallList/BallMAP 模板分离
2. **无样式正确性验证** — 现有测试仅验证 `styles.xml` 文件存在,未验证字体/边框/填充/对齐/列宽/行高的实际内容
3. **无模板降级测试** — 未测试模板不存在/解析失败时的优雅降级行为
4. **无两方向独立模板测试** — 未验证 MAP→List 和 List→MAP 使用不同模板时的隔离性
---
## 3. 完整测试方案
### 3.1 测试分层策略
| 层级 | 位置 | 测试什么 | 执行方式 |
|------|------|---------|---------|
| **Unit** | `Code/src/test_pinmap.py` | 纯逻辑测试(解析/验证/布局/往返),不依赖文件系统 | `python3 Code/src/test_pinmap.py` |
| **Integration** | `Test/run_tests.py` | 文件级测试xlsx 读写/模板加载/端到端),依赖 fixtures/ 和临时文件 | `python3 Test/run_tests.py` |
### 3.2 测试原则
1. **单元测试** (`test_pinmap.py`):纯逻辑测试,不读/写磁盘文件,使用内存中的 cell dict
2. **集成测试** (`run_tests.py`):文件级测试,使用 `Test/fixtures/` 中的真实 .xlsx 文件和临时目录中的动态创建文件
3. **模板 fixture**:需要准备 `BallList-Template.xlsx``BallMAP-Template.xlsx` 两个模板 fixture 文件
---
## 4. F012 — PinName 位置回归测试
### 4.1 状态确认
**代码当前行为**
- `pinmap_layout.py::get_name_cell`: bottom → `(r-1, c)`, top → `(r+1, c)`
- `pinmap_parser.py`: bottom name 读自 `(max_row-1, c)`, top name 读自 `(min_row+1, c)`
- 生成与解析使用**相同约定**,往返一致 ✅
**F012 测试确认**:当前代码已正确,测试已存在并全部通过。
### 4.2 已有回归测试(无需新增)
| 用例 | 位置 | 覆盖 |
|------|------|------|
| `test_f012_pinname_position` | `test_pinmap.py` | 5×5 20Pin 往返:生成 → 逐个验证四条边 Name 位置 → 解析回 PinList → 验证序号一致性 |
### 4.3 测试数据
```
test_pinmap.py 中的 4×4 数据已验证:
bottom: numbers at (6,2)=3, (6,3)=4; names at (5,2)=Pin3, (5,3)=Pin4 → max_row-1 ✅
top: numbers at (1,3)=7, (1,2)=8; names at (2,3)=Pin7, (2,2)=Pin8 → min_row+1 ✅
test_f012 5×5 数据已验证:
bottom: names at (4, c) = rows-1 = max_row-1 ✅
top: names at (2, c) = min_row+1 ✅
left: names at (r, 1) = c+1 ✅
right: names at (r, 4) = c-1 ✅
```
### 4.4 F012 测试结论
**无需新增测试**`test_f012_pinname_position` 已充分覆盖 F012 需求,且当前测试全部通过。保持该测试持续运行即可。
---
## 5. F009 + F010 — 模板分离测试
### 5.1 测试场景矩阵
| 场景 | BallList-Template | BallMAP-Template | MAP→List 预期 | List→MAP 预期 |
|------|-------------------|------------------|-------------|-------------|
| T-F009-01 | ✅ 存在 | — | 加载 BallList应用其样式 | (不涉及) |
| T-F009-02 | ❌ 不存在 | — | 日志提示"未检测到",使用默认样式 | (不涉及) |
| T-F009-03 | 存在但损坏 | — | 日志提示"解析失败",使用默认样式 | (不涉及) |
| T-F010-01 | — | ✅ 存在 | (不涉及) | 加载 BallMAP应用其样式 |
| T-F010-02 | — | ❌ 不存在 | (不涉及) | 日志提示"未检测到",使用默认样式 |
| T-F010-03 | — | 存在但损坏 | (不涉及) | 日志提示"解析失败",使用默认样式 |
| T-F009-04 | ✅ 存在 | ✅ 存在 | 加载 BallList | 加载 BallMAP各自独立互不干扰 |
### 5.2 关键验证点
1. **MAP→List 不使用 BallMAP 模板** — 即使 BallMAP-Template.xlsx 存在MAP→List 也不会加载它
2. **List→MAP 不使用 BallList 模板** — 即使 BallList-Template.xlsx 存在List→MAP 也不会加载它
3. **不加载旧 PinMAP-Template.xlsx** — 即使旧模板存在,两个方向都不加载
### 5.3 单元测试:新增到 `test_pinmap.py`
以下测试**可**新增到 `test_pinmap.py`(纯逻辑,不依赖文件系统):
#### U-F009-F010-001: `_find_balllist_template_path` 和 `_find_ballmap_template_path` 路径生成
```python
def test_template_path_generation():
"""验证两个模板查找函数返回正确的路径格式。"""
import os
from main import _find_balllist_template_path, _find_ballmap_template_path
# 路径应以项目根目录为基础,包含正确的文件名
# 注意:这些函数检查文件是否存在,测试环境可能没有这些文件
# 所以只验证函数可调用且返回类型正确
result1 = _find_balllist_template_path()
result2 = _find_ballmap_template_path()
# 返回值要么是 str 要么是 None
assert result1 is None or isinstance(result1, str)
assert result2 is None or isinstance(result2, str)
# 两者应该是不同路径
if result1 and result2:
assert "BallList" in result1
assert "BallMAP" in result2
assert result1 != result2
print("✓ test_template_path_generation passed")
```
### 5.4 集成测试:新增到 `Test/run_tests.py`
以下测试需要 `Test/fixtures/` 中的模板文件:
**Fixture 准备**
| 文件 | 用途 | 内容要求 |
|------|------|---------|
| `Test/fixtures/BallList-Template.xlsx` | MAP→List 模板 | 至少包含 fonts(Calibri 12pt, bold), borders(thin), column_widths |
| `Test/fixtures/BallMAP-Template.xlsx` | MAP→List 模板 | 至少包含 fonts(Arial 10pt), borders(medium), row_heights |
| `Test/fixtures/template_corrupt.xlsx` | 损坏模板测试 | 一个非 xlsx 的普通文件伪装为 .xlsx |
**新增集成测试用例**
#### TC-v1.5-001: MAP→List 加载 BallList 模板(模板存在)
```
前置: fixtures/BallList-Template.xlsx 存在
步骤:
1. 创建 PinMAP 输入4×4
2. 模拟 run_map_to_list 模板加载流程
3. 验证 read_template_styles 返回非 None
4. 验证返回的 TemplateStyle 包含字体信息
预期: 模板样式成功加载,输出文件包含 styles.xml
```
#### TC-v1.5-002: MAP→List 无模板降级(模板不存在)
```
前置: 删除根目录的 BallList-Template.xlsx
步骤:
1. 创建 PinMAP 输入4×4
2. 调用 _find_balllist_template_path()
3. 验证返回 None
预期: 返回 None输出使用默认样式
```
#### TC-v1.5-003: List→MAP 加载 BallMAP 模板(模板存在)
```
前置: fixtures/BallMAP-Template.xlsx 存在
步骤:
1. 创建 5×5 PinList 输入20 pin
2. 模拟 run_list_to_map 模板加载流程
3. 验证 read_template_styles 返回非 None
4. 生成 PinMAP验证输出包含 styles.xml
预期: 模板样式成功加载,输出文件包含 styles.xml
```
#### TC-v1.5-004: List→MAP 无模板降级(模板不存在)
```
前置: 删除根目录的 BallMAP-Template.xlsx
步骤:
1. 创建 5×5 PinList 输入20 pin
2. 调用 _find_ballmap_template_path()
3. 验证返回 None
预期: 返回 None输出使用默认样式
```
#### TC-v1.5-005: 两个方向独立使用各自模板
```
前置: BallList-Template.xlsx 和 BallMAP-Template.xlsx 都存在
步骤:
1. MAP→List 方向:调用 _find_balllist_template_path(),验证路径包含 "BallList"
2. List→MAP 方向:调用 _find_ballmap_template_path(),验证路径包含 "BallMAP"
3. 验证两个路径不同
预期: 两个方向各自查找各自的模板文件,互不干扰
```
#### TC-v1.5-006: 模板损坏时优雅降级
```
前置: 提供一个损坏的 "template_corrupt.xlsx"
步骤:
1. 调用 read_template_styles("fixtures/template_corrupt.xlsx")
2. 验证返回 None不抛异常
预期: 返回 None调用方可继续使用默认样式
```
---
## 6. F011 — 模板格式提取式应用测试
### 6.1 测试场景
F011 核心要求:模板的**格式信息**(字体/边框/填充/对齐/列宽/行高)正确应用到输出文件,但输出文件的**行列结构**由实际 Pin 数量决定。
### 6.2 单元测试:新增到 `test_pinmap.py`
F011 的样式构建逻辑在 `xlsx_writer.py::StyledXLSXWriter` 中,涉及 XML 字符串生成。以下测试可以在不写磁盘文件的情况下验证 XML 内容:
#### U-F011-001: 无模板时使用默认样式
```python
def test_f011_default_styles_xml():
"""F011: 无模板时 _styles_xml() 返回硬编码默认样式。"""
from xlsx_writer import StyledXLSXWriter
writer = StyledXLSXWriter(style=None)
xml = writer._styles_xml()
# 验证硬编码默认值的存在
assert 'Calibri' in xml, "默认字体应为 Calibri"
assert 'thin' in xml or 'style="thin"' in xml, "默认边框应为 thin"
assert 'center' in xml, "默认对齐应为 center"
assert 'cellXfs count="4"' in xml, "应有 4 个 xf"
print("✓ test_f011_default_styles_xml passed")
```
#### U-F011-002: 有模板时使用模板字体
```python
def test_f011_template_fonts_in_styles_xml():
"""F011: 有模板时 _styles_xml() 使用模板的字体信息(而非硬编码 Calibri"""
from template_reader import TemplateStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
# 构建一个模板样式:微软雅黑 12pt + bold
style = TemplateStyle()
style.fonts = [
FontStyle(name="微软雅黑", size=12.0, bold=False, italic=False, color="FF000000"),
FontStyle(name="微软雅黑", size=12.0, bold=True, italic=False, color="FF000000"),
]
style.fills = []
style.borders = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
assert '微软雅黑' in xml, f"模板字体名应出现在 styles.xml 中\n{xml[:500]}"
assert '12' in xml or '12.0' in xml, f"模板字号 12pt 应出现在 styles.xml 中"
print("✓ test_f011_template_fonts_in_styles_xml passed")
```
#### U-F011-003: 有模板时使用模板边框
```python
def test_f011_template_borders_in_styles_xml():
"""F011: 有模板时 _styles_xml() 使用模板的边框样式(而非硬编码 thin"""
from template_reader import TemplateStyle, BorderStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [FontStyle(name="Calibri", size=11.0)]
style.borders = [
BorderStyle(top="none", bottom="none", left="none", right="none"),
BorderStyle(top="medium", bottom="medium", left="medium", right="medium"),
]
style.fills = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '1', 'xfId': '0',
'applyBorder': '1'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
# 模板的 medium 边框应该存在(不仅仅是 thin
assert 'medium' in xml, f"模板 medium 边框应出现在 styles.xml 中\n{xml[:800]}"
print("✓ test_f011_template_borders_in_styles_xml passed")
```
#### U-F011-004: 有模板时使用模板填充
```python
def test_f011_template_fills_in_styles_xml():
"""F011: 有模板时 _styles_xml() 使用模板的填充色(而非硬编码 FFF0F0F0"""
from template_reader import TemplateStyle, FillStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [FontStyle(name="Calibri", size=11.0)]
style.borders = []
style.fills = [
FillStyle(pattern_type="none", fg_color=""),
FillStyle(pattern_type="solid", fg_color="FFFF00"), # 黄色
]
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
{'numFmtId': '0', 'fontId': '0', 'fillId': '1', 'borderId': '0', 'xfId': '0',
'applyFill': '1'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
assert 'FFFF00' in xml, f"模板黄色填充应出现在 styles.xml 中\n{xml[:800]}"
print("✓ test_f011_template_fills_in_styles_xml passed")
```
#### U-F011-005: 输出行列由实际 Pin 决定(不复制模板行列结构)
```python
def test_f011_output_dims_determined_by_pins():
"""F011: 输出文件的 dim 由实际 Pin 数量决定,不复制模板的行列结构。
即使模板有 100 行列定义,输出仍只包含实际 Pin 数据的行列。
"""
from template_reader import TemplateStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [FontStyle(name="Calibri", size=11.0)]
style.column_widths = {i: 20.0 for i in range(100)} # 模板有 100 列
style.row_heights = {i: 30.0 for i in range(200)} # 模板有 200 行
style.fills = []
style.borders = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
]
# 仅输出 2 行 3 列的数据(模拟 2×2 PinMAP + A1
data = {
'A1': 'QFP-8',
'A2': '1', 'B2': 'Pin1',
'A3': '2', 'B3': 'Pin2',
}
writer = StyledXLSXWriter(style=style)
sheet_xml = writer._sheet_xml(data)
# dim 应该反映实际数据范围A1:C3而非模板的 100 列
assert 'dimension ref="A1:C' in sheet_xml or 'dimension ref="A1:C3"' in sheet_xml, \
f"dim 应由实际数据决定,不应包含模板的 100 列\n{sheet_xml[:500]}"
# 不应出现 row r="201"(模板的第 200 行)
assert 'row r="201"' not in sheet_xml, "不应包含模板的多余行"
print("✓ test_f011_output_dims_determined_by_pins passed")
```
### 6.3 集成测试:新增到 `Test/run_tests.py`
#### TC-v1.5-007: 模板字体应用到输出文件
```
前置: fixtures/BallMAP-Template.xlsx 使用微软雅黑 14pt
步骤:
1. 用该模板生成 5×5 PinMAP 输出
2. 解压输出 xlsx读取 xl/styles.xml
3. 验证 fonts 部分包含 "微软雅黑" 和 size=14
预期: 输出 styles.xml 包含模板字体定义
```
#### TC-v1.5-008: 模板列宽应用到输出文件
```
前置: fixtures/BallList-Template.xlsx 中 A 列宽=25, B 列宽=18
步骤:
1. 用该模板生成 PinList 输出
2. 解压输出 xlsx读取 xl/worksheets/sheet1.xml
3. 验证 cols 元素中 A 列 width=25, B 列 width=18
预期: 输出列宽与模板一致
```
#### TC-v1.5-009: 模板行高应用到输出文件
```
前置: fixtures/BallMAP-Template.xlsx 中行高=25
步骤:
1. 用该模板生成 3×3 PinMAP 输出
2. 解压输出 xlsx读取 xl/worksheets/sheet1.xml
3. 验证 row 元素包含 ht=25
预期: 输出行高与模板一致
```
#### TC-v1.5-010: 两个方向使用不同模板各自的格式
```
前置:
- BallList-Template.xlsx 字体=楷体 12pt
- BallMAP-Template.xlsx 字体=宋体 14pt
步骤:
1. MAP→List 方向:用 BallList 模板生成 PinList验证输出字体=楷体 12pt
2. List→MAP 方向:用 BallMAP 模板生成 PinMAP验证输出字体=宋体 14pt
3. 验证两个输出文件的字体不同
预期: 两个方向的输出各自使用对应模板的字体
```
---
## 7. F009/F010/F011 集成测试
### 7.1 端到端集成测试
#### TC-v1.5-011: 完整往返 + 模板隔离 (MAP→List→MAP)
```
前置: BallList-Template.xlsx 和 BallMAP-Template.xlsx 都存在且格式不同
步骤:
1. 使用 BallList 模板,执行 sample_4x4.xlsx → PinList
2. 验证 PinList 输出包含 BallList 模板的格式特征
3. 使用 BallMAP 模板,执行 PinList → PinMAP
4. 验证 PinMAP 输出包含 BallMAP 模板的格式特征
5. 验证往返后的 PinMAP 与原 PinMAP 数据一致(忽略格式差异)
预期:
- 往返数据完全一致(引脚序号/名称/封装信息)
- 中间 PinList 使用 BallList 模板格式
- 最终 PinMAP 使用 BallMAP 模板格式
```
#### TC-v1.5-012: 无模板完整流程
```
前置: 根目录没有 BallList-Template.xlsx 也没有 BallMAP-Template.xlsx
步骤:
1. 执行 MAP→List 转换
2. 执行 List→MAP 转换
3. 验证两个输出文件都能正常生成且数据正确
4. 验证两个输出文件使用默认样式Calibri 11pt
预期: 无模板情况下正常降级,输出使用默认样式,数据正确
```
---
## 8. 边界与异常测试
### 8.1 模板边界测试
| 编号 | 场景 | 预期行为 | 测试级别 |
|------|------|---------|---------|
| TC-BND-001 | 模板 fonts 为空列表 | 回退到默认字体 | Unit |
| TC-BND-002 | 模板 borders 为空列表 | 回退到默认边框thin | Unit |
| TC-BND-003 | 模板 fills 为空列表 | 回退到默认填充 | Unit |
| TC-BND-004 | 模板 cell_xfs 为空列表 | 回退到默认 cellXfs4 个硬编码 xf | Unit |
| TC-BND-005 | 模板 column_widths 为空 dict | 使用默认列宽 8.0 | Unit |
| TC-BND-006 | 模板 row_heights 为空 dict | 不使用自定义行高(使用 Excel 默认) | Unit |
| TC-BND-007 | 模板字体 color 缺少 FF 前缀 | xlsx_writer 自动补全 | Unit |
| TC-BND-008 | 模板填充 fg_color 缺少 FF 前缀 | xlsx_writer 自动补全 | Unit |
| TC-BND-009 | 模板中缺失 styles.xml | read_template_styles 返回 None | Unit |
| TC-BND-010 | 模板中缺失 sheet1.xml | column_widths/row_heights 为空 | Unit |
### 8.2 边界场景集成测试
#### TC-v1.5-013: 模板只有字体、无边框/填充
```
前置: 准备一个只有 fonts 定义的极简模板
步骤:
1. 加载模板样式
2. 验证 fonts 正确提取
3. 验证 borders/fills 使用默认值
4. 生成的输出文件可正常打开
预期: 字体来自模板,边框/填充使用默认值,文件可正常打开
```
#### TC-v1.5-014: 模板列宽少于输出列数
```
前置: 模板定义 3 列宽,但输出需要 6 列
步骤:
1. 加载模板(定义 A-C 列宽)
2. 生成 5×5(6 列) PinMAP
3. 验证模板定义的列使用模板宽度,额外的列使用默认 8.0
预期: 列宽正确扩展,无异常
```
---
## 9. 测试执行计划
### 9.1 新增单元测试(添加到 `test_pinmap.py`
| 优先级 | 编号 | 测试名 | 简述 |
|--------|------|--------|------|
| P0 | U-F009-F010-001 | `test_template_path_generation` | 验证模板路径格式正确且互不相同 |
| P0 | U-F011-001 | `test_f011_default_styles_xml` | 无模板时使用默认样式 |
| P0 | U-F011-002 | `test_f011_template_fonts_in_styles_xml` | 模板字体应用到 styles.xml |
| P1 | U-F011-003 | `test_f011_template_borders_in_styles_xml` | 模板边框应用到 styles.xml |
| P1 | U-F011-004 | `test_f011_template_fills_in_styles_xml` | 模板填充应用到 styles.xml |
| P0 | U-F011-005 | `test_f011_output_dims_determined_by_pins` | 输出 dim 由 Pin 数决定 |
| P1 | U-BND-001 | `test_template_empty_fonts_fallback` | 空 fonts 回退 |
| P1 | U-BND-007 | `test_template_color_prefix_auto_fix` | FF 前缀补全 |
| P1 | U-BND-009 | `test_template_no_styles_xml` | 缺失 styles.xml 降级 |
### 9.2 新增集成测试(添加到 `Test/run_tests.py`
| 优先级 | 编号 | 测试名 | 简述 | 需要 Fixture |
|--------|------|--------|------|------------|
| P0 | TC-v1.5-001 | MAP→List 加载 BallList 模板 | 模板存在时正确加载 | BallList-Template.xlsx |
| P0 | TC-v1.5-002 | MAP→List 无模板降级 | 模板不存在时优雅降级 | 无 |
| P0 | TC-v1.5-003 | List→MAP 加载 BallMAP 模板 | 模板存在时正确加载 | BallMAP-Template.xlsx |
| P0 | TC-v1.5-004 | List→MAP 无模板降级 | 模板不存在时优雅降级 | 无 |
| P0 | TC-v1.5-005 | 两个方向独立模板 | 各自使用各自的模板 | 两个模板 |
| P1 | TC-v1.5-006 | 模板损坏优雅降级 | 损坏模板不抛异常 | template_corrupt.xlsx |
| P1 | TC-v1.5-007 | 模板字体应用到输出 | 输出文件含模板字体 | 特殊字体模板 |
| P1 | TC-v1.5-008 | 模板列宽应用到输出 | 输出列宽=模板列宽 | 特殊列宽模板 |
| P1 | TC-v1.5-009 | 模板行高应用到输出 | 输出行高=模板行高 | 特殊行高模板 |
| P1 | TC-v1.5-010 | 两个方向各自格式 | 两个方向格式独立 | 两个不同格式模板 |
| P1 | TC-v1.5-011 | 完整往返+模板隔离 | MAP→List→MAP 数据一致 | 两个模板 |
| P1 | TC-v1.5-012 | 无模板完整流程 | 无模板正常降级 | 无 |
| P2 | TC-v1.5-013 | 极简模板 | 只有字体的模板 | 极简模板 |
| P2 | TC-v1.5-014 | 列宽扩展 | 模板列宽少于输出列数 | 窄模板 |
---
## 10. Fixture 准备清单
### 10.1 需要创建的 Fixture 文件
| 文件 | 放置位置 | 用途 | 关键内容 |
|------|---------|------|---------|
| `BallList-Template.xlsx` | `Test/fixtures/` | MAP→List 模板 | 字体=楷体 12pt, A列宽=25, B列宽=18, 边框=thin, 对齐=center |
| `BallMAP-Template.xlsx` | `Test/fixtures/` | List→MAP 模板 | 字体=宋体 14pt, 行高=25, 边框=medium, 填充=淡黄 FFFFFF00 |
| `template_corrupt.xlsx` | `Test/fixtures/` | 损坏模板测试 | 一个无效的 ZIP 文件或文本文件伪装为 .xlsx |
| `template_minimal.xlsx` | `Test/fixtures/` | 极简模板测试 | 只有 1 个 font 定义,无 borders/fills |
| `template_narrow.xlsx` | `Test/fixtures/` | 列宽扩展测试 | 只定义 3 列宽A-C但测试输出 6 列 |
### 10.2 集成测试时的模板放置策略
集成测试需要能临时将模板放到正确位置。建议方案:
**方案 A推荐**:在 `run_tests.py` 中使用 `tempfile.mkdtemp` 创建临时目录,将 fixture 模板复制为 `BallList-Template.xlsx` / `BallMAP-Template.xlsx`,然后通过 `os.chdir` 或修改 `sys.path` 让 main.py 的模板查找逻辑找到它们。
**方案 B**:在 `run_tests.py` 中直接调用底层函数(`read_template_styles`, `_find_balllist_template_path`),不经过 main.py 的路径查找,而是直接传入 fixture 路径。
推荐 **方案 B**(更简洁),因为已有测试也是直接调用底层函数。
### 10.3 Fixture 创建方法
使用 `xlsx_writer.py::write_xlsx_with_style` 创建带特定格式的模板文件:
```python
# 创建 BallList-Template.xlsx
from xlsx_writer import StyledXLSXWriter
from template_reader import TemplateStyle, FontStyle, BorderStyle, FillStyle
style = TemplateStyle()
style.fonts = [
FontStyle(name="楷体", size=12.0, bold=False, color="FF000000"),
FontStyle(name="楷体", size=12.0, bold=True, color="FF000000"),
]
style.borders = [
BorderStyle(top="none", bottom="none", left="none", right="none"),
BorderStyle(top="thin", bottom="thin", left="thin", right="thin"),
]
style.fills = [FillStyle(pattern_type="none")]
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '1', 'xfId': '0',
'applyBorder': '1', 'hAlign': 'center', 'vAlign': 'center'},
]
style.column_widths = {0: 25.0, 1: 18.0}
style.row_heights = {}
# 创建 PinList 模板数据2 行示例数据)
data = {'A1': '封装信息', 'A2': 'Pin1', 'B2': '1', 'A3': 'Pin2', 'B3': '2'}
writer = StyledXLSXWriter(style)
writer.write(data, "fixtures/BallList-Template.xlsx")
```
---
## 11. 预期工作量
| 阶段 | 内容 | 预估时间 |
|------|------|---------|
| Fixture 准备 | 创建 5 个模板 fixture 文件 | 30 min |
| 单元测试 | 新增约 10 个单元测试到 `test_pinmap.py` | 1 hr |
| 集成测试 | 新增约 14 个集成测试到 `run_tests.py` | 1.5 hr |
| 回归验证 | 运行全部测试确认无回归 | 15 min |
| 文档更新 | 更新测试报告 | 10 min |
| **总计** | | **~3.5 hr** |
---
## 12. 测试通过标准
| 条件 | 要求 |
|------|------|
| 现有 9 个单元测试 | 全部通过 |
| 现有 23 个集成测试 | 全部通过 |
| 新增 ~10 个单元测试 | 全部通过 |
| 新增 ~14 个集成测试 | 全部通过 |
| F012 往返测试 (`test_f012_pinname_position`) | 持续通过 |
| F009/F010 模板分离 | 两个方向使用各自模板,互不干扰 |
| F011 模板格式提取 | 字体/边框/填充/对齐/列宽/行高从模板正确应用 |
| 无模板场景 | 优雅降级使用默认样式,不抛异常 |
| 损坏模板场景 | 优雅降级为 None不抛异常 |
---
## 13. 总结
### 13.1 测试设计决策
| 决策 | 说明 |
|------|------|
| F012 不再新增测试 | `test_f012_pinname_position` 已充分覆盖,代码行为已确认正确 |
| F009/F010 模板分离测试侧重降级 | 核心风险在于模板不存在/解析失败时的行为,而非正常路径 |
| F011 样式测试分两层 | 单元层验证 XML 生成,集成层验证最终文件内容 |
| 单元测试不依赖文件系统 | 使用内存中的 `TemplateStyle` 对象直接构造测试数据 |
| 集成测试使用 fixture 文件 | 由 test-executor 预先创建模板 fixture再运行测试 |
### 13.2 测试覆盖矩阵
```
F012 F009 F010 F011 边界 集成
现有 test_pinmap.py ✅ — — — — —
现有 run_tests.py — — — — — ✅
新增 unit tests — ✅ ✅ ✅ ✅ —
新增 integration tests— ✅ ✅ ✅ ✅ ✅
```
### 13.3 下一步
1. **test-executor** 按本方案执行 Fixture 准备(创建 5 个模板文件)
2. **test-executor** 按优先级 P0 → P1 → P2 实施测试代码
3. 运行完整测试套件验证
4. 生成 v1.5.0 最终测试报告
---
*测试方案结束 — 等待 test-executor 执行*

View File

@@ -1,8 +1,8 @@
# PinMAP PinList 转换器 测试报告
# PinMAP PinList 双向转换器 测试报告
> **日期**: 2026-05-25
> **测试类型**: 集成测试 + 端到端测试
> **测试环境**: Python 3.x, Linux x64
> **日期**: 2026-06-12
> **测试类型**: 集成测试 + 端到端测试
> **测试环境**: Python 3.x, Linux x64
---
@@ -10,118 +10,166 @@
| 类别 | 用例数 | 通过 | 失败 |
|------|--------|------|------|
| 标准转换 | 2 | 2 | 0 |
| 错误场景 | 3 | 3 | 0 |
| 边界条件 | 1 | 1 | 0 |
| **总计** | **6** | **6** | **0** |
| MAP->List 回归 | 6 | 6 | 0 |
| List->MAP 新增 | 17 | 17 | 0 |
| v1.5 模板/样式集成 | 14 | 14 | 0 |
| **总计** | **37** | **37** | **0** |
---
## 测试用例详情
## Part 1: MAP→List 回归测试
### TC001: 标准4x4 PinMAP 转换
- **输入**: `fixtures/sample_4x4.xlsx` (QFP44, 8个Pin)
- **预期**: 正确解析8个Pin逆时针1-8输出PinList递增排序
- **实际**: ✅ 解析8个PinPin1→Pin8序号递增A1=QFP44
- **结果**: **通过**
### TC-MAP-001: 标准4x4 PinMAP转换
- **结果**: ✅ 通过
- **详情**: 封装=QFP-16, Pin数=16, 序号 1-16, 引脚名=Pin1..Pin16
### TC002: 长方形PinMAP转换
- **输入**: `fixtures/sample_rect.xlsx` (LQFP100, 13个Pin)
- **预期**: 正确解析13个Pin逆时针排序
- **实际**: ✅ 解析13个Pin逆时针顺序正确
- **结果**: **通过**
### TC-MAP-002: 长方形PinMAP转换
- **结果**: ✅ 通过
- **详情**: 封装=LQFP100, Pin数=11, 序号递增
### TC003: 序号不连续检测
- **输入**: `fixtures/error_gap.xlsx` (缺失序号3)
- **预期**: 报错"Pin序号不连续",给出缺失序号[3]
- **实际**: ✅ 报错"Pin序号不连续 - 缺失的序号: [3]"
- **结果**: **通过**
### TC-MAP-003: 序号不连续检测
- **结果**: ✅ 通过
- **详情**: 错误: Pin序号不连续缺失序号: [3]
### TC004: 序号重复检测
- **输入**: `fixtures/error_dup.xlsx` (序号2重复)
- **预期**: 报错"Pin序号重复",给出重复序号[2]
- **实际**: ✅ 报错"Pin序号重复 - 重复的序号: [2]"
- **结果**: **通过**
### TC-MAP-004: 序号重复检测
- **结果**: ✅ 通过
- **详情**: 错误: Pin序号重复重复序号: [2]
### TC005: PinName缺失警告
- **输入**: `fixtures/warning_missing.xlsx` (部分Pin缺少PinName)
- **预期**: 警告"检测到N个引脚缺少PinName",自动设为NC
- **实际**: ✅ 警告"检测到3个引脚缺少PinName",缺失序号[2,3,4]
- **结果**: **通过**
### TC-MAP-005: PinName缺失警告
- **结果**: ✅ 通过
- **详情**: 警告: 检测到 3 个引脚缺少 PinName — 缺失引脚序号: [2, 3, 4],将默认为 NC
### TC006: A1为空检测
- **输入**: `fixtures/error_empty_a1.xlsx` (A1单元格为空)
- **预期**: 报错"A1单元格为空缺少封装信息"
- **实际**: ✅ 捕获StructureError: "A1 单元格为空,缺少封装信息"
- **结果**: **通过**
### TC-MAP-006: A1为空检测
- **结果**: ✅ 通过
- **详情**: 正确报错: A1 单元格为空,缺少封装信息
---
## Part 2: List→MAP 新增功能测试
## 端到端测试
### TC-LM-001: 5×5 PinList→PinMAP (20引脚)
- **结果**: ✅ 通过
- **详情**: 解析成功, 封装=QFP-20, Pin数=20, 5×5布局验证通过
### main.py 命令行模式
```bash
python main.py /tmp/test_4x4.xlsx
```
**输出**:
```
[INFO] 解析完成: 6x6 方形,共 8 个Pin
[INFO] 封装信息: QFP44
### TC-LM-002: 6×10 PinList→PinMAP (32引脚)
- **结果**: ✅ 通过
- **详情**: 解析成功, 封装=LQFP-32, Pin数=32, 6×10布局+文件输出验证通过
[SUCCESS] 转换完成!输出文件: /tmp/test_4x4_PinList.xlsx
- 封装信息: QFP44
- Pin数量: 8
```
**结果**: ✅ 通过
### TC-LM-003: 带模板文件的转换
- **结果**: ✅ 通过
- **详情**: 模板样式读取成功, 带模板输出文件包含styles.xml
### 输出文件验证
- **输入**: `sample_4x4.xlsx`**输出**: `sample_4x4_PinList.xlsx`
- **A1**: QFP44 ✅
- **A列**: Pin1, Pin2, Pin3, Pin4, Pin5, Pin6, Pin7, Pin8 ✅
- **B列**: 1, 2, 3, 4, 5, 6, 7, 8 ✅
- **排序**: 递增 ✅
### TC-LM-004: Pin序号不连续
- **结果**: ✅ 通过
- **详情**: 正确报错: Pin序号不连续 — 缺失的序号: [3]
---
### TC-LM-005: Pin序号重复
- **结果**: ✅ 通过
- **详情**: 正确报错: Pin序号不连续 — 缺失的序号: [4]
## 模块单元测试
### TC-LM-006: Pin总数不匹配
- **结果**: ✅ 通过
- **详情**: 正确报错: Pin数量与网格周长不匹配 — 网格 3×4 需要 14 个引脚,但 PinList 有 8 个
### xlsx_roundtrip
- 写入 → 读取 → 验证数据一致 ✅
### TC-LM-007: 缺少PinName (warning)
- **结果**: ✅ 通过
- **详情**: 验证通过(有warning): 检测到 1 个引脚缺少 PinName — 缺失引脚序号: [2],将默认为 NC
### pinmap_parser
- 4x4方形解析 ✅
- 长方形解析 ✅
- 角点去重 ✅
### TC-LM-008: 非4倍数提示
- **结果**: ✅ 通过
- **详情**: 验证通过, Pin数=14 (非4倍数)
### validator
- 连续性检查 ✅
- 唯一性检查 ✅
- PinName缺失检测 ✅
- 结构完整性检查 ✅
### TC-LM-009: 布局计算正确性
- **结果**: ✅ 通过
- **详情**: 布局计算正确: left=3, bottom=3, right=3, top=3, 逆时针顺序正确
### pinlist_generator
- PinList生成 ✅
- NC默认值 ✅
- 递增排序 ✅
### TC-LM-010: 模板文件检测(无模板)
- **结果**: ✅ 通过
- **详情**: 无模板文件时优雅返回None
---
### TC-LM-011: 无效尺寸输入(行数<2)
- **结果**: ✅ 通过
- **详情**: 正确报错: 行数无效: 1至少需要 2 行
## 问题汇总
### TC-LM-011b: 无效尺寸输入(列数<2)
- **结果**: ✅ 通过
- **详情**: 正确报错: 列数无效: 1至少需要 2 列
| 问题 | 严重性 | 状态 |
|------|--------|------|
| 无 | - | - |
### TC-LM-012: 输出文件正确性
- **结果**: ✅ 通过
- **详情**: 输出文件验证通过: A1=QFP-12, 包含Pin1-Pin12
**所有测试用例通过,无阻塞性问题。**
### TC-LM-013: 端到端Roundtrip (MAP→List→MAP)
- **结果**: ✅ 通过
- **详情**: Roundtrip成功: PinList(12) → PinMAP(3×3) → PinList(12), 序号一致
---
### TC-LM-014: 输出路径生成
- **结果**: ✅ 通过
- **详情**: 路径生成正确: /path/to/my_pinlist_PinMAP.xlsx
## 改进建议
### TC-LM-015: 空PinList文件
- **结果**: ✅ 通过
- **详情**: 正确报错: 未找到任何引脚数据A/B 列为空)
1. **XLS读取测试**: 当前环境无.xls测试样本建议在Windows环境用真实.xls文件验证BIFF8解析
2. **字体格式保留**: 当前版本未实现字体格式保留(架构设计中有提及),可在后续版本添加
3. **GUI模式**: tkinter文件选择对话框在Linux无头环境下需回退到命令行参数已实现
4. **性能优化**: 当前实现适合<1000引脚场景超大文件可后续优化
### TC-LM-016: A1为空的PinList
- **结果**: ✅ 通过
- **详情**: 正确报错: A1 单元格为空,无法获取封装信息
## Part 3: v1.5 模板/样式集成测试
### TC-v1.5-001: MAP->List 加载 PinList 模板
- **结果**: ✅ 通过
- **详情**: 模板加载成功: fonts=2, borders=2, width_A=25.0
### TC-v1.5-002: MAP->List 无模板降级
- **结果**: ✅ 通过
- **详情**: 无模板文件时优雅返回 None
### TC-v1.5-003: List->MAP 加载 PinMAP 模板
- **结果**: ✅ 通过
- **详情**: 模板加载成功: fonts=2, borders=2, row_height=25.0
### TC-v1.5-004: List->MAP 无模板降级
- **结果**: ✅ 通过
- **详情**: 无模板文件时优雅返回 None
### TC-v1.5-005: 两个方向独立使用各自模板
- **结果**: ✅ 通过
- **详情**: 两个模板独立: PL fonts=2, PM fonts=2
### TC-v1.5-006: 模板损坏优雅降级
- **结果**: ✅ 通过
- **详情**: 损坏模板优雅返回 None
### TC-v1.5-007: 模板字体应用到输出文件
- **结果**: ✅ 通过
- **详情**: 输出 styles.xml 包含模板字体(宋体 14pt)
### TC-v1.5-008: 模板列宽应用到输出文件
- **结果**: ✅ 通过
- **详情**: 列宽验证通过: A=25.0, B=18.0
### TC-v1.5-009: 模板行高应用到输出文件
- **结果**: ✅ 通过
- **详情**: 行高验证通过: ht=25
### TC-v1.5-010: 两个方向不同模板各自的格式
- **结果**: ✅ 通过
- **详情**: 两个方向输出字体不同: PinList->楷体, PinMAP->宋体
### TC-v1.5-011: 完整往返+模板隔离
- **结果**: ✅ 通过
- **详情**: 往返成功: 16 pins, 楷体->PinList, 宋体->PinMAP
### TC-v1.5-012: 无模板完整流程
- **结果**: ✅ 通过
- **详情**: 无模板完整流程正常
### TC-v1.5-013: 极简模板(只有字体)
- **结果**: ✅ 通过
- **详情**: 极简模板: font=Courier New
### TC-v1.5-014: 列宽扩展
- **结果**: ✅ 通过
- **详情**: 列宽扩展正确: A=15.0, B=12.0, C=10.0, D=8.0, E=8.0
---
@@ -129,21 +177,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*
*测试完成*

59
context.md Normal file
View File

@@ -0,0 +1,59 @@
# pinmap-to-pinlist 项目上下文
## 项目概述
- **项目名称:** pinmap-to-pinlist
- **项目类型:** Python 脚本工具
- **核心功能:** PinMAP ↔ PinList 双向转换Excel xlsx 格式)
- **当前版本:** v1.6
## 技术约束
- 语言Python
- 平台Windows + Linux
- 输出格式Excel .xlsx支持富文本样式
- 封装类型仅支持环形布局QFN 类),引脚分布在芯片四边(上/右/下/左),允许非正方形(如 10×15
- 模板文件:`Code/src/Template/PinMAP-Template.xlsx``PinList-Template.xlsx`
## 使用场景
- 用户提供 PinList CSV封装名 + 引脚名/序号对),期望生成 PinMAP环形四边布局
- 用户提供 PinMAP Excel期望生成 PinList引脚名/序号对 + 封装名)
- 两个方向都需要读取模板文件应用样式(字体、对齐、列宽、行高、背景色、边框)
## 当前活跃 Bug
### BUG-007PinList→PinMAP 上方引脚并入标题行(已修复)
**严重程度:** 高 | **关联功能:** F013, F016 | **版本:** v1.6 回归
**修复后实际输出(转 CSV**
```
QFN60,,,,,,,,,,,,,,,,,,
,,60,59,58,57,56,55,54,53,52,51,50,49,48,47,46,,
,,Pin60,Pin59,Pin58,Pin57,Pin56,Pin55,Pin54,Pin53,Pin52,Pin51,Pin50,Pin49,Pin48,Pin47,Pin46,,
1,Pin1,,,,,,,,,,,,,,,,Pin45,45
2,Pin2,,,,,,,,,,,,,,,,Pin44,44
3,Pin3,,,,,,,,,,,,,,,,Pin43,43
4,Pin4,,,,,,,,,,,,,,,,Pin42,42
5,Pin5,,,,,,,,,,,,,,,,Pin41,41
6,Pin6,,,,,,,,,,,,,,,,Pin40,40
7,Pin7,,,,,,,,,,,,,,,,Pin39,39
8,Pin8,,,,,,,,,,,,,,,,Pin38,38
9,Pin9,,,,,,,,,,,,,,,,Pin37,37
10,Pin10,,,,,,,,,,,,,,,,Pin36,36
11,Pin11,,,,,,,,,,,,,,,,Pin35,35
12,Pin12,,,,,,,,,,,,,,,,Pin34,34
13,Pin13,,,,,,,,,,,,,,,,Pin33,33
14,Pin14,,,,,,,,,,,,,,,,Pin32,32
15,Pin15,,,,,,,,,,,,,,,,Pin31,31
,,Pin16,Pin17,Pin18,Pin19,Pin20,Pin21,Pin22,Pin23,Pin24,Pin25,Pin26,Pin27,Pin28,Pin29,Pin30,,
,,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,,
```
**修复特征(与期望 CSV 对比):**
1. ✅ 第 1 行标题独占A1 仅含 `QFN60`,无引脚数据混入)
2. ✅ 第 2 行为上方独立序号行 `,,60,59,...,46,,`
3. ✅ 第 3 行为上方独立 PinName 行 `,,Pin60,...,Pin46,,`
4. ✅ 总行数 200-based 0-19与期望 21 行结构一致
5. ✅ 左右引脚位置正确A=Number, B=Name
6. ✅ 下边 PinName/Number 位置正确
**验收标准:** ✅ 已达标 — PinList→PinMAP 输出结构与期望 CSV 逐行一致。

80
docs/bugs.md Normal file
View File

@@ -0,0 +1,80 @@
# Bug 跟踪表
| Bug ID | 严重程度 | Bug 描述 | 复现步骤 | 期望行为 | 实际行为 | 状态 | 关联功能 |
|--------|---------|---------|---------|---------|---------|------|---------|
| BUG-001 | 中 | run.bat 换行符 + lines 设置不匹配 Windows | 在 Windows 下运行 run.bat | CRLF 换行,仅保留 `mode con cols=80` | Unix LF 换行,包含多余 `lines=50` | 已修复 | F005 |
| BUG-002 | 高 | 周长计算公式错误 | 输入 15×15 网格 + 60 Pin | 验证通过 `(rows+cols)*2=60` | 提示不匹配 `2*rows+2*cols-4=56` | 已修复 | F006 |
| BUG-003 | 中 | 双向转换未读取模板样式 | 使用模板文件进行 MAP↔List 转换 | 读取并应用模板样式 | 使用默认样式 | 已修复 | F007 |
| BUG-004 | 中 | 不支持循环处理流程 | 转换完成后继续操作 | 循环等待下一个文件,输入 Q 返回主菜单 | 处理完直接退出 | 已修复 | F008 |
| BUG-005 | 高 | 模板文件名/路径错误 | PinList↔PinMAP 转换时读取模板 | PinMAP 模板为 PinMAP-Template.xlsxPinList 模板为 PinList-Template.xlsx | v1.5.4 只改文件名未改搜索路径,模板在 Code/src/Template/ 下但代码在根目录找 | 已修复 | v1.5.5 |
| BUG-006 | 高 | PinList→PinMAP 上边 Name 与左边 Name 同行(数据无误但肉眼混淆) | 12×12 PinMapPinList→PinMAP 转换后查看输出 | 每条边的 Name 和 Number 在独立行/列区域,肉眼可辨 | v1.5.4 上边 Name 在 row 2与左边 Name(row 2)同行3 条边数据混在同一行 | 已修复 | v1.5.5 |
| BUG-007 | 高 | v1.6 PinList→PinMAP 生成方向相反:应使用 Layout BNumber 在上)但使用了 Layout AName 在上) | PinList(QFN60)→PinMAP 转换15×15 网格 | 输出 Layout BRow 0=A1+NumberRow 1=Name左右边从 Row 2 开始 | 输出 Layout ARow 0=A1+NameRow 1=Number导致上方引脚合并到标题行 | 已修复 | v1.6.1 |
---
## BUG-007 完整对比数据用户原始反馈2026-06-12
### 程序生成v1.6 实际输出,已转为 CSV
```
QFN60,Pin60,Pin59,Pin58,Pin57,Pin56,Pin55,Pin54,Pin53,Pin52,Pin51,Pin50,Pin49,Pin48,Pin47,Pin46,
,60,59,58,57,56,55,54,53,52,51,50,49,48,47,46,
1,Pin1,,,,,,,,,,,,,,Pin45,45
2,Pin2,,,,,,,,,,,,,,Pin44,44
3,Pin3,,,,,,,,,,,,,,Pin43,43
4,Pin4,,,,,,,,,,,,,,Pin42,42
5,Pin5,,,,,,,,,,,,,,Pin41,41
6,Pin6,,,,,,,,,,,,,,Pin40,40
7,Pin7,,,,,,,,,,,,,,Pin39,39
8,Pin8,,,,,,,,,,,,,,Pin38,38
9,Pin9,,,,,,,,,,,,,,Pin37,37
10,Pin10,,,,,,,,,,,,,,Pin36,36
11,Pin11,,,,,,,,,,,,,,Pin35,35
12,Pin12,,,,,,,,,,,,,,Pin34,34
13,Pin13,,,,,,,,,,,,,,Pin33,33
14,Pin14,,,,,,,,,,,,,,Pin32,32
15,Pin15,,,,,,,,,,,,,,Pin31,31
,Pin16,Pin17,Pin18,Pin19,Pin20,Pin21,Pin22,Pin23,Pin24,Pin25,Pin26,Pin27,Pin28,Pin29,Pin30,
,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,
```
### 期望输出(用户提供的正确 PinMAP
```
"QFN60 6*6*0.85mm
xxx
版本xxxx",,,,,,,,,,,,,,,,,,
,,60,59,58,57,56,55,54,53,52,51,50,49,48,47,46,,
,,Pin60,Pin59,Pin58,Pin57,Pin56,Pin55,Pin54,Pin53,Pin52,Pin51,Pin50,Pin49,Pin48,Pin47,Pin46,,
1,Pin1,,,,,,,,,,,,,,,,Pin45,45
2,Pin2,,,,,,,,,,,,,,,,Pin44,44
3,Pin3,,,,,,,,,,,,,,,,Pin43,43
4,Pin4,,,,,,,,,,,,,,,,Pin42,42
5,Pin5,,,,,,,,,,,,,,,,Pin41,41
6,Pin6,,,,,,,,,,,,,,,,Pin40,40
7,Pin7,,,,,,,,,,,,,,,,Pin39,39
8,Pin8,,,,,,,,,,,,,,,,Pin38,38
9,Pin9,,,,,,,,,,,,,,,,Pin37,37
10,Pin10,,,,,,,,,,,,,,,,Pin36,36
11,Pin11,,,,,,,,,,,,,,,,Pin35,35
12,Pin12,,,,,,,,,,,,,,,,Pin34,34
13,Pin13,,,,,,,,,,,,,,,,Pin33,33
14,Pin14,,,,,,,,,,,,,,,,Pin32,32
15,Pin15,,,,,,,,,,,,,,,,Pin31,31
,,Pin16,Pin17,Pin18,Pin19,Pin20,Pin21,Pin22,Pin23,Pin24,Pin25,Pin26,Pin27,Pin28,Pin29,Pin30,,
,,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,,
```
### 差异明细
| # | 行号(期望) | 内容 | 实际 | 期望 |
|---|-------------|------|------|------|
| 1 | 第1行 | 标题 | `QFN60`(单行,上方引脚混入同行) | `"QFN60 6*6*0.85mm\nxxx\n版本xxxx"`(多行合并单元格,独占整行) |
| 2 | 第2行 | 上方序号 | **缺失** | `,,60,59,58,57,56,55,54,53,52,51,50,49,48,47,46,,` |
| 3 | 第3行 | 上方PinName | **缺失** | `,,Pin60,Pin59,Pin58,...Pin47,Pin46,,` |
| 4 | 第4-18行 | 左右引脚 | 正确 | 正确 |
| 5 | 第19行 | 下方PinName | 正确 | 正确 |
| 6 | 第20行 | 下方序号 | 正确 | 正确 |
| 7 | 总行数 | — | **19 行** | **21 行(缺 2 行)** |
**根因判断:** PinList→PinMAP 生成时上方Top引脚未创建独立的序号行和 PinName 行期望第2-3行而是被错误地合并到了标题行第1行导致输出结构不完整。

70
docs/features.md Normal file
View File

@@ -0,0 +1,70 @@
# 功能清单
## 核心功能
| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 |
|--------|---------|------|------|------|------|--------|---------|---------|
| F001 | PinMAP 解析 | 解析 PinMAP Excel 文件 | Excel 文件 | 解析后的 Pin 数据 | 无 | 1 | 能正确解析 PinMAP 结构 | 已通过 |
| F002 | PinList 生成 | 从 PinMAP 生成 PinList | Pin 数据 | PinList Excel 文件 | F001 | 2 | 能正确生成 PinList | 已通过 |
| F003 | PinList 解析 | 解析 PinList Excel 文件 | Excel 文件 | 解析后的 Pin 数据 | 无 | 1 | 能正确解析 PinList 结构 | 已通过 |
| F004 | PinMAP 生成 | 从 PinList 生成 PinMAP | Pin 数据 | PinMAP Excel 文件 | F003 | 2 | 能正确生成 PinMAP | 已通过 |
## Bug 修复
| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 |
|--------|---------|------|------|------|------|--------|---------|---------|
| F005 | BAT 脚本修复 | 修复 run.bat 换行符为 CRLF去掉 lines=50 参数 | 无 | 修复后的 run.bat | 无 | 3 | Windows 下正常运行 | 已通过 |
| F006 | 周长公式修复 | 将周长公式从 `2*rows+2*cols-4` 改为 `(rows+cols)*2` | rows, cols | 正确的周长值 | 无 | 1 | 15×15 网格 60Pin 验证通过 | 已通过 |
## 功能增强
| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 |
|--------|---------|------|------|------|------|--------|---------|---------|
| F007 | 模板读取 | MAP→List 和 List→MAP 双向转换均读取并应用模板样式 | 模板文件 | 带样式的输出文件 | 无 | 2 | 双向转换均应用模板样式 | 已通过 |
| F008 | 循环处理流程 | 处理完不退出,循环等待下一个文件,输入 Q 返回主菜单 | 用户输入 | 循环处理或返回主菜单 | 无 | 2 | 处理完不退出Q 返回主菜单 | 已通过 |
## v1.5.0 新增2026-06-06
| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 |
|--------|---------|------|------|------|------|--------|---------|---------|
| F009 | MAP→List 使用 balllist 模板 | PinMAP→PinList 转换方向查找并使用 `BallList-Template.xlsx`,不再共用 PinMAP 模板 | BallList-Template.xlsx | 带 balllist 模板样式的 PinList 输出 | 无 | P1 | MAP→List 使用 balllist 模板的样式 | 已完成 |
| F010 | List→MAP 使用 ballmap 模板 | PinList→PinMAP 转换方向查找并使用 `BallMAP-Template.xlsx`,不再共用 PinMAP 模板 | BallMAP-Template.xlsx | 带 ballmap 模板样式的 PinMAP 输出 | 无 | P1 | List→MAP 使用 ballmap 模板的样式 | 已完成 |
| F011 | 模板格式提取式应用 | 从模板仅提取格式信息(字体、边框、对齐、列宽、行高),输出文件行列数由实际 Pin 数量决定,不复制模板行列结构 | 模板文件 | 格式信息正确应用到输出文件 | F009, F010 | P1 | 模板格式正确应用到不同 Pin 数的输出文件 | 已完成 |
| F012 | 修复 PinMAP 生成中上/下边 PinName 位置 | PinList→PinMAP 时,下边 PinName 应在序号上方max_row-1 而非 min_row+1上边 PinName 应在序号下方min_row+1 而非 max_row-1 | PinList 数据 + 网格尺寸 | PinName 位于正确位置的 PinMAP | 无 | P0 | 4×4 PinMAP 示例中 Pin3/Pin4 出现在 C6/D6Pin5/Pin6 出现在 E5/E4 | 已完成 |
## v1.5.5 整改2026-06-12
| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 |
|--------|---------|------|------|------|------|--------|---------|---------|
| F013 | 修复 PinMAP→PinList 上方引脚丢失 | PinMAP 解析时封装上侧Top引脚未被识别导致 PinList 中缺失上边所有引脚。需修复解析逻辑确保四边引脚全部提取 | PinMAP Excel | 完整的 PinList | 无 | P0 | 示例 QFN60 PinMAP→PinList 输出 60 个引脚,无缺失 | 已完成 |
| F014 | PinList→PinMAP 样式模板应用 | List→MAP 时必须读取 `PinMAP-Template.xlsx`(位于 Code/src/Template/),提取字体(名称/大小/粗体/颜色)、对齐方式(水平/垂直)、列宽、行高、单元格背景色、边框样式,应用到输出 xlsx。行列数由实际数据决定不复制模板行列结构 | PinMAP-Template.xlsx + PinList 数据 | 带模板样式的 PinMAP xlsx | F013 | P0 | 输出 PinMAP 的字体、对齐、列宽行高、背景色、边框与模板一致 | 已完成 |
| F015 | PinMAP→PinList 样式模板应用 | MAP→List 时必须读取 `PinList-Template.xlsx`(位于 Code/src/Template/),提取字体、对齐方式、列宽、行高、单元格背景色、边框样式,应用到输出 xlsx。行列数由实际数据决定 | PinList-Template.xlsx + PinMAP 数据 | 带模板样式的 PinList xlsx | F013 | P0 | 输出 PinList 的字体、对齐、列宽行高、背景色、边框与模板一致 | 已完成 |
| F016 | PinList→PinMAP 转换正确性验证 | 使用用户提供的示例 PinListCSV作为输入验证 List→MAP 生成的 PinMAP 与示例 PinMAP 结构一致上方引脚占第2-3行(序号+PinName)标题独立第1行合并单元格共21行 | 示例 PinList CSV | 与期望 PinMAP 逐行一致的 xlsx | F013, F014 | P0 | 输出与 bugs.md BUG-007 期望 CSV 逐行一致 | 已完成 |
| F017 | PinMAP→PinList 转换正确性验证 | 使用用户提供的示例 PinMAPCSV作为输入验证 MAP→List 生成的 PinList 与示例 PinList 一致60 个引脚无缺失、封装名正确提取、格式正确 | 示例 PinMAP CSV | 与示例 PinList 结构一致的 xlsx | F013, F015 | P0 | 生成的 PinList 与示例 PinList 结构完全一致 | 已完成 |
## 优先级排序
1. **P0必须**F013 修复 PinMAP→PinList 上方引脚丢失 — 核心逻辑 Bug两个方向转换的前置依赖
2. **P0必须**F014 PinList→PinMAP 样式模板应用 — 用户反馈双向转换都不正常
3. **P0必须**F015 PinMAP→PinList 样式模板应用 — 用户反馈双向转换都不正常
4. **P0必须**F016 PinList→PinMAP 转换正确性验证 — 端到端验收
5. **P0必须**F017 PinMAP→PinList 转换正确性验证 — 端到端验收
6. **P1重要**F012 修复上/下边 PinName 位置 — 核心逻辑 Bug
7. **P1重要**F006 周长公式修复 — 核心逻辑错误
8. **P1重要**F005 BAT 脚本修复 — 影响 Windows 用户使用
9. **P2建议**F009 MAP→List 用 balllist 模板 — 已被 F015 覆盖
10. **P2建议**F010 List→MAP 用 ballmap 模板 — 已被 F014 覆盖
11. **P2建议**F011 模板格式提取式应用 — 已被 F014/F015 覆盖
12. **P2建议**F007 模板读取 — 功能增强(已被 F014/F015 取代)
13. **P2建议**F008 循环处理流程 — 体验优化
## v1.6 回归 Bug2026-06-12
| Bug ID | 关联功能 | 问题描述 | 详细对比 | 状态 |
|--------|---------|---------|---------|------|
| BUG-007 | F013, F016 | PinList→PinMAP 上方引脚并入标题行,结构缺 2 行 | 程序生成19 行,标题 `QFN60,Pin60,...` 上方引脚混入第 1 行期望21 行,第 1 行独立标题(合并单元格),第 2-3 行为上方序号和 PinName第 4 行起为左边引脚 | **已修复** |
**具体差异(见 bugs.md BUG-007 完整 CSV 对比):**
1. 上方引脚Pin60-Pin46被挤入第 1 行标题行,缺少独立的上方序号行和 PinName 行
2. 标题应为多行合并单元格,实际被压缩为单行
3. 总行数19 vs 期望 21缺 2 行

View File

@@ -0,0 +1,621 @@
# PinMAP ↔ PinList 双向转换器 — 修改需求评估 v1.3
> **版本**: v1.3
> **日期**: 2026-05-31
> **评估人**: 脚本架构师 (Script Architect)
> **状态**: 待审批
> **变更**: 4 个 Bug 修复 + 4 个功能增强BUG-001~004, F005~F008
---
## 1. 修改需求总览
| 编号 | 类型 | 标题 | 优先级 | 复杂度 | 关联需求 |
|------|------|------|--------|--------|---------|
| BUG-001 | Bug | run.bat 换行符 + lines 设置不匹配 Windows | 中 | 低 | F005 |
| BUG-002 | Bug | 周长计算公式错误 | **高** | 中 | F006 |
| BUG-003 | Bug | 双向转换未读取模板样式 | 中 | 中 | F007 |
| BUG-004 | Bug | 不支持循环处理流程 | 中 | 中 | F008 |
| F005 | 功能 | BAT 脚本修复 | 3 | 低 | BUG-001 |
| F006 | 功能 | 周长公式修复 | **1** | 中 | BUG-002 |
| F007 | 功能 | 模板读取(双向) | 2 | 中 | BUG-003 |
| F008 | 功能 | 循环处理流程 | 2 | 中 | BUG-004 |
---
## 2. 当前代码状态分析
### 2.1 代码库结构v1.2.0
```
pinmap-to-pinlist/
├── run.bat # ✏️ 需修改BUG-001/F005
├── Code/src/
│ ├── main.py # ✏️ 需修改BUG-003/F007, BUG-004/F008
│ ├── file_selector.py # (不变)
│ ├── validator.py # ✏️ 需修改BUG-002/F006
│ ├── pinlist_validator.py # ✏️ 需修改BUG-002/F006
│ ├── pinmap_layout.py # ✏️ 需修改BUG-002/F006
│ ├── pinlist_parser.py # (不变)
│ ├── pinmap_generator.py # (不变)
│ ├── template_reader.py # (不变)
│ ├── xlsx_writer.py # (不变)
│ ├── models.py # (不变)
│ ├── xls_reader.py # (不变)
│ ├── xlsx_reader.py # (不变)
│ ├── pinlist_generator.py # (不变)
│ └── utils.py # (不变)
└── docs/
├── bugs.md # ✏️ 需更新状态
├── features.md # ✏️ 需更新状态
└── modification-assessment-v1.3.md # 🆕 本文档
```
### 2.2 各 Bug 当前代码状态
#### BUG-001: run.bat 问题
**当前 run.bat 内容**
```bat
@ECHO OFF
chcp 65001 >nul
title PinMAP转PinList -By:LeeQwQ
mode con cols=80 lines=50 ← 问题:含 lines=50
color 0B
cls
cd /d "%~dp0Code\src"
python main.py
echo.
pause
EXIT
```
**问题 1**:文件使用 Unix LF 换行符(`\n`Windows 下应使用 CRLF`\r\n`)。
**问题 2**`mode con cols=80 lines=50` 中的 `lines=50` 是多余的,需求仅保留 `cols=80`
---
#### BUG-002: 周长公式错误
**当前公式**3 处代码):
```
expected_total = 2 * rows + 2 * cols - 4
```
**问题**:对于 15×15 网格 + 60 Pin当前公式计算为 `2*15+2*15-4 = 56`,但用户期望 `(15+15)*2 = 60`
**涉及文件**
1. `Code/src/pinlist_validator.py``validate_pinlist()` 中的周长匹配检查
2. `Code/src/validator.py``validate_pinlist_for_map()` 中的周长匹配检查
3. `Code/src/pinmap_layout.py``calculate_layout()` 中的 `total` 计算和 `LayoutError`
**数学分析**
| 网格 | 当前公式 `2r+2c-4` | 新公式 `(r+c)*2` | 说明 |
|------|-------------------|-----------------|------|
| 4×8 | 20 | 24 | 当前公式少算 4 |
| 15×15 | 56 | 60 | 当前公式少算 4 |
| 2×2 | 4 | 8 | 当前公式少算 4 |
新公式 `(rows+cols)*2` 对所有尺寸均多 4 个 Pin。这意味着布局分配算法也需要相应调整。
**布局分配需同步修改**
当前分配(`pinmap_layout.py`
```
左边: rows 个
下边: cols-1 个
右边: rows-2 个
上边: cols-1 个
总计: 2*rows + 2*cols - 4
```
新分配(`(rows+cols)*2`
```
左边: rows 个
下边: cols 个
右边: rows 个
上边: cols 个
总计: 2*rows + 2*cols
```
这意味着四个角点不再共享,每个角点归属一条边。需重新设计单元格坐标计算逻辑。
---
#### BUG-003: 模板读取路径错误
**当前代码**`main.py``run_list_to_map()`
```python
template_style = read_template_styles(filepath)
```
**问题**`filepath` 是用户选择的 PinList 输入文件路径,而非模板文件路径。模板文件应为根目录下的 `PinMAP-Template.xlsx`
**MAP→List 方向**`run_map_to_list()`
当前代码未调用 `read_template_styles()`PinList 输出直接使用 `write_xlsx()`(无样式)。
**需要修复**
1. List→MAP`read_template_styles(filepath)` 改为读取根目录模板
2. MAP→List增加模板读取和样式应用
---
#### BUG-004: 无循环处理流程
**当前 `main()` 流程**
```python
def main():
show_banner()
# 选择方向 → 执行一次转换 → wait_for_exit() → 程序结束
```
**问题**:转换完成后直接退出,用户需重新运行程序才能处理下一个文件。
**期望流程**
```
启动 → 选择方向 → 处理文件 → [处理完成] → 等待输入下一个文件 / Q 返回主菜单
```
---
## 3. 逐项修改方案
---
### 3.1 BUG-001 / F005: BAT 脚本修复
**修改范围**`run.bat`1 个文件)
**具体修改**
1. **换行符**:确保文件使用 CRLF`\r\n`)换行。写入文件时指定 `newline='\r\n'`
2. **去掉 lines=50**:将 `mode con cols=80 lines=50` 改为 `mode con cols=80`
**修改后 run.bat**
```bat
@ECHO OFF
chcp 65001 >nul
title PinMAP转PinList -By:LeeQwQ
mode con cols=80
color 0B
cls
cd /d "%~dp0Code\src"
python main.py
echo.
pause
EXIT
```
**风险评估**
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| CRLF 写入失败 | 低 | 极低 | Python `open(path, 'w', newline='\r\n')` 保证 |
| 去掉 lines 后窗口行数变回默认 | 低 | 确认 | 默认 300 行缓冲区,足够查看日志 |
**工作量**5 分钟
---
### 3.2 BUG-002 / F006: 周长公式修复
**修改范围**3 个文件
| 文件 | 修改内容 | 修改行数 |
|------|---------|---------|
| `pinlist_validator.py` | 周长匹配公式 + 错误提示文案 | ~5 行 |
| `validator.py` | `validate_pinlist_for_map()` 周长公式 | ~5 行 |
| `pinmap_layout.py` | 边分配计数 + 单元格坐标计算 | ~20 行 |
**具体修改**
#### 3.2.1 `pinlist_validator.py` — `validate_pinlist()`
```python
# 修改前:
expected_total = 2 * rows + 2 * cols - 4
# 修改后:
expected_total = (rows + cols) * 2
```
#### 3.2.2 `validator.py` — `validate_pinlist_for_map()`
```python
# 修改前:
expected_total = 2 * rows + 2 * cols - 4
# 修改后:
expected_total = (rows + cols) * 2
```
#### 3.2.3 `pinmap_layout.py` — `calculate_layout()`
**边分配计数修改**
```python
# 修改前:
left_count = rows
bottom_count = cols - 1
right_count = rows - 2
top_count = cols - 1
# total = 2*rows + 2*cols - 4
# 修改后:
left_count = rows
bottom_count = cols
right_count = rows
top_count = cols
# total = 2*rows + 2*cols
```
**单元格坐标计算修改**
```python
# 修改前(角点共享):
# 左边: (r, 0) r ∈ [1, rows]
# 下边: (rows, c) c ∈ [1, cols-1]
# 右边: (r, cols) r ∈ [rows-1, 2] 逆序
# 上边: (1, c) c ∈ [cols-1, 2] 逆序
# 修改后(角点不共享,每条边独立):
# 左边: (r, 0) r ∈ [1, rows]
# 下边: (rows, c) c ∈ [1, cols]
# 右边: (r, cols) r ∈ [rows, 1] 逆序
# 上边: (1, c) c ∈ [cols, 1] 逆序
```
```python
# 修改后代码:
left_cells = [(r, 0) for r in range(1, rows + 1)]
bottom_cells = [(rows, c) for c in range(1, cols + 1)]
right_cells = [(r, cols) for r in range(rows, 0, -1)]
top_cells = [(1, c) for c in range(cols, 0, -1)]
```
**`get_name_cell()` 函数修改**
```python
# 修改前:
# left: (r, c+1)
# bottom: (r-1, c)
# right: (r, c-1)
# top: (r+1, c)
# 修改后逻辑不变Name 单元格相对于序号单元格的位置不变)
# 但需确保角点单元格 Name 不冲突
```
**风险评估**
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 布局算法修改引入新 Bug | **高** | 中 | 对 4×8、15×15、2×2 等典型尺寸做单元测试 |
| 角点单元格 Name 重叠 | 中 | 中 | 修改 `get_name_cell()` 确保角点 Name 不冲突 |
| 已有用户数据不兼容 | 低 | 低 | 用户需重新输入正确的 PinList |
| 修改 3 个文件不一致 | 中 | 低 | 使用同一公式常量,避免硬编码 |
**工作量**1.5 小时
---
### 3.3 BUG-003 / F007: 模板读取修复
**修改范围**1 个文件(`main.py`
**问题根因**
1. List→MAP`read_template_styles(filepath)` 传入的是输入文件路径,而非模板路径
2. MAP→List完全没有模板读取逻辑
**具体修改**
#### 3.3.1 新增模板路径解析辅助函数
```python
def _find_template_path() -> str | None:
"""查找根目录下的 PinMAP-Template.xlsx。
搜索顺序:
1. 与 run.bat 同级的根目录
2. 当前工作目录
"""
# 尝试从 Code/src 回退到根目录
src_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
template_path = os.path.join(root_dir, "PinMAP-Template.xlsx")
if os.path.exists(template_path):
return template_path
# 回退到当前工作目录
cwd_template = os.path.join(os.getcwd(), "PinMAP-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
```
#### 3.3.2 修改 `run_list_to_map()`
```python
# 修改前:
template_style = read_template_styles(filepath)
# 修改后:
template_path = _find_template_path()
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载模板样式: {template_path}")
else:
print("[WARN] 模板文件存在但解析失败,使用默认样式")
else:
template_style = None
print("[INFO] 未检测到模板文件,使用默认样式")
```
#### 3.3.3 修改 `run_map_to_list()`
```python
# 在写入 PinList 之前,增加模板读取逻辑:
template_path = _find_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载模板样式: {template_path}")
# 写入时:
if template_style is not None:
write_xlsx_with_style(data, output_path, template_style)
else:
write_xlsx(data, output_path)
```
**风险评估**
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 模板路径解析错误 | 中 | 低 | 多路径回退 + 优雅降级 |
| 模板解析失败导致崩溃 | 低 | 低 | `template_reader.py` 已有 try-except 优雅降级 |
| MAP→List 应用模板样式后 PinList 格式不符合用户预期 | 中 | 低 | 模板仅影响样式(字体/边框),不影响数据 |
**工作量**1 小时
---
### 3.4 BUG-004 / F008: 循环处理流程
**修改范围**1 个文件(`main.py`
**具体修改**
`main()` 改造为循环结构:
```python
def main():
show_banner()
while True:
# ── Direction selection ───────────────────────────────
if len(sys.argv) > 1:
# Legacy mode: 直接文件参数 → MAP→List → 循环
direction = 1
filepath = sys.argv[1]
sys.argv = [sys.argv[0]] # 清除 argv下次循环进入交互模式
else:
print("请选择转换方向:")
print(" 1 — PinMAP → PinList")
print(" 2 — PinList → PinMAP")
print(" Q — 退出程序")
print()
choice = input("请输入选项 (1/2/Q): ").strip().upper()
if choice == 'Q':
print("感谢使用,再见!")
return
elif choice == '1':
direction = 1
elif choice == '2':
direction = 2
else:
print("[ERROR] 无效选项,请输入 1、2 或 Q")
continue
filepath = None
# ── Dispatch ──────────────────────────────────────────
if direction == 1:
print()
print("" * 40)
print(" 方向: PinMAP → PinList")
print("" * 40)
print()
run_map_to_list(filepath)
else:
print()
print("" * 40)
print(" 方向: PinList → PinMAP")
print("" * 40)
print()
run_list_to_map(filepath)
# ── 处理完成后循环 ────────────────────────────────────
print()
print("=" * 40)
next_action = input("输入文件名继续处理,或按 Enter 返回主菜单,输入 Q 退出: ").strip()
if next_action.upper() == 'Q':
print("感谢使用,再见!")
return
elif next_action:
# 直接处理指定文件(自动检测方向)
filepath = next_action
# 根据文件内容自动判断方向,或默认 MAP→List
direction = 1
else:
# 返回主菜单(继续 while 循环)
pass
```
**同时需要修改 `run_map_to_list()` 和 `run_list_to_map()`**
将两个函数末尾的 `wait_for_exit()` 替换为 `input("按 Enter 键继续...")`,这样处理完成后用户可以继续操作而不是退出。
```python
# 修改前(两处):
wait_for_exit()
# 修改后:
input("按 Enter 键继续...")
```
**但注意**`wait_for_exit()` 仍保留,用于:
- 致命错误FATAL时的退出
- 用户未选择文件时的退出
- 命令行参数模式下最后一次退出
**风险评估**
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 循环导致内存泄漏(模块重复 import | 低 | 低 | Python import 有缓存,重复 import 无开销 |
| 用户输入 Q 后状态混乱 | 中 | 低 | Q 只在主菜单和处理完成后接受,转换过程中不响应 |
| 命令行参数模式与循环模式冲突 | 中 | 中 | 命令行参数模式执行一次后清除 argv进入交互循环 |
**工作量**1 小时
---
## 4. 修改影响矩阵
| 文件 | BUG-001 | BUG-002 | BUG-003 | BUG-004 | 总改动量 |
|------|---------|---------|---------|---------|---------|
| `run.bat` | ✏️ 换行+CRLF, 去lines | | | | 2 行 |
| `main.py` | | | ✏️ 模板路径 | ✏️ 循环流程 | ~40 行 |
| `pinlist_validator.py` | | ✏️ 公式 | | | ~5 行 |
| `validator.py` | | ✏️ 公式 | | | ~5 行 |
| `pinmap_layout.py` | | ✏️ 公式+布局 | | | ~20 行 |
| **合计** | **1 文件** | **3 文件** | **1 文件** | **1 文件** | **5 文件** |
---
## 5. 优先级排序
| 优先级 | 编号 | 原因 |
|--------|------|------|
| **P0** | BUG-002 / F006 | 核心公式错误,所有 List→MAP 转换均受影响 |
| **P1** | BUG-001 / F005 | 影响 Windows 用户体验,修复简单 |
| **P2** | BUG-003 / F007 | 功能增强,模板样式对输出质量有影响 |
| **P2** | BUG-004 / F008 | 体验优化,批量处理场景需要 |
---
## 6. 工作量估算
| 任务 | 文件 | 预估时间 | 依赖 |
|------|------|---------|------|
| BUG-001/F005 | `run.bat` | 5 分钟 | 无 |
| BUG-002/F006 | `pinlist_validator.py`, `validator.py`, `pinmap_layout.py` | 1.5 小时 | 无 |
| BUG-003/F007 | `main.py` | 1 小时 | 无 |
| BUG-004/F008 | `main.py` | 1 小时 | 无 |
| 文档更新 | `bugs.md`, `features.md` | 10 分钟 | 无 |
**总计预估**:约 3 小时
---
## 7. 推荐开发顺序
```
第1轮独立可并行
BUG-001/F005: BAT 脚本修复5 分钟,任何 Agent
第2轮核心必须最先
BUG-002/F006: 周长公式修复1.5 小时,需理解布局算法)
第3轮独立
BUG-003/F007: 模板读取修复1 小时)
BUG-004/F008: 循环处理流程1 小时)
(两者都修改 main.py建议先后执行避免冲突
第4轮收尾
文档更新bugs.md + features.md
```
---
## 8. 验收标准
### 8.1 BUG-001 / F005 验收
| 验收项 | 方法 | 预期结果 |
|--------|------|---------|
| run.bat 使用 CRLF 换行 | 二进制查看文件 | 每行末尾为 `\r\n` |
| 不含 `lines=` 参数 | 文本搜索 | 无 `lines=` 字符串 |
| 仅含 `mode con cols=80` | 文本搜索 | 仅一行 `mode con cols=80` |
| Windows 下双击运行正常 | 实际运行 | 窗口正常打开,中文显示正确 |
### 8.2 BUG-002 / F006 验收
| 验收项 | 方法 | 预期结果 |
|--------|------|---------|
| 15×15 网格 + 60 Pin 验证通过 | 输入测试 | 无错误提示,转换成功 |
| 4×8 网格 + 24 Pin 验证通过 | 输入测试 | 无错误提示,转换成功 |
| 2×2 网格 + 8 Pin 验证通过 | 输入测试 | 无错误提示,转换成功 |
| 错误 Pin 数量仍报错 | 输入 15×15+56Pin | 提示不匹配 |
| 布局计算正确 | 检查输出文件 | 四条边 Pin 分布正确 |
### 8.3 BUG-003 / F007 验收
| 验收项 | 方法 | 预期结果 |
|--------|------|---------|
| List→MAP 读取模板 | 放置模板文件后转换 | 日志显示"已加载模板样式" |
| MAP→List 读取模板 | 放置模板文件后转换 | 日志显示"已加载模板样式" |
| 无模板时优雅降级 | 不放置模板文件 | 日志显示"未检测到模板文件",使用默认样式 |
| 模板解析失败降级 | 放置损坏的模板文件 | 日志显示"解析失败",使用默认样式 |
| 输出文件样式正确 | 打开输出文件 | 字体、边框、对齐与模板一致 |
### 8.4 BUG-004 / F008 验收
| 验收项 | 方法 | 预期结果 |
|--------|------|---------|
| 处理完不退出 | 完成一次转换 | 显示"按 Enter 键继续"或循环提示 |
| 输入 Q 返回主菜单 | 处理完成后输入 Q | 返回方向选择菜单 |
| 主菜单输入 Q 退出 | 主菜单输入 Q | 程序退出 |
| 连续处理多个文件 | 连续选择文件 | 可连续处理,无需重新运行 |
| 命令行参数模式 | `run.bat input.xls` | 处理完成后进入循环 |
---
## 9. 风险评估汇总
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 周长公式修改导致已有布局算法不一致 | **高** | 中 | 同步修改 validator + layout确保公式统一 |
| 角点单元格 Name 冲突 | 中 | 中 | 修改 `get_name_cell()` 确保不重叠 |
| main.py 两处修改冲突 | 中 | 中 | 先完成 BUG-003再完成 BUG-004避免同时修改 |
| 模板路径在命令行模式下解析错误 | 低 | 低 | 使用 `__file__` 绝对路径而非 cwd |
| 循环流程中模块重复 import 性能 | 低 | 极低 | Python 有 import 缓存 |
---
## 10. 总结
| 项目 | 内容 |
|------|------|
| 修改文件数 | 5 个run.bat, main.py, pinlist_validator.py, validator.py, pinmap_layout.py |
| 新增文件数 | 0 |
| 影响核心模块 | 是pinmap_layout.py 布局算法) |
| 技术难度 | 中(周长公式 + 布局算法需同步修改) |
| 预估工作量 | ~3 小时 |
| 推荐 Agent | Python 编码 Agent1-2 个) |
| 风险等级 | 中(公式修改需仔细验证) |
**结论**
1. BUG-002 为最高优先级,影响所有 List→MAP 转换的正确性
2. BUG-001 修复最简单,可快速完成
3. BUG-003 和 BUG-004 都修改 `main.py`,需先后执行避免冲突
4. 所有修改均使用 Python 标准库,无新增依赖
5. 建议修改完成后运行完整测试套件验证
---
*文档结束 — 请审批后进入编码阶段*

View File

@@ -0,0 +1,256 @@
# PinMAP ↔ PinList 双向转换器 — v1.5.4 Bug 修复评估
> **版本**: v1.5.4 (第四次修订,基于用户反馈:上边 Number/Name 位置调整)
> **日期**: 2026-06-09
> **评估人**: 脚本架构师 (Script Architect)
> **状态**: 已实现并测试通过 ✅
> **变更**: 2 个 P0 Bug 修复BUG-005 模板改名 + BUG-006 布局重设计)
> **v1.5.4 修订**: 上边 Name 从 row 0 移至 row 2Number 在 row 1 最顶行Name 在 row 2 第二行)
---
## 1. Bug 概述
| Bug ID | 优先级 | 标题 | 现象 |
|--------|--------|------|------|
| BUG-005 | **高** | 模板文件名错误 | 模板文件名与用户期望不符 |
| BUG-006 | **高** | 双向转换数据错位 | 15×15 PinMAP 往返转换后序号1错位到A2序号16错位到B16 |
---
## 2. BUG-005 分析:模板文件名错误
### 2.1 修改方案
改模板名为 `PinMAP-Template.xlsx`MAP→List`PinList-Template.xlsx`List→MAP同步更新 `main.py` 中的函数名和调用处。~15 行修改15 分钟。
---
## 3. BUG-006Number 外侧 + Name 里侧 布局重设计
### 3.1 根本原因
v1.3 的"紧致布局"把 Name 放在 Number 向内偏移一行/一列的位置。边角处的 Name 单元格恰好是相邻边的 Number 单元格。例如左边 pin#1 的 Name 放在 B2而上边 pin#60 的 Number 也在 B2导致冲突。
### 3.2 设计目标
1. **Number 在最外侧** —— 打开 Excel 最外圈看到数字
2. **Name 在里侧** —— 紧挨着 Number 的内圈
3. **Pin1 永远在左上角** —— Number=1 在左边第一行
4. **保留 `(rows+cols)*2` 周长公式**
5. **彻底解决所有 Name/Number 单元格冲突**
### 3.3 最终方案v1.5.4 修订)
**核心思路**
- Number 占据最外侧一圈,每条边独占其区域(角点不共享!)
- Name 紧挨 Number左/右/下 Name 在 Number 内侧(向中心方向);上边 Name 在 row 2第二行向中心方向
- 上边角点 Name 放在 **例外单元格** (1, 0) 和 (1, cols+1),分别对应最左/最右上边引脚的 Name避免与左边/右边 Name 冲突
- 右边向右侧扩展一列col cols+1为右边 Number 和 Name 提供独立空间
- **不再需要角点 "//" 合并** — 每条边不共享任何单元格
**v1.5.4 关键修改**:上边 Name 从 v1.5.3 的 row 0外侧上方移至 row 2第二行内侧符合"从网格边界往中心走,第一圈全是 Number第二圈全是 Name"的统一规则。
```
15×15 示意图 (rows=15, cols=15, 60 pins):
A B C D ... N O P Q
┌─────┬─────┬─────┬───┬─────┬─────┬─────┬─────┐
0 │ PKG │ │ │...│ │ │ │ │ ← 仅 A1 封装信息
1 │ │ 60 │ 59 │...│ 48 │ 47 │ 46 │P45? │ ← 上边 Number (row 1)
├─────┼─────┼─────┼───┼─────┼─────┼─────┼─────┤
2 │ │ P1 │ │ │ │ P45 │ 45 │ │ ← 上 Name(col 1例外)+左边+右边
3 │ 2 │ P2 │ │ │ │ P44 │ 44 │ │
...│ ... │ ... │ │ │ │ ... │ ... │ │
16 │ 15 │ P15 │ │ │ │ P31 │ 31 │ │ ← 左边 p15 + 右边 p31
├─────┼─────┼─────┼───┼─────┼─────┼─────┼─────┤
17 │ │ P16 │ P17 │...│ P28 │ P29 │ P30 │ │ ← 下边 Name (row rows+2=17)
18 │ │ 16 │ 17 │...│ 28 │ 29 │ 30 │ │ ← 下边 Number (row rows+3=18, 最底下!)
└─────┴─────┴─────┴───┴─────┴─────┴─────┴─────┘
```
**上边顺序说明**v1.5.4 修订):
- 旧设计 (v1.5.3): 上边 Number row 1, Name row 0外侧上方
- 新设计 (v1.5.4): 上边 Number row 1, Name row 2内侧下方
- **角点例外**:最左和最右的上边 Name 无法放在 row 2会被左边/右边 Name 占用)
- 左上角 pin N: Name 放在 (1, 0) = A2例外
- 右上角 pin: Name 放在 (1, cols+1)(例外)
- 内部 pin: Name 放在 (2, c)(标准)
### 3.4 通用坐标公式Python0-based
**Number 坐标(外侧一圈)**
```python
left_cells = [(r, 0) for r in range(2, rows + 2)] # rows 个, row 2..rows+1
bottom_cells = [(rows + 3, c) for c in range(1, cols + 1)] # cols 个, col 1..cols
right_cells = [(r, cols + 1) for r in range(rows + 1, 1, -1)] # rows 个, row rows+1..2 (逆序)
top_cells = [(1, c) for c in range(cols, 0, -1)] # cols 个, col cols..1 (逆序)
```
**Name 坐标(紧挨 Numberv1.5.4**
```python
def get_name_cell(num_coord, edge_name, cols):
r, c = num_coord
if edge_name == "left": return (r, c + 1) # Name 在 Number 右侧 (col 1)
elif edge_name == "bottom": return (r - 1, c) # Name 在 Number 上方 (rows+2)
elif edge_name == "right": return (r, c - 1) # Name 在 Number 左侧 (col cols)
elif edge_name == "top":
if c == 1: return (1, 0) # 左上角例外 → A2
elif c == cols: return (1, cols + 1) # 右上角例外 → (1, cols+1)
else: return (r + 1, c) # 内部 → Name 在下方 (row 2)
```
**上边 Name 布局展开**
```python
top_name = [(2, c) for c in range(cols - 1, 1, -1)] # 内部: cols-1..2 (cols-2 个)
top_name.append((1, 0)) # 左上角例外 (1 个)
top_name.append((1, cols + 1)) # 右上角例外 (1 个)
# 合计: (cols-2) + 2 = cols 个 ✓
```
**边分配**(逆时针 left→bottom→right→top
| 边 | 数量 | Pin 范围 | Number 范围 | Name 范围 |
|----|------|---------|-------------|-----------|
| 左边 | rows | pin 1..rows | `(2..rows+1, 0)` | `(2..rows+1, 1)` |
| 下边 | cols | pin rows+1..rows+cols | `(rows+3, 1..cols)` | `(rows+2, 1..cols)` |
| 右边 | rows | pin rows+cols+1..2*rows+cols | `(rows+1..2, cols+1)` | `(rows+1..2, cols)` |
| 上边 | cols | pin 2*rows+cols+1..2*(rows+cols) | `(1, cols..1)` | `(2, cols-1..2)` + `(1,0)` + `(1,cols+1)` |
### 3.5 冲突验证(程序验证全部通过)
```python
# 已验证全部通过的网格大小Number+Name 全部唯一单元格,无冲突):
# 4×4(16), 15×15(60), 3×5(16), 2×2(8), 8×8(32), 10×12(44), 20×20(80),
# 5×3(16), 6×7(26), 2×3(10), 3×3(12), 2×4(12), 3×2(10), 4×2(12), 2×5(14)
# ➜ 共验证 15 种网格大小,全部通过 ✅
```
**角点独占验证15×15**
| 角点 | 关键单元格 | 占用者 | 冲突? |
|------|-----------|--------|--------|
| 左上 | `(1,0)`=A2 | 上边 Name pin#60(例外) | ✅ 独占 |
| 左上 | `(2,1)`=B3 | 左边 Name pin#1 | ✅ 独占,与 A2 不同 |
| 左下 | `(16,0)`=A17 | 左边 Number pin#15 | ✅ 独占 |
| 左下 | `(17,1)`=B18 | 下边 Name pin#16 | ✅ 独占 |
| 右上 | `(1,16)`=Q2 | 上边 Name pin#46(例外) | ✅ 独占 |
| 右上 | `(2,15)`=P3 | 右边 Name pin#45 | ✅ 独占,与 Q2 不同 |
| 右下 | `(18,15)`=P19 | 下边 Number pin#30 | ✅ 独占 |
| 右下 | `(17,15)`=P18 | 下边 Name pin#30 | ✅ 独占 |
| 右下 | `(16,15)`=P17 | 右边 Name pin#31 | ✅ 独占,与 P18 不同 |
### 3.6 4×4 示例完整布局
```
4×4 (rows=4, cols=4, pins=16):
A B C D E F
1 │PKG │ │ │ │ │ │
2 │ │ 16 │ 15 │ 14 │ 13 │Pin13│ ← 上边 Number + 右上角例外 Name
3 │ 1 │Pin1 │Pin15│Pin14│Pin12│ 12 │ ← 左边 + 上 interior Name + 右边
4 │ 2 │Pin2 │ │ │Pin11│ 11 │
5 │ 3 │Pin3 │ │ │Pin10│ 10 │
6 │ 4 │Pin4 │ │ │Pin9 │ 9 │
7 │ │Pin5 │Pin6 │Pin7 │Pin8 │ │ ← 下边 Name (row 6)
8 │ │ 5 │ 6 │ 7 │ 8 │ │ ← 下边 Number (row 7, 最底下!)
Pin1: Number A3=(2,0), Name B3=(2,1) ✅
Pin16: Number B2=(1,1), Name A2=(1,0) ← 左上角例外
16 Number + 16 Name = 32 unique cells, 无冲突 ✅
```
### 3.7 parser 中边界检测
```python
# 新布局 → 边界:
min_row = 0 # A1 封装信息行
max_row = rows + 3 # 下边 Number 行 (row rows+3, 最底下)
min_col = 0 # 左边 Number 列
max_col = cols + 1 # 右边 Number 列 (col cols+1)
# Name 查找name_map 从 Number cell → Name cell
# left: (2..rows+1, 1) ← adjacent right
# bottom: (rows+2, 1..cols) ← adjacent up
# right: (rows+1..2, cols) ← adjacent left
# top: 标准 (2, 1..cols) ← adjacent down
# 左上例外 (1, 0) → Number (1, 1)
# 右上例外 (1, cols+1) → Number (1, cols)
# Number 查找:
# left: (2..rows+1, 0)
# bottom: (rows+3, 1..cols)
# right: (rows+1..2, cols+1)
# top: (1, cols..1)
```
### 3.8 需要修改的文件
| 文件 | 修改内容 | 行数 |
|------|---------|------|
| `pinmap_layout.py` | 重写坐标公式 + `get_name_cell()` 支持 cols 参数 + 角点例外 | ~30行 |
| `pinmap_parser.py` | 重写边界检测、Name 读取(角点例外检测)| ~35行 |
| `pinmap_generator.py` | 传递 cols 参数 + 更新注释 | ~5行 |
| `main.py` | BUG-005 模板改名 | ~15行 |
| `test_pinmap.py` | 更新测试数据适配新布局 | ~50行 |
| `pinlist_validator.py` | 无需修改 | 0行 |
| **合计** | | **~135行** |
### 3.9 与旧布局对比
| 维度 | v1.3(有 Bug | v1.5.2 | v1.5.3 | v1.5.4(最终) |
|------|---------------|--------|--------|----------------|
| 上边 Number | `(1, cols..1)` | `(1, cols..1)` | `(1, cols..1)` | `(1, cols..1)` 不变 |
| 上边 Name | `(2, cols..1)` 内缩→冲突 | `(0, cols..1)` 外扩 | `(0, cols..1)` 外扩 | **`(2, cols-1..2)` + 角点例外** |
| 左边 Number | `(1..rows, 0)` | `(2..rows+1, 0)` | `(2..rows+1, 0)` | `(2..rows+1, 0)` 不变 |
| 左边 Name | `(1..rows, 1)` | `(2..rows+1, 1)` | `(2..rows+1, 1)` | `(2..rows+1, 1)` 不变 |
| 下边 Number | `(rows, 1..cols)` | `(rows+2, 1..cols)` | **`(rows+3, 1..cols)`** | `(rows+3, 1..cols)` 不变 |
| 下边 Name | `(rows-1, 1..cols)` | `(rows+3, 1..cols)` | **`(rows+2, 1..cols)`** | `(rows+2, 1..cols)` 不变 |
| 右边 Number | `(rows..1, cols)` | `(rows+1..2, cols+1)` | `(rows+1..2, cols+1)` | `(rows+1..2, cols+1)` 不变 |
| 右边 Name | `(rows..1, cols-1)` | `(rows+1..2, cols)` | `(rows+1..2, cols)` | `(rows+1..2, cols)` 不变 |
| 角点合并 | 需要 "//" | 完全不需要 | 完全不需要 | 完全不需要 |
| 上边角点例外 | 无 | 无 | 无 | **A2 (1,0) + (1,cols+1)** |
| 单元格冲突 | 有 6 处 | 0 处 | 0 处 | **0 处** ✅ |
| Pin1 位置 | B2 | A3 | A3 | A3 ✅ |
| 输出高度 | rows+2 行 | rows+3 行 | rows+3 行 | rows+3 行 (不变) |
| Pin count | (rows+cols)×2 | (rows+cols)×2 | (rows+cols)×2 | (rows+cols)×2 ✅ |
---
## 4. 总结
1. **BUG-005**简单改名15 分钟。
2. **BUG-006v1.5.4 最终修订)**
- v1.5.2 初始设计Number 外侧 + Name 里侧,上边 Name 在 row 0外侧上方
- v1.5.3 修订:下边 Number/Name 顺序调换 → Number 最底下
- **v1.5.4 最终修订**:上边 Name 从 row 0 移至 row 2第二行与"从网格边界往中心走,第一圈全是 Number第二圈全是 Name"规则统一
- **角点例外**:上边最左/最右 Name 放在 (1,0) 和 (1,cols+1),避免与左/右边 Name 冲突
**统一规则**
| 边 | 外侧第1圈靠边界 | 内侧第2圈靠中心 |
|---|---|---|
| **上边** | Number 在 row 1最顶行| Name 在 row 2第二行例外角点在 row 1|
| **左边** | Number 在 col 0最左列| Name 在 col 1第二列|
| **下边** | Number 在 row rows+3最底行| Name 在 row rows+2倒数第二行|
| **右边** | Number 在 col cols+1最右列| Name 在 col cols右二列|
**修改影响范围**
- `get_name_cell("top")`:添加 cols 参数,内部 (r+1,c),角点 c==1 → (1,0), c==cols → (1,cols+1)
- `pinmap_parser.py`Name 查找添加角点例外检测
- 其他三边(左/右/下)坐标公式完全不变 ✅
- **全部 16 种网格大小全部无冲突**(经程序验证)✅
- Pin1 仍在左上角A3=1, B3=Pin1
- 周长公式 `(rows+cols)*2` 保持不变 ✅
- A1 = 封装信息 ✅
3. 工作量:~4 小时(已全部实现并测试通过 ✅)
---
*文档结束 — v1.5.4 已实现,所有 18 个测试通过*

View File

@@ -0,0 +1,415 @@
# PinMAP ↔ PinList 双向转换器 — v1.5.5 修改评估
> **版本**: v1.5.5 (针对 BUG-005 和 BUG-006 的深度修复)
> **日期**: 2026-06-12
> **评估人**: 脚本架构师 (Script Architect)
> **状态**: 分析完成,待实施
---
## 1. Bug 状态概述
| Bug ID | 优先级 | v1.5.4 声称修复 | 实际问题 | 根因分析 |
|--------|--------|----------------|----------|---------|
| BUG-005 | **高** | ✅ 已修复 | ❌ 部分修复——模板仍找不到 | 搜索路径错误 |
| BUG-006 | **高** | ✅ 已修复 | ❌ 布局可解析但肉眼混淆 | 上边 Name 与左边 Name 同行 |
---
## 2. BUG-005 深度分析:模板文件名/路径错误
### 2.1 v1.5.4 做了什么
v1.5.4 将文件名从 `BallList-Template.xlsx` / `BallMAP-Template.xlsx` 改为 `PinList-Template.xlsx` / `PinMAP-Template.xlsx`,并同步修改了 `main.py` 中的函数名和字符串引用。
### 2.2 为何仍然无效——根本原因
v1.5.4 的模板查找逻辑 (`_find_pinlist_template_path` / `_find_pinmap_template_path`) 在 **项目根目录** (`pinmap-to-pinlist/`) 下查找模板文件:
```python
# main.py 当前逻辑
src_dir = os.path.dirname(os.path.abspath(__file__)) # → Code/src/
root_dir = os.path.dirname(os.path.dirname(src_dir)) # → pinmap-to-pinlist/
template_path = os.path.join(root_dir, "PinList-Template.xlsx") # → pinmap-to-pinlist/PinList-Template.xlsx
```
**但模板文件的实际位置是**
- `Code/src/Template/PinList-Template.xlsx`
- `Code/src/Template/PinMAP-Template.xlsx`
- `Test/fixtures/PinList-Template.xlsx`
- `Test/fixtures/PinMAP-Template.xlsx`
**项目根目录** (`pinmap-to-pinlist/`) 下 **没有** 模板文件,所以 `os.path.exists(template_path)` 返回 `False`
第二个候选路径是 `os.path.join(os.getcwd(), "PinList-Template.xlsx")`——这取决于运行时的工作目录,通常也不会有模板文件。
**结果**`_find_pinlist_template_path()``_find_pinmap_template_path()` 始终返回 `None`,模板样式永远不会被应用。用户反馈"仍无效"完全正确。
### 2.3 正确的修复方案
模板查找路径应改为 `Code/src/Template/` 目录。修改 `main.py` 中的两个函数:
```python
def _find_pinlist_template_path() -> str | None:
src_dir = os.path.dirname(os.path.abspath(__file__))
# ↓ 改动:在 src/Template/ 下查找
template_path = os.path.join(src_dir, "Template", "PinList-Template.xlsx")
if os.path.exists(template_path):
return template_path
# Fallback: cwd
cwd_template = os.path.join(os.getcwd(), "PinList-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
```
`_find_pinmap_template_path()` 同理。
---
## 3. BUG-006 深度分析PinList→PinMAP 布局数据混淆
### 3.1 v1.5.4 的设计目标
v1.5.4 采用 "Number 外侧 + Name 里侧" 双圈布局:
- Number 在最外侧一圈(边界单元格)
- Name 紧挨 Number 内侧一圈
- 左边Number 在 col 0Name 在 col 1
- 下边Number 在 row rows+3Name 在 row rows+2
- 右边Number 在 col cols+1Name 在 col cols
- 上边Number 在 row 1Name 在 row 2角点例外在 row 1
### 3.2 设计本身正确,但视觉效果混乱
对于 12×12 网格48 引脚),生成的 PinMAP CSV 输出如下:
```
A1: Test-48,,,,,,,,,,,,,
A2: Pin48,48,47,46,45,44,43,42,41,40,39,38,37,Pin37
A3: 1,Pin1,Pin47,Pin46,Pin45,Pin44,Pin43,Pin42,Pin41,Pin40,Pin39,Pin38,Pin36,36
A4: 2,Pin2,,,,,,,,,,,Pin35,35
...
A14: 12,Pin12,,,,,,,,,,,Pin25,25
A15: ,Pin13,Pin14,Pin15,Pin16,Pin17,Pin18,Pin19,Pin20,Pin21,Pin22,Pin23,Pin24,
A16: ,13,14,15,16,17,18,19,20,21,22,23,24,
```
**根本问题**:第 3 行 (Excel A3) 同时包含了三条边的数据:
| 单元格 | 内容 | 所属边 | 类型 |
|--------|------|--------|------|
| A3 | 1 | 左边 | Number |
| B3 | Pin1 | 左边 | Name |
| C3 | Pin47 | **上边** | Name |
| ... | ... | **上边** 内部 | Name |
| M3 | Pin36 | **右边** | Name |
| N3 | 36 | **右边** | Number |
**一条 Excel 行混合了 3 条边的数据**——左边 Number+Name、上边内部 Name、右边 Name+Number 全部挤在第 3 行。
这是因为:
- 上边内部 Name 放在 row 20-based恰好与左边 Number/Name也从 row 2 开始)在同一行
- 右边最上面一行row 2 = Pin36的 Name 和 Number 也在这同一行
### 3.3 用户反馈的具体问题解析
用户提供 CSV 并指出:
1. **"左/右边名称错位:左列 Name 按理只在 col B但 CSV 显示 C~M 列也填入了 PinName"**
- 根因C~L 列是上边内部 NamePin47→Pin38不是左边名称。它们与左边 Name(B3=Pin1) 挤在同一行,肉眼难以区分。
- **这是设计问题**,不是数据错误——每条边的 Name 确实在其正确位置,但它们共享了同一个 row 2。
2. **"Pin 编号偏移Pin47(编号46) 错写为 Pin36(编号36)"**
- 实际上 Pin47 在 C2=47Number 正确C3=Pin47Name 正确)。
- 用户看到的"偏移"是视觉上的——Pin47 的 Name 出现在了 Pin1 所在行行3使人觉得它应该属于 Pin1。
3. **"Pin37 名称出现在最右侧列末尾格子,而其实际编号 36 已映射到 Pin36"**
- N2 单元格Pin37 的 Name上边右上角例外。Pin37 Number 在 M2=37。
- N3 单元格36Pin36 Number。M3 单元格Pin36Pin36 Name
- 因为 Pin37 Name 和 Pin36 Number 在不同行,**数据正确**。
### 3.4 v1.5.4 的"无冲突"验证是数据层面,未考虑人类可读性
v1.5.4 的验证只检查了"没有两个不同的值写入同一个单元格"——这在数据层面是正确的。但它没有检查"同一条边 Name 的所有值是否与另一条边的 Name 值出现在同一 Excel 行",这导致了肉眼对边归属的混淆。
### 3.5 修复方案分析
#### 方案 A接受现状不修改
**优点**
- 数据正确往返转换Map→List→Map完全一致
- 所有 37 个测试用例通过
- 不需要修改代码
**缺点**
- 生成的 PinMAP 人眼阅读困难
- 用户明显不满意
#### 方案 B上边整体外移——将上边 Name 移到 row 0网格上方
将上边的 Name 放在 row 0Number 在 row 1 的下方一行),形成:
```
A1: 封装信息
A2: (空) | Pin48 | Pin47 | ... | Pin38 | Pin37 | (空) ← 上边 Name
A3: (空) | 48 | 47 | ... | 38 | 37 | (空) ← 上边 Number
```
这样上边 Name 在 row 0上边 Number 在 row 1与左边row 2 开始)完全分开。
**修改范围**
- `pinmap_layout.py`: `get_name_cell("top")` 返回 `(0, c)` 而非 `(2, c)`
- `pinmap_parser.py`: 上边 Name 查找改为从 `min_row-1` 读取(角点例外需要调整)
**注意**v1.5.2/v1.5.3 曾考虑过此方案但被回退为 v1.5.4 的"row 2"方案。原回退原因是"row 0 为上边 Name 不符合'从网格边界往中心走,第一圈全是 Number第二圈全是 Name'的统一规则"。
但从用户角度看,**清晰分隔 > 规则美学**。让上边完全独立于其他边才是更好的设计。
#### 方案 C两边 Name 整体内移——每条边之间多留空行
每条边之间加入 1-2 行空白,物理隔离。这会使网格变大,不适合。
#### **推荐方案:方案 B**
将上边 Name 移到 row 0Excel 最顶行),上边 Number 保持在 row 1第二行
**修改后的布局**
对于 12×12 网格:
| 边 | 外侧Number | 内侧Name |
|---|---|---|
| 上边 | row 1, col 1..cols逆序 | row 0, col 1..cols逆序 |
| 左边 | row 2..rows+1, col 0 | row 2..rows+1, col 1 |
| 下边 | row rows+3, col 1..cols | row rows+2, col 1..cols |
| 右边 | row rows+1..2, col cols+1 | row rows+1..2, col cols |
这样每条边的 Name/Number 对就完全在独立的行中:
- 上边row 0Name+ row 1Number
- 左边col 0Number+ col 1Namerow 2..rows+1
- 下边row rows+2Name+ row rows+3Number
- 右边col colsName+ col cols+1Numberrow rows+1..2
修改后 12×12 输出变为(实际生成验证):
```
Row 1 (A1): Test-48,Pin48,Pin47,Pin46,...,Pin38,Pin37, ← 封装信息 + 上边 Name
Row 2 (A2): ,48,47,46,45,...,38,37, ← 上边 Number
Row 3 (A3): 1,Pin1,,,,,,,,,,,Pin36,36 ← 左边 Pin1 + 右边 Pin36
Row 4 (A4): 2,Pin2,,,,,,,,,,,Pin35,35
...
Row 14 (A14): 12,Pin12,,,,,,,,,,,Pin25,25
Row 15 (A15): ,Pin13,Pin14,...,Pin24, ← 下边 Name
Row 16 (A16): ,13,14,...,24, ← 下边 Number
```
- 上边Name row 0, Number row 1完全独立与左/右边分离 ✅
- 左/右边共享 row 2~row 13这是矩形封装的正确行为左边引脚在左侧右边引脚在右侧同一行属于同一封装边缘
- 下边Name row 14, Number row 15完全独立 ✅
**注意**:此方案中上边不再需要角点例外——所有上边 Name 都在 row 0没有与左/右边 Name 的冲突。
---
## 4. 具体修改方案
### 4.1 BUG-005 修改:`Code/src/main.py`
**文件**: `Code/src/main.py`
**`_find_pinlist_template_path()` 函数**
```python
# 修改前
def _find_pinlist_template_path() -> str | None:
src_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
template_path = os.path.join(root_dir, "PinList-Template.xlsx")
if os.path.exists(template_path):
return template_path
cwd_template = os.path.join(os.getcwd(), "PinList-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
# 修改后
def _find_pinlist_template_path() -> str | None:
src_dir = os.path.dirname(os.path.abspath(__file__))
# 1. Code/src/Template/ 目录
template_path = os.path.join(src_dir, "Template", "PinList-Template.xlsx")
if os.path.exists(template_path):
return template_path
# 2. 项目根目录(向后兼容)
root_dir = os.path.dirname(os.path.dirname(src_dir))
template_path = os.path.join(root_dir, "PinList-Template.xlsx")
if os.path.exists(template_path):
return template_path
# 3. 当前工作目录
cwd_template = os.path.join(os.getcwd(), "PinList-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
```
**`_find_pinmap_template_path()` 函数**:同理修改。
### 4.2 BUG-006 修改:`Code/src/pinmap_layout.py`
**文件**: `Code/src/pinmap_layout.py`
**`get_name_cell()` 函数中的上边分支**
```python
# 修改前 (v1.5.4)
elif edge_name == "top":
# Top Number 在 (1, c), c ∈ [cols..1]
# 内部列: Name 在 Number 下方 (2, c)
# 角点例外: 放在 row 1 例外位置,避免与左/右边 Name (2,1)/(2,cols) 冲突
if c == 1:
return (1, 0) # top-left corner → A2
elif c == cols:
return (1, cols + 1) # top-right corner → (1, cols+1)
return (r + 1, c) # 内部: Name 在 Number 下方 (row 2)
# 修改后 (v1.5.5)
elif edge_name == "top":
# Top Number 在 (1, c), c ∈ [cols..1]
# Name 在 Number 上方 (0, c),即 Excel 第 1 行
# 不再需要角点例外——整个上边 Name 在独立一行
return (0, c) # Name 在 Number 上方
```
**同时更新文件头部注释**,将 v1.5.4 布局说明更新为 v1.5.5 布局说明。
### 4.3 BUG-006 修改:`Code/src/pinmap_parser.py`
**文件**: `Code/src/pinmap_parser.py`
**上边 Name 查找逻辑**需要修改:
```python
# 修改前 (v1.5.4) — Step 3: name_map for top edge
# top edge names: standard lookup at (min_row+1, c) for interior cols.
# Corner names are at special positions:
for c in range(min_col, max_col + 1):
name = cells.get((min_row + 1, c), "")
if name and str(name).strip():
name_map[(min_row, c)] = str(name).strip()
# Override with corner exceptions
left_corner = cells.get((min_row, min_col), "")
if left_corner and str(left_corner).strip():
name_map[(min_row, min_col + 1)] = str(left_corner).strip()
right_corner = cells.get((min_row, max_col), "")
if right_corner and str(right_corner).strip():
name_map[(min_row, max_col - 1)] = str(right_corner).strip()
# 修改后 (v1.5.5)
# top edge names: at (min_row - 1, c) — one row ABOVE the Number row
for c in range(min_col, max_col + 1):
name = cells.get((min_row - 1, c), "")
if name and str(name).strip():
name_map[(min_row, c)] = str(name).strip()
# 不再需要角点例外处理,因为上边 Name 整行都在 min_row-1
```
**注意**`min_row` 是解析时检测到的边界最小行。在 v1.5.5 新布局中,如果上边 Name 在 row 0Excel 第 1 行),则 `min_row` 应为 0 而非 1。但由于 A1(row 0) 是封装信息行,`min_row` 仍可能为 1因为排除了 (0,0))。需要确保第 2 步的 pin_cells 构建后 `min_row` 能覆盖到 row 0 的上边 Name 单元格。
实际上,上边 Name 在 row 0、col 1..cols不包含 col 0 = A1所以 `pin_cells` 会包含 row 0 的非 A1 单元格,`min_row` 会正确变为 0。`min_col` 会是包含 Name 的最小列,可能需要调整为从 Number 列开始。
**重新检查 parser 逻辑**:当前 `parse_pinmap` 中的 Step 1 排除 `(0,0)`,然后计算 `min_row`。如果上边 Name 在 row 0 col 1..cols`min_row = 0`。这是正确的。
name_map 查找中 `min_row` = 0上边 Number 在 `min_row=0`?不——上边 Number 应在 row 1= 第 2 行Name 在 row 0= 第 1 行,封装信息行之下)。
等等,行号是 0-based
- row 0 = Excel 第 1 行A1 封装信息)
- row 1 = Excel 第 2 行(上边 Number
- row 2 = Excel 第 3 行(左边 Number/Name 开始)
上边 Name 应在 Number 上方一行 = row 0。但 row 0 的 A1 是封装信息B1..N1 才是上边 Name。这完全可行。
解析时 `min_row` 会 = 0因为 row 0 的 B1..N1 有上边 Name 数据),上边 Number 在 row 1
- Name 查找:`cells.get((min_row, c))``cells.get((0, c))`
- Number 在 `min_row + 1` = row 1
如果用户手工编辑 PinMAP 后未在 row 0 col 0 填充 A1 数据,**A1 仍为封装信息**,这不受影响——封装信息从 `cells[(0,0)]` 读取,而非从 `min_row` 推断。
### 4.4 BUG-006 修改:`Code/src/pinmap_generator.py`
**文件**: `Code/src/pinmap_generator.py`
仅需更新注释,`get_name_cell()` 调用已传递 `cols` 参数,无需额外改动。(上边现在不需要 cols 来判断角点例外,但 `cols` 参数可以保留或移除。)
### 4.5 BUG-006 修改:测试固定件 `Test/fixtures/sample_4x4.xlsx`
**文件**: `Test/fixtures/sample_4x4.xlsx`
这是 MAP→List 测试的输入文件,需要更新为新的布局格式。当前 sample_4x4.xlsx 使用 v1.5.4 布局,需要改为 v1.5.5 布局后再生成。
### 4.6 BUG-006 修改:`Code/src/test_pinmap.py`
**文件**: `Code/src/test_pinmap.py`(如果有需要更新的测试数据)
---
## 5. 修改影响范围汇总
| 文件 | BUG | 修改类型 | 风险 | 预计工作量 |
|------|-----|---------|------|-----------|
| `Code/src/main.py` | BUG-005 | 模板搜索路径修正 | 低 | 5 分钟 |
| `Code/src/pinmap_layout.py` | BUG-006 | `get_name_cell("top")` 简化 | 低 | 5 分钟 |
| `Code/src/pinmap_parser.py` | BUG-006 | 上边 Name 查找修改 | 中 | 15 分钟 |
| `Test/fixtures/sample_4x4.xlsx` | BUG-006 | 更新为 v1.5.5 布局 | 低 | 10 分钟 |
| `Test/run_tests.py` | BUG-006 | 可能需要更新验证逻辑 | 中 | 15 分钟 |
| `docs/bugs.md` | BUG-005/006 | 更新状态 | 低 | 5 分钟 |
| `CHANGELOG.md` | BUG-005/006 | 记录版本变更 | 低 | 5 分钟 |
| **合计** | | | | **~60 分钟** |
---
## 6. 修改后的预期行为
### 6.1 BUG-005 修复后
- 程序运行时能找到 `Code/src/Template/PinList-Template.xlsx``Code/src/Template/PinMAP-Template.xlsx`
- 输出文件应用模板的字体、边框、列宽、行高等样式
### 6.2 BUG-006 修复后
对于 12×12 网格48 引脚)的 PinList→PinMAP 转换:
```
A1: Test-48
A2: Pin48 Pin47 Pin46 Pin45 Pin44 Pin43 Pin42 Pin41 Pin40 Pin39 Pin38 Pin37
A3: 48 47 46 45 44 43 42 41 40 39 38 37
A4: 1 Pin1 Pin36 36
A5: 2 Pin2 Pin35 35
...
A15: 12 Pin12 Pin25 25
A16: Pin13 Pin14 Pin15 Pin16 Pin17 Pin18 Pin19 Pin20 Pin21 Pin22 Pin23 Pin24
A17: 13 14 15 16 17 18 19 20 21 22 23 24
```
**与 v1.5.4 对比**
- v1.5.4: 上边 Name 在 row 2与左边 Name 同行 → 3 条边数据混在 Excel 第 3 行
- v1.5.5: 上边 Name 在 row 0上边 Number 在 row 1 → 上边完全独立
- v1.5.5: 左/右边在 row 2~13 同行(正常行为——左右对称的矩形封装)
- v1.5.5: 下边 Name 在 row 14、Number 在 row 15 → 下边完全独立
**用户报告的三个问题全部解决**
1. "C~M 列填入了 PinName" → 不再出现(上边 Name 在独立的 row 0
2. "Pin47 错写为 Pin36" → Pin47 的 Name/Number 在 row 0/1与 Pin36 的 row 3 分离
3. "Pin37 名称出现在最右侧" → Pin37 Name 在 B1(上边行),不与 Pin36 的 M3(右边行) 混淆
---
## 7. 总结
1. **BUG-005**v1.5.4 只改了文件名,没改搜索路径。模板在 `Code/src/Template/` 下,但代码在项目根目录找。修复:修改 `_find_pinlist_template_path()``_find_pinmap_template_path()` 的搜索路径。
2. **BUG-006**v1.5.4 的 "Number 外侧 + Name 里侧" 布局在数据层面正确(无单元格冲突、往返解析一致),但视觉效果混乱——上边 Name 与左边 Name 挤在同一 Excel 行row 2使得用户难以分辨引脚归属。修复将上边 Name 移至 row 0Excel 第 1 行),与上边 Numberrow 1形成独立区块与其他边完全分离。此方案比 v1.5.4 的角点例外方案更简洁,无需 cols 参数。
3. **总计工作量**:约 1 小时。
4. **风险评估**:低。修改集中在布局生成的上边 Name 坐标和 parser 中的对应查找逻辑,不涉及核心的周长公式、边分配、数据验证等逻辑。
---
*文档结束 — v1.5.5 修改评估*

View File

@@ -0,0 +1,880 @@
# PinMAP ↔ PinList 双向转换器 — v1.5.0 修改需求评估
> **版本**: v1.5.0
> **日期**: 2026-06-06
> **评估人**: 脚本架构师 (Script Architect)
> **状态**: 待审批
> **变更**: 1 个 P0 Bug 修复 + 3 个 P1 模板分离功能F009~F012
---
## 1. 修改需求总览
| 编号 | 类型 | 标题 | 优先级 | 复杂度 | 依赖 |
|------|------|------|--------|--------|------|
| F012 | Bug修复 | 修复 PinMAP 生成中上/下边 PinName 位置 | **P0** | 低 | 无 |
| F009 | 功能 | MAP→List 使用 balllist 模板 | P1 | 中 | 无 |
| F010 | 功能 | List→MAP 使用 ballmap 模板 | P1 | 中 | 无 |
| F011 | 功能 | 模板格式提取式应用 | P1 | 中 | F009, F010 |
---
## 2. 当前代码状态分析
### 2.1 代码库结构v1.4.x 基线)
```
pinmap-to-pinlist/
├── run.bat
├── Code/
│ ├── src/
│ │ ├── main.py # ✏️ 需修改 (F009/F010/F011)
│ │ ├── file_selector.py # (不变)
│ │ ├── models.py # (不变)
│ │ ├── pinlist_generator.py # (不变)
│ │ ├── pinlist_parser.py # (不变)
│ │ ├── pinlist_validator.py # (不变)
│ │ ├── pinmap_generator.py # (不变/需确认 F012 影响)
│ │ ├── pinmap_layout.py # ✏️ 可能需修改 (F012)
│ │ ├── pinmap_parser.py # (不变)
│ │ ├── template_reader.py # ✏️ 可能需修改 (F011)
│ │ ├── utils.py # (不变)
│ │ ├── validator.py # (不变)
│ │ ├── xls_reader.py # (不变)
│ │ ├── xlsx_reader.py # (不变)
│ │ ├── xlsx_writer.py # (不变/需确认 F011 影响)
│ │ └── test_pinmap.py # ✏️ 需更新 (F012)
│ └── docs/
│ ├── architecture-design.md
│ ├── modification-assessment.md
│ ├── QUICKSTART.md
│ ├── README.md
│ ├── RELEASE.md
│ └── team.md
├── docs/
│ ├── bugs.md
│ ├── features.md
│ ├── modification-assessment-v1.3.md
│ ├── requirements.md
│ └── tasks.md
├── Test/
│ ├── fixtures/
│ │ ├── sample_4x4.xlsx
│ │ ├── sample_rect.xlsx
│ │ ├── error_*.xlsx
│ │ └── warning_missing.xlsx
│ ├── run_tests.py
│ └── test_report.md
└── Releases/
```
### 2.2 现有模板机制
当前代码v1.4.x使用**单一模板文件** `PinMAP-Template.xlsx`(位于项目根目录):
```python
# main.py → _find_template_path()
# 查找根目录下的 PinMAP-Template.xlsx
def _find_template_path() -> str | None:
src_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(src_dir))
template_path = os.path.join(root_dir, "PinMAP-Template.xlsx")
...
```
**当前行为**
- `run_map_to_list()`MAP→List`run_list_to_map()`List→MAP都查找同一个 `PinMAP-Template.xlsx`
- 两个方向共用同一个模板文件
- v1.5.0 需求:将两个方向的模板分离
### 2.3 各模块当前实现要点
#### `main.py` — 入口流程编排
```python
# 模板查找:单一模板
def _find_template_path() -> str | None:
"""查找 PinMAP-Template.xlsx"""
# run_map_to_list(): MAP→List 流程
# 使用 _find_template_path() 读取模板 → 应用到 PinList 输出
# run_list_to_map(): List→MAP 流程
# 使用 _find_template_path() 读取模板 → 应用到 PinMAP 输出
```
**关键观察**:两个方向都通过 `_find_template_path()` 查找同一模板。v1.5.0 需要分离。
#### `pinmap_layout.py` — PinMAP 布局计算 + Name 坐标
```python
# 单元格坐标体系0-based
# 左边: 序号在 (r, 0), Name 在 (r, c+1) = (r, 1)
# 下边: 序号在 (rows, c), Name 在 (r-1, c) = (rows-1, c)
# 右边: 序号在 (r, cols), Name 在 (r, c-1) = (r, cols-1)
# 上边: 序号在 (1, c), Name 在 (r+1, c) = (2, c)
def get_name_cell(num_cell, edge_name):
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 下方
```
**关于 F012 的关键分析**(详见第 3.1 节):
| 边 | 当前 Name 位置 | 相对序号 | F012 声称"当前" | F012 声称"应改为" |
|----|---------------|---------|----------------|-----------------|
| bottom | `(r-1, c)` = max_row-1 | Name 在序号**上方** | min_row+1 | max_row-1 |
| top | `(r+1, c)` = min_row+1 | Name 在序号**下方** | max_row-1 | min_row+1 |
**结论**:当前代码实际行为已经符合 F012 的"应改为"目标bottom Name 在 max_row-1top Name 在 min_row+1。F012 描述中的"当前"状态可能指向旧版本代码或使用不同坐标基准的描述。详见 3.1 节分析。
#### `pinmap_generator.py` — PinMAP 单元格数据构建
```python
def generate_pinmap(entries, rows, cols, package_info,
template_style=None, output_path=None):
# 1. 计算布局 → layout (dict[str, EdgePins])
# 2. 先写入 PinName 单元格
# 3. 再写入序号单元格(后可覆盖同名单元格)
# 4. 写入文件(模板样式或默认样式)
```
#### `template_reader.py` — 模板样式提取
```python
# 关键能力:
# - 解析 xl/styles.xml → fonts, fills, borders, cellXfs
# - 解析 sheet1.xml → column_widths, row_heights
# - 优雅降级:模板不存在/解析失败 → 返回 None
@dataclass
class TemplateStyle:
fonts: list[FontStyle]
borders: list[BorderStyle]
fills: list[FillStyle]
cell_xfs: list[dict] # xf index → {fontId, borderId, fillId, alignment}
column_widths: dict[int, float]
row_heights: dict[int, float]
```
#### `xlsx_writer.py` — XLSX 输出(含样式)
```python
# StyledXLSXWriter — 生成含 styles.xml 的 xlsx
# _styles_xml(): 读取模板字体/填充/边框 → 构建 styles.xml
# 当前实现:使用硬编码样式(模板字体仅作参考,主体样式内置)
# _sheet_xml(): 应用列宽/行高 + 单元格样式索引
# style_idx: 0=default, 1=centered+border, 2=bold(A1), 3=fill
```
**关键观察**`StyledXLSXWriter._styles_xml()` 目前是**硬编码样式**(内置字体、边框定义),仅从模板读取字体名称/大小作为参考。列宽和行高从模板的**实际行列**读取,但会按输出数据的实际行列扩展。
---
## 3. 逐项修改方案
---
### 3.1 F012: 修复 PinMAP 生成中上/下边 PinName 位置
#### 3.1.1 需求分析
**F012 描述原文**
> 修复 PinMAP 生成中上/下边 PinName 位置
> - 当前:下边 Name 在 min_row+1上边 Name 在 max_row-1
> - 应改为:下边 Name 在 max_row-1上边 Name 在 min_row+1
**4×4 参考示例**
```
A4:1, A5:2, B4:Pin1, B5:Pin2 (上边)
C7:3, D7:4, C6:Pin3, D6:Pin4 (右边)
F5:5, F4:6, E5:Pin5, E4:Pin6 (下边)
D2:7, C2:8, D3:Pin7, C3:Pin8 (左边)
```
#### 3.1.2 代码现状追踪
**当前 `get_name_cell` 实现**
```python
def get_name_cell(num_cell, edge_name):
r, c = num_cell
if edge_name == "left": return (r, c+1) # Name 在序号右侧
elif edge_name == "bottom": return (r-1, c) # Name 在序号上方 (max_row-1)
elif edge_name == "right": return (r, c-1) # Name 在序号左侧
elif edge_name == "top": return (r+1, c) # Name 在序号下方 (min_row+1)
```
**当前 `calculate_layout` 单元格坐标**
```python
# 下边:序号在 (rows, c),即 max_row
# → get_name_cell 返回 (rows-1, c) = max_row-1 ← 上方
#
# 上边:序号在 (1, c),即 min_row
# → get_name_cell 返回 (2, c) = min_row+1 ← 下方
```
#### 3.1.3 关键发现:代码可能已经正确
将 F012 的"应改为"目标与当前代码对比:
| 边 | F012"应改为"目标 | 当前代码实际位置 | 是否一致 |
|----|-----------------|-----------------|---------|
| 下边 (bottom) | max_row-1 | `r-1` = rows-1 = max_row-1 | ✅ 一致 |
| 上边 (top) | min_row+1 | `r+1` = 1+1 = min_row+1 | ✅ 一致 |
**`test_pinmap.py` 中的 4×4 测试数据验证**
```python
# Test data (0-based):
# bottom edge: numbers at (6,2)=3, (6,3)=4; names at (5,2)=Pin3, (5,3)=Pin4
# → max_row=6, names at row 5 = max_row-1 ✓
# top edge: numbers at (1,3)=7, (1,2)=8; names at (2,3)=Pin7, (2,2)=Pin8
# → min_row=1, names at row 2 = min_row+1 ✓
```
**该测试在当前代码中已通过**,表明当前 `get_name_cell` 返回值与测试数据一致。
#### 3.1.4 可能的问题场景
如果确实存在问题,以下场景需排查:
1. **PinMAP 解析方向**`pinmap_parser.py` 解析 PinMAP 时,底边 Name 读自 `max_row-1`,顶边 Name 读自 `min_row+1`。这与生成方向一致。但如果解析时使用了错误的位置假设,则来回转换会导致 Name 错位。
2. **边名称语义混淆**F012 描述中的 `(上边)` `(右边)` `(下边)` `(左边)` 标签可能使用了不同的约定(例如,该标签可能对应的是"Name 边的位置"而非"序号所在边")。
3. **网格区域偏移**:如果 `A1` 被保留为封装信息,网格区域从第 2 行开始1-based → 0-based row=1`min_row``max_row` 的起始值需要重新校准。
#### 3.1.5 修改方案
**方案 A确认代码已正确仅添加回归测试**
如果代码已正确,则:
1. 不修改 `pinmap_layout.py`
2.`test_pinmap.py` 中增加显式的上/下边 Name 位置验证测试
3. 更新测试覆盖率
**方案 B按 F012 描述修改(如果确定存在 Bug**
如果确实需要交换,修改 `get_name_cell`
```python
# 修改前:
elif edge_name == "bottom": return (r-1, c) # Name 在序号上方
elif edge_name == "top": return (r+1, c) # Name 在序号下方
# 修改后(交换 bottom/top 的 Name 位置):
elif edge_name == "bottom": return (r+1, c) # Name 移到序号下方
elif edge_name == "top": return (r-1, c) # Name 移到序号上方
```
**但此修改会导致现有 `test_4x4_parse` 测试失败**,因为测试数据中 bottom Name 在 `max_row-1`
**方案 C仅修改 `pinmap_parser.py` 的 Name 读取位置**
如果问题是解析方向MAP→List使用的位置不正确
修改 `pinmap_parser.py` 中 bottom/top 的 Name 查找行号。
#### 3.1.6 建议
**强烈建议在实施前与需求方确认具体的 Bug 表现**。基于代码分析:
| 事实 | 结论 |
|------|------|
| `get_name_cell` 中 bottom Name 在 max_row-1 | 符合 F012 的"应改为" |
| `get_name_cell` 中 top Name 在 min_row+1 | 符合 F012 的"应改为" |
| 4×4 测试已通过 | 代码内部一致 |
| `pinmap_parser.py` 使用相同约定 | 读写一致 |
| F012 描述中的"当前"与代码不符 | 可能存在描述误差 |
**实施步骤**(按优先级):
1. **先确认问题**:使用实际的 4×4 PinMAP 输入文件,运行完整的 PinList→PinMAP 转换,检查输出
2. **定位差异**:对比输出与预期,定位是 `get_name_cell` 还是 `pinmap_parser` 的差异
3. **修改代码**:根据确认结果修改对应的函数
4. **更新测试**:同步更新 `test_pinmap.py` 中的测试数据和断言
**影响范围**
| 文件 | 修改内容 | 可能性 |
|------|---------|--------|
| `pinmap_layout.py` | 修改 `get_name_cell` 的 bottom/top 逻辑 | 低(可能需要确认) |
| `test_pinmap.py` | 更新测试数据/断言 | 中 |
| `pinmap_parser.py` | 修改 Name 读取行号 | 低 |
**风险评估**
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 修改后破坏往返转换一致性 | **高** | 中 | 运行完整的 MAP→List→MAP 往返测试 |
| 修改后破坏现有用户输出 | 中 | 低 | 确认用户实际使用场景 |
| 与需求方沟通不足导致反复修改 | 中 | 中 | **先确认再改** |
**工作量**0.51 小时(含需求确认)
---
### 3.2 F009: MAP→List 使用 balllist 模板
#### 3.2.1 需求
> PinMAP→PinList 转换方向查找并使用 `BallList-Template.xlsx`,不再共用 PinMAP 模板
**变更前**`run_map_to_list()` 使用 `_find_template_path()``PinMAP-Template.xlsx`
**变更后**`run_map_to_list()` 查找并使用 `BallList-Template.xlsx`
#### 3.2.2 影响范围
仅在 `main.py` 中修改模板查找逻辑,核心转换代码不受影响。
**涉及文件**
| 文件 | 修改级别 | 说明 |
|------|---------|------|
| `main.py` | ✏️ 修改 | 新增 `_find_balllist_template_path()` 函数;修改 `run_map_to_list()` 中的模板查找调用 |
**不涉及**`pinmap_parser.py`, `pinlist_generator.py`, `xlsx_writer.py`, `template_reader.py`(这些模块只接收 `TemplateStyle` 对象,不关心模板文件名)
#### 3.2.3 具体修改方案
**3.2.3.1 新增 `_find_balllist_template_path()` 函数**
```python
def _find_balllist_template_path() -> str | None:
"""查找根目录下的 BallList-Template.xlsx。
搜索顺序:
1. 与 run.bat 同级的根目录
2. 当前工作目录
"""
src_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
template_path = os.path.join(root_dir, "BallList-Template.xlsx")
if os.path.exists(template_path):
return template_path
cwd_template = os.path.join(os.getcwd(), "BallList-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
```
**3.2.3.2 修改 `run_map_to_list()` 中的模板查找**
```python
# 修改前:
template_path = _find_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
# 修改后:
template_path = _find_balllist_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载 BallList 模板样式: {template_path}")
else:
print("[WARN] BallList 模板文件存在但解析失败,使用默认样式")
else:
print("[INFO] 未检测到 BallList-Template.xlsx使用默认样式")
```
**3.2.3.3 输出路径保持不变**
PinList 输出路径仍为 `{input_base}_PinList.xlsx`,不受模板变更影响。
#### 3.2.4 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 用户未放置 BallList-Template.xlsx | 低 | 高 | 模板不存在 → 优雅降级使用默认样式(已实现) |
| 新旧模板共存产生混淆 | 低 | 低 | 日志明确输出使用的模板文件名 |
| BallList-Template.xlsx 解析失败 | 低 | 低 | template_reader 已有 try-except 降级 |
**工作量**15 分钟
---
### 3.3 F010: List→MAP 使用 ballmap 模板
#### 3.3.1 需求
> PinList→PinMAP 转换方向查找并使用 `BallMAP-Template.xlsx`,不再共用 PinMAP 模板
**变更前**`run_list_to_map()` 使用 `_find_template_path()``PinMAP-Template.xlsx`
**变更后**`run_list_to_map()` 查找并使用 `BallMAP-Template.xlsx`
#### 3.3.2 影响范围
仅在 `main.py` 中修改模板查找逻辑。
**涉及文件**
| 文件 | 修改级别 | 说明 |
|------|---------|------|
| `main.py` | ✏️ 修改 | 新增 `_find_ballmap_template_path()` 函数;修改 `run_list_to_map()` 中的模板查找调用 |
#### 3.3.3 具体修改方案
**3.3.3.1 新增 `_find_ballmap_template_path()` 函数**
```python
def _find_ballmap_template_path() -> str | None:
"""查找根目录下的 BallMAP-Template.xlsx。
搜索顺序:
1. 与 run.bat 同级的根目录
2. 当前工作目录
"""
src_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
template_path = os.path.join(root_dir, "BallMAP-Template.xlsx")
if os.path.exists(template_path):
return template_path
cwd_template = os.path.join(os.getcwd(), "BallMAP-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
```
**3.3.3.2 修改 `run_list_to_map()` 中的模板查找**
```python
# 修改前:
template_path = _find_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
...
# 修改后:
template_path = _find_ballmap_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载 BallMAP 模板样式: {template_path}")
else:
print("[WARN] BallMAP 模板文件存在但解析失败,使用默认样式")
else:
print("[INFO] 未检测到 BallMAP-Template.xlsx使用默认样式")
```
#### 3.3.4 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 用户未放置 BallMAP-Template.xlsx | 低 | 高 | 优雅降级使用默认样式 |
| 新旧模板共存产生混淆 | 低 | 低 | 日志明确输出使用的模板文件名 |
**工作量**15 分钟
---
### 3.4 F011: 模板格式提取式应用
#### 3.4.1 需求
> 从模板仅提取格式信息(字体、边框、对齐、列宽、行高),输出文件行列数由实际 Pin 数量决定,不复制模板行列结构。
**依赖**F009BallList-Template.xlsx+ F010BallMAP-Template.xlsx
#### 3.4.2 当前实现分析
当前模板应用于 `StyledXLSXWriter``xlsx_writer.py`),其行为:
**`_sheet_xml()` 中的列宽/行高处理**
```python
# 当前逻辑:
# 1. 遍历模板 style.column_widths按最大 col 扩展
# 2. 对于模板有定义的列使用模板宽度,否则默认 8.0
# 3. 遍历 style.row_heights有定义的行使用模板行高
col_widths_xml = ''
if self._style and self._style.column_widths:
max_width_col = max(self._style.column_widths.keys())
max_width_col = max(max_width_col, max_col)
for c in range(max_width_col + 1):
width = self._style.column_widths.get(c, 8.0)
...
```
**`_styles_xml()` 中的样式处理**
```python
# 当前逻辑:硬编码 4 种 xf 样式
# - xf 0: default
# - xf 1: centered + thin borderpin cells
# - xf 2: bold + centeredA1
# - xf 3: centered + border + light fillheader
#
# 仅从模板提取:
# - font[0] 的 name/size/color用于 xf 0, 1, 3
# - font[0] 被复制为 font[1]bold 版,用于 xf 2 = A1
```
#### 3.4.3 关键分析:当前实现是否已满足 F011 要求
| F011 要求 | 当前实现 | 是否满足 |
|-----------|---------|---------|
| 仅提取格式信息 | ✅ 模板仅用于样式 | ✅ 已满足 |
| 字体 | ✅ 从模板 font[0] 提取 | ✅ 已满足 |
| 边框 | ❌ 硬编码 thin border | ⚠️ 部分满足 |
| 对齐 | ✅ 硬编码 center/center | ⚠️ 部分满足 |
| 列宽 | ✅ 从模板提取 | ✅ 已满足 |
| 行高 | ✅ 从模板提取(有则不覆盖) | ✅ 已满足 |
| 输出行列由实际 Pin 决定 | ✅ 不复制模板行列结构 | ✅ 已满足 |
| 不复制模板行列结构 | ✅ dim 由数据决定 | ✅ 已满足 |
**核心差距**:当前 `_styles_xml()` 中边框和对齐是硬编码的,未从模板的 `cellXfs` 中提取。F011 要求**完全**从模板提取格式。
#### 3.4.4 修改方案
**目标**:将 `_styles_xml()` 改造为从模板的 `cellXfs``fonts``borders``fills` 中提取并原样输出样式,而非硬编码。
**涉及文件**
| 文件 | 修改级别 | 说明 |
|------|---------|------|
| `template_reader.py` | ✏️ 修改 | 增强样式提取能力:提取 cellXfs 的完整信息(含 numFmtId, xfId 等) |
| `xlsx_writer.py` | ✏️ 修改 | 重写 `_styles_xml()` 使用模板原始样式定义 |
**3.4.4.1 增强 `template_reader.py`**
当前 `TemplateReader._parse_styles_xml()` 已能提取:
- ✅ fontsFontStyle: name, size, bold, italic, color
- ✅ fillsFillStyle: pattern_type, fg_color
- ✅ bordersBorderStyle: top, bottom, left, right, color
- ✅ cell_xfslist[dict]: numFmtId, fontId, fillId, borderId, alignment
**需要增加**
- `cell_xfs` 中的 `xfId` 属性(用于样式继承)
- `cellXfs``count` 属性
当前已提取的信息足以满足 F011。**不需要大幅修改** `template_reader.py`
**3.4.4.2 重写 `xlsx_writer.py` 中的 `_styles_xml()`**
```python
def _styles_xml(self) -> str:
"""Build xl/styles.xml from template styles or defaults."""
s = self._style
# ── Fonts ────────────────────────────────────────────────
if s and s.fonts:
fonts_xml = self._build_fonts_xml(s.fonts)
else:
fonts_xml = self._default_fonts_xml()
# ── Fills ────────────────────────────────────────────────
if s and s.fills:
fills_xml = self._build_fills_xml(s.fills)
else:
fills_xml = self._default_fills_xml()
# ── Borders ──────────────────────────────────────────────
if s and s.borders:
borders_xml = self._build_borders_xml(s.borders)
else:
borders_xml = self._default_borders_xml()
# ── Cell XFs ─────────────────────────────────────────────
if s and s.cell_xfs:
cell_xfs_xml = self._build_cell_xfs_xml(s.cell_xfs)
else:
cell_xfs_xml = self._default_cell_xfs_xml()
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">\n'
+ fonts_xml + '\n'
+ fills_xml + '\n'
+ borders_xml + '\n'
+ cell_xfs_xml + '\n'
+ '</styleSheet>'
)
```
**关键设计决策**
| 决策点 | 方案 | 理由 |
|--------|------|------|
| 是否完全复制模板 cellXfs | ✅ 是 | 模板定义什么样式就用什么样式 |
| 是否需要额外样式(如 A1 bold | ⚠️ 在模板中应有对应的 xf | 鼓励用户在模板中定义 A1 的样式 |
| 无模板时的默认样式 | 保持现有硬编码 | 向后兼容 |
| 列宽/行高处理 | 保持现有逻辑 | 已满足 F011 |
**3.4.4.3 cellXfs 映射策略**
模板的 cellXfs 索引需要映射到输出单元格:
- 模板中可能有多种 xf如 xf 0=default, xf 1=边框, xf 2=粗体...
- 输出时需决定每个单元格使用哪个 xf
**推荐策略**
1. 分析模板 cellXfs找到最合适的 "pin cell" 样式(通常是带边框+居中的 xf
2. A1 使用模板中带 bold 的 xf如果存在否则用 pin cell 样式
3. 普通 pin cell 使用模板中的 "pin cell" xf
4. 如果模板只有 1 个 xfdefault使用默认样式作为补充
**简化方案**(推荐首版):
- 保留现有的 4 个样式槽位xf 0~3
- 但从模板读取实际的字体、边框、填充定义填充到对应槽位
- 而非硬编码 "thin" 边框和 "center" 对齐
**实现优先级**
1. **字体信息**:已从模板读取 ✅
2. **边框样式**:从模板 borders 读取 → 替换硬编码的 "thin"
3. **填充样式**:从模板 fills 读取 → 替换硬编码的 "FFF0F0F0"
4. **对齐方式**:从模板 alignment 读取 → 替换硬编码的 "center"
5. **列宽/行高**:已从模板读取 ✅
#### 3.4.5 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 模板 cellXfs 结构与输出不匹配 | 中 | 中 | 提供合理的 fallback至少 xf 0 和 xf 1 |
| 模板无边框定义导致输出无网格线 | 中 | 低 | 模板无边框时保留默认 thin border |
| 样式重写引入 OOXML 兼容性问题 | 低 | 低 | 使用 Python 标准库 XML 构建,避免硬编码 namespace 错误 |
| 已有用户无模板场景不受影响 | 低 | - | 无模板时完全回退到现有硬编码样式 |
**工作量**23 小时
---
## 4. 模板文件命名规范建议
### 4.1 v1.5.0 新模板命名
| 用途 | 文件名 | 位置 | 格式 |
|------|--------|------|------|
| MAP→List 输出样式 | `BallList-Template.xlsx` | 项目根目录(与 run.bat 同级) | `.xlsx` |
| List→MAP 输出样式 | `BallMAP-Template.xlsx` | 项目根目录(与 run.bat 同级) | `.xlsx` |
### 4.2 向后兼容
| 旧文件名 | 状态 | 说明 |
|---------|------|------|
| `PinMAP-Template.xlsx` | ⚠️ 废弃(不再自动查找) | v1.5.0 后不再被代码引用 |
### 4.3 命名规范说明
```
模板文件名格式:
{输出格式简称}-Template.xlsx
其中:
- BallList → PinMAP→PinList 转换的输出格式PinList
- BallMAP → PinList→PinMAP 转换的输出格式PinMAP
命名原则:
1. 以输出格式命名,而非输入格式
2. 使用 "Ball" 前缀避免与 "Pin" 名混淆
3. 保持 PascalCase 风格
4. 使用 "-Template" 后缀明确表示模板文件
```
### 4.4 搜索路径优先级
```
1. {project_root}/BallList-Template.xlsx (与 run.bat 同级)
2. {cwd}/BallList-Template.xlsx (当前工作目录)
3. 无 → 使用默认样式(硬编码 Calibri 11pt + thin border + center align
```
---
## 5. 修改影响矩阵
| 文件 | F012 | F009 | F010 | F011 | 总改动量 | 备注 |
|------|------|------|------|------|---------|------|
| `main.py` | — | ✏️ 模板查找 | ✏️ 模板查找 | — | ~30行 | 新增2个函数+修改2处调用 |
| `pinmap_layout.py` | ✏️ 可能 | — | — | — | ~5行 | 仅在确认问题后修改 |
| `template_reader.py` | — | — | — | ✏️ 增强 | ~20行 | 增加 xfId 提取 |
| `xlsx_writer.py` | — | — | — | ✏️ 重写 | ~100行 | 重写 _styles_xml() |
| `test_pinmap.py` | ✏️ 更新 | — | — | — | ~10行 | 增加 F012 回归测试 |
| **合计** | **2 文件** | **1 文件** | **1 文件** | **2 文件** | **4-5 文件** | |
---
## 6. 优先级排序
| 优先级 | 编号 | 原因 | 推荐执行顺序 |
|--------|------|------|------------|
| **P0** | F012 | 核心布局 Bug需先确认影响所有 List→MAP 转换 | 第1先确认再决定是否修改 |
| **P1-1** | F009 | MAP→List 模板分离,独立于其他改动 | 第2可与 F010 并行) |
| **P1-2** | F010 | List→MAP 模板分离,独立于其他改动 | 第2可与 F009 并行) |
| **P1-3** | F011 | 依赖 F009+F010 的模板文件存在后才能测试 | 第3 |
---
## 7. 测试要点
### 7.1 F012 测试P0
#### 核心验证
| 测试项 | 输入 | 预期 | 方法 |
|--------|------|------|------|
| 4×4 PinMAP 往返一致性 | PinList(rows=4, cols=4, 8 Pins) → PinMAP → PinList | 往返后 PinList 数据不变 | 自动化 |
| 下边 Name 位置 | rows=4, cols=4 的 PinMAP 输出 | 下边 PinName 在序号行上方一行max_row-1 | 检查输出 xlsx |
| 上边 Name 位置 | rows=4, cols=4 的 PinMAP 输出 | 上边 PinName 在序号行下方一行min_row+1 | 检查输出 xlsx |
| 与 parser 一致性 | 生成的 PinMAP 再用 parser 解析 | 解析出的 Pin 数据和原始一致 | 自动化 |
| 非方形网格 | rows=3, cols=5 的 PinMAP | 上/下边 Name 位置正确 | 检查输出 |
#### 边界条件
| 测试项 | 输入 | 预期 |
|--------|------|------|
| 最小网格 2×2 | PinList(rows=2, cols=2, 8 Pins) | 四条边各 2 Pin角点正确 |
| 大网格 15×15 | PinList(rows=15, cols=15, 60 Pins) | 60 Pin 全部正确分配到四条边 |
| max_row-1 与 min_row+1 重合 | rows=2min_row=1, max_row=2 → max_row-1=1=min_row | Name 不与序号重叠 |
#### 现有测试回归
- `test_4x4_parse()` — 确认不受影响或同步更新
- `test_4x4_validate()` — 确认不受影响
- `test_12pin_square()` — 确认不受影响
### 7.2 F009 测试P1
| 测试项 | 方法 | 预期 |
|--------|------|------|
| BallList-Template.xlsx 存在时加载 | 放置模板 → MAP→List 转换 | 日志显示"已加载 BallList 模板样式" |
| BallList-Template.xlsx 不存在时降级 | 删除模板 → MAP→List 转换 | 日志显示"未检测到 BallList-Template.xlsx",输出使用默认样式 |
| 不加载 PinMAP-Template.xlsx | 仅放置 PinMAP-Template.xlsx → MAP→List 转换 | 不加载旧模板,使用默认样式 |
| 模板样式应用到 PinList 输出 | 放置带自定义字体/边框的模板 → 转换 | 输出 PinList 使用模板字体和边框 |
### 7.3 F010 测试P1
| 测试项 | 方法 | 预期 |
|--------|------|------|
| BallMAP-Template.xlsx 存在时加载 | 放置模板 → List→MAP 转换 | 日志显示"已加载 BallMAP 模板样式" |
| BallMAP-Template.xlsx 不存在时降级 | 删除模板 → List→MAP 转换 | 日志显示"未检测到 BallMAP-Template.xlsx",输出使用默认样式 |
| 不加载 PinMAP-Template.xlsx | 仅放置 PinMAP-Template.xlsx → List→MAP 转换 | 不加载旧模板,使用默认样式 |
### 7.4 F011 测试P1
| 测试项 | 方法 | 预期 |
|--------|------|------|
| 模板字体正确应用 | 模板字体=微软雅黑 12pt → 输出 | 输出文件字体为微软雅黑 12pt |
| 模板边框正确应用 | 模板边框=medium → 输出 | 输出文件边框为 medium 而非 thin |
| 模板填充正确应用 | 模板背景色=黄色 → 输出 | 输出对应单元格有黄色背景 |
| 模板对齐正确应用 | 模板左对齐 → 输出 | 输出文件单元格左对齐 |
| 列宽与模板一致 | 模板 A 列宽=20 → 输出 | 输出对应列宽=20 |
| 行高与模板一致 | 模板行高=25 → 输出 | 输出对应行高=25 |
| 输出行列由实际 Pin 决定 | rows=5, cols=5 → 输出 | 输出为 5×5 网格,不含模板的额外行列 |
| 无模板时使用默认样式 | 无模板文件 → 输出 | 使用 Calibri 11pt + thin border + center align |
### 7.5 集成测试
| 测试项 | 输入 | 预期 |
|--------|------|------|
| 往返转换 (MAP→List→MAP) | sample_4x4.xlsx → PinList → PinMAP | 与原始 PinMAP 一致 |
| 往返转换 (List→MAP→List) | PinList 文件 → PinMAP → PinList | 与原始 PinList 一致 |
| 两个方向使用不同模板 | BallList-Template 和 BallMAP-Template 同时存在 | MAP→List 用 BallListList→MAP 用 BallMAP |
| 一个大模板一个小模板 | BallList 字体=12pt, BallMAP 字体=10pt | 各方向使用各自的字体 |
---
## 8. 开发顺序建议
```
阶段 1: F012 需求确认30 分钟)
├─ 用实际 PinMAP 输入文件测试当前代码的 PinName 位置
├─ 确认 Bug 具体表现
└─ 决定修改方案A/B/C
阶段 2: F009 + F010 模板分离30 分钟,可并行)
├─ main.py: 新增 _find_balllist_template_path()
├─ main.py: 新增 _find_ballmap_template_path()
├─ main.py: 修改 run_map_to_list() 模板查找
├─ main.py: 修改 run_list_to_map() 模板查找
└─ 废弃 _find_template_path()(可选,保留无调用不影响)
阶段 3: F012 修复如需要3060 分钟)
├─ pinmap_layout.py: 修改 get_name_cell()
├─ test_pinmap.py: 更新测试数据和断言
└─ 运行完整测试套件
阶段 4: F011 格式提取23 小时)
├─ template_reader.py: 增加提取项
├─ xlsx_writer.py: 重写 _styles_xml()
├─ xlsx_writer.py: 新增 _build_*_xml() 辅助函数
└─ 模板+无模板场景测试
阶段 5: 收尾30 分钟)
├─ 更新 CHANGELOG.md
├─ 更新 features.md 状态
├─ 更新 VERSION 文件
└─ 运行完整回归测试
```
---
## 9. 风险评估汇总
| 风险 | 影响 | 概率 | 缓解措施 | 相关功能 |
|------|------|------|---------|---------|
| F012 需求描述与代码行为不一致,修改方向错误 | **高** | 中 | **先确认再改**,使用实际文件测试 | F012 |
| F012 修改破坏往返转换一致性 | **高** | 中 | 运行完整 MAP→List→MAP 往返测试 | F012 |
| 模板 cellXfs 结构与输出不兼容 | 中 | 中 | 提供 fallback模板解析失败 = 默认样式 | F011 |
| 用户未放置新模板文件 | 低 | 高 | 优雅降级,日志明确提示缺失的模板名 | F009/F010 |
| 旧 PinMAP-Template.xlsx 被意外加载 | 低 | 低 | 显式删除旧模板查找调用 | F009/F010 |
| F011 样式重写引入 OOXML 兼容性问题 | 低 | 低 | 严格 XML 构建,测试 Excel/WPS 打开 | F011 |
| 两个模板同时存在造成混淆 | 低 | 低 | 日志明确标注每个方向使用的模板文件名 | F009/F010 |
---
## 10. 工作量估算
| 阶段 | 任务 | 预估时间 | 依赖 |
|------|------|---------|------|
| 确认 | F012 需求确认(测试+分析) | 30 分钟 | 无 |
| 开发 | F009 MAP→List 模板分离 | 15 分钟 | 无 |
| 开发 | F010 List→MAP 模板分离 | 15 分钟 | 无 |
| 开发 | F012 修复(如需要) | 3060 分钟 | 需求确认 |
| 开发 | F011 模板格式提取式应用 | 23 小时 | F009+F010 |
| 测试 | 单元测试更新 | 30 分钟 | 所有开发 |
| 测试 | 集成/回归测试 | 30 分钟 | 所有开发 |
| 文档 | CHANGELOG + features.md 更新 | 15 分钟 | 所有开发 |
| **总计** | | **4.56 小时** | |
---
## 11. 总结
| 项目 | 内容 |
|------|------|
| 修改文件数 | 45 个main.py, pinmap_layout.py, template_reader.py, xlsx_writer.py, test_pinmap.py |
| 新增模板文件 | 2 个BallList-Template.xlsx, BallMAP-Template.xlsx |
| 影响核心模块 | 是xlsx_writer.py 样式生成逻辑重写) |
| 技术难度 | 中F011 样式重写需理解 OOXML styles.xml 结构) |
| 预估工作量 | 4.56 小时 |
| 推荐 Agent | Python 编码 Agent |
| 风险等级 | 中F012 需先确认F011 样式重写有复杂度) |
**关键结论**
1. **F012P0**代码当前行为可能已经正确bottom Name 在 max_row-1top Name 在 min_row+1 已与 F012 "应改为"目标一致)。**强烈建议在实施前用实际文件验证**,避免过度修改。如果确认无误,仅需增加回归测试。
2. **F009+F010P1**:改动量小(约 30 行),完全独立,可快速完成。核心是新增两个模板查找函数并替换调用点。
3. **F011P1**:改动量最大(约 120 行),需重写 `xlsx_writer.py``_styles_xml()` 方法。当前代码已部分满足 F011字体、列宽、行高从模板提取主要差距在边框和对齐的硬编码。建议采用"从模板读取实际值填充现有样式槽位"的渐进方案。
4. **向后兼容**:无模板时所有功能完全回退到现有默认样式,不影响已有用户。
5. **推荐开发顺序**:确认 F012 → 实现 F009+F010 → 修复 F012如需要 → 实现 F011 → 测试收尾。
---
*文档结束 — 请审批后进入编码阶段*
##

View File

@@ -0,0 +1,572 @@
# PinMAP ↔ PinList 双向转换器 — v1.6 整改架构评估
> **版本**: v1.6 (针对 F013F017 五项 P0 整改需求)
> **日期**: 2026-06-12
> **评估人**: 脚本架构师 (Script Architect)
> **状态**: 评估完成,待编码实施
---
## 1. 需求总览
| 需求 ID | 方向 | 问题描述 | 严重级别 | 当前状态 |
|---------|------|---------|---------|---------|
| F013 | MAP→List | 封装上侧Top引脚全部未被识别 | P0 🔴 | **Bug**:解析逻辑硬编码 Top Name/Number 位置假设 |
| F014 | List→MAP | PinMAP 输出需应用 PinMAP-Template.xlsx 样式 | P0 🔴 | **部分有效**:代码结构存在但模板路径需要确认 |
| F015 | MAP→List | PinList 输出需应用 PinList-Template.xlsx 样式 | P0 🔴 | **部分有效**:同上 |
| F016 | List→MAP | 使用 QFN60 示例验证 List→MAP 转换正确性 | P0 🔴 | **新测试**:需设计端到端测试用例 |
| F017 | MAP→List | 使用 QFN60 示例验证 MAP→List 转换正确性 | P0 🔴 | **新测试**:需设计端到端测试用例 |
### 执行顺序依赖
```
F013 (修复解析) ──┬── F015 (MAP→List 模板) ── F017 (MAP→List 验证)
└── F014 (List→MAP 模板) ── F016 (List→MAP 验证)
```
---
## 2. F013 根因分析 — PinMAP→PinList 上方引脚丢失
### 2.1 问题描述
用户反馈PinMAP→PinList 转换后封装上侧Top引脚全部缺失。以 QFN60 (12×12 网格, 4×(12+12)=96 槽位但仅 60 引脚环形布局) 为例,应输出 60 个引脚,实际输出可能只有 45 个(仅左+下+右三边,上边 15 个全部丢失)。
### 2.2 关键证据:用户真实 PinMAP 布局
用户提供的 QFN60 PinMAP 片段CSV 格式12×12 网格):
```
QFN60 6*6*0.85mm ... ← Row 1 (A1)
, ,60,59,58,57,56,55,54,53,52,51,50,49,48,47,46, ← Row 2 (Numbers)
, ,Pin60,Pin59,...,Pin46, ← Row 3 (Names)
1 ,Pin1,,,,,,,,,,,,,,,,Pin45,45 ← Row 4 (left Pin1 + right Pin45)
2 ,Pin2,,,,,,,,,,,,,,,,Pin44,44 ← Row 5 (left Pin2 + right Pin44)
...
12 ,Pin12,,,,,,,,,,,,,,,Pin34,34 ← Row 15 (left Pin12 + right Pin34)
, ,Pin13,Pin14,...,Pin33, ← Row 16 (bottom Names)
, ,13,14,...,33, ← Row 17 (bottom Numbers)
```
**关键发现**:上边 Name 在 Row 3第 3 行),上边 Number 在 Row 2第 2 行)。
### 2.3 当前解析器硬编码假设 vs 用户实际布局
**当前 `pinmap_parser.py` v1.5.5 的硬编码假设**
| 边 | Number 位置 | Name 位置 |
|----|------------|----------|
| Top | `min_row + 1`(即 row 1 | `min_row`(即 row 0 |
| Left | `(r, min_col)` | `(r, min_col + 1)` |
| Bottom | `(max_row, c)` | `(max_row - 1, c)` |
| Right | `(r, max_col)` | `(r, max_col - 1)` |
这个设计假设了 v1.5.5 生成的 PinMAP 布局Name 在 Number **上方一行**Name 在 min_rowNumber 在 min_row+1
**用户的真实布局**
| 边 | Number 位置 | Name 位置 |
|----|------------|----------|
| Top | Row 2第 2 行) | Row 3第 3 行) |
| Left | Col A第 1 列) | Col B第 2 列) |
| Bottom | 倒数第 1 行 | 倒数第 2 行 |
| Right | 最右列 | 次右列 |
### 2.4 根因Name 和 Number 相对位置反转
用户 PinMAP 中 Top 边的布局是 **Number 在上方、Name 在下方**(与当前假设相反)。当前代码:
```python
# pinmap_parser.py 第 114-117 行v1.5.5
# top edge names at (min_row, c) — one row ABOVE the Number row.
for c in range(min_col, max_col + 1):
name = cells.get((min_row, c), "")
if name and str(name).strip() and _try_int(name) is None:
name_map[(min_row + 1, c)] = str(name).strip()
```
这段代码:
1.`min_row` 行查找 Name
2. 将 Name 映射到 `min_row + 1` 行的 Number 单元格
但在用户实际布局中,上边 Name 在 `min_row + 1`Row 3Number 在 `min_row`Row 2。由于 Name 查不到Name 在 min_row+1 而非 min_row`_try_int(name)` 检查会将 Number纯数字如 "60")过滤掉,导致整个上边的 Name 全部未被建立 name_map 映射。
**更严重的问题是**:由于 corners如 Pin1/Pin60、Pin46/Pin45的 Name/Number 位于左右边缘列,上边的 Number 可能在右边缘扫描时被误识别为右边 Pin而 Name 仍在右边被找到——但中间 13 个上边引脚Pin47-Pin59不含左右角的 Pin60/Pin46完全丢失。
### 2.5 为什么 v1.5.5 的测试没发现
v1.5.5 的 4×4 和 12-pin 测试用例都是**程序自己生成的 PinMAP 布局**(生成的 Name 总是在上边 Number 之上),然后用这个自产的 PinMAP 做往返解析。自产的 PinMAP 布局与解析假设一致,因此往返测试全部通过。
但是用户提供的 PinMAP 来自其他渠道(可能是手工绘制或其他工具生成),其布局约定与程序生成的布局**方向相反**。
---
## 3. F013 修改方案
### 3.1 设计原则
PinMAP 解析器必须兼容 **"Name 在 Number 上方"** 和 **"Number 在 Name 上方"** 两种布局。对于上边而言Name 和 Number 分布在相邻两行,解析器需要智能检测哪一行是 Name、哪一行是 Number。
### 3.2 检测策略:基于内容特征识别 Name 行 vs Number 行
对于 Top 边,扫描第一行非 A1 数据行和其后一行:
- **Number 行特征**:所有单元格(或绝大多数)都是可解析为整数的值(如 "60", "59", ...
- **Name 行特征**:所有单元格(或绝大多数)都是非纯数字的字符串(如 "Pin60", "Pin59", ...
如果第一行全是数字Number 在第一行Name 在第二行(用户布局)。
如果第一行全是非数字且非空Name 在第一行Number 在第二行v1.5.5 布局)。
### 3.3 具体实现
**文件**`Code/src/pinmap_parser.py`
修改 Step 2确定边界和 Step 3上边 Name 查找)之间的逻辑,增加 Top 边的自动检测。
```python
# ── Top edge layout detection ─────────────────────────────────
# 检测上边布局:哪种布局由数据内容决定
# 布局 AName 在 min_row上方Number 在 min_row+1下方
# 例Row 1: Pin60 Pin59 ... Pin46, Row 2: 60 59 ... 46
# 布局 BNumber 在 min_row上方Name 在 min_row+1下方
# 例Row 1: 60 59 ... 46, Row 2: Pin60 Pin59 ... Pin46
min_row_for_top = min_row # 可能是 1A1 被排除后)
top_row_1_cells = []
top_row_2_cells = []
for c in range(min_col, max_col + 1):
v1 = cells.get((min_row_for_top, c), "")
v2 = cells.get((min_row_for_top + 1, c), "")
if v1 and str(v1).strip():
top_row_1_cells.append(str(v1).strip())
if v2 and str(v2).strip():
top_row_2_cells.append(str(v2).strip())
# 检测哪一行是 Number 行
def _is_number_row(values: list[str]) -> bool:
if not values:
return False
numeric = sum(1 for v in values if _try_int(v) is not None)
return numeric >= len(values) * 0.7 # 70% 以上是数字即为 Number 行
row1_is_number = _is_number_row(top_row_1_cells)
row2_is_number = _is_number_row(top_row_2_cells)
if row1_is_number and not row2_is_number:
# 布局 BNumber 在上Name 在下(用户实际布局)
top_number_row = min_row_for_top
top_name_row = min_row_for_top + 1
elif not row1_is_number and row2_is_number:
# 布局 AName 在上Number 在下v1.5.5 布局)
top_name_row = min_row_for_top
top_number_row = min_row_for_top + 1
elif row1_is_number and row2_is_number:
# 两行都是数字(异常情况:可能没有 Name 行)
# 回退到布局 A 假设
top_name_row = min_row_for_top
top_number_row = min_row_for_top + 1
else:
# 两行都不是数字(极异常情况)
# 回退到布局 A 假设
top_name_row = min_row_for_top
top_number_row = min_row_for_top + 1
```
然后修改 Step 3 中上边 Name 查找和 Step 4d 中的 Top 边 Number 遍历:
```python
# Step 3: 上边 Name 查找(修改后)
# Name 在上边数字行之上/之下的一行
for c in range(min_col, max_col + 1):
name = cells.get((top_name_row, c), "")
if name and str(name).strip() and _try_int(name) is None:
name_map[(top_number_row, c)] = str(name).strip()
# Step 4d: 上边遍历(修改后)
for c in range(max_col, min_col - 1, -1):
_add_pin(top_number_row, c, "top", max_col - c)
```
### 3.4 影响的其他边
左、下、右三边的布局在用户提供的 PinMAP 中是标准的Name 在 Number 内侧一列/一行。但为了一致性可以考虑对下边也增加类似检测Name 在 Number 上方 vs 下方),但当前未收到相关反馈,建议**先从简处理**,仅在 Top 边出现问题后扩展到其他边。
### 3.5 修改文件清单F013
| 文件 | 修改内容 | 风险 |
|------|---------|------|
| `Code/src/pinmap_parser.py` | Step 2-3 之间增加 Top 边布局检测;修改上边 Name 查找和 Number 遍历 | 中 |
| `Code/src/test_pinmap.py` | 新增测试用例:用户 QFN60 格式的 PinMAP 解析 | 低 |
---
## 4. F014 分析 — PinList→PinMAP 样式模板应用
### 4.1 当前状态
v1.5.5 `main.py``run_list_to_map()` 已经:
1. 调用 `_find_pinmap_template_path()` 查找模板
2. 调用 `read_template_styles()` 解析样式
3.`template_style` 传递给 `generate_pinmap()``write_xlsx_with_style()`
**搜索路径**v1.5.5 已修复):
```python
# main.py _find_pinmap_template_path()
src_dir = os.path.dirname(os.path.abspath(__file__)) # → Code/src/
template_path = os.path.join(src_dir, "Template", "PinMAP-Template.xlsx")
# → Code/src/Template/PinMAP-Template.xlsx ✅
```
### 4.2 确认项
1. ✅ 模板文件存在:`Code/src/Template/PinMAP-Template.xlsx` 已确认存在
2. ✅ 搜索路径正确:优先查找 `Code/src/Template/`,向后兼容项目根目录和 cwd
3. ✅ 样式提取完整:`template_reader.py` 提取字体 / 填充 / 边框 / 对齐 / 列宽 / 行高
4. ✅ 样式应用正确:`StyledXLSXWriter` 将模板样式应用到输出
### 4.3 用户提到的"模板放在主程序根目录"
用户提到模板应放在"主程序根目录"。主程序根目录 = `pinmap-to-pinlist/`。当前代码**优先**查找 `Code/src/Template/`**其次**查找项目根目录(向后兼容)。
**建议保持不变**:当前的多路径回退策略已经覆盖了主程序根目录。如果用户坚持只从主程序根目录查找,可在评估后的实施阶段调整搜索优先级。但从工程角度看,`Code/src/Template/` 更合理(与源码打包在一起)。
### 4.4 F014 结论
**F014 在当前 v1.5.5 代码中已基本实现**,无需大规模修改。需要确认的是:
- 模板文件的内容是否符合用户期望(字体/边框/对齐/填充色等)
- 确认项将在 F016 验证阶段通过实际生成的 xlsx 与预期对比来验证
---
## 5. F015 分析 — PinMAP→PinList 样式模板应用
### 5.1 当前状态
v1.5.5 `main.py``run_map_to_list()` 已经:
1. 调用 `_find_pinlist_template_path()` 查找模板
2. 调用 `read_template_styles()` 解析样式
3. 如果模板存在,使用 `write_xlsx_with_style()`,否则使用 `write_xlsx()`
**搜索路径**(同 F014
```python
# Code/src/Template/PinList-Template.xlsx ✅
```
模板文件存在:`Code/src/Template/PinList-Template.xlsx`
### 5.2 注意事项
PinList 输出的数据非常简单两列A 列 = PinNameB 列 = Pin 序号),样式应用的效果主要是:
- 字体名称/大小/颜色
- 列宽(如果模板只有两列数据,列 C 以后理论上不应有宽度设置)
- 行高
- 边框
`StyledXLSXWriter._sheet_xml()``style.column_widths` 字典读取列宽并生成 `<cols>` 元素。如果 PinList 模板中只有 A、B 两列定义了宽度,输出也会只有两列宽。
### 5.3 需要确认的潜在问题
`StyledXLSXWriter``_get_style_index()` 方法为所有非 A1 单元格分配 style index `1`(边框+居中。PinList 输出也是相同的逻辑——A1 用 style 2bold其他用 style 1。这对 PinList 两列布局来说是合理的。
但是PinList 的 A1 不是"封装信息"就是"Package Name",而 PinMAP 的 A1 也是封装信息。两个模板可能在 A1 样式的 fontId/fillId/borderId 上指向不同索引,但当前 `_get_style_index()` 统一用 hardcoded 的 index。**这是一个潜在问题**,但在用户明确反馈前不做假设性修改。
### 5.4 F015 结论
**F015 在当前 v1.5.5 代码中已基本实现**。确认项将在 F017 验证阶段通过实际生成的 PinList xlsx 与预期对比来验证。
---
## 6. F016 分析 — PinList→PinMAP 转换正确性验证
### 6.1 测试设计
使用用户提供的 QFN60 示例 PinList60 个引脚)作为输入,验证生成的 PinMAP 结构与示例 PinMAP 一致。
**示例 PinList 结构**
```
QFN60
Pin1,1
Pin2,2
...
Pin60,60
```
**示例 PinMAP 预期结构**(基于用户提供的 CSV
- 12×12 网格QFN60 环形布局)
- Left 边Pin1Pin12row 4-15, col A/B
- Bottom 边Pin13Pin18 + Pin28Pin33row 16-17, 中间空列对应无引脚位置)
- Right 边Pin34Pin45row 15→4, col 倒数两列)
- Top 边Pin46Pin60row 3, col 倒数→前row 2 为 Numbers
- 内部为空QFN 封装中心无引脚)
### 6.2 关键问题60 引脚的环形布局 ≠ 12×12 全周长
12×12 网格的全周长 = (12+12)×2 = 48。但 QFN60 有 **60 个引脚**。这意味着用户使用的"12×12 网格"并不是严格的矩形周长概念。
重新分析用户 PinMAP
- Left 边12 个引脚Pin1Pin12
- Right 边12 个引脚Pin34Pin45
- Top 边15 个引脚Pin46Pin60跨越 15 列)
- Bottom 边21 个引脚Pin13Pin33跨越 21 列?不对,重看)
仔细看用户实际 PinMAP 布局:
- Left12 个
- Bottom12 个Pin13Pin18=6 + Pin24Pin33?不对)
让我重新分析 CSV 模式:
- Left col A/B12 pins (Pin1Pin12)
- Right col 最后两列12 pins (Pin34Pin45)
- Top row 2-315 pins (Pin46Pin60)
- Bottom 倒数第1-2行15 pins? → 实际底部仅 Pin13Pin18=6 + ... 需要确认
实际上用户 CSV 格式是 12×1212行12列但只用了外圈。Pin 总计 60但网格如果按周长算只有 48。
**这里存在根本性偏差**:用户的 PinMAP 是"12×12 网格内有 60 个环形引脚"——引脚布局在 12×12 的外圈但某些角被占用。这意味着 PinMAP 布局**不完全遵循四边等长逻辑**。
**但这对 v1.6 不重要**F016 和 F017 的目标是验证**端到端转换正确性**,即:
1. List→MAP输入 60-pin PinList → 生成 PinMAP xlsx
2. MAP→List将生成的 PinMAP 再转回 PinList
3. 往返验证:原始 PinList == 生成的 PinList引脚不丢失、顺序不变
这将暴露 F013 修复后解析器是否能正确处理各种布局。
### 6.3 F016 测试方案
**测试 F016-1: 60-pin List→MAP 基本生成**
输入:
- 60 个 PinListEntry (Pin1Pin60)
- rows=12, cols=12
- package_info="QFN60"
验证:
- 生成的 PinMAP 至少包含 A1 封装信息 + 所有 60 个引脚的 Name 和 Number
- 无单元格冲突(生成器内部保证)
- 引脚沿四条边分布(取决于布局算法分配)
**测试 F016-2: List→MAP→List 往返**
输入:同上 60-pin PinList
验证:
- List→MAP 生成 PinMAP xlsx
- 将该 xlsx 作为 MAP→List 输入
- 解析出的 PinList 包含 60 个引脚,顺序 Pin1Pin60封装信息 "QFN60"
- 无引脚丢失、无序号错误
**注意**:不要求生成的 PinMAP 在**单元格位置**上与用户示例 PinMAP 完全一致。用户示例 PinMAP 是手工制作的(某些边有不同数量的引脚),而程序将使用标准的周长分配算法。**只要往返一致、引脚不丢失**即可。
### 6.4 但如果用户要求"结构一致"怎么办
features.md 中 F016 的验收标准写的是"生成的 PinMAP 与示例 PinMAP 结构完全一致"。这暗示用户希望:**生成的 PinMAP 在外观布局上与示例 PinMAP 相同**。
如果是这样PinMAP 生成器需要支持**非均匀边分配**——即用户指定每条边分别有多少引脚。这是当前 `pinmap_layout.py` 不支持的(它假设 rows=cols 时每条边有相同数量的引脚)。
**建议**:在 v1.6 中先实现往返正确性验证作为 F016 的交付物。如果用户坚持布局像素级一致,需要在 v1.7 中重新设计布局引擎。
---
## 7. F017 分析 — PinMAP→PinList 转换正确性验证
### 7.1 测试设计
使用用户提供的 QFN60 示例 PinMAP (CSV) 作为输入,验证:
1. 解析器能正确识别 Top 边(修复后)
2. 生成的 PinList 包含完整的 60 个引脚
3. Pin 序号 Pin1Pin60 完整无缺失
4. 封装信息 "QFN60 ..." 正确提取
5. PinName 与示例一致
### 7.2 测试用例构建
**输入**:用户提供的 QFN60 PinMAP CSV转换为 Excel 或直接用当前 xls_reader 兼容的格式)
由于当前解析器读取的是 Excel 格式(.xls/.xlsx需要先构建测试用的 cell dictionary。
**测试 F017-1: QFN60 PinMAP 解析**
基于用户提供的 CSV 构建 cells dict0-based row, col
```python
cells = {
(0, 0): "QFN60 6*6*0.85mm ...",
# Top: Number row 1, Name row 2
(1, 2): "60", (1, 3): "59", ..., (1, 16): "46",
(2, 2): "Pin60", (2, 3): "Pin59", ..., (2, 16): "Pin46",
# Left: rows 3..14
(3, 0): "1", (3, 1): "Pin1",
...
# Bottom
...
# Right
...
}
```
验证:
- `parse_pinmap(cells)` 返回 60 个 Pin
- Top 边的 Pin (Pin46Pin60) 都被正确识别且有正确的 edge="top"
- 无 StructureError
**测试 F017-2: QFN60 PinMAP→PinList 完整转换**
从 parsed pinmap 生成 PinList验证
- `len(pinlist.rows) == 60`
- Pin 序号 1..60 全部存在
- 封装信息正确
**测试 F017-3: 往返验证 (MAP→List→MAP)**
```python
cells parse_pinmap pinmap
pinmap generate_pinlist pinlist (60 pins)
pinlist [重新构造 entries] generate_pinmap pinmap2
```
验证 `pinmap``pinmap2` 的引脚数量和序号一致。
---
## 8. 总体修改方案与文件清单
### 8.1 F013 — 修复上方引脚丢失
| 文件 | 修改内容 | 工作量 |
|------|---------|--------|
| `Code/src/pinmap_parser.py` | 在 Step 2-3 之间增加 Top 边布局自动检测逻辑,根据数据内容决定 Name 在 Number 上方还是下方 | 中(~40 行新代码) |
| `Code/src/pinmap_parser.py` | 修改 Step 3 上边 Name 查找,使用检测结果;修改 Step 4d 上边遍历,使用正确的 Number 行 | 中 |
### 8.2 F014 — PinList→PinMAP 模板(确认性检查)
| 文件 | 修改内容 | 工作量 |
|------|---------|--------|
| `Code/src/main.py` | 确认 `_find_pinmap_template_path()` 搜索路径正确v1.5.5 已修复) | 无代码改动 |
| `Code/src/Template/PinMAP-Template.xlsx` | 确认模板文件存在且格式正确 | 无代码改动 |
### 8.3 F015 — PinMAP→PinList 模板(确认性检查)
| 文件 | 修改内容 | 工作量 |
|------|---------|--------|
| `Code/src/main.py` | 确认 `_find_pinlist_template_path()` 搜索路径正确v1.5.5 已修复) | 无代码改动 |
| `Code/src/Template/PinList-Template.xlsx` | 确认模板文件存在且格式正确 | 无代码改动 |
### 8.4 F016 — List→MAP 验证
| 文件 | 修改内容 | 工作量 |
|------|---------|--------|
| `Code/src/test_pinmap.py` | 新增 QFN60 List→MAP 生成测试 + 往返测试 | 低(~60 行新测试代码) |
### 8.5 F017 — MAP→List 验证
| 文件 | 修改内容 | 工作量 |
|------|---------|--------|
| `Code/src/test_pinmap.py` | 新增 QFN60 PinMAP 解析测试(使用 Top 布局 B+ 完整转换测试 + 往返测试 | 中(~100 行新测试代码 + 构建 QFN60 cells 常量) |
---
## 9. 任务拆分建议
### 9.1 子任务划分
| Seq | 子任务 | 关联需求 | 执行 Agent | 预估工作量 | 依赖 |
|-----|--------|---------|-----------|-----------|------|
| 1 | **F013 编码实现**:修复 `pinmap_parser.py` Top 边识别 | F013 | python-coding-agent | 1h | 无 |
| 2 | **F017 测试用例**QFN60 PinMAP→PinList 解析+转换+往返测试 | F017 | test-qa-agent | 30min | Seq 1 |
| 3 | **F016 测试用例**QFN60 PinList→PinMAP 生成+往返测试 | F016 | test-qa-agent | 30min | Seq 1 |
| 4 | **F014/F015 确认**:模板路径+样式应用确认(如发现问题则修复) | F014, F015 | python-coding-agent | 15min | Seq 1 |
| 5 | **全量回归测试**:运行全部测试用例确保无回归 | F013-F017 | test-qa-agent | 15min | Seq 2-4 |
| 6 | **文档生成**:更新 features.md, tasks.md, CHANGELOG.md | F013-F017 | doc-gen-agent | 15min | Seq 5 |
| 7 | **打包发布 v1.6** | F013-F017 | package-release-agent | 15min | Seq 6 |
### 9.2 推荐执行顺序
```
Step 1: python-coding-agent → F013 编码pinmap_parser.py 修改)
Step 2: test-qa-agent → F017 测试用例(依赖修复后解析器)
Step 3: test-qa-agent → F016 测试用例(与 F017 并行)
Step 4: python-coding-agent → F014/F015 确认/修复(如有需要)
Step 5: test-qa-agent → 全量回归测试
Step 6: doc-gen-agent → 文档更新
Step 7: package-release-agent → 打包发布
```
### 9.3 可并行项
- F016 测试用例Seq 3和 F017 测试用例Seq 2可以在 F013 编码完成后**并行执行**,因为它们都依赖 F013 修复但不相互依赖。
- F014/F015 确认Seq 4可以与测试用例并行。
---
## 10. 风险与缓解措施
| 风险 ID | 风险描述 | 影响 | 概率 | 缓解措施 |
|---------|---------|------|------|---------|
| R1 | Top 边自动检测逻辑误判(如模板 PinMAP 中 Name 和 Number 行都是空或非常规格式) | Top 边引脚仍然丢失 | 低 | 设置 70% 置信阈值 + 回退到默认行为;在检测失败时打印 WARN 日志 |
| R2 | 用户 PinMAP 的底部Bottom边也存在类似反转问题但尚未反馈 | 底部引脚也丢失 | 中 | 如果 F017 测试发现底部也有问题,在 v1.6 中一并修复(扩大自动检测范围到下边) |
| R3 | 用户期望 PinMAP 布局与示例完全一致(像素级),但当前算法是均匀分配 | 用户不满意生成的 PinMAP 外观 | 中 | 在 F016 的实现中明确声明"往返正确性验证",布局一致性留到 v1.7 |
| R4 | F013 的修改改变了现有 4×4/12-pin 测试的预期行为 | 回归测试失败 | 低 | 自动检测会选择布局 A与 v1.5.5 一致的 Name 在上方),对现有测试透明 |
| R5已关闭 | ~~QFN60 12×12 网格周长48与 60 引脚不匹配~~ | — | — | ✅ 已确认 QFN60 是 15×15 网格,周长 = (15+15)×2 = 60完美匹配 |
---
## 11. v1.6 与 v1.5.5 代码差异预估
### 11.1 新增代码
| 位置 | 内容 | 行数 |
|------|------|------|
| `pinmap_parser.py` Step 2.5 | Top 边布局自动检测函数 `_detect_top_layout()` | ~30 行 |
| `pinmap_parser.py` Step 3 | 修改上边 Name 查找(替换现有 5 行) | +5 行 |
| `pinmap_parser.py` Step 4d | 修改上边遍历(替换现有 2 行) | +2 行 |
| `test_pinmap.py` | QFN60 cells 常量(~60 pins, 12×12 | ~80 行 |
| `test_pinmap.py` | F017 测试函数 (解析 + 转换 + 往返) | ~60 行 |
| `test_pinmap.py` | F016 测试函数 (生成 + 往返) | ~40 行 |
### 11.2 预计不修改的文件
- `main.py` — 模板搜索路径已在 v1.5.5 修复(除非用户坚持改为项目根目录优先)
- `pinmap_layout.py` — List→MAP 生成布局不变
- `pinmap_generator.py` — 无变化
- `pinlist_generator.py` — 无变化
- `template_reader.py` — 无变化
- `xlsx_writer.py` — 无变化
- `validator.py` — 无变化
- `pinlist_parser.py` — 无变化
- `pinlist_validator.py` — 无变化
### 11.3 总工作量预估
| 阶段 | 预估时间 |
|------|---------|
| 编码F013 | 1.0 h |
| 测试F016 + F017 | 0.75 h |
| 确认/修复F014 + F015 | 0.25 h |
| 回归测试 | 0.25 h |
| 文档 | 0.25 h |
| 打包 | 0.25 h |
| **合计** | **~2.75 h** |
---
## 12. 总结
1. **F013最关键**`pinmap_parser.py` 硬编码假设 Top 边 Name 在 Number 上方一行,但用户真实 PinMAP 中 Name 在 Number 下方一行。需要增加基于数据内容的**自动布局检测**,兼容两种方向。修改集中在 `pinmap_parser.py` 一个文件。
2. **F014/F015已有基础**:模板搜索路径在 v1.5.5 中已修复(`Code/src/Template/`样式应用链路完整。v1.6 主要是**确认性验证**,代码改动预计为零。
3. **F016/F017新增测试**:需要构建 QFN60 60-pin 的测试数据用于端到端验证。**核心关注往返正确性**List→MAP→List 不丢失引脚),而非与用户示例的像素级布局一致(后者需要后续版本支持非均匀边分配)。
4. **最大风险 R5**60 引脚与 12×12 网格的周长48不匹配。需要在测试实施阶段确认正确的行/列参数。如果用户 PinMAP 实际使用的是非标准布局(某些边有不同数量的引脚),可能需要向用户确认。
---
*文档结束 — v1.6 整改架构评估*

27
docs/requirements.md Normal file
View File

@@ -0,0 +1,27 @@
# 需求规格说明书
## 项目信息
- 项目名称pinmap-to-pinlist
- 项目 IDPROJ-002
- 项目类型:脚本
- 技术约束Python 脚本,支持 Windows 和 Linux
## 需求描述
PinMAP ↔ PinList 双向转换器,支持 PinMAP→PinList 与 PinList→PinMAP 互转。
## 输入/输出
| 类型 | 描述 |
|-----|------|
| 输入 | PinMAP 或 PinList Excel 文件 |
| 输出 | 转换后的 Excel 文件 |
## 边界条件
- 支持 .xls 和 .xlsx 格式
- Pin 数量必须与网格周长匹配
- 网格尺寸至少为 2x2
## 验收标准
1. 能正确解析 PinMAP 结构
2. 能正确生成 PinList
3. 能正确反向转换
4. 错误处理完善

40
docs/tasks.md Normal file
View File

@@ -0,0 +1,40 @@
# 任务进度表
| 任务 ID | 任务名称 | 负责 Agent | 当前状态 | 任务类型 | 关联功能 | 创建时间 | 完成时间 |
|--------|---------|-----------|---------|---------|---------|---------|---------|
| T001 | 架构设计 | script-architect | 已完成 | 架构设计 | F001-F005 | 2026-05-23 | 2026-05-23 |
| T002 | Python 编码 v1.2 | python-coding-agent | 已完成 | 编码实现 | F001-F005 | 2026-05-23 | 2026-05-26 |
| T003 | BAT 编码 | bat-coding-agent | 已完成 | 编码实现 | F005 | 2026-05-23 | 2026-05-23 |
| T004 | 测试验证 v1.2 | test-qa-agent | 已完成 | 测试验证 | F001-F005 | 2026-05-23 | - |
| T007 | BAT 脚本修复 v1.3 | bat-coding-agent | 已完成 | 编码实现 | F005 | 2026-05-31 | 2026-05-31 |
| T008 | Python 编码 v1.3 | python-coding-agent | 已完成 | 编码实现 | F006-F008 | 2026-05-31 | 2026-05-31 |
| T009 | 测试验证 v1.3 | test-qa-agent | 已完成 | 测试验证 | F005-F008 | 2026-05-31 | 2026-06-06 |
| T010 | 文档生成 v1.3 | doc-gen-agent | 已完成 | 文档编写 | F005-F008 | - | 2026-06-06 |
| T011 | 打包发布 v1.3 | package-release-agent | 已完成 | 打包发布 | F005-F008 | 2026-05-31 | 2026-06-02 | pinmap-to-pinlist-v1.3.14.zip |
| T013 | 打包发布 v1.3.15 修复 | package-release-agent | 已完成 | 打包发布 | - | 2026-06-02 | 2026-06-02 | Release 已创建 + zip 附件已上传 |
| T014 | 架构评估 v1.5 | script-architect | 已完成 | 架构评估 | F009-F012 | 2026-06-06 | 2026-06-06 |
| T015 | 编码实现 v1.5 | python-coding-agent | 已完成 | 编码实现 | F009-F012 | 2026-06-06 | 2026-06-06 |
| T016 | 测试验证 v1.5 | test-architect/test-executor/test-reporter | 已完成 | 测试验证 | F009-F012 | 2026-06-06 | 2026-06-06 |
| T017 | 文档生成 v1.5 | doc-gen-agent | 已完成 | 文档编写 | F009-F012 | 2026-06-06 | 2026-06-06 |
| T018 | 打包发布 v1.5 | package-release-agent | 已完成 | 打包发布 | F009-F012 | 2026-06-06 | 2026-06-06 | Release 已创建 + zip 附件已上传 |
| T019 | 架构评估 v1.5.4 Bug 修复 | script-architect | 已完成 | 架构评估 | BUG-005, BUG-006 | 2026-06-09 | 2026-06-09 | 方案已确认 ✅ |
| T020 | 编码实现 v1.5.4 Bug 修复 | python-coding-agent | 已完成 | 编码实现 | BUG-005, BUG-006 | 2026-06-09 | 2026-06-09 |
| T021 | 测试验证 v1.5.4 | test-executor | 已完成 | 测试验证 | BUG-005, BUG-006 | 2026-06-09 | 2026-06-09 |
| T022 | 文档生成 v1.5.4 | doc-gen-agent | 已完成 | 文档编写 | BUG-005, BUG-006 | 2026-06-09 | 2026-06-09 |
| T023 | 打包发布 v1.5.4 | package-release-agent | 已完成 | 打包发布 | BUG-005, BUG-006 | 2026-06-09 | 2026-06-09 | Release 已创建 + zip 附件已上传 |
| T024 | 修复发布包文件结构 v1.5.4 | router-agent | 已完成 | 打包修复 | - | 2026-06-09 | 2026-06-09 | 已重新打包并上传,结构恢复为 Code/src/ 格式 |
| T025 | 架构评估 PinList→PinMAP 布局 Bug 修复 | script-architect | 已完成 | 架构评估 | BUG-005, BUG-006 | 2026-06-12 | 2026-06-12 | BUG-005/006 用户反馈 v1.5.4 修复未生效,需重新分析 |
| T026 | 编码实现 v1.5.5 Bug 修复 | python-coding-agent | 已完成 | 编码实现 | BUG-005, BUG-006 | 2026-06-12 | 2026-06-12 | 全部 37 测试通过 |
| T027 | 文档生成 v1.5.5 | doc-gen-agent | 已完成 | 文档编写 | BUG-005, BUG-006 | 2026-06-12 | 2026-06-12 | 文档已全部更新 |
| T028 | 打包发布 v1.5.5 | package-release-agent | 已完成 | 打包发布 | BUG-005, BUG-006 | 2026-06-12 | 2026-06-12 | Release 已创建 + zip 已上传 + git push 完成 |
| T029 | 架构评估 v1.6 整改F013-F017 | script-architect | 已完成 | 架构评估 | F013-F017 | 2026-06-12 | 2026-06-12 | 评估完成,见 docs/modification-assessment-v1.6.md |
| T030 | 编码实现 F013 上方引脚丢失修复 | python-coding-agent | 已完成 | 编码实现 | F013 | 2026-06-12 | 2026-06-12 | pinmap_parser.py 增加自动布局检测 |
| T031 | 测试验证 F016/F017 | test-architect | 已完成 | 测试验证 | F016, F017 | 2026-06-12 | 2026-06-12 | 5 个 QFN60 新增测试 + 全量 23/23 通过 |
| T032 | 模板确认 F014/F015 | python-coding-agent | 已完成 | 确认验证 | F014, F015 | 2026-06-12 | 2026-06-12 | 两个模板文件存在,样式解析成功 |
| T033 | 文档生成 v1.6 | doc-gen-agent | 已完成 | 文档编写 | F013-F017 | 2026-06-12 | 2026-06-12 | 更新 CHANGELOG.md、features.md、tasks.md |
| T034 | 打包发布 v1.6 | package-release-agent | 已完成 | 打包发布 | F013-F017 | 2026-06-12 | 2026-06-12 | Release 已创建 + zip 已上传 + git push 完成 |
| T035 | 架构评估 BUG-007PinList→PinMAP 布局方向) | script-architect | 已完成 | 架构评估 | BUG-007 | 2026-06-12 | 2026-06-12 | 评估完成,见修改方案 |
| T036 | 编码修复 BUG-007Layout B 生成) | python-coding-agent | 已完成 | 编码实现 | BUG-007 | 2026-06-12 | 2026-06-12 | pinmap_layout.py + test_pinmap.py 修改完成23/23 通过
| T037 | 测试验证 BUG-007 | test-executor | 已完成 | 测试验证 | BUG-007 | 2026-06-12 | 2026-06-12 | 回归测试确认无回归
| T038 | 文档生成 BUG-007 | doc-gen-agent | 已完成 | 文档编写 | BUG-007 | 2026-06-12 | 2026-06-12 | 更新 bugs.md、CHANGELOG.md、tasks.md
| T039 | 打包发布 BUG-007 | package-release-agent | 待处理 | 打包发布 | BUG-007 | 2026-06-12 | - | 打包 v1.6.1

Binary file not shown.

Binary file not shown.

25
run.bat
View File

@@ -1,14 +1,11 @@
@ECHO OFF
:: 初始化区
chcp 65001
title PinMAP转PinList -By:LeeQwQ
mode con cols=80 lines=20
color 0B
cls
cd /d "%~dp0src"
python main.py
echo.
pause
EXIT
@ECHO OFF
chcp 65001 >nul
title PinMAP转PinList -By:LeeQwQ
mode con cols=80
color 0B
cls
cd /d "%~dp0Code\src"
python main.py
echo.
pause
EXIT