feat(tools): 白模关卡自动生成Python脚本——Level Builder

两阶段流水线:搭建指南Markdown → JSON → UE5 Actor
- 解析器:4种表格变体+范围展开+坐标表达式计算
- 配置:37种BSP资产映射+13种材质+7区域定义
- 验证:265个Actor全部通过,0错误
- Builder:UE5 Python API自动放置Actor+材质+Outliner
- 报告:自动生成搭建报告Markdown

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 23:03:45 +08:00
parent 8011cb6472
commit 0f96b79dba
74 changed files with 10468 additions and 109 deletions
@@ -0,0 +1,772 @@
"""搭建指南 Markdown 解析器。
解析 docs/technical/关卡-v01-搭建指南.md,输出统一的 Actor 列表。
搭建指南的表格格式(4 种变体 + 特殊表格):
- 结构体表: # / Actor名称 / 资产 / 位置 / [旋转] / [缩放] / 材质
- 布景表: # / Actor名称 / 资产 / 位置 / [缩放] / 材质 / 说明
- 交互物表: # / Actor名称 / 位置 / 触发半径 / [交互类型] / 引用
- 收集品表: # / Actor名称 / 位置 / 稀有度 / 引用
- 边界表: # / Actor名称 / 资产 / 位置 / [旋转] / [缩放] / 材质
- 光照表: # / Actor名称 / 类型 / 位置 / 参数
"""
import json
import re
import logging
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import Optional
from parsers.markdown_table import (
parse_table,
parse_location,
parse_rotation,
parse_scale,
clean_cell,
)
logger = logging.getLogger("level_builder.parser")
# ============================================================
# 数据结构
# ============================================================
@dataclass
class ActorData:
"""单个 Actor 的完整规格。"""
actor_name: str
asset_key: str = ""
asset_type: str = "static_mesh" # static_mesh | player_start | directional_light | sky_light | point_light
category: str = "" # STR | PROP | INTR | COLL | BND | LGT | PLAY
zone_id: str = "" # Z1-Z7 | GLOBAL
location: tuple = (0.0, 0.0, 0.0)
rotation: tuple = (0.0, 0.0, 0.0)
scale: tuple = (1.0, 1.0, 1.0)
material: str = ""
folder: str = ""
note: str = ""
trigger_radius: float = 0.0
interact_type: str = ""
rarity: str = ""
source_ref: str = ""
# ============================================================
# 标题/上下文检测
# ============================================================
# 区域标题匹配:### C1. Z1 — 家(Home)
ZONE_HEADING = re.compile(
r'###\s+C\d+\.\s+(Z\d+)\s*[—\-–]\s*(.+?)(?:((.+)))?$'
)
# 分类子标题匹配:#### C1.1 结构体(Structure)
CATEGORY_HEADING = re.compile(
r'####\s+C\d+\.\d+\s+(结构体|布景|交互物|收集品|布景物)'
)
# Phase 标题
PHASE_HEADING = re.compile(r'^##\s+(二|三|四|五|六|七|八)')
# 边界区域标题
BOUNDARY_HEADING = re.compile(r'####\s+(西边界|东边界|北边界|南边界)')
# 全局物件标题
GLOBAL_HEADING = re.compile(r'###\s+G-(\d+|E\d+)\s+(.+)')
# 光照标题
LIGHT_HEADING = re.compile(r'###\s+D\d+\s+(.+)')
# 地面子标题
FLOOR_HEADING = re.compile(r'####\s+Z\d+\s+(.+?)\s+地面')
CATEGORY_MAP = {
"结构体": "STR",
"布景": "PROP",
"布景物": "PROP",
"交互物": "INTR",
"交互物标记": "INTR",
"收集品": "COLL",
"收集品标记": "COLL",
}
ZONE_ID_MAP = {
"Z1": "Z1", "Z2": "Z2", "Z3": "Z3",
"Z4": "Z4", "Z5": "Z5", "Z6": "Z6", "Z7": "Z7",
}
# ============================================================
# 核心解析逻辑
# ============================================================
class BuildGuideParser:
"""搭建指南解析器。"""
def __init__(self):
self.actors: list[ActorData] = []
self.current_zone = ""
self.current_category = ""
self.current_phase = ""
self.metadata = {}
def parse_file(self, filepath: str) -> dict:
"""解析搭建指南文件,返回完整的关卡数据。"""
path = Path(filepath)
if not path.exists():
raise FileNotFoundError(f"搭建指南文件不存在: {filepath}")
text = path.read_text(encoding="utf-8")
return self.parse_text(text)
def parse_text(self, text: str) -> dict:
"""解析搭建指南文本。"""
lines = text.split("\n")
# 解析全局参数(第〇章)
self._parse_global_info(lines)
# 解析资产映射表(第一章)— 可选,用于验证
# 逐 Phase 解析
self._parse_phase_ground(lines)
self._parse_phase_boundaries(lines)
self._parse_phase_zones(lines)
self._parse_phase_global_objects(lines)
self._parse_phase_lighting(lines)
# 添加 Player Start
self._add_player_start()
# 构建输出
result = {
"metadata": self.metadata,
"actors": [asdict(a) for a in self.actors],
"summary": self._build_summary(),
}
return result
# ----------------------------------------------------------
# 全局参数
# ----------------------------------------------------------
def _parse_global_info(self, lines: list[str]):
"""解析场景全局参数。"""
self.metadata = {
"source": "关卡-v01-搭建指南.md",
"scene_size": [8000, 6000],
"ground_z": 0,
"character_height": 120,
"camera_height": 100,
}
# ----------------------------------------------------------
# Phase B — 地面
# ----------------------------------------------------------
def _parse_phase_ground(self, lines: list[str]):
"""解析 Phase B 地面铺设表格。"""
text = "\n".join(lines)
# 找到 Phase B 区域
b_start = self._find_section_start(lines, "Phase B")
b_end = self._find_section_start(lines, "Phase C")
if b_start < 0:
return
if b_end < 0:
b_end = len(lines)
section_text = "\n".join(lines[b_start:b_end])
# 地面子区域标题:#### Z1 家 地面(MI_DevGray)
floor_pattern = re.compile(
r'####\s+Z(\d+)\s+\S+\s+地面(?:((.+?)))?'
)
current_zone = ""
current_material = ""
section_lines = lines[b_start:b_end]
i = 0
while i < len(section_lines):
line = section_lines[i].strip()
# 检测地面子区域
fm = floor_pattern.match(line)
if fm:
current_zone = f"Z{fm.group(1)}"
current_material = fm.group(2) or ""
i += 1
continue
# 检测水面区域
if '水面' in line and current_zone:
current_material = "MI_DevSkyBlue"
i += 1
continue
# 检测表格
if '|' in line and '#' in line:
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
for row in rows:
actor = self._parse_floor_row(row, current_zone, current_material)
if actor:
self.actors.append(actor)
continue
i += 1
def _parse_floor_row(self, row: dict, zone: str, default_material: str) -> Optional[ActorData]:
"""解析地面表格行。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
if not name:
return None
asset_key = clean_cell(row.get("资产", ""))
location = parse_location(row.get("位置 (X,Y,Z)", row.get("位置", "")))
scale = parse_scale(row.get("缩放", "—"))
material = clean_cell(row.get("材质", ""))
if not material:
material = default_material
return ActorData(
actor_name=name,
asset_key=asset_key,
asset_type="static_mesh",
category="STR",
zone_id=zone,
location=location,
scale=scale,
material=material,
folder=self._get_folder(zone, "STR"),
)
# ----------------------------------------------------------
# Phase B — 边界
# ----------------------------------------------------------
def _parse_phase_boundaries(self, lines: list[str]):
"""解析 Phase B 边界围挡。"""
b_start = self._find_section_start(lines, "放置边界围挡")
b_end = self._find_section_start(lines, "Phase C")
if b_start < 0:
return
if b_end < 0:
b_end = len(lines)
section_lines = lines[b_start:b_end]
i = 0
while i < len(section_lines):
line = section_lines[i].strip()
# 边界子区域标题
bm = BOUNDARY_HEADING.match(line)
# 检测表格
if '|' in line:
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
for row in rows:
actors = self._parse_boundary_row(row)
self.actors.extend(actors)
continue
i += 1
def _parse_boundary_row(self, row: dict) -> list[ActorData]:
"""解析边界表格行,处理范围格式如 '1-15 | LVL_BND_East_01~15'。"""
name_col = row.get("Actor 名称", row.get("Actor名称", ""))
name = clean_cell(name_col)
# 处理范围格式:名称中含 ~ 或编号含 -
if '~' in name:
return self._expand_range_row(row, name)
asset_key = clean_cell(row.get("资产", ""))
location = parse_location(row.get("位置 (X,Y,Z)", row.get("位置", "")))
rotation = parse_rotation(row.get("旋转 (Pitch,Yaw,Roll)", row.get("旋转", "(0,0,0)")))
scale = parse_scale(row.get("缩放", "—"))
material = clean_cell(row.get("材质", ""))
actor = ActorData(
actor_name=name,
asset_key=asset_key,
category="BND",
zone_id="GLOBAL",
location=location,
rotation=rotation,
scale=scale,
material=material,
folder=self._get_folder("GLOBAL", "BND"),
)
return [actor]
def _expand_range_row(self, row: dict, name: str) -> list[ActorData]:
"""展开范围格式行。如 'LVL_BND_East_01~15' → 15 个 Actor。"""
asset_key = clean_cell(row.get("资产", ""))
rotation = parse_rotation(row.get("旋转 (Pitch,Yaw,Roll)", row.get("旋转", "(0,0,0)")))
scale = parse_scale(row.get("缩放", "—"))
material = clean_cell(row.get("材质", ""))
# 解析名称范围:LVL_BND_East_01~15
m = re.match(r'(.+?)_(\d+)~(\d+)', name)
if not m:
return []
prefix = m.group(1)
start = int(m.group(2))
end = int(m.group(3))
# 解析位置表达式 — 格式: (X, Y_expr, Z) n=start~end
# 例: (8000, 200+400n, 0) n=0~14
loc_col = row.get("位置 (X,Y,Z)", row.get("位置", ""))
loc_col = clean_cell(loc_col)
# 提取 n 的范围
n_match = re.search(r'n\s*=\s*(\d+)~(\d+)', loc_col)
n_start = int(n_match.group(1)) if n_match else 0
n_end = int(n_match.group(2)) if n_match else (end - start)
# 去掉 n=xx~yy 后缀以便解析坐标
loc_clean = re.sub(r'\s*n\s*=\s*\d+~\d+\s*', '', loc_col).strip()
# 提取三个坐标表达式
coord_match = re.match(r'\((.+?)\s*,\s*(.+?)\s*,\s*(.+?)\)', loc_clean)
if not coord_match:
return []
x_expr = coord_match.group(1).strip()
y_expr = coord_match.group(2).strip()
z_expr = coord_match.group(3).strip()
actors = []
for idx, n in enumerate(range(n_start, n_end + 1)):
x = self._eval_coord_expr(x_expr, n)
y = self._eval_coord_expr(y_expr, n)
z = self._eval_coord_expr(z_expr, n)
actor_num = start + idx
actor_name = f"{prefix}_{actor_num:02d}"
actor = ActorData(
actor_name=actor_name,
asset_key=asset_key,
category="BND",
zone_id="GLOBAL",
location=(x, y, z),
rotation=rotation,
scale=scale,
material=material,
folder=self._get_folder("GLOBAL", "BND"),
)
actors.append(actor)
return actors
def _eval_coord_expr(self, expr: str, n: int) -> float:
"""计算坐标表达式。如 '200+400n' → 200+400*n。"""
expr = expr.strip()
# 简单情况:纯数字
try:
return float(expr)
except ValueError:
pass
# 含 n 的表达式:200+400n
expr = expr.replace('n', f'*{n}')
# 处理连续运算符:*+→+, *-→-
expr = expr.replace('*+', '+').replace('*-', '-')
try:
# 安全计算简单算术
result = eval(expr) # 受限输入,仅数字和基本运算
return float(result)
except Exception:
return 0.0
# ----------------------------------------------------------
# Phase C — 逐区域搭建
# ----------------------------------------------------------
def _parse_phase_zones(self, lines: list[str]):
"""解析 Phase C 逐区域搭建。"""
c_start = self._find_section_start(lines, "逐区域搭建")
c_end = self._find_section_start(lines, "Phase C — 全局物件")
if c_start < 0:
c_start = self._find_section_start(lines, "全局物件")
if c_start < 0:
return
if c_end < 0:
c_end = len(lines)
section_lines = lines[c_start:c_end]
current_zone = ""
current_category = ""
i = 0
while i < len(section_lines):
line = section_lines[i].strip()
# 检测区域标题
zm = ZONE_HEADING.match(line)
if zm:
current_zone = zm.group(1)
i += 1
continue
# 检测分类标题
cm = CATEGORY_HEADING.match(line)
if cm:
cat_name = cm.group(1)
current_category = CATEGORY_MAP.get(cat_name, "")
i += 1
continue
# 检测结构体子标题(如 "C1.1 结构体(Structure)")
sub_match = re.match(r'####\s+C\d+\.\d+\s+(结构体|布景|布景物|交互物|交互物标记|收集品|收集品标记)', line)
if sub_match:
cat_name = sub_match.group(1)
current_category = CATEGORY_MAP.get(cat_name, "")
i += 1
continue
# 处理表格
if '|' in line:
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
for row in rows:
actor = self._parse_zone_row(row, current_zone, current_category)
if actor:
self.actors.append(actor)
continue
i += 1
def _parse_zone_row(self, row: dict, zone: str, category: str) -> Optional[ActorData]:
"""解析区域内的 Actor 行。根据列内容判断表格类型。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
if not name:
return None
# 判断表格类型:有"资产"列 → 结构体/布景;有"触发半径" → 交互物;有"稀有度" → 收集品
has_asset = "资产" in row and clean_cell(row.get("资产", ""))
has_trigger = "触发半径" in row or "触发半径 (cm)" in row
has_rarity = "稀有度" in row
if has_trigger and not has_asset:
# 交互物表
return self._parse_interactable_row(row, zone)
elif has_rarity and not has_asset:
# 收集品表
return self._parse_collectible_row(row, zone)
else:
# 结构体/布景表
return self._parse_structure_or_prop_row(row, zone, category)
def _parse_structure_or_prop_row(self, row: dict, zone: str, category: str) -> Optional[ActorData]:
"""解析结构体或布景表格行。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
asset_key = clean_cell(row.get("资产", ""))
location = parse_location(
row.get("位置 (X,Y,Z)", row.get("位置", ""))
)
# 旋转:结构体表可能有,布景表可能没有
rot_col = row.get("旋转 (Pitch,Yaw,Roll)", row.get("旋转", ""))
rotation = parse_rotation(rot_col) if rot_col else (0.0, 0.0, 0.0)
# 缩放
scale = parse_scale(row.get("缩放", "—"))
# 材质
material = clean_cell(row.get("材质", ""))
if material in ('—', '–', '-'):
material = ""
# 说明
note = clean_cell(row.get("说明", ""))
if not category:
category = "STR"
return ActorData(
actor_name=name,
asset_key=asset_key,
category=category,
zone_id=zone,
location=location,
rotation=rotation,
scale=scale,
material=material,
folder=self._get_folder(zone, category),
note=note,
)
def _parse_interactable_row(self, row: dict, zone: str) -> ActorData:
"""解析交互物行。用占位 Cube 表示。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
location = parse_location(
row.get("位置 (X,Y,Z)", row.get("位置", ""))
)
trigger = row.get("触发半径", row.get("触发半径 (cm)", "150"))
try:
trigger_radius = float(trigger)
except (ValueError, TypeError):
trigger_radius = 150.0
ref = clean_cell(row.get("引用", ""))
interact_type = clean_cell(row.get("交互类型", ""))
return ActorData(
actor_name=name,
asset_key="Cube",
category="INTR",
zone_id=zone,
location=location,
scale=(0.3, 0.3, 0.3),
material="MI_DevYellow",
folder=self._get_folder(zone, "INTR"),
trigger_radius=trigger_radius,
interact_type=interact_type,
source_ref=ref,
note="交互物占位标记",
)
def _parse_collectible_row(self, row: dict, zone: str) -> ActorData:
"""解析收集品行。用占位 Sphere/Cube 表示。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
location = parse_location(
row.get("位置 (X,Y,Z)", row.get("位置", ""))
)
rarity = clean_cell(row.get("稀有度", "普通"))
ref = clean_cell(row.get("引用", ""))
# 稀有用金色,普通用白色
material = "MI_DevYellow" if rarity == "稀有" else "MI_DevWhite"
return ActorData(
actor_name=name,
asset_key="CirclePlatform",
category="COLL",
zone_id=zone,
location=location,
scale=(0.03, 0.03, 0.03),
material=material,
folder=self._get_folder(zone, "COLL"),
rarity=rarity,
source_ref=ref,
note="收集品占位标记",
)
# ----------------------------------------------------------
# Phase C — 全局物件
# ----------------------------------------------------------
def _parse_phase_global_objects(self, lines: list[str]):
"""解析全局物件(路灯、垃圾桶等)。"""
g_start = self._find_section_start(lines, "全局物件")
g_end = self._find_section_start(lines, "Phase D")
if g_start < 0:
return
if g_end < 0:
g_end = len(lines)
section_lines = lines[g_start:g_end]
i = 0
while i < len(section_lines):
line = section_lines[i].strip()
if '|' in line:
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
for row in rows:
actor = self._parse_global_row(row)
if actor:
self.actors.append(actor)
continue
i += 1
def _parse_global_row(self, row: dict) -> Optional[ActorData]:
"""解析全局物件行。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
if not name:
return None
asset_key = clean_cell(row.get("资产", ""))
location = parse_location(row.get("位置 (X,Y,Z)", row.get("位置", "")))
material = clean_cell(row.get("材质", ""))
if material in ('—', '–', '-'):
material = ""
# 从 Actor 名称推断类别
category = "PROP"
if "_LGT_" in name:
category = "LGT"
elif "_PROP_" in name:
category = "PROP"
zone_id = self._infer_zone_from_name(name)
return ActorData(
actor_name=name,
asset_key=asset_key,
category=category,
zone_id=zone_id,
location=location,
material=material,
folder=self._get_folder(zone_id, category),
)
# ----------------------------------------------------------
# Phase D — 光照
# ----------------------------------------------------------
def _parse_phase_lighting(self, lines: list[str]):
"""解析光照配置。"""
d_start = self._find_section_start(lines, "Phase D")
d_end = self._find_section_start(lines, "Phase E")
if d_start < 0:
return
if d_end < 0:
d_end = len(lines)
section_lines = lines[d_start:d_end]
# 全局光源:DirectionalLight + SkyLight
self._parse_global_lights(section_lines)
# 路灯点光源
self._parse_street_lights(section_lines)
def _parse_global_lights(self, lines: list[str]):
"""解析全局光源表格。"""
text = "\n".join(lines)
# Directional Light 和 Sky Light
if "Directional Light" in text or "DirectionalLight" in text:
self.actors.append(ActorData(
actor_name="LVL_Global_DirectionalLight",
asset_key="",
asset_type="directional_light",
category="LGT",
zone_id="GLOBAL",
location=(0, 0, 500),
rotation=(-45, 315, 0),
folder=self._get_folder("GLOBAL", "LGT"),
note="色温~5500K暖白;从西南照射;强度中等",
))
if "Sky Light" in text or "SkyLight" in text:
self.actors.append(ActorData(
actor_name="LVL_Global_SkyLight",
asset_key="",
asset_type="sky_light",
category="LGT",
zone_id="GLOBAL",
location=(0, 0, 0),
folder=self._get_folder("GLOBAL", "LGT"),
note="强度低-中;环境补光",
))
def _parse_street_lights(self, lines: list[str]):
"""解析路灯点光源表格。"""
i = 0
while i < len(lines):
line = lines[i].strip()
# 检测表格起始(含 | 的行)
if '|' in line:
table_lines = []
while i < len(lines) and '|' in lines[i]:
table_lines.append(lines[i])
i += 1
# 检查表格内容是否包含 Point Light
table_text = "\n".join(table_lines)
if "Point" in table_text or "point_light" in table_text.lower():
rows = parse_table(table_lines)
for row in rows:
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
loc_col = row.get("位置 (X,Y,Z)", row.get("位置", ""))
location = parse_location(loc_col)
if name:
self.actors.append(ActorData(
actor_name=name,
asset_key="",
asset_type="point_light",
category="LGT",
zone_id="Z3",
location=location,
folder=self._get_folder("GLOBAL", "LGT"),
note="暖黄色,低强度",
))
continue
i += 1
# ----------------------------------------------------------
# Player Start
# ----------------------------------------------------------
def _add_player_start(self):
"""添加 Player Start Actor。"""
self.actors.append(ActorData(
actor_name="LVL_PlayerStart",
asset_key="",
asset_type="player_start",
category="PLAY",
zone_id="GLOBAL",
location=(500, 720, 0),
rotation=(0, 0, 0),
folder=self._get_folder("GLOBAL", "PLAY"),
note="Z1 院内院门内侧约60cm,面向+Y(北)",
))
# ----------------------------------------------------------
# 辅助方法
# ----------------------------------------------------------
def _find_section_start(self, lines: list[str], keyword: str) -> int:
"""找到包含关键词的标题行。"""
for i, line in enumerate(lines):
if keyword in line:
return i
return -1
def _infer_zone_from_name(self, name: str) -> str:
"""从 Actor 名称推断区域 ID。"""
m = re.match(r'LVL_(Z\d+)_', name)
if m:
return m.group(1)
if 'BND' in name:
return "GLOBAL"
if 'Global' in name:
return "GLOBAL"
return "GLOBAL"
def _get_folder(self, zone_id: str, category: str) -> str:
"""获取 Outliner 文件夹路径。"""
from config.zone_config import get_folder_path
return get_folder_path(zone_id, category)
def _build_summary(self) -> dict:
"""构建解析摘要。"""
total = len(self.actors)
by_category = {}
by_zone = {}
asset_keys = set()
for a in self.actors:
by_category[a.category] = by_category.get(a.category, 0) + 1
by_zone[a.zone_id] = by_zone.get(a.zone_id, 0) + 1
if a.asset_key:
asset_keys.add(a.asset_key)
return {
"total_actors": total,
"by_category": by_category,
"by_zone": by_zone,
"unique_assets": len(asset_keys),
"asset_list": sorted(asset_keys),
}
@@ -0,0 +1,186 @@
"""通用 Markdown 管道表格解析器。
将 Markdown 表格文本解析为 list[dict],每个字典代表一行数据。
支持中文列名,处理列数不一致的情况。
"""
import re
from typing import Optional
def parse_table(lines: list[str]) -> list[dict]:
"""解析单个 Markdown 表格。
Args:
lines: 表格的文本行列表(包含表头、分隔行和数据行)。
Returns:
列表,每个元素是一行数据的字典,key 为表头文本。
返回空列表如果输入不是有效表格。
"""
if not lines:
return []
# 找到分隔行(包含 |---| 的行)
sep_idx = -1
for i, line in enumerate(lines):
if re.match(r'\s*\|[\s\-:]+\|', line):
sep_idx = i
break
if sep_idx < 1:
return [] # 没有有效的表头+分隔行
# 提取表头
header_line = lines[sep_idx - 1]
headers = _split_row(header_line)
if not headers:
return []
# 提取数据行(分隔行之后的所有 | 开头的行)
data_rows = []
for line in lines[sep_idx + 1:]:
stripped = line.strip()
if not stripped or not stripped.startswith('|'):
break # 表格结束
cells = _split_row(stripped)
data_rows.append(cells)
# 构建结果
result = []
for row in data_rows:
entry = {}
for j, header in enumerate(headers):
entry[header] = row[j] if j < len(row) else ""
result.append(entry)
return result
def parse_all_tables(text: str) -> list[tuple[list[str], list[dict]]]:
"""解析文本中的所有 Markdown 表格。
Args:
text: 完整的 Markdown 文本。
Returns:
列表,每个元素为 (header, rows) 元组。
header 为表头列表,rows 为 list[dict]。
"""
results = []
lines = text.split('\n')
i = 0
while i < len(lines):
# 寻找潜在的表格起始行(包含 | 的行)
if '|' in lines[i]:
# 收集连续的表格行
table_lines = []
while i < len(lines) and '|' in lines[i]:
table_lines.append(lines[i].strip())
i += 1
# 尝试解析
rows = parse_table(table_lines)
if rows:
# 提取表头
for tl in table_lines:
if re.match(r'\s*\|[\s\-:]+\|', tl):
sep_idx = table_lines.index(tl)
if sep_idx > 0:
headers = _split_row(table_lines[sep_idx - 1])
results.append((headers, rows))
break
continue
i += 1
return results
def find_tables_in_section(text: str, section_start: int, section_end: int) -> list[list[dict]]:
"""在文本的指定范围内找到所有表格。
Args:
text: 完整文本。
section_start: 区域起始行号。
section_end: 区域结束行号。
Returns:
表格列表,每个表格是 list[dict]。
"""
lines = text.split('\n')
section_lines = lines[section_start:section_end]
tables = []
i = 0
while i < len(section_lines):
if '|' in section_lines[i]:
# 收集连续表格行
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
tables.append(rows)
continue
i += 1
return tables
def _split_row(line: str) -> list[str]:
"""将 | 分隔的行拆分为单元格列表,去除首尾空格。"""
# 去除首尾空白
line = line.strip()
# 按管道符分割
if not line.startswith('|'):
return []
if not line.endswith('|'):
# 有些行末尾可能没有 |
parts = line.split('|')
else:
parts = line.split('|')
# 去除首尾空元素(管道符分割产生的)
cells = [c.strip() for c in parts]
# 去掉首尾空字符串
while cells and cells[0] == '':
cells.pop(0)
while cells and cells[-1] == '':
cells.pop()
return cells
def parse_location(text: str) -> tuple[float, float, float]:
"""解析位置字符串 '(X,Y,Z)' 为三元组。"""
if not text:
return (0.0, 0.0, 0.0)
m = re.search(r'\(([-\d.]+)\s*[,,]\s*([-\d.]+)\s*[,,]\s*([-\d.]+)\)', text)
if m:
return (float(m.group(1)), float(m.group(2)), float(m.group(3)))
return (0.0, 0.0, 0.0)
def parse_rotation(text: str) -> tuple[float, float, float]:
"""解析旋转字符串 '(P,Y,R)' 为三元组(度数)。"""
return parse_location(text)
def parse_scale(text: str) -> tuple[float, float, float]:
"""解析缩放字符串 '(X,Y,Z)' 为三元组。'—' 或空返回 (1,1,1)。"""
if not text or text.strip() in ('—', '–', '-', '无', ''):
return (1.0, 1.0, 1.0)
return parse_location(text)
def clean_cell(text: str) -> str:
"""清理单元格文本:去首尾空白、去反引号。"""
if not text:
return ""
text = text.strip()
# 去除 Markdown 反引号包裹
if text.startswith('`') and text.endswith('`'):
text = text[1:-1]
return text.strip()