from __future__ import annotations import argparse import itertools import json import re import xml.etree.ElementTree as ET from pathlib import Path from typing import Any DEFAULT_XML_ROOT = Path(r"Z:\codex\1C\XML\UPO\Структура базы 1с\Конфигурация") PLACEHOLDER_RE = re.compile(r"\[([A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_.]*)\]") STYLE_TAGS = { "format", "formatIndex", "f", "width", "height", "horizontalAlignment", "verticalAlignment", "border", "font", "textColor", "backgroundColor", } def namespace_uri(value: str) -> str | None: if value.startswith("{") and "}" in value: return value[1:].split("}", 1)[0] return None def local_name(value: str) -> str: return value.rsplit("}", 1)[-1] if "}" in value else value def xml_kind(root: ET.Element) -> str: name = local_name(root.tag) namespace = namespace_uri(root.tag) or "" if name == "document" and "data/spreadsheet" in namespace: return "tabular_document" if name == "DataCompositionSchema": return "data_composition_schema" return name def text_value(node: ET.Element | None) -> str | None: if node is None or node.text is None: return None value = node.text.strip() return value or None def as_int(value: Any) -> int | None: try: return int(str(value).strip()) except (TypeError, ValueError): return None def children(node: ET.Element, name: str | None = None) -> list[ET.Element]: items = list(node) if name is None: return items return [item for item in items if local_name(item.tag) == name] def descendants(node: ET.Element, name: str | None = None) -> list[ET.Element]: result = [] for item in node.iter(): if item is node: continue if name is None or local_name(item.tag) == name: result.append(item) return result def first_child_text(node: ET.Element, name: str) -> str | None: for item in children(node, name): value = text_value(item) if value is not None: return value return None def direct_columns_size(root: ET.Element) -> int | None: for columns in children(root, "columns"): size = as_int(first_child_text(columns, "size")) if size is not None: return size return None def direct_height(root: ET.Element) -> int | None: for name in ("height", "vgRows"): value = as_int(first_child_text(root, name)) if value is not None: return value return None def cell_texts(cell: ET.Element) -> list[str]: values = [] for item in descendants(cell): if local_name(item.tag) in {"content", "parameter"}: value = text_value(item) if value: values.append(value) return values def row_cells(row: ET.Element) -> list[dict[str, Any]]: result = [] current_column = 0 for wrapper in children(row, "c"): explicit_index = as_int(first_child_text(wrapper, "i")) if explicit_index is not None: current_column = explicit_index payload = next((item for item in children(wrapper, "c")), wrapper) texts = cell_texts(payload) parameter = text_value(next((item for item in descendants(payload, "parameter")), None)) format_index = as_int(first_child_text(payload, "f")) or as_int(first_child_text(payload, "formatIndex")) if texts or parameter or format_index is not None: result.append( { "column": current_column + 1, "zero_based": {"column": current_column}, "formatIndex": format_index, "texts": texts, **({"parameter": parameter} if parameter else {}), } ) current_column += 1 return result def merge_ranges(root: ET.Element, *, limit: int = 200) -> list[dict[str, Any]]: result = [] def append_range(row: int | None, column: int | None, width: int | None, height: int | None) -> bool: if row is None or column is None: return False width = width or 1 height = height or 1 result.append( { "row": row + 1, "column": column + 1, "width": width, "height": height, "range": { "one_based": { "top": row + 1, "left": column + 1, "bottom": row + height, "right": column + width, }, "zero_based": { "top": row, "left": column, "bottom": row + height - 1, "right": column + width - 1, }, }, "source": "xml_template_merge", } ) return len(result) >= limit for merge in descendants(root, "merge"): scalar_values = [(local_name(item.tag), as_int(text_value(item))) for item in children(merge)] index = 0 while index < len(scalar_values): if scalar_values[index][0] != "r": index += 1 continue row = scalar_values[index][1] column = None width = None height = None cursor = index + 1 while cursor < len(scalar_values) and scalar_values[cursor][0] != "r": name, value = scalar_values[cursor] if name == "c": column = value elif name == "w": width = value elif name == "h": height = value cursor += 1 if append_range(row, column, width, height): return result index = cursor for item in descendants(merge, "r"): row = as_int(first_child_text(item, "r")) column = as_int(first_child_text(item, "c")) width = as_int(first_child_text(item, "w")) or 1 height = as_int(first_child_text(item, "h")) or 1 if append_range(row, column, width, height): return result return result def profile_template(path: Path, root_dir: Path) -> dict[str, Any]: xml_root = ET.parse(path).getroot() rows = [] max_row = 0 max_column = 0 parameter_names: list[str] = [] text_values: list[str] = [] placeholder_names: list[str] = [] for rows_item in descendants(xml_root, "rowsItem"): row_index = as_int(first_child_text(rows_item, "index")) row = next((item for item in children(rows_item, "row")), None) if row_index is None or row is None: continue cells = row_cells(row) if cells: max_row = max(max_row, row_index + 1) for cell in cells: max_column = max(max_column, int(cell.get("column") or 0)) for value in cell.get("texts") or []: text_values.append(value) for match in PLACEHOLDER_RE.finditer(value): placeholder_names.append(match.group(1)) if cell.get("parameter"): parameter_names.append(str(cell["parameter"])) rows.append( { "index": row_index, "row": row_index + 1, "formatIndex": as_int(first_child_text(row, "formatIndex")), "cells": cells[:50], "cell_count": len(cells), } ) merges = merge_ranges(xml_root) for item in merges: one_based = (item.get("range") or {}).get("one_based") or {} max_row = max(max_row, int(one_based.get("bottom") or 0)) max_column = max(max_column, int(one_based.get("right") or 0)) style_counts = { name: sum(1 for item in descendants(xml_root, name) if text_value(item) is not None) for name in sorted(STYLE_TAGS) } format_indexes = [ as_int(text_value(item)) for item in descendants(xml_root) if local_name(item.tag) in {"formatIndex", "f"} and as_int(text_value(item)) is not None ] capacity_rows = direct_height(xml_root) capacity_columns = direct_columns_size(xml_root) return { "path": str(path), "relative_path": str(path.relative_to(root_dir)) if path.is_relative_to(root_dir) else str(path), "xml_kind": xml_kind(xml_root), "xml_root": {"name": local_name(xml_root.tag), "namespace": namespace_uri(xml_root.tag)}, "capacity_dimensions": {"rows": capacity_rows, "columns": capacity_columns}, "used_dimensions": {"rows": max_row or None, "columns": max_column or None, "evidence": ["rowsItem", "cells"] + (["merge"] if merges else [])}, "counts": { "rows": len(rows), "cells": sum(int(row.get("cell_count") or 0) for row in rows), "texts": len(text_values), "parameters": len(set(parameter_names)), "placeholders": len(set(placeholder_names)), "merges": len(merges), "format_indexes": len(format_indexes), "distinct_format_indexes": len(set(format_indexes)), }, "style_counts": style_counts, "samples": { "rows": rows[:20], "texts": text_values[:50], "parameters": sorted(set(parameter_names))[:50], "placeholders": sorted(set(placeholder_names))[:50], "merges": merges[:50], }, } def analyze(root: Path, *, limit: int | None = None) -> dict[str, Any]: file_iter = root.rglob("Template.xml") files = list(itertools.islice(file_iter, limit)) if limit is not None else list(file_iter) templates = [] errors = [] for path in files: try: templates.append(profile_template(path, root)) except Exception as exc: errors.append({"path": str(path), "error": str(exc)}) return { "schema": "codex_1c_template_xml_profiles.v1", "source": "xml_analysis_fixture_only", "root": str(root), "templates": templates, "counts": { "files": len(files), "templates": len(templates), "errors": len(errors), "with_merges": sum(1 for item in templates if int((item.get("counts") or {}).get("merges") or 0) > 0), "with_parameters": sum(1 for item in templates if int((item.get("counts") or {}).get("parameters") or 0) > 0), "with_placeholders": sum(1 for item in templates if int((item.get("counts") or {}).get("placeholders") or 0) > 0), "by_xml_kind": { kind: sum(1 for item in templates if item.get("xml_kind") == kind) for kind in sorted({str(item.get("xml_kind") or "unknown") for item in templates}) }, }, **({"errors": errors[:100]} if errors else {}), } def render_markdown(payload: dict[str, Any]) -> str: def dimension_text(value: dict[str, Any]) -> str: rows = value.get("rows") if value.get("rows") is not None else "-" columns = value.get("columns") if value.get("columns") is not None else "-" return f"{rows}x{columns}" lines = ["# 1C Template XML Profiles", ""] counts = payload.get("counts") or {} lines.append(f"- Source: `{payload.get('source')}`") lines.append(f"- Templates: `{counts.get('templates')}`") lines.append(f"- With merges: `{counts.get('with_merges')}`") lines.append(f"- With parameters: `{counts.get('with_parameters')}`") lines.append(f"- Errors: `{counts.get('errors')}`") lines.append("") lines.append("| Template | XML kind | Capacity | Used | Cells | Texts | Params | Merges | Formats |") lines.append("| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: |") for item in payload.get("templates") or []: item_counts = item.get("counts") or {} capacity = item.get("capacity_dimensions") or {} used = item.get("used_dimensions") or {} lines.append( f"| `{item.get('relative_path')}` | " f"`{item.get('xml_kind')}` | " f"`{dimension_text(capacity)}` | " f"`{dimension_text(used)}` | " f"{item_counts.get('cells')} | {item_counts.get('texts')} | {item_counts.get('parameters')} | " f"{item_counts.get('merges')} | {item_counts.get('distinct_format_indexes')} |" ) lines.append("") return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser(description="Profile 1C Template.xml spreadsheet exports as analysis fixtures for SQL MOXCEL decoding.") parser.add_argument("--root", default=str(DEFAULT_XML_ROOT), help="XML export root. Use Конфигурация by default, not extensions.") parser.add_argument("--limit", type=int, help="Optional max Template.xml files to scan.") parser.add_argument("--output-json", default="reports/1c-template-baselines/xml-template-profiles.json") parser.add_argument("--output-markdown", default="reports/1c-template-baselines/xml-template-profiles.md") args = parser.parse_args() root = Path(args.root).resolve() payload = analyze(root, limit=args.limit) json_path = Path(args.output_json) md_path = Path(args.output_markdown) json_path.parent.mkdir(parents=True, exist_ok=True) md_path.parent.mkdir(parents=True, exist_ok=True) json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") md_path.write_text(render_markdown(payload), encoding="utf-8") print(json.dumps({"status": "ok", "json": str(json_path), "markdown": str(md_path), "counts": payload["counts"]}, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())