wechat-robot-skills/skills/xlsx/scripts/inspect_workbook.py
2026-07-26 02:45:00 +08:00

326 lines
11 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
from pathlib import Path
from typing import Any, Optional
from _xlsx_common import (
EXCEL_INPUT_SUFFIXES,
TABULAR_INPUT_SUFFIXES,
SkillArgumentParser,
input_file,
normalize_formula_error,
openpyxl_load_options,
run_cli,
workbook_has_external_links,
)
def _color_value(color: Any) -> Optional[str]:
if color is None:
return None
color_type = getattr(color, "type", None)
if color_type == "rgb":
return getattr(color, "rgb", None)
if color_type == "theme":
theme = getattr(color, "theme", None)
tint = getattr(color, "tint", 0)
return f"theme:{theme}:tint:{tint}"
if color_type == "indexed":
return f"indexed:{getattr(color, 'indexed', None)}"
return None
def _cell_payload(formula_cell: Any, cached_cell: Any) -> Optional[dict[str, Any]]:
formula_value = formula_cell.value
cached_value = cached_cell.value
if formula_value is None and cached_value is None and not formula_cell.has_style:
return None
is_formula = formula_cell.data_type == "f" or (
isinstance(formula_value, str) and formula_value.startswith("=")
)
value = cached_value if is_formula else formula_value
error = normalize_formula_error(value)
payload: dict[str, Any] = {
"cell": formula_cell.coordinate,
"value": value,
"type": formula_cell.data_type,
}
if is_formula:
payload["formula"] = formula_value
payload["cached_value"] = cached_value
if error:
payload["error"] = error
if formula_cell.has_style:
payload["style"] = {
"style_id": formula_cell.style_id,
"number_format": formula_cell.number_format,
"font": formula_cell.font.name,
"font_size": formula_cell.font.sz,
"bold": bool(formula_cell.font.bold),
"italic": bool(formula_cell.font.italic),
"font_color": _color_value(formula_cell.font.color),
"fill_color": _color_value(formula_cell.fill.fgColor),
"horizontal": formula_cell.alignment.horizontal,
"vertical": formula_cell.alignment.vertical,
"wrap_text": bool(formula_cell.alignment.wrap_text),
"locked": bool(formula_cell.protection.locked),
}
if formula_cell.comment:
payload["comment"] = {
"author": formula_cell.comment.author,
"text": formula_cell.comment.text,
}
if formula_cell.hyperlink:
payload["hyperlink"] = formula_cell.hyperlink.target
return payload
def _defined_names(workbook: Any) -> list[dict[str, Any]]:
names: list[dict[str, Any]] = []
try:
values = workbook.defined_names.values()
except AttributeError:
values = workbook.defined_names.definedName
for item in values:
names.append(
{
"name": getattr(item, "name", None),
"value": getattr(item, "attr_text", None),
"local_sheet_id": getattr(item, "localSheetId", None),
"hidden": bool(getattr(item, "hidden", False)),
}
)
return names
def inspect_excel(
source: Path,
*,
sheet_name: Optional[str],
start_row: int,
start_column: int,
max_rows: int,
max_columns: int,
) -> dict[str, Any]:
from openpyxl import load_workbook
options = openpyxl_load_options(source)
formulas = load_workbook(source, data_only=False, **options)
cached = load_workbook(source, data_only=True, **options)
summaries: list[dict[str, Any]] = []
total_formulas = 0
total_errors = 0
for worksheet in formulas.worksheets:
formula_count = 0
error_count = 0
for cell in worksheet._cells.values():
if cell.data_type == "f" or (
isinstance(cell.value, str) and cell.value.startswith("=")
):
formula_count += 1
if normalize_formula_error(cell.value):
error_count += 1
total_formulas += formula_count
total_errors += error_count
summaries.append(
{
"name": worksheet.title,
"state": worksheet.sheet_state,
"max_row": worksheet.max_row,
"max_column": worksheet.max_column,
"freeze_panes": (
str(worksheet.freeze_panes) if worksheet.freeze_panes else None
),
"auto_filter": worksheet.auto_filter.ref,
"merged_ranges": [str(item) for item in worksheet.merged_cells.ranges],
"tables": list(worksheet.tables.keys()),
"chart_count": len(worksheet._charts),
"image_count": len(worksheet._images),
"formula_count": formula_count,
"literal_error_count": error_count,
"print_area": str(worksheet.print_area) if worksheet.print_area else None,
}
)
if sheet_name:
if sheet_name not in formulas.sheetnames:
raise ValueError(
f"工作表不存在:{sheet_name};可选:{''.join(formulas.sheetnames)}"
)
selected_name = sheet_name
else:
selected_name = formulas.active.title
formula_sheet = formulas[selected_name]
cached_sheet = cached[selected_name]
end_row = min(formula_sheet.max_row, start_row + max_rows - 1)
end_column = min(
formula_sheet.max_column,
start_column + max_columns - 1,
)
cells: list[dict[str, Any]] = []
for row in formula_sheet.iter_rows(
min_row=start_row,
max_row=end_row,
min_col=start_column,
max_col=end_column,
):
for formula_cell in row:
cached_cell = cached_sheet[formula_cell.coordinate]
payload = _cell_payload(formula_cell, cached_cell)
if payload:
cells.append(payload)
next_row = end_row + 1 if end_row < formula_sheet.max_row else None
next_column = (
end_column + 1 if end_column < formula_sheet.max_column else None
)
properties = formulas.properties
calculation = getattr(formulas, "calculation", None)
return {
"path": str(source),
"format": source.suffix.lower(),
"macro_enabled": source.suffix.lower() in {".xlsm", ".xltm"},
"has_external_links": workbook_has_external_links(source),
"active_sheet": formulas.active.title,
"sheet_names": formulas.sheetnames,
"sheets": summaries,
"defined_names": _defined_names(formulas),
"properties": {
"title": properties.title,
"subject": properties.subject,
"creator": properties.creator,
"last_modified_by": properties.lastModifiedBy,
"created": properties.created,
"modified": properties.modified,
"category": properties.category,
"keywords": properties.keywords,
"description": properties.description,
},
"calculation": {
"mode": getattr(calculation, "calcMode", None),
"full_calc_on_load": getattr(calculation, "fullCalcOnLoad", None),
"force_full_calc": getattr(calculation, "forceFullCalc", None),
},
"formula_count": total_formulas,
"literal_error_count": total_errors,
"selection": {
"sheet": selected_name,
"start_row": start_row,
"end_row": end_row,
"start_column": start_column,
"end_column": end_column,
"cells": cells,
"has_more": next_row is not None or next_column is not None,
"next_row": next_row,
"next_column": next_column,
},
}
def inspect_delimited(
source: Path,
*,
start_row: int,
start_column: int,
max_rows: int,
max_columns: int,
) -> dict[str, Any]:
delimiter = "\t" if source.suffix.lower() == ".tsv" else ","
rows: list[list[str]] = []
total_rows = 0
max_seen_columns = 0
with source.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.reader(handle, delimiter=delimiter)
for index, row in enumerate(reader, start=1):
total_rows = index
max_seen_columns = max(max_seen_columns, len(row))
if index < start_row or len(rows) >= max_rows:
continue
rows.append(row[start_column - 1 : start_column - 1 + max_columns])
return {
"path": str(source),
"format": source.suffix.lower(),
"delimiter": delimiter,
"row_count": total_rows,
"max_column_count": max_seen_columns,
"selection": {
"start_row": start_row,
"end_row": min(total_rows, start_row + len(rows) - 1),
"start_column": start_column,
"end_column": min(
max_seen_columns, start_column + max_columns - 1
),
"rows": rows,
"has_more": (
start_row + len(rows) - 1 < total_rows
or start_column + max_columns - 1 < max_seen_columns
),
"next_row": (
start_row + len(rows)
if start_row + len(rows) - 1 < total_rows
else None
),
},
}
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="读取 Excel、CSV 或 TSV 的结构、公式、缓存值和局部单元格。"
)
parser.add_argument("--input", required=True, help="输入文件路径")
parser.add_argument("--sheet", help="要读取的工作表;默认活动工作表")
parser.add_argument("--start-row", type=int, default=1)
parser.add_argument("--start-column", type=int, default=1)
parser.add_argument("--max-rows", type=int, default=40)
parser.add_argument("--max-columns", type=int, default=20)
return parser
def main() -> dict[str, Any]:
args = build_parser().parse_args()
if args.start_row < 1 or args.start_row > 1_048_576:
raise ValueError("start-row 必须在 1 到 1048576 之间")
if args.start_column < 1 or args.start_column > 16_384:
raise ValueError("start-column 必须在 1 到 16384 之间")
if args.max_rows < 1 or args.max_rows > 200:
raise ValueError("max-rows 必须在 1 到 200 之间")
if args.max_columns < 1 or args.max_columns > 100:
raise ValueError("max-columns 必须在 1 到 100 之间")
source = input_file(args.input, TABULAR_INPUT_SUFFIXES)
if source.suffix.lower() == ".xls":
raise ValueError(
"旧版 .xls 请先使用 convert_workbook.py 转为 .xlsx 后再检查"
)
if source.suffix.lower() in EXCEL_INPUT_SUFFIXES:
return inspect_excel(
source,
sheet_name=args.sheet,
start_row=args.start_row,
start_column=args.start_column,
max_rows=args.max_rows,
max_columns=args.max_columns,
)
if args.sheet:
raise ValueError("CSV/TSV 没有工作表,不能传 --sheet")
return inspect_delimited(
source,
start_row=args.start_row,
start_column=args.start_column,
max_rows=args.max_rows,
max_columns=args.max_columns,
)
if __name__ == "__main__":
raise SystemExit(run_cli(main))