# 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-1,top 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.5–1 小时(含需求确认) --- ### 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 数量决定,不复制模板行列结构。 **依赖**:F009(BallList-Template.xlsx)+ F010(BallMAP-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 border(pin cells) # - xf 2: bold + centered(A1) # - xf 3: centered + border + light fill(header) # # 仅从模板提取: # - 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()` 已能提取: - ✅ fonts(FontStyle: name, size, bold, italic, color) - ✅ fills(FillStyle: pattern_type, fg_color) - ✅ borders(BorderStyle: top, bottom, left, right, color) - ✅ cell_xfs(list[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 ( '\n' '\n' + fonts_xml + '\n' + fills_xml + '\n' + borders_xml + '\n' + cell_xfs_xml + '\n' + '' ) ``` **关键设计决策**: | 决策点 | 方案 | 理由 | |--------|------|------| | 是否完全复制模板 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 个 xf(default),使用默认样式作为补充 **简化方案**(推荐首版): - 保留现有的 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 错误 | | 已有用户无模板场景不受影响 | 低 | - | 无模板时完全回退到现有硬编码样式 | **工作量**:2–3 小时 --- ## 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=2(min_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 用 BallList,List→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 修复(如需要,30–60 分钟) ├─ pinmap_layout.py: 修改 get_name_cell() ├─ test_pinmap.py: 更新测试数据和断言 └─ 运行完整测试套件 阶段 4: F011 格式提取(2–3 小时) ├─ 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 修复(如需要) | 30–60 分钟 | 需求确认 | | 开发 | F011 模板格式提取式应用 | 2–3 小时 | F009+F010 | | 测试 | 单元测试更新 | 30 分钟 | 所有开发 | | 测试 | 集成/回归测试 | 30 分钟 | 所有开发 | | 文档 | CHANGELOG + features.md 更新 | 15 分钟 | 所有开发 | | **总计** | | **4.5–6 小时** | | --- ## 11. 总结 | 项目 | 内容 | |------|------| | 修改文件数 | 4–5 个(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.5–6 小时 | | 推荐 Agent | Python 编码 Agent | | 风险等级 | 中(F012 需先确认,F011 样式重写有复杂度) | **关键结论**: 1. **F012(P0)**:代码当前行为可能已经正确(bottom Name 在 max_row-1,top Name 在 min_row+1 已与 F012 "应改为"目标一致)。**强烈建议在实施前用实际文件验证**,避免过度修改。如果确认无误,仅需增加回归测试。 2. **F009+F010(P1)**:改动量小(约 30 行),完全独立,可快速完成。核心是新增两个模板查找函数并替换调用点。 3. **F011(P1)**:改动量最大(约 120 行),需重写 `xlsx_writer.py` 的 `_styles_xml()` 方法。当前代码已部分满足 F011(字体、列宽、行高从模板提取),主要差距在边框和对齐的硬编码。建议采用"从模板读取实际值填充现有样式槽位"的渐进方案。 4. **向后兼容**:无模板时所有功能完全回退到现有默认样式,不影响已有用户。 5. **推荐开发顺序**:确认 F012 → 实现 F009+F010 → 修复 F012(如需要) → 实现 F011 → 测试收尾。 --- *文档结束 — 请审批后进入编码阶段* ##