128 lines
5.5 KiB
Python
128 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
||
"""将 XLSX 工作簿的二维数据转换为 Markdown。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import datetime as dt
|
||
import importlib.util
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
def display(value: object) -> str:
|
||
"""将单元格值转换为稳定、可读且适合表格的文本。"""
|
||
|
||
if value is None:
|
||
return ""
|
||
if isinstance(value, dt.datetime):
|
||
value = value.isoformat(sep=" ", timespec="seconds")
|
||
elif isinstance(value, (dt.date, dt.time)):
|
||
value = value.isoformat()
|
||
elif isinstance(value, bool):
|
||
value = "TRUE" if value else "FALSE"
|
||
text = str(value).replace("\\", "\\\\").replace("|", "\\|")
|
||
return text.replace("\r\n", "<br>").replace("\n", "<br>").replace("\r", "<br>")
|
||
|
||
|
||
def matrix_for(sheet, merged_mode: str) -> tuple[list[list[str]], int]:
|
||
"""读取有效区域,并按选择的策略表达合并单元格。"""
|
||
|
||
values = [[cell.value for cell in row] for row in sheet.iter_rows()]
|
||
merged_count = len(sheet.merged_cells.ranges)
|
||
if merged_mode == "fill":
|
||
for merged in sheet.merged_cells.ranges:
|
||
anchor = values[merged.min_row - 1][merged.min_col - 1]
|
||
for row in range(merged.min_row - 1, merged.max_row):
|
||
for column in range(merged.min_col - 1, merged.max_col):
|
||
values[row][column] = anchor
|
||
while values and all(value is None for value in values[-1]):
|
||
values.pop()
|
||
width = max((max((i + 1 for i, value in enumerate(row) if value is not None), default=0) for row in values), default=0)
|
||
return [[display(value) for value in row[:width]] for row in values], merged_count
|
||
|
||
|
||
def render_sheet(title: str, rows: list[list[str]]) -> str:
|
||
"""将单个工作表渲染为 Markdown 章节。"""
|
||
|
||
lines = [f"## {title}", ""]
|
||
if not rows or not rows[0]:
|
||
return "\n".join(lines + ["_空工作表_"])
|
||
width = max(map(len, rows))
|
||
normalized = [row + [""] * (width - len(row)) for row in rows]
|
||
lines.extend((f"| {' | '.join(normalized[0])} |", f"| {' | '.join(['---'] * width)} |"))
|
||
lines.extend(f"| {' | '.join(row)} |" for row in normalized[1:])
|
||
return "\n".join(lines)
|
||
|
||
|
||
def convert(source: Path, output: Path, values_only: bool, merged_mode: str) -> tuple[int, int, int, list[str]]:
|
||
"""打开工作簿、转换全部工作表并写入 UTF-8 Markdown。"""
|
||
|
||
# 第三方库在真正读取工作簿时才加载,使 --help 和参数校验可在干净环境中执行。
|
||
from openpyxl import load_workbook
|
||
|
||
workbook = load_workbook(source, read_only=False, data_only=values_only)
|
||
sections = [f"# {source.stem}"]
|
||
row_count = merged_count = 0
|
||
warnings: list[str] = []
|
||
for sheet in workbook.worksheets:
|
||
rows, merged = matrix_for(sheet, merged_mode)
|
||
sections.append(render_sheet(sheet.title, rows))
|
||
row_count += len(rows)
|
||
merged_count += merged
|
||
if not rows or not rows[0]:
|
||
warnings.append(f"空工作表:{sheet.title}")
|
||
sheet_count = len(workbook.worksheets)
|
||
workbook.close()
|
||
output.write_text("\n\n".join(sections).rstrip() + "\n", encoding="utf-8")
|
||
return sheet_count, row_count, merged_count, warnings
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
"""校验文件类型、覆盖授权和输出路径后执行转换。"""
|
||
|
||
parser = argparse.ArgumentParser(description="将 XLSX 工作簿转换为 Markdown")
|
||
parser.add_argument("input", type=Path)
|
||
parser.add_argument("--output", type=Path)
|
||
parser.add_argument("--values", action="store_true", help="读取缓存值而不是公式文本")
|
||
parser.add_argument("--merged", choices=("fill", "anchor"), default="fill")
|
||
parser.add_argument("--force", action="store_true")
|
||
args = parser.parse_args(argv)
|
||
source = args.input.resolve()
|
||
output = (args.output or source.with_suffix(".md")).resolve()
|
||
if not source.is_file() or source.suffix.lower() != ".xlsx":
|
||
print("错误:只支持存在的 .xlsx 文件;旧版 .xls 不受支持", file=sys.stderr); return 2
|
||
if output.suffix.lower() not in {".md", ".markdown"}:
|
||
print("错误:输出文件必须是 Markdown", file=sys.stderr); return 2
|
||
if output.exists() and not args.force:
|
||
print(f"错误:输出文件已存在:{output}", file=sys.stderr); return 1
|
||
# 在创建输出目录前完成依赖预检,保证缺少运行库时不会留下空目录或半成品。
|
||
if importlib.util.find_spec("openpyxl") is None:
|
||
print(
|
||
"错误:缺少 openpyxl。请使用 Codex 工作区依赖运行时,"
|
||
"或在已获授权的本地 Python 环境中安装 openpyxl 后重试。",
|
||
file=sys.stderr,
|
||
)
|
||
return 3
|
||
output.parent.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
sheets, rows, merged, warnings = convert(source, output, args.values, args.merged)
|
||
except ModuleNotFoundError as error:
|
||
if error.name == "openpyxl":
|
||
print(
|
||
"错误:缺少 openpyxl。请使用 Codex 工作区依赖运行时,"
|
||
"或在已获授权的本地 Python 环境中安装 openpyxl 后重试。",
|
||
file=sys.stderr,
|
||
)
|
||
return 3
|
||
raise
|
||
except (OSError, ValueError) as error:
|
||
print(f"错误:{error}", file=sys.stderr); return 1
|
||
print(f"Markdown:{output}")
|
||
print(f"工作表:{sheets},数据行:{rows},合并区域:{merged}")
|
||
for warning in warnings: print(f"警告:{warning}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|