diff --git a/Code/src/test_pinmap.py b/Code/src/test_pinmap.py
index b45faeb..dc7f480 100644
--- a/Code/src/test_pinmap.py
+++ b/Code/src/test_pinmap.py
@@ -363,6 +363,227 @@ def test_f012_pinname_position():
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_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")
+
+
+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")
+
+
+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', '')
+ zf.writestr('xl/worksheets/sheet1.xml', '')
+
+ style = read_template_styles(bad_path)
+ assert style is not None, "缺失 styles.xml 不应导致 read_template_styles 返回 None"
+ assert len(style.fonts) == 0, "无 styles.xml,font 列表应为空"
+ assert len(style.fills) == 0, "无 styles.xml,fill 列表应为空"
+ 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()
@@ -373,4 +594,14 @@ if __name__ == "__main__":
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()
print("\n✅ All tests passed!")
diff --git a/Releases/pinmap-to-pinlist-v1.3.14.zip b/Releases/pinmap-to-pinlist-v1.3.14.zip
new file mode 100644
index 0000000..f9b5bd4
Binary files /dev/null and b/Releases/pinmap-to-pinlist-v1.3.14.zip differ
diff --git a/Releases/pinmap-to-pinlist-v1.3.15.zip b/Releases/pinmap-to-pinlist-v1.3.15.zip
new file mode 100644
index 0000000..14774a7
Binary files /dev/null and b/Releases/pinmap-to-pinlist-v1.3.15.zip differ
diff --git a/Test/fixtures/BallList-Template.xlsx b/Test/fixtures/BallList-Template.xlsx
new file mode 100644
index 0000000..e4d0fa4
Binary files /dev/null and b/Test/fixtures/BallList-Template.xlsx differ
diff --git a/Test/fixtures/BallMAP-Template.xlsx b/Test/fixtures/BallMAP-Template.xlsx
new file mode 100644
index 0000000..0b87a2c
Binary files /dev/null and b/Test/fixtures/BallMAP-Template.xlsx differ
diff --git a/Test/fixtures/template_corrupt.xlsx b/Test/fixtures/template_corrupt.xlsx
new file mode 100644
index 0000000..18245c2
--- /dev/null
+++ b/Test/fixtures/template_corrupt.xlsx
@@ -0,0 +1 @@
+This is not a valid xlsx file. It's just plain text pretending to be xlsx.
\ No newline at end of file
diff --git a/Test/fixtures/template_minimal.xlsx b/Test/fixtures/template_minimal.xlsx
new file mode 100644
index 0000000..8f06dfb
Binary files /dev/null and b/Test/fixtures/template_minimal.xlsx differ
diff --git a/Test/fixtures/template_narrow.xlsx b/Test/fixtures/template_narrow.xlsx
new file mode 100644
index 0000000..d254074
Binary files /dev/null and b/Test/fixtures/template_narrow.xlsx differ
diff --git a/Test/run_tests.py b/Test/run_tests.py
index e6ed6e4..aa287dd 100644
--- a/Test/run_tests.py
+++ b/Test/run_tests.py
@@ -561,6 +561,342 @@ def test_list_to_map(r: TestRunner):
shutil.rmtree(tmpdir, ignore_errors=True)
+# ── Part 3: v1.5 Template / Style Integration Tests ────────────────
+
+def test_v15_styles(r: TestRunner):
+ """v1.5: 模板分离与样式应用集成测试(方案B — 直接调用底层函数传入 fixture 路径)。"""
+ fixture_dir = os.path.join(os.path.dirname(__file__), 'fixtures')
+ tmpdir = tempfile.mkdtemp(prefix="pinmap_v15_")
+ from xlsx_writer import write_xlsx_with_style
+
+ try:
+ # ── TC-v1.5-001: MAP→List 加载 BallList 模板 ──
+ def _tc_v15_001(result):
+ template_path = os.path.join(fixture_dir, 'BallList-Template.xlsx')
+ assert os.path.exists(template_path), f"BallList 模板文件不存在: {template_path}"
+
+ style = read_template_styles(template_path)
+ assert style is not None, "BallList 模板样式应成功读取"
+ assert len(style.fonts) > 0, "应有字体定义"
+ assert len(style.borders) > 0, "应有边框定义"
+ assert 0 in style.column_widths, "应有列宽定义"
+ result.ok(f"模板加载成功: fonts={len(style.fonts)}, borders={len(style.borders)}, width_A={style.column_widths.get(0)}")
+
+ r.run("TC-v1.5-001: MAP->List 加载 BallList 模板", _tc_v15_001)
+
+ # ── TC-v1.5-002: MAP→List 无模板降级 ──
+ def _tc_v15_002(result):
+ style = read_template_styles('/nonexistent/nonexistent_template.xlsx')
+ assert style is None, "不存在的模板应返回 None"
+ result.ok("无模板文件时优雅返回 None")
+
+ r.run("TC-v1.5-002: MAP->List 无模板降级", _tc_v15_002)
+
+ # ── TC-v1.5-003: List→MAP 加载 BallMAP 模板 ──
+ def _tc_v15_003(result):
+ template_path = os.path.join(fixture_dir, 'BallMAP-Template.xlsx')
+ assert os.path.exists(template_path), f"BallMAP 模板文件不存在: {template_path}"
+
+ style = read_template_styles(template_path)
+ assert style is not None, "BallMAP 模板样式应成功读取"
+ assert len(style.fonts) > 0, "应有字体定义"
+ assert len(style.borders) > 0, "应有边框定义"
+ assert 0 in style.row_heights, "应有行高定义"
+ result.ok(f"模板加载成功: fonts={len(style.fonts)}, borders={len(style.borders)}, row_height={style.row_heights.get(0)}")
+
+ r.run("TC-v1.5-003: List->MAP 加载 BallMAP 模板", _tc_v15_003)
+
+ # ── TC-v1.5-004: List→MAP 无模板降级 ──
+ def _tc_v15_004(result):
+ style = read_template_styles('/nonexistent/nonexistent_template.xlsx')
+ assert style is None, "不存在的模板应返回 None"
+ result.ok("无模板文件时优雅返回 None")
+
+ r.run("TC-v1.5-004: List->MAP 无模板降级", _tc_v15_004)
+
+ # ── TC-v1.5-005: 两个方向独立使用各自模板 ──
+ def _tc_v15_005(result):
+ bl_path = os.path.join(fixture_dir, 'BallList-Template.xlsx')
+ bm_path = os.path.join(fixture_dir, 'BallMAP-Template.xlsx')
+
+ style_bl = read_template_styles(bl_path)
+ style_bm = read_template_styles(bm_path)
+
+ assert style_bl is not None, "BallList 模板应成功加载"
+ assert style_bm is not None, "BallMAP 模板应成功加载"
+
+ # BallList 有列宽,BallMAP 有行高
+ assert 0 in style_bl.column_widths, "BallList 应有列宽"
+ assert 0 in style_bm.row_heights, "BallMAP 应有行高"
+ result.ok(f"两个模板独立: BL fonts={len(style_bl.fonts)}, BM fonts={len(style_bm.fonts)}")
+
+ r.run("TC-v1.5-005: 两个方向独立使用各自模板", _tc_v15_005)
+
+ # ── TC-v1.5-006: 模板损坏优雅降级 ──
+ def _tc_v15_006(result):
+ corrupt_path = os.path.join(fixture_dir, 'template_corrupt.xlsx')
+ assert os.path.exists(corrupt_path), f"损坏模板文件不存在: {corrupt_path}"
+
+ style = read_template_styles(corrupt_path)
+ assert style is None, "损坏模板应返回 None(优雅降级)"
+ result.ok("损坏模板优雅返回 None")
+
+ r.run("TC-v1.5-006: 模板损坏优雅降级", _tc_v15_006)
+
+ # ── TC-v1.5-007: 模板字体应用到输出文件 ──
+ def _tc_v15_007(result):
+ template_path = os.path.join(fixture_dir, 'BallMAP-Template.xlsx')
+ style = read_template_styles(template_path)
+
+ assert style is not None, "模板样式应成功读取"
+
+ # 用模板生成 3x3 PinMAP
+ entries = [PinListEntry(number=i+1, name=f"PIN{i+1:02d}") for i in range(12)]
+ outpath = os.path.join(tmpdir, 'v15_007_output.xlsx')
+ generate_pinmap(entries, 3, 3, "QFP-12", template_style=style, output_path=outpath)
+
+ # 验证输出 styles.xml 包含模板字体
+ import zipfile
+ with zipfile.ZipFile(outpath, 'r') as zf:
+ assert 'xl/styles.xml' in zf.namelist(), "输出应包含 styles.xml"
+ styles_xml = zf.read('xl/styles.xml').decode('utf-8')
+ assert '宋体' in styles_xml, f"输出 styles.xml 应包含宋体"
+ assert '14' in styles_xml, f"输出 styles.xml 应包含字号 14"
+
+ result.ok("输出 styles.xml 包含模板字体(宋体 14pt)")
+
+ r.run("TC-v1.5-007: 模板字体应用到输出文件", _tc_v15_007)
+
+ # ── TC-v1.5-008: 模板列宽应用到输出文件 ──
+ def _tc_v15_008(result):
+ template_path = os.path.join(fixture_dir, 'BallList-Template.xlsx')
+ style = read_template_styles(template_path)
+ assert style is not None, "模板样式应成功读取"
+
+ # 用模板生成 MAP->List 输出
+ data = {'A1': 'QFP-44', 'A2': 'Pin1', 'B2': '1', 'A3': 'Pin2', 'B3': '2'}
+ outpath = os.path.join(tmpdir, 'v15_008_output.xlsx')
+ write_xlsx_with_style(data, outpath, style)
+
+ # 验证列宽
+ import zipfile
+ import xml.etree.ElementTree as ET
+ _S = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
+ with zipfile.ZipFile(outpath, 'r') as zf:
+ sheet_xml = zf.read('xl/worksheets/sheet1.xml')
+ root = ET.fromstring(sheet_xml)
+ cols_elem = root.find(f'{{{_S}}}cols')
+ assert cols_elem is not None, "输出 sheet1.xml 应包含 cols"
+ cols = cols_elem.findall(f'{{{_S}}}col')
+ assert len(cols) >= 2, f"应有至少2列宽度定义,实际: {len(cols)}"
+ width_a = float(cols[0].get('width', '0'))
+ width_b = float(cols[1].get('width', '0'))
+ assert abs(width_a - 25.0) < 0.5, f"A列宽 ({width_a}) 应与模板 25 接近"
+ assert abs(width_b - 18.0) < 0.5, f"B列宽 ({width_b}) 应与模板 18 接近"
+
+ result.ok(f"列宽验证通过: A={width_a:.1f}, B={width_b:.1f}")
+
+ r.run("TC-v1.5-008: 模板列宽应用到输出文件", _tc_v15_008)
+
+ # ── TC-v1.5-009: 模板行高应用到输出文件 ──
+ def _tc_v15_009(result):
+ template_path = os.path.join(fixture_dir, 'BallMAP-Template.xlsx')
+ style = read_template_styles(template_path)
+ assert style is not None, "模板样式应成功读取"
+
+ entries = [PinListEntry(number=i+1, name=f"PIN{i+1:02d}") for i in range(12)]
+ outpath = os.path.join(tmpdir, 'v15_009_output.xlsx')
+ generate_pinmap(entries, 3, 3, "QFP-12", template_style=style, output_path=outpath)
+
+ import zipfile
+ import xml.etree.ElementTree as ET
+ _S = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
+ with zipfile.ZipFile(outpath, 'r') as zf:
+ sheet_xml = zf.read('xl/worksheets/sheet1.xml')
+ root = ET.fromstring(sheet_xml)
+ sheet_data = root.find(f'{{{_S}}}sheetData')
+ rows = sheet_data.findall(f'{{{_S}}}row')
+ found_ht = False
+ for row in rows:
+ ht = row.get('ht')
+ if ht:
+ found_ht = True
+ ht_val = float(ht)
+ assert abs(ht_val - 25.0) < 0.5, f"行高 ({ht_val}) 应与模板 25 接近"
+ break
+ assert found_ht, "至少应有一个 row 包含 ht 属性"
+
+ result.ok("行高验证通过: ht=25")
+
+ r.run("TC-v1.5-009: 模板行高应用到输出文件", _tc_v15_009)
+
+ # ── TC-v1.5-010: 两个方向使用不同模板各自的格式 ──
+ def _tc_v15_010(result):
+ bl_path = os.path.join(fixture_dir, 'BallList-Template.xlsx')
+ bm_path = os.path.join(fixture_dir, 'BallMAP-Template.xlsx')
+
+ style_bl = read_template_styles(bl_path)
+ style_bm = read_template_styles(bm_path)
+ assert style_bl and style_bm, "两个模板都应该成功加载"
+
+ # MAP->List 方向:用 BallList 模板
+ pinlist_data = {'A1': 'QFP-44', 'A2': 'Pin1', 'B2': '1'}
+ pinlist_path = os.path.join(tmpdir, 'v15_010_pinlist.xlsx')
+ write_xlsx_with_style(pinlist_data, pinlist_path, style_bl)
+
+ # List->MAP 方向:用 BallMAP 模板
+ entries = [PinListEntry(number=i+1, name=f"PIN{i+1:02d}") for i in range(12)]
+ pinmap_path = os.path.join(tmpdir, 'v15_010_pinmap.xlsx')
+ generate_pinmap(entries, 3, 3, "QFP-12", template_style=style_bm, output_path=pinmap_path)
+
+ import zipfile
+ with zipfile.ZipFile(pinlist_path, 'r') as zf:
+ pl_styles = zf.read('xl/styles.xml').decode('utf-8')
+ with zipfile.ZipFile(pinmap_path, 'r') as zf:
+ pm_styles = zf.read('xl/styles.xml').decode('utf-8')
+
+ assert '楷体' in pl_styles, "BallList 输出应包含楷体"
+ assert '宋体' in pm_styles, "BallMAP 输出应包含宋体"
+
+ result.ok("两个方向输出字体不同: BL->楷体, BM->宋体")
+
+ r.run("TC-v1.5-010: 两个方向不同模板各自的格式", _tc_v15_010)
+
+ # ── TC-v1.5-011: 完整往返+模板隔离 ──
+ def _tc_v15_011(result):
+ bl_path = os.path.join(fixture_dir, 'BallList-Template.xlsx')
+ bm_path = os.path.join(fixture_dir, 'BallMAP-Template.xlsx')
+
+ style_bl = read_template_styles(bl_path)
+ style_bm = read_template_styles(bm_path)
+ assert style_bl and style_bm, "两个模板都应该成功加载"
+
+ fixture_4x4 = os.path.join(fixture_dir, 'sample_4x4.xlsx')
+ cells = read_xlsx_cells(fixture_4x4)
+ pinmap = parse_pinmap(cells)
+ validation = validate_pinmap(pinmap)
+ pinlist = generate_pinlist(pinmap, validation)
+
+ pinlist_data = {'A1': pinlist.package_info}
+ for i, (pin_name, pin_num) in enumerate(pinlist.rows):
+ pinlist_data[f'A{i+2}'] = pin_name
+ pinlist_data[f'B{i+2}'] = str(pin_num)
+ pinlist_path = os.path.join(tmpdir, 'v15_011_pinlist.xlsx')
+ write_xlsx_with_style(pinlist_data, pinlist_path, style_bl)
+
+ pkg2, entries2 = parse_pinlist(pinlist_path)
+ pinmap_path = os.path.join(tmpdir, 'v15_011_pinmap.xlsx')
+ generate_pinmap(entries2, 3, 3, pkg2, template_style=style_bm, output_path=pinmap_path)
+
+ rt_cells = read_xlsx_cells(pinmap_path)
+ rt_pinmap = parse_pinmap(rt_cells)
+ assert len(rt_pinmap.pins) == len(pinmap.pins), f"往返后引脚数应一致: {len(rt_pinmap.pins)} vs {len(pinmap.pins)}"
+ rt_numbers = sorted([p.number for p in rt_pinmap.pins])
+ orig_numbers = sorted([p.number for p in pinmap.pins])
+ assert rt_numbers == orig_numbers, f"往返引脚序号应一致"
+
+ import zipfile
+ with zipfile.ZipFile(pinlist_path, 'r') as zf:
+ pl_xml = zf.read('xl/styles.xml').decode('utf-8')
+ with zipfile.ZipFile(pinmap_path, 'r') as zf:
+ pm_xml = zf.read('xl/styles.xml').decode('utf-8')
+ assert '楷体' in pl_xml, "中间 PinList 应使用 BallList 的楷体"
+ assert '宋体' in pm_xml, "最终 PinMAP 应使用 BallMAP 的宋体"
+
+ result.ok(f"往返成功: {len(pinmap.pins)} pins, 楷体->PinList, 宋体->PinMAP")
+
+ r.run("TC-v1.5-011: 完整往返+模板隔离", _tc_v15_011)
+
+ # ── TC-v1.5-012: 无模板完整流程 ──
+ def _tc_v15_012(result):
+ fixture_4x4 = os.path.join(fixture_dir, 'sample_4x4.xlsx')
+ cells = read_xlsx_cells(fixture_4x4)
+ pinmap = parse_pinmap(cells)
+ validation = validate_pinmap(pinmap)
+ pinlist = generate_pinlist(pinmap, validation)
+
+ pinlist_data = {'A1': pinlist.package_info}
+ for i, (pin_name, pin_num) in enumerate(pinlist.rows):
+ pinlist_data[f'A{i+2}'] = pin_name
+ pinlist_data[f'B{i+2}'] = str(pin_num)
+ pinlist_path = os.path.join(tmpdir, 'v15_012_pinlist.xlsx')
+ write_xlsx_with_style(pinlist_data, pinlist_path, None)
+
+ assert os.path.exists(pinlist_path), "输出文件应存在"
+ rt_cells = read_xlsx_cells(pinlist_path)
+ assert (0, 0) in rt_cells, "A1 应有封装信息"
+
+ pkg2, entries2 = parse_pinlist(pinlist_path)
+ pinmap_path = os.path.join(tmpdir, 'v15_012_pinmap.xlsx')
+ generate_pinmap(entries2, 3, 3, pkg2, template_style=None, output_path=pinmap_path)
+ assert os.path.exists(pinmap_path), "PinMAP 输出文件应存在"
+
+ result.ok("无模板完整流程正常")
+
+ r.run("TC-v1.5-012: 无模板完整流程", _tc_v15_012)
+
+ # ── TC-v1.5-013: 极简模板(只有字体) ──
+ def _tc_v15_013(result):
+ template_path = os.path.join(fixture_dir, 'template_minimal.xlsx')
+ assert os.path.exists(template_path), f"极简模板文件不存在: {template_path}"
+
+ style = read_template_styles(template_path)
+ assert style is not None, "极简模板应成功加载"
+ assert len(style.fonts) > 0, "应有字体定义"
+ assert style.fonts[0].name == "Courier New", f"字体应为 Courier New, 实际: {style.fonts[0].name}"
+ assert len(style.borders) == 0, "极简模板不应有边框"
+ assert len(style.fills) == 0, "极简模板不应有填充"
+
+ pinlist_data = {'A1': 'QFP-8', 'A2': 'Pin1', 'B2': '1'}
+ outpath = os.path.join(tmpdir, 'v15_013_output.xlsx')
+ write_xlsx_with_style(pinlist_data, outpath, style)
+
+ import zipfile
+ with zipfile.ZipFile(outpath, 'r') as zf:
+ styles_xml = zf.read('xl/styles.xml').decode('utf-8')
+ assert 'Courier New' in styles_xml, "输出应包含 Courier New"
+
+ result.ok(f"极简模板: font={style.fonts[0].name}")
+
+ r.run("TC-v1.5-013: 极简模板(只有字体)", _tc_v15_013)
+
+ # ── TC-v1.5-014: 列宽扩展 ──
+ def _tc_v15_014(result):
+ template_path = os.path.join(fixture_dir, 'template_narrow.xlsx')
+ style = read_template_styles(template_path)
+ assert style is not None, "窄模板应成功加载"
+ assert 0 in style.column_widths, "应有第 0 列宽"
+ assert abs(style.column_widths[0] - 15.0) < 0.5
+
+ entries = [PinListEntry(number=i+1, name=f"PIN{i+1:02d}") for i in range(20)]
+ outpath = os.path.join(tmpdir, 'v15_014_output.xlsx')
+ generate_pinmap(entries, 5, 5, "QFN-20", template_style=style, output_path=outpath)
+
+ import zipfile
+ import xml.etree.ElementTree as ET
+ _S = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
+ with zipfile.ZipFile(outpath, 'r') as zf:
+ sheet_xml = zf.read('xl/worksheets/sheet1.xml')
+ root = ET.fromstring(sheet_xml)
+ cols_elem = root.find(f'{{{_S}}}cols')
+ assert cols_elem is not None, "输出应包含 cols"
+ cols = cols_elem.findall(f'{{{_S}}}col')
+ assert len(cols) >= 6, f"需要至少 6 列,实际 {len(cols)}"
+ widths = [float(c.get('width', '0')) for c in cols]
+ assert abs(widths[0] - 15.0) < 0.5, f"A列应为 15.0, 实际: {widths[0]}"
+ assert abs(widths[1] - 12.0) < 0.5, f"B列应为 12.0, 实际: {widths[1]}"
+ assert abs(widths[2] - 10.0) < 0.5, f"C列应为 10.0, 实际: {widths[2]}"
+ assert abs(widths[3] - 8.0) < 0.5, f"D列应为默认 8.0, 实际: {widths[3]}"
+ assert abs(widths[4] - 8.0) < 0.5, f"E列应为默认 8.0, 实际: {widths[4]}"
+
+ result.ok(f"列宽扩展正确: A={widths[0]}, B={widths[1]}, C={widths[2]}, D={widths[3]}, E={widths[4]}")
+
+ r.run("TC-v1.5-014: 列宽扩展", _tc_v15_014)
+
+ finally:
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+
# ── Main ────────────────────────────────────────────────────────────
def main():
@@ -579,6 +915,10 @@ def main():
test_list_to_map(runner)
print()
+ print("-- Part 3: v1.5 模板/样式集成测试 --")
+ test_v15_styles(runner)
+ print()
+
total, passed, failed = runner.summary()
print("=" * 60)
print(f" 总计: {total} | 通过: {passed} | 失败: {failed}")
@@ -611,13 +951,17 @@ def generate_report(runner: TestRunner, total: int, passed: int, failed: int):
# Count by category
map_results = [r for r in runner.results if r.name.startswith("TC-MAP")]
lm_results = [r for r in runner.results if r.name.startswith("TC-LM")]
+ v15_results = [r for r in runner.results if r.name.startswith("TC-v1.5")]
map_pass = sum(1 for r in map_results if r.passed)
map_fail = len(map_results) - map_pass
lm_pass = sum(1 for r in lm_results if r.passed)
lm_fail = len(lm_results) - lm_pass
+ v15_pass = sum(1 for r in v15_results if r.passed)
+ v15_fail = len(v15_results) - v15_pass
- lines.append(f"| MAP→List 回归 | {len(map_results)} | {map_pass} | {map_fail} |")
- lines.append(f"| List→MAP 新增 | {len(lm_results)} | {lm_pass} | {lm_fail} |")
+ lines.append(f"| MAP->List 回归 | {len(map_results)} | {map_pass} | {map_fail} |")
+ lines.append(f"| List->MAP 新增 | {len(lm_results)} | {lm_pass} | {lm_fail} |")
+ lines.append(f"| v1.5 模板/样式集成 | {len(v15_results)} | {v15_pass} | {v15_fail} |")
lines.append(f"| **总计** | **{total}** | **{passed}** | **{failed}** |")
lines.append("")
lines.append("---")
@@ -649,6 +993,19 @@ def generate_report(runner: TestRunner, total: int, passed: int, failed: int):
lines.append(f"- **错误**: {r.error}")
lines.append("")
+ # v1.5 details
+ lines.append("## Part 3: v1.5 模板/样式集成测试")
+ lines.append("")
+ for r in v15_results:
+ status = "✅ 通过" if r.passed else "❌ 失败"
+ lines.append(f"### {r.name}")
+ lines.append(f"- **结果**: {status}")
+ if r.details:
+ lines.append(f"- **详情**: {r.details}")
+ if r.error:
+ lines.append(f"- **错误**: {r.error}")
+ lines.append("")
+
# Summary
lines.append("---")
lines.append("")
diff --git a/Test/test_plan_v1.5.md b/Test/test_plan_v1.5.md
new file mode 100644
index 0000000..3494d0f
--- /dev/null
+++ b/Test/test_plan_v1.5.md
@@ -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 为空列表 | 回退到默认 cellXfs(4 个硬编码 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 执行*
\ No newline at end of file
diff --git a/Test/test_report.md b/Test/test_report.md
index a1952b5..08f3f4e 100644
--- a/Test/test_report.md
+++ b/Test/test_report.md
@@ -1,18 +1,51 @@
-# PinMAP ↔ PinList 双向转换器 测试报告
+# PinMAP ↔ PinList 双向转换器 测试报告 (v1.5.0)
-> **日期**: 2026-06-01
-> **测试类型**: 集成测试 + 端到端测试
+> **版本**: v1.5.0
+> **日期**: 2026-06-06
+> **测试类型**: 单元测试 + 集成测试 + 端到端测试
> **测试环境**: Python 3.x, Linux x64
---
+## v1.5.0 变更覆盖
+
+v1.5.0 引入三项核心变更:
+- **F009**: MAP→List 使用 BallList-Template.xlsx(独立模板)
+- **F010**: List→MAP 使用 BallMAP-Template.xlsx(独立模板)
+- **F011**: 模板格式提取式应用(字体/边框/填充/对齐/列宽/行高)
+- **F012**: PinName 位置确认(bottom=max_row-1, top=min_row+1)
+
+## 测试覆盖矩阵
+
+| 特性 | 单元测试 | 集成测试 | 状态 |
+|------|---------|---------|------|
+| F009 — BallList 模板加载 | ✅ `test_template_path_generation` | ✅ TC-v1.5-001/002/005 | ✅ |
+| F010 — BallMAP 模板加载 | ✅ `test_template_path_generation` | ✅ TC-v1.5-003/004/005 | ✅ |
+| F011 — 模板字体应用 | ✅ `test_f011_template_fonts_in_styles_xml` | ✅ TC-v1.5-007/010/013 | ✅ |
+| F011 — 模板边框应用 | ✅ `test_f011_template_borders_in_styles_xml` | ✅ TC-v1.5-007/010 | ✅ |
+| F011 — 模板填充应用 | ✅ `test_f011_template_fills_in_styles_xml` | ✅ TC-v1.5-010 | ✅ |
+| F011 — 默认样式降级 | ✅ `test_f011_default_styles_xml` | ✅ TC-v1.5-002/004/012 | ✅ |
+| F011 — 输出 dim 由 Pin 决定 | ✅ `test_f011_output_dims_determined_by_pins` | ✅ TC-v1.5-014 | ✅ |
+| F011 — 列宽应用 | — | ✅ TC-v1.5-008/014 | ✅ |
+| F011 — 行高应用 | — | ✅ TC-v1.5-009 | ✅ |
+| F012 — PinName 位置 | ✅ `test_f012_pinname_position` | — | ✅ |
+| 损坏模板优雅降级 | — | ✅ TC-v1.5-006 | ✅ |
+| 极简模板 | — | ✅ TC-v1.5-013 | ✅ |
+| 无模板完整流程 | — | ✅ TC-v1.5-012 | ✅ |
+| 完整往返+模板隔离 | — | ✅ TC-v1.5-011 | ✅ |
+| 空 fonts/样式回退 | ✅ `test_template_empty_fonts_fallback` | — | ✅ |
+| FF 颜色前缀补全 | ✅ `test_template_color_prefix_auto_fix` | — | ✅ |
+| 缺失 styles.xml 降级 | ✅ `test_template_no_styles_xml` | — | ✅ |
+
## 测试概览
| 类别 | 用例数 | 通过 | 失败 |
|------|--------|------|------|
-| MAP→List 回归 | 6 | 6 | 0 |
-| List→MAP 新增 | 17 | 17 | 0 |
-| **总计** | **23** | **23** | **0** |
+| 单元测试 (test_pinmap.py) | **18** | **18** | **0** |
+| MAP->List 回归 | 6 | 6 | 0 |
+| List->MAP 新增 | 17 | 17 | 0 |
+| v1.5 模板/样式集成 | 14 | 14 | 0 |
+| **总计** | **55** | **55** | **0** |
---
@@ -112,6 +145,64 @@
- **结果**: ✅ 通过
- **详情**: 正确报错: A1 单元格为空,无法获取封装信息
+## Part 3: v1.5 模板/样式集成测试
+
+### TC-v1.5-001: MAP->List 加载 BallList 模板
+- **结果**: ✅ 通过
+- **详情**: 模板加载成功: fonts=2, borders=2, width_A=25.0
+
+### TC-v1.5-002: MAP->List 无模板降级
+- **结果**: ✅ 通过
+- **详情**: 无模板文件时优雅返回 None
+
+### TC-v1.5-003: List->MAP 加载 BallMAP 模板
+- **结果**: ✅ 通过
+- **详情**: 模板加载成功: fonts=2, borders=2, row_height=25.0
+
+### TC-v1.5-004: List->MAP 无模板降级
+- **结果**: ✅ 通过
+- **详情**: 无模板文件时优雅返回 None
+
+### TC-v1.5-005: 两个方向独立使用各自模板
+- **结果**: ✅ 通过
+- **详情**: 两个模板独立: BL fonts=2, BM 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: 两个方向不同模板各自的格式
+- **结果**: ✅ 通过
+- **详情**: 两个方向输出字体不同: BL->楷体, BM->宋体
+
+### TC-v1.5-011: 完整往返+模板隔离
+- **结果**: ✅ 通过
+- **详情**: 往返成功: 12 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
+
---
## 结论
diff --git a/docs/modification-assessment-v1.5.md b/docs/modification-assessment-v1.5.md
new file mode 100644
index 0000000..09115be
--- /dev/null
+++ b/docs/modification-assessment-v1.5.md
@@ -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-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 → 测试收尾。
+
+---
+
+*文档结束 — 请审批后进入编码阶段*
+
+##
\ No newline at end of file
diff --git a/docs/tasks.md b/docs/tasks.md
index aff6a6b..cc2d003 100644
--- a/docs/tasks.md
+++ b/docs/tasks.md
@@ -14,6 +14,7 @@
| 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 | - | - |
-| T017 | 文档生成 v1.5 | doc-gen-agent | 待处理 | 文档编写 | F009-F012 | - | - |
+| 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 | - |
| T018 | 打包发布 v1.5 | package-release-agent | 待处理 | 打包发布 | F009-F012 | - | - |