v1.3.0: 修复pinmap_layout周长公式,新增PinList→PinMAP反向转换完整支持

This commit is contained in:
2026-06-01 11:43:53 +08:00
parent 3228c1a2e6
commit 8ad31cbf04
16 changed files with 967 additions and 183 deletions

View File

@@ -33,6 +33,29 @@ def wait_for_exit():
# ── Path helpers ────────────────────────────────────────────────────
def _find_template_path() -> str | None:
"""查找根目录下的 PinMAP-Template.xlsx。
搜索顺序:
1. 与 run.bat 同级的根目录
2. 当前工作目录
"""
# 从 Code/src 回退到根目录
src_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
template_path = os.path.join(root_dir, "PinMAP-Template.xlsx")
if os.path.exists(template_path):
return template_path
# 回退到当前工作目录
cwd_template = os.path.join(os.getcwd(), "PinMAP-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
def _build_output_path_map_to_list(input_path: str) -> str:
"""Generate output path: {original_filename}_PinList.xlsx"""
base, _ = os.path.splitext(input_path)
@@ -55,7 +78,8 @@ def run_map_to_list(filepath: str):
from pinmap_parser import parse_pinmap
from validator import validate_pinmap
from pinlist_generator import generate_pinlist
from xlsx_writer import write_xlsx
from xlsx_writer import write_xlsx, write_xlsx_with_style
from template_reader import read_template_styles
from models import FileFormatError, StructureError
# ── 1. File selection ───────────────────────────────────────────
@@ -126,7 +150,22 @@ def run_map_to_list(filepath: str):
data[f'A{row}'] = pin_name
data[f'B{row}'] = str(pin_num)
write_xlsx(data, output_path)
# 尝试读取模板样式F007
template_path = _find_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载模板样式: {template_path}")
else:
print("[WARN] 模板文件存在但解析失败,使用默认样式")
else:
print("[INFO] 未检测到模板文件,使用默认样式")
if template_style is not None:
write_xlsx_with_style(data, output_path, template_style)
else:
write_xlsx(data, output_path)
except Exception as e:
print(f"[FATAL] 输出失败: {e}")
wait_for_exit()
@@ -139,7 +178,7 @@ def run_map_to_list(filepath: str):
print(f" 封装信息: {pinlist.package_info}")
print(f" Pin数量: {len(pinlist.rows)}")
wait_for_exit()
# F008: 不再退出,返回主循环
# ── Direction 2: List → MAP ────────────────────────────────────────
@@ -221,8 +260,17 @@ def run_list_to_map(filepath: str):
print(f"[INFO] 正在生成 PinMAP 并写入: {output_path}")
try:
# 尝试读取模板样式(优雅降级
template_style = read_template_styles(filepath)
# 尝试读取模板样式(F007 — 从根目录读取而非输入文件路径
template_path = _find_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载模板样式: {template_path}")
else:
print("[WARN] 模板文件存在但解析失败,使用默认样式")
else:
print("[INFO] 未检测到模板文件,使用默认样式")
generate_pinmap(
entries=entries,
@@ -249,7 +297,7 @@ def run_list_to_map(filepath: str):
print(f" PinMAP 尺寸: {rows}×{cols}")
print(f" Pin数量: {len(entries)}")
wait_for_exit()
# F008: 不再退出,返回主循环
# ── Main entry ──────────────────────────────────────────────────────
@@ -257,41 +305,59 @@ def run_list_to_map(filepath: str):
def main():
show_banner()
# ── Direction selection ─────────────────────────────────────────
if len(sys.argv) > 1:
# Legacy mode: direct file argument → MAP→List
direction = 1
filepath = sys.argv[1]
else:
print("请选择转换方向:")
print(" 1 — PinMAP → PinList")
print(" 2 — PinList → PinMAP")
print()
# F008: 循环处理流程
while True:
# ── Direction selection ─────────────────────────────────────
if len(sys.argv) > 1:
# Legacy mode: direct file argument → MAP→List
direction = 1
filepath = sys.argv[1]
sys.argv = [sys.argv[0]] # 清除 argv下次循环进入交互模式
else:
print("请选择转换方向:")
print(" 1 — PinMAP → PinList")
print(" 2 — PinList → PinMAP")
print(" Q — 退出程序")
print()
while True:
choice = input("请输入选项 (1/2): ").strip()
if choice in ('1', '2'):
direction = int(choice)
break
print("[ERROR] 无效选项,请输入 1 或 2")
choice = input("请输入选项 (1/2/Q): ").strip().upper()
if choice == 'Q':
print("感谢使用,再见!")
return
elif choice == '1':
direction = 1
elif choice == '2':
direction = 2
else:
print("[ERROR] 无效选项,请输入 1、2 或 Q")
continue
filepath = None # will be selected inside the flow
filepath = None
# ── Dispatch ────────────────────────────────────────────────────
if direction == 1:
# ── Dispatch ────────────────────────────────────────────────
if direction == 1:
print()
print("" * 40)
print(" 方向: PinMAP → PinList")
print("" * 40)
print()
run_map_to_list(filepath)
else:
print()
print("" * 40)
print(" 方向: PinList → PinMAP")
print("" * 40)
print()
run_list_to_map(filepath)
# ── 处理完成后循环 ──────────────────────────────────────────
print()
print("" * 40)
print(" 方向: PinMAP → PinList")
print("" * 40)
print()
run_map_to_list(filepath)
else:
print()
print("" * 40)
print(" 方向: PinList → PinMAP")
print("" * 40)
print()
run_list_to_map(filepath)
print("=" * 40)
next_choice = input("输入 Q 退出,或按 Enter 返回主菜单继续转换: ").strip().upper()
if next_choice == 'Q':
print("感谢使用,再见!")
return
# 否则继续 while 循环,回到主菜单
if __name__ == '__main__':

View File

@@ -3,7 +3,7 @@
Validates a PinList for:
1. Pin numbers starting from 1 with no gaps
2. No duplicate pin numbers
3. Total pin count matches grid perimeter (2×rows + 2×cols 4)
3. Total pin count matches grid perimeter (rows + cols) × 2
4. Missing PinName defaults to NC (warning)
5. Pin count not a multiple of 4 (info)
"""
@@ -22,7 +22,7 @@ def validate_pinlist(
检查项:
1. Pin 序号从 1 开始连续无缺失
2. Pin 序号无重复
3. Pin 总数 = 2×rows + 2×cols 4(周长匹配)
3. Pin 总数 = (rows + cols) × 2(周长匹配)
4. Pin 缺少 PinName 时默认为 NCwarning
5. Pin 数量不是 4 的倍数时提示info
@@ -68,7 +68,8 @@ def validate_pinlist(
))
# ── 3. 周长匹配 ──────────────────────────────────────────────
expected_total = 2 * rows + 2 * cols - 4
# 周长公式:(rows + cols) * 2
expected_total = (rows + cols) * 2
actual_total = len(entries)
if actual_total != expected_total:
errors.append(ValidationError(

View File

@@ -61,10 +61,17 @@ def generate_pinmap(
data[name_ref] = pin_name if pin_name and pin_name.strip() else "NC"
# 再写入序号单元格(覆盖同位置的名字,确保序号优先)
# v1.3: 角点单元格被两条边共享,需写入两个引脚序号
cell_pins: dict[str, list[str]] = {}
for edge_name, edge in layout.items():
for (pin_num, pin_name), num_cell in zip(edge.pins, edge.cells):
num_ref = rc_to_cell_ref(num_cell[0], num_cell[1])
data[num_ref] = str(pin_num)
if num_ref not in cell_pins:
cell_pins[num_ref] = []
cell_pins[num_ref].append(str(pin_num))
for num_ref, pins in cell_pins.items():
data[num_ref] = "/".join(pins)
# 3. 写入文件(应用模板样式)
if output_path:

View File

@@ -6,11 +6,13 @@ from the top-left corner (pin 1).
Edge assignment (counter-clockwise, top-left = pin 1):
left → rows pins
bottom → cols-1 pins
right → rows-2 pins
top → cols-1 pins
bottom → cols pins
right → rows pins
top → cols pins
Total: rows + (cols-1) + (rows-2) + (cols-1) = 2×rows + 2×cols 4
Total: rows + cols + rows + cols = 2×rows + 2×cols = (rows + cols) × 2
v1.3: 每条边独立包含其端点,角点单元格会被两条边共享。
"""
from models import PinListEntry, EdgePins, LayoutError
@@ -27,6 +29,12 @@ def calculate_layout(
逆时针分配(左上角为 1 脚):
左边 → 下边 → 右边 → 上边
角点分配策略v1.3:每条边独立包含其端点):
- 左边: 包含左下角
- 下边: 包含右下角
- 右边: 包含右上角
- 上边: 包含左上角
Parameters
----------
entries : list[PinListEntry]
@@ -52,13 +60,13 @@ def calculate_layout(
if cols < 2:
raise LayoutError(f"列数无效: {cols},至少需要 2 列")
# ── 边分配计数 ────────────────────────────────────────────────
# ── 边分配计数v1.3:每条边独立包含其端点)─────────────────
left_count = rows
bottom_count = cols - 1
right_count = rows - 2
top_count = cols - 1
bottom_count = cols
right_count = rows
top_count = cols
total = left_count + bottom_count + right_count + top_count
total = (rows + cols) * 2
if len(entries) != total:
raise LayoutError(
f"Pin数量 ({len(entries)}) 与网格周长 ({total}) 不匹配"
@@ -83,22 +91,24 @@ def calculate_layout(
# 网格坐标体系0-based
# 方形区域:行 [1..rows],列 [0..cols]
# 左边: 序号在 (r, 0), Name 在 (r, 1) 其中 r ∈ [1, rows]
# 下边: 序号在 (rows, c), Name 在 (rows-1, c) 其中 c ∈ [1, cols-1]
# 右边: 序号在 (r, cols), Name 在 (r, cols-1) 其中 r ∈ [rows-1, 2] 逆序
# 上边: 序号在 (1, c), Name 在 (2, c) 其中 c ∈ [cols-1, 2] 逆序
# 下边: 序号在 (rows, c), Name 在 (rows-1, c) 其中 c ∈ [1, cols]
# 右边: 序号在 (r, cols), Name 在 (r, cols-1) 其中 r ∈ [rows, 1] 逆序
# 上边: 序号在 (1, c), Name 在 (2, c) 其中 c ∈ [cols, 1] 逆序
#
# v1.3: 每条边独立包含其端点,角点单元格会被两条边共享
#
# 左边:从上到下
left_cells = [(r, 0) for r in range(1, rows + 1)]
# 下边:从左到右
bottom_cells = [(rows, c) for c in range(1, cols)]
bottom_cells = [(rows, c) for c in range(1, cols + 1)]
# 右边:从下到上(逆序)
right_cells = [(r, cols) for r in range(rows - 1, 1, -1)]
right_cells = [(r, cols) for r in range(rows, 0, -1)]
# 上边:从右到左(逆序)
top_cells = [(1, c) for c in range(cols - 1, 0, -1)]
top_cells = [(1, c) for c in range(cols, 0, -1)]
# ── 构建 EdgePins ─────────────────────────────────────────────
def _make_edge(edge_name: str, pin_list: list[PinListEntry],

View File

@@ -117,41 +117,49 @@ def parse_pinmap(cells: dict[tuple[int, int], str]) -> PinMAP:
if name and str(name).strip():
name_map[(min_row, c)] = str(name).strip()
# ── Step 4: walk edges counter-clockwise ─────────────────────
# Deduplicate by *cell position* (corners are shared cells),
# NOT by pin number — duplicate numbers are a data error for
# the validator to catch.
# ── Step 4: walk edges counter-clockwise (v1.3 formula) ──────
# Each edge independently includes its endpoints (corners).
# Corner cells are read by two edges — this is expected per
# v1.3: total = (rows + cols) × 2.
pins: list[Pin] = []
seen_cells: set[tuple[int, int]] = set()
def _add_pin(r: int, c: int, edge: str, pos: int) -> None:
# Skip if this cell was already processed (corner visited by two edges)
if (r, c) in seen_cells:
return # corner cell already processed
seen_cells.add((r, c))
num = _try_int(cells.get((r, c), ""))
if num is None:
return
pins.append(Pin(
number=num,
name=name_map.get((r, c), ""),
edge=edge,
position_on_edge=pos,
))
seen_cells.add((r, c))
raw = cells.get((r, c), "")
if not raw:
return
# Handle "6/7" format from corner cells
parts = str(raw).strip().split("/")
for part in parts:
num = _try_int(part)
if num is None:
continue
pins.append(Pin(
number=num,
name=name_map.get((r, c), ""),
edge=edge,
position_on_edge=pos,
))
# 4a. Left edge: top → bottom
# 4a. Left edge: top → bottom (includes bottom-left corner)
for r in range(min_row, max_row + 1):
_add_pin(r, min_col, "left", r - min_row)
# 4b. Bottom edge: left → right (skip min_col corner already done)
for c in range(min_col + 1, max_col + 1):
# 4b. Bottom edge: left → right (includes bottom-right corner)
for c in range(min_col, max_col + 1):
_add_pin(max_row, c, "bottom", c - min_col)
# 4c. Right edge: bottom → top (skip max_row corner already done)
for r in range(max_row - 1, min_row - 1, -1):
# 4c. Right edge: bottom → top (includes top-right corner)
for r in range(max_row, min_row - 1, -1):
_add_pin(r, max_col, "right", max_row - r)
# 4d. Top edge: right → left (skip max_col corner already done)
for c in range(max_col - 1, min_col - 1, -1):
# 4d. Top edge: right → left (includes top-left corner)
for c in range(max_col, min_col - 1, -1):
_add_pin(min_row, c, "top", max_col - c)
if not pins:

View File

@@ -115,7 +115,7 @@ def validate_pinlist_for_map(
1. **Continuity** — pin numbers must start from 1 with no gaps.
2. **Uniqueness** — no duplicate pin numbers.
3. **Perimeter match** — total pin count must equal
2×rows + 2×cols 4 (the grid perimeter).
(rows + cols) × 2 (the grid perimeter).
4. **Non-multiple-of-4** — if pin count is not a multiple of 4,
a warning is issued (but conversion is not blocked).
@@ -158,7 +158,8 @@ def validate_pinlist_for_map(
))
# ── 3. Perimeter match ───────────────────────────────────────
expected_total = 2 * rows + 2 * cols - 4
# 周长公式:(rows + cols) * 2
expected_total = (rows + cols) * 2
actual_total = len(entries)
if actual_total != expected_total:
result.errors.append(ValidationError(