#!/usr/bin/env python3 # mxl-decompile v1.20 — Decompile 1C spreadsheet to JSON # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import os import sys from collections import OrderedDict from lxml import etree # Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet] # регистр не различают, в argparse совпадение точное. def ci_parse_args(parser, argv=None): """parse_args по правилам PS: имена параметров и значения choices регистронезависимы.""" argv = list(sys.argv[1:] if argv is None else argv) names = {s.lower(): s for a in parser._actions for s in a.option_strings} for i, tok in enumerate(argv): if tok.startswith('-') and tok.lower() in names: argv[i] = names[tok.lower()] # choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр choice_map = {} for a in parser._actions: if a.choices: for s in a.option_strings: choice_map[s] = {str(c).lower(): c for c in a.choices} for i in range(len(argv) - 1): m = choice_map.get(argv[i]) if m and argv[i + 1].lower() in m: argv[i + 1] = m[argv[i + 1].lower()] return parser.parse_args(argv) # --- Namespace map --- NSMAP = { "d": "http://v8.1c.ru/8.2/data/spreadsheet", "v8": "http://v8.1c.ru/8.1/data/core", "v8ui": "http://v8.1c.ru/8.1/data/ui", "xsi": "http://www.w3.org/2001/XMLSchema-instance", } XSI_NS = "http://www.w3.org/2001/XMLSchema-instance" def find(node, xpath): return node.find(xpath, NSMAP) def findall(node, xpath): return node.findall(xpath, NSMAP) def text_of(node): if node is not None and node.text: return node.text return None def to_font_size(raw): """Размер шрифта бывает дробным (8.3, 11.3 — в корпусе ERP это треть макетов). int() на таком падал, а ps1 ТИХО округлял. Целое держим целым, иначе "10" → "10.0".""" s = str(raw).strip() if raw is not None else '' if not s: return 0 try: d = float(s) except (TypeError, ValueError): return 0 return int(d) if d == int(d) else d def format_tag_kind(): """Тип значения каждого тега — выведен из корпуса, а не выписан на глаз. line — ссылка в палитру ; color — #RRGGBB / style: / web: / win: ml — многоязычная строка; enum — замкнутый список (см. format_enum_values). containsValue / valueType / controlType сюда НЕ входят: это свойства конкретной ячейки, а стиль — сущность общая, один на многие ячейки.""" return { 'autoIndent': 'int', 'autoMarkIncomplete': 'bool', 'autoWidthCalculation': 'bool', 'backColor': 'color', 'border': 'line', 'borderColor': 'color', 'bottomBorder': 'line', 'bySelectedColumns': 'bool', 'columnSizeChange': 'enum', 'detailsUse': 'enum', 'drawingBorder': 'int', 'drawingHaveBottomBorder': 'bool', 'drawingHaveLeftBorder': 'bool', 'drawingHaveRightBorder': 'bool', 'drawingHaveTopBorder': 'bool', 'editFormat': 'ml', 'fillType': 'enum', 'font': 'int', 'format': 'ml', 'height': 'int', 'hidden': 'bool', 'horizontalAlignment': 'enum', 'hyperLink': 'bool', 'indent': 'int', 'leftBorder': 'line', 'markNegatives': 'bool', 'mask': 'ml', 'pattern': 'enum', 'patternColor': 'color', 'picHorizontalAlignment': 'enum', 'picIndex': 'int', 'picVerticalAlignment': 'enum', 'pictureSizeMode': 'enum', 'print': 'bool', 'protection': 'bool', 'rightBorder': 'line', 'textColor': 'color', 'textOrientation': 'int', 'textPlacement': 'enum', 'textPosition': 'enum', 'topBorder': 'line', 'verticalAlignment': 'enum', 'width': 'int', 'widthWeightFactor': 'int', } RU_NAME = 'Русский' def format_ml_tags(): """Теги, значение которых — многоязычная строка ( на язык), а не скаляр.""" return {'format': True, 'editFormat': True, 'mask': True} def color_to_dsl(node, val): """Цвет платформа хранит значением с префиксом пространства имён. style: объявлен в корне документа и читается как есть; web/win объявлены прямо на узле сгенерированным префиксом (d3p1), поэтому его надо разрешить в URI и вернуть привычное web:/win:.""" if ':' not in val or val.startswith('style:'): return val prefix, name = val.split(':', 1) uri = node.nsmap.get(prefix) if uri == 'http://v8.1c.ru/8.1/data/ui/colors/web': return 'web:' + name if uri == 'http://v8.1c.ru/8.1/data/ui/colors/windows': return 'win:' + name return val def int_of(node, default=0): if node is not None and node.text: return int(node.text) return default # Custom JSON serializer — компактный, 2-пробельный indent, массивы примитивов inline. # В отличие от ConvertTo-Json (PS5.1): # - не выравнивает ключи объекта по самому длинному # - не разворачивает массивы примитивов на отдельные строки # - кириллица в UTF-8 (без \uXXXX-escapes) def convert_string_to_json_literal(s): if s is None: return 'null' out = ['"'] for ch in s: code = ord(ch) if code == 0x22: out.append('\\"') elif code == 0x5C: out.append('\\\\') elif code == 0x08: out.append('\\b') elif code == 0x09: out.append('\\t') elif code == 0x0A: out.append('\\n') elif code == 0x0C: out.append('\\f') elif code == 0x0D: out.append('\\r') elif code < 0x20: out.append('\\u%04x' % code) else: out.append(ch) out.append('"') return ''.join(out) def _fmt_number(v): if isinstance(v, bool): return 'true' if v else 'false' if isinstance(v, int): return str(v) if isinstance(v, float): # Invariant culture: '.' decimal sep if v == int(v): # Preserve float-ness: PS [double] 5.0 → "5" # Match PS ToString invariant: 5.0 → "5" return str(int(v)) return repr(v) return str(v) def try_inline_json(obj): if obj is None: return 'null' if isinstance(obj, bool): return 'true' if obj else 'false' if isinstance(obj, str): return convert_string_to_json_literal(obj) if isinstance(obj, (int, float)): return _fmt_number(obj) if isinstance(obj, dict): if len(obj) == 0: return '{}' parts = [] for k, v in obj.items(): vs = try_inline_json(v) if vs is None: return None parts.append(convert_string_to_json_literal(str(k)) + ': ' + vs) return '{ ' + ', '.join(parts) + ' }' if isinstance(obj, (list, tuple)): if len(obj) == 0: return '[]' parts = [] for it in obj: vs = try_inline_json(it) if vs is None: return None parts.append(vs) return '[' + ', '.join(parts) + ']' return None def convert_to_compact_json(obj, depth=0, indent_unit=' ', line_limit=400): indent = indent_unit * depth child_indent = indent_unit * (depth + 1) if obj is None: return 'null' if isinstance(obj, bool): return 'true' if obj else 'false' if isinstance(obj, str): return convert_string_to_json_literal(obj) if isinstance(obj, (int, float)): return _fmt_number(obj) # Try inline для объектов и массивов с объектами — если помещается в lineLimit с учётом текущего indent. is_container = isinstance(obj, (dict, list, tuple)) if is_container: inline_attempt = try_inline_json(obj) if inline_attempt is not None and (len(indent) + len(inline_attempt)) <= line_limit: return inline_attempt if isinstance(obj, dict): if len(obj) == 0: return '{}' parts = [] for k, v in obj.items(): val = convert_to_compact_json(v, depth + 1, indent_unit, line_limit) parts.append(child_indent + convert_string_to_json_literal(str(k)) + ': ' + val) return "{\n" + ",\n".join(parts) + "\n" + indent + "}" if isinstance(obj, (list, tuple)): if len(obj) == 0: return '[]' parts = [child_indent + convert_to_compact_json(it, depth + 1, indent_unit, line_limit) for it in obj] return "[\n" + ",\n".join(parts) + "\n" + indent + "]" return convert_string_to_json_literal(str(obj)) # --- Main --- def main(): sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser(description="Decompile 1C spreadsheet to JSON", allow_abbrev=False) parser.add_argument("-TemplatePath", "-Path", required=True, help="Path to Template.xml") parser.add_argument("-OutputPath", default=None, help="Output JSON path (stdout if omitted)") args = ci_parse_args(parser) template_path = args.TemplatePath output_path = args.OutputPath # --- 1. Load and parse XML --- if not os.path.isfile(template_path): print(f"File not found: {template_path}", file=sys.stderr) sys.exit(1) parser_xml = etree.XMLParser(remove_blank_text=False) tree = etree.parse(template_path, parser_xml) root = tree.getroot() # --- 2. Extract font palette --- raw_fonts = [] for f_node in findall(root, "d:font"): raw_fonts.append({ # Шрифт бывает ссылкой на элемент стиля (style:) или системный шрифт (sys:) — # тогда своих атрибутов у него нет, и без этого он превращался в пустую запись. "Ref": f_node.get("ref", ""), "Face": f_node.get("faceName", ""), "Size": to_font_size(f_node.get("height", "0")), "Bold": f_node.get("bold") == "true", "Italic": f_node.get("italic") == "true", "Underline": f_node.get("underline") == "true", "Strikeout": f_node.get("strikeout") == "true", }) # --- 3. Extract line palette --- raw_lines = [] for l_node in findall(root, "d:line"): st = find(l_node, "v8ui:style") raw_lines.append({ "Width": int(l_node.get("width", "0")), "Gap": l_node.get("gap", "false"), "Style": (text_of(st) or "Solid") if st is not None else "Solid", }) # --- 4. Extract format palette --- # Формат читаем целиком — «тег → значение», а не выборочные девять полей. Тип значения # берём из общей таблицы, она же у компилятора. kinds = format_tag_kind() ml_tags = format_ml_tags() raw_formats = [] for fmt_node in findall(root, "d:format"): # Совместимость с прежними полями: ими пользуются ширина колонки, высота строки и # признак заполнения — они не часть стиля. fmt = { "FontIdx": -1, "Width": 0, "Height": 0, "FillType": "", "Props": OrderedDict(), } for child in fmt_node: tag = etree.QName(child).localname if tag in ml_tags: item = find(child, "v8:item/v8:content") val = text_of(item) if item is not None else None if val: fmt["Props"][tag] = val continue # Вложенный элемент скаляром не является: .text дал бы пустоту, а ps1-порт брал # InnerText и получал склейку поддерева — отсюда расхождение портов. if len(child): continue val = (child.text or "").strip() if not val: continue if kinds.get(tag) == "color": val = color_to_dsl(child, val) fmt["Props"][tag] = val if "font" in fmt["Props"]: fmt["FontIdx"] = int(fmt["Props"]["font"]) if "width" in fmt["Props"]: fmt["Width"] = int(fmt["Props"]["width"]) if "height" in fmt["Props"]: fmt["Height"] = int(fmt["Props"]["height"]) if "fillType" in fmt["Props"]: fmt["FillType"] = fmt["Props"]["fillType"] raw_formats.append(fmt) def get_format(idx): if idx <= 0 or idx > len(raw_formats): return None return raw_formats[idx - 1] # --- 5. Extract columns and default width --- # Колоночная раскладка («индивидуальная ширина колонок» для группы строк) — элемент . # Их бывает несколько: раскладка БЕЗ — умолчание (ровно одна в каждом макете корпуса), # остальные адресуются GUID из , на который ссылаются строки () и # области (). Раньше читался только первый — отсюда терялись # ширины и вылезал columns: 0. default_fmt_idx = 0 n = find(root, "d:defaultFormatIndex") if n is not None and n.text: default_fmt_idx = int(n.text) default_width = 10 if default_fmt_idx > 0: def_fmt = get_format(default_fmt_idx) if def_fmt and def_fmt["Width"] > 0: default_width = def_fmt["Width"] def read_column_set(node): by_idx = {} for ci in findall(node, "d:columnsItem"): by_idx[int_of(find(ci, "d:index"))] = int_of(find(ci, "d:column/d:formatIndex")) # Карта ширин (1-based колонка → ширина), только отличные от умолчания. widths = OrderedDict() for col0 in sorted(by_idx.keys()): fmt = get_format(by_idx[col0]) if fmt and fmt["Width"] > 0 and fmt["Width"] != default_width: widths[str(col0 + 1)] = fmt["Width"] id_node = find(node, "d:id") size_node = find(node, "d:size") return { "Id": (text_of(id_node) or None) if id_node is not None else None, "Size": int_of(size_node) if size_node is not None else 0, "Widths": widths, # Формат колонки несёт не только ширину — имя стиля подставим ниже, когда стили # будут поименованы. "FmtIdx": {str(c0 + 1): by_idx[c0] for c0 in sorted(by_idx.keys())}, } column_sets = [read_column_set(cn) for cn in findall(root, "d:columns")] default_set = next((c for c in column_sets if not c["Id"]), None) if default_set is None and column_sets: default_set = column_sets[0] total_columns = default_set["Size"] if default_set else 0 col_width_map = default_set["Widths"] if default_set else OrderedDict() # --- 6. Extract merges --- merge_map = {} for m_node in findall(root, "d:merge"): r = int_of(find(m_node, "d:r")) c = int_of(find(m_node, "d:c")) w = int_of(find(m_node, "d:w")) h_node = find(m_node, "d:h") h = int_of(h_node) if h_node is not None else 0 merge_map[f"{r},{c}"] = {"W": w, "H": h} # --- 7. Extract named items --- # Захватываем области ВСЕХ типов. Раньше здесь стоял `if area_type != "Rows": continue`, # из-за чего терялись Rectangle и Columns — а они есть у 61% макетов корпуса. named_areas = [] for ni_node in findall(root, "d:namedItem"): xsi_type = ni_node.get(f"{{{XSI_NS}}}type", "") if xsi_type != "NamedItemCells": continue area_node = find(ni_node, "d:area") if area_node is None: continue def coord(tag, node=area_node): n = find(node, "d:" + tag) return int_of(n) if n is not None else -1 named_areas.append({ # Порядок в исходнике — явный ключ сортировки: sorted тут стабилен, а # Sort-Object в ps1 нет, и без него порты расходились. "Ord": len(named_areas), "Name": text_of(find(ni_node, "d:name")) or "", "Type": text_of(find(area_node, "d:type")) or "", "BeginRow": coord("beginRow"), "EndRow": coord("endRow"), "BeginCol": coord("beginColumn"), "EndCol": coord("endColumn"), }) # --- 8. Extract rows --- row_data = {} # Языки, на которых в макете вообще есть текст. Порядок — первого появления. doc_langs = OrderedDict() for ri_node in findall(root, "d:rowsItem"): row_idx = int_of(find(ri_node, "d:index")) row_node = find(ri_node, "d:row") index_to = row_idx it_node = find(ri_node, "d:indexTo") if it_node is not None and it_node.text: index_to = int(it_node.text) row_fmt_idx = 0 fmt_node = find(row_node, "d:formatIndex") if fmt_node is not None and fmt_node.text: row_fmt_idx = int(fmt_node.text) is_empty = False empty_node = find(row_node, "d:empty") if empty_node is not None and empty_node.text == "true": is_empty = True cells = [] if not is_empty: col = -1 for c_group in findall(row_node, "d:c"): i_node = find(c_group, "d:i") if i_node is not None and i_node.text: col = int(i_node.text) else: col += 1 c_content = find(c_group, "d:c") if c_content is None: continue cell_fmt_idx = 0 f_node = find(c_content, "d:f") if f_node is not None and f_node.text: cell_fmt_idx = int(f_node.text) param = None p_node = find(c_content, "d:parameter") if p_node is not None and p_node.text: param = p_node.text detail = None d_node = find(c_content, "d:detailParameter") if d_node is not None and d_node.text: detail = d_node.text # Текст ячейки платформа хранит по элементу на язык. Раньше брался ПЕРВЫЙ, и всё # остальное терялось — в корпусе ERP 98% макетов держат текст и под ru, и под en. # Здесь собираем «язык → текст» как есть; свернётся в строку позже, когда станет # известен набор языков всего макета (get_dsl_text). text = None has_text = False # Пустой — отдельное состояние: тег текста есть, языков в нём нет. Без # этого ветвления такая ячейка теряла текст вовсе (1 из 15 ячеек с тегом текста). tl_node = find(c_content, "d:tl") items = findall(c_content, "d:tl/v8:item") if tl_node is not None and not items: text = OrderedDict() has_text = True elif items: by_lang = OrderedDict() for it in items: lang_node = find(it, "v8:lang") content_node = find(it, "v8:content") lang = (text_of(lang_node) or '') if lang_node is not None else '' by_lang[lang] = (text_of(content_node) or '') if content_node is not None else '' doc_langs[lang] = True text = by_lang # Единственный пустой русский текст содержимым не считается — такая ячейка # уходит в заполнители строки. has_text = not (len(by_lang) == 1 and by_lang.get('ru') == '') cells.append({ "Col": col, "FormatIdx": cell_fmt_idx, "Param": param, "Detail": detail, "Text": text, "HasText": has_text, }) # Ссылка строки на колоночную раскладку; пусто = раскладка по умолчанию. cid_node = find(row_node, "d:columnsID") row_columns_id = text_of(cid_node) if cid_node is not None else None for r in range(row_idx, index_to + 1): row_data[r] = { "FormatIdx": row_fmt_idx, "Cells": cells, "Empty": is_empty, "ColumnsId": row_columns_id, } # Языки текстов макета и языки, объявленные в конфигурации, — разные вещи: у типовых они # не совпадают. Строкой пишем текст, одинаковый на ВСЁМ наборе языков макета; остальное — # объектом. text_languages = list(doc_langs.keys()) def get_dsl_text(by_lang): if not isinstance(by_lang, dict): return by_lang if len(by_lang) != len(text_languages): return by_lang common = None for lang in text_languages: if lang not in by_lang: return by_lang if common is None: common = by_lang[lang] elif by_lang[lang] != common: return by_lang return common # --- 9. Build style key (ignoring fillType) --- # Свойства формата, которые стилем НЕ являются: ширина принадлежит колонке, высота — # строке, вид заполнения выводится из того, чем задано содержимое ячейки. # containsValue/valueType/controlType — свойства ЗНАЧЕНИЯ ячейки, а не оформления: стиль # общий на многие ячейки, а они индивидуальны. Компилятор таких ключей не знает, так что # в DSL они были чистым шумом. NON_STYLE_TAGS = ("width", "height", "fillType", "font", "containsValue", "valueType", "controlType") BORDER_TAGS = ("border", "leftBorder", "topBorder", "rightBorder", "bottomBorder") def line_to_dsl(idx): """Ссылку в палитру разворачиваем в описание линии. Ширина 1 подразумевается, поэтому обычная линия пишется просто именем стиля.""" i = int(idx) if i < 0 or i >= len(raw_lines): return "Solid" ln = raw_lines[i] out = OrderedDict() out["style"] = ln["Style"] if ln["Width"] != 1: out["width"] = ln["Width"] if ln["Gap"] == "true": out["gap"] = True if len(out) == 1: return out["style"] return out def style_props(fmt): """Свойства формата, попадающие в стиль, — в каноническом порядке тегов.""" if not fmt: return OrderedDict() out = OrderedDict() for tag, val in fmt["Props"].items(): if tag in NON_STYLE_TAGS: continue if tag in BORDER_TAGS: out[tag] = line_to_dsl(val) elif kinds.get(tag) == "bool": out[tag] = (val == "true") elif kinds.get(tag) == "int": out[tag] = int(val) else: out[tag] = val return out def get_style_key(fmt): if not fmt: return "empty" # «шрифта нет» и «шрифт с индексом 0» — разные форматы: нулевой шрифт вовсе не # обязан быть обычным, в макете он бывает курсивным или жирным. parts = ["f=" + (str(fmt["FontIdx"]) if fmt["FontIdx"] >= 0 else "none")] for tag, val in style_props(fmt).items(): parts.append(f"{tag}={val}") return "|".join(parts) # --- 10. Name fonts --- font_names = {} font_defs = OrderedDict() if len(raw_fonts) > 0: font_names[0] = "default" font_defs["default"] = raw_fonts[0] def get_font_key(f): # Ссылку различаем по ней самой: своих атрибутов у такого шрифта нет, и без этого # две РАЗНЫЕ ссылки схлопывались в одну как «пустые». if f.get("Ref"): return "ref=" + f["Ref"] return f"{f['Face']}|{f['Size']}|{f['Bold']}|{f['Italic']}|{f['Underline']}|{f['Strikeout']}" font_key_map = {} if len(raw_fonts) > 0: font_key_map[get_font_key(raw_fonts[0])] = "default" for i in range(1, len(raw_fonts)): f = raw_fonts[i] df = raw_fonts[0] # Dedup: if identical font already named, reuse f_key = get_font_key(f) if f_key in font_key_map: font_names[i] = font_key_map[f_key] continue name = None if f.get("Ref"): # Имя из ссылки: читаемее самой ссылки ничего не придумать. name = f["Ref"].split(":", 1)[1].lower() elif f["Face"] == df["Face"] and f["Size"] == df["Size"]: if f["Bold"] and not df["Bold"] and not f["Italic"] and not f["Underline"] and not f["Strikeout"]: name = "bold" elif f["Italic"] and not df["Italic"] and not f["Bold"]: name = "italic" elif f["Underline"] and not df["Underline"] and not f["Bold"] and not f["Italic"]: name = "underline" elif f["Face"] == df["Face"] and f["Size"] > df["Size"] and f["Bold"]: name = "header" elif f["Face"] == df["Face"] and f["Size"] < df["Size"]: name = "small" if not name: parts = [] if f["Face"] and f["Face"] != df["Face"]: parts.append(f["Face"].lower()) parts.append(str(f["Size"])) if f["Bold"]: parts.append("bold") if f["Italic"]: parts.append("italic") if f["Underline"]: parts.append("underline") if f["Strikeout"]: parts.append("strikeout") name = "-".join(parts) base_name = name suffix = 2 while name in font_defs: name = f"{base_name}{suffix}" suffix += 1 font_names[i] = name font_defs[name] = f font_key_map[f_key] = name # --- 11. Collect and name styles --- style_keys = OrderedDict() format_to_style_key = {} for rd in row_data.values(): for cell in rd["Cells"]: fmt = get_format(cell["FormatIdx"]) if not fmt: continue key = get_style_key(fmt) if key not in style_keys: style_keys[key] = fmt format_to_style_key[cell["FormatIdx"]] = key def row_style_fmt(fmt): """Оформление строки без её собственных свойств: скрытие уезжает инлайном к height, поэтому в именованный стиль попадать не должно.""" if not fmt: return None props = style_props(fmt) props.pop("hidden", None) # Шрифт в style_props не входит (он адресуется именем), поэтому формат строки, # несущий ТОЛЬКО шрифт, здесь выглядел пустым и терялся. if not props and fmt["FontIdx"] < 0: return None reduced = OrderedDict() for tag, raw in fmt["Props"].items(): if tag != "hidden": reduced[tag] = raw return {"FontIdx": fmt["FontIdx"], "Width": 0, "Height": 0, "FillType": "", "Props": reduced} # Строка — тоже владелец формата: её оформление становится именованным стилем. for rd in row_data.values(): rf = row_style_fmt(get_format(rd["FormatIdx"])) if rd["FormatIdx"] > 0 else None if not rf: continue key = get_style_key(rf) if key not in style_keys: style_keys[key] = rf # Колонка — третий владелец формата, её оформление тоже становится именованным стилем. for cs in column_sets: for fi in cs["FmtIdx"].values(): fmt = get_format(fi) if not fmt or not style_props(fmt): continue key = get_style_key(fmt) if key not in style_keys: style_keys[key] = fmt format_to_style_key[fi] = key def name_style(fmt): """Имя стиля — читаемая метка по самым заметным свойствам. Полнота тут не нужна: различает стили ключ, имя лишь помогает автору ориентироваться.""" if not fmt: return "default" parts = [] props = style_props(fmt) fi = fmt["FontIdx"] if fi >= 0 and font_names.get(fi, "default") != "default": parts.append(font_names[fi]) if "border" in props: parts.append("bordered") else: sides = [s[:-6] for s in ("leftBorder", "topBorder", "rightBorder", "bottomBorder") if s in props] if sides: parts.append("border-" + ",".join(sides)) ha = props.get("horizontalAlignment") if ha == "Center": parts.append("center") elif ha == "Right": parts.append("right") va = props.get("verticalAlignment") if va == "Center": parts.append("vcenter") elif va == "Top": parts.append("vtop") if props.get("textPlacement") == "Wrap": parts.append("wrap") if "backColor" in props: parts.append("bg") if "format" in props: parts.append("fmt") if len(parts) == 0: # Имя "default" зарезервировано за стилем БЕЗ свойств: под ним компилятор # понимает отсутствие оформления, и ячейка такой стиль не записывает. Набор # из одних неприметных свойств (отступ, защита) или из одного шрифта обязан # получить своё имя, иначе он потеряется. has_content = bool(props) or fmt["FontIdx"] >= 0 return "style" if has_content else "default" return "-".join(parts) style_names = OrderedDict() style_defs = OrderedDict() for key in style_keys: fmt = style_keys[key] name = name_style(fmt) base_name = name suffix = 2 while name in style_defs: name = f"{base_name}{suffix}" suffix += 1 style_names[key] = name s_def = OrderedDict() if fmt["FontIdx"] >= 0: s_def["font"] = font_names.get(fmt["FontIdx"], "default") s_def.update(style_props(fmt)) style_defs[name] = s_def def get_style_name(fmt_idx): key = format_to_style_key.get(fmt_idx) if key and key in style_names: return style_names[key] return "default" def column_styles_of(cs): """Колонки раскладки, у которых формат несёт не только ширину, — «колонка → стиль».""" out = OrderedDict() for col, fi in cs["FmtIdx"].items(): if fi == 0: # Колонка перечислена, формата у неё нет. Для авторинга бесполезно, но без # этого раундтрип теряет элемент целиком. out[col] = None continue fmt = get_format(fi) if fmt and style_props(fmt): out[col] = get_style_name(fi) return out def to_positional_cells(cells): """Позиционная запись списка ячеек: позиция берётся из порядка, `col` не пишется. Ячейка, у которой кроме текста или параметра ничего нет, пишется значением; span раскрывается маркерами ">"; прочее — объектным элементом без col. Пропуск колонки — null. Выбираем ту запись, которая КОРОЧЕ: раньше позиционная форма отбрасывалась, как только первая ячейка стояла не в первой колонке, хотя один-два null впереди обычно короче объектной записи с `col`.""" if not cells: return cells out = [] expected = 1 for c in cells: col = int(c.get("col", 0)) if col < expected: return cells # перекрытие — позиционно не выразить while expected < col: out.append(None) expected += 1 span = int(c.get("span", 1) or 1) keys = [k for k in c if k not in ("col", "span")] # Текст — единственное содержимое: пишем значением. Оно бывает строкой либо # объектом «язык → текст», и в позиционной записи элемент И ЕСТЬ это значение. plain_text = len(keys) == 1 and keys[0] == "text" plain_param = len(keys) == 1 and keys[0] == "param" if plain_text: out.append(c["text"]) elif plain_param: out.append("{%s}" % c["param"]) else: obj = OrderedDict((k, v) for k, v in c.items() if k != "col") out.append(obj) # span раскрываем маркерами — кроме объектного элемента, он несёт span сам. if (plain_text or plain_param) and span > 1: out.extend([">"] * (span - 1)) expected = col + span # Позиционная форма ценна компактностью: если она длиннее объектной, смысла в ней нет. a, b = try_inline_json(out), try_inline_json(cells) if a is not None and b is not None and len(a) > len(b): return cells return out # --- 12. Build areas --- # Сетка нарезается на блоки: непересекающиеся области типа Rows задают границы, строки вне # них становятся БЕЗЫМЯННЫМИ блоками. Раньше строки вне областей просто терялись — в корпусе # такие дыры у 34% макетов, а макетов вовсе без Rows-областей 21%. # Всё, что блоком не выражается (области не-Rows и пересекающиеся Rows), уходит в namedAreas # координатами. Правило детерминированное — иначе раундтрип поехал бы. max_row_idx = max(row_data.keys()) if row_data else -1 def row_columns_id(r): rd = row_data.get(r) return rd["ColumnsId"] if rd else None def uniform_column_set(frm, to): """Область годится в качестве области-диапазона, только если у всех её строк ОДНА раскладка: иначе её пришлось бы резать, а имя резать нельзя.""" first = row_columns_id(frm) return all(row_columns_id(r) == first for r in range(frm + 1, to + 1)) block_areas = [] overlay_areas = [] claimed = set() for a in sorted(named_areas, key=lambda x: (x["BeginRow"], x["EndRow"], x["Ord"])): fits = a["Type"] == "Rows" and a["BeginRow"] >= 0 and a["EndRow"] >= a["BeginRow"] if fits: for r in range(a["BeginRow"], a["EndRow"] + 1): if r in claimed: fits = False break if fits: fits = uniform_column_set(a["BeginRow"], a["EndRow"]) if fits: claimed.update(range(a["BeginRow"], a["EndRow"] + 1)) block_areas.append(a) else: overlay_areas.append(a) def split_gap_by_column_set(frm, to): """Безымянный промежуток режем на куски с одной раскладкой: границы наборов не совпадают с границами именованных областей.""" out = [] if to < frm: return out run_start = frm run_id = row_columns_id(frm) for r in range(frm + 1, to + 1): cid = row_columns_id(r) if cid != run_id: out.append({"Name": None, "BeginRow": run_start, "EndRow": r - 1, "ColumnsId": run_id}) run_start, run_id = r, cid out.append({"Name": None, "BeginRow": run_start, "EndRow": to, "ColumnsId": run_id}) return out # Области в порядке строк + безымянные заполнители дыр. blocks = [] cursor = 0 for a in sorted(block_areas, key=lambda x: x["BeginRow"]): if a["BeginRow"] > cursor: blocks.extend(split_gap_by_column_set(cursor, a["BeginRow"] - 1)) blocks.append({"Name": a["Name"], "BeginRow": a["BeginRow"], "EndRow": a["EndRow"], "ColumnsId": row_columns_id(a["BeginRow"])}) cursor = a["EndRow"] + 1 if cursor <= max_row_idx: blocks.extend(split_gap_by_column_set(cursor, max_row_idx)) dsl_areas = [] for area in blocks: area_rows = [] for global_row in range(area["BeginRow"], area["EndRow"] + 1): rd = row_data.get(global_row) if not rd or rd["Empty"]: area_rows.append(OrderedDict()) continue dsl_row = OrderedDict() # Формат самой строки: высота и скрытие — её собственные свойства (у ячейки # таких нет), остальное — оформление, оно же может лежать и на ячейках. row_fmt = get_format(rd["FormatIdx"]) if rd["FormatIdx"] > 0 else None row_own = style_props(row_fmt) if row_fmt else OrderedDict() row_hidden = row_own.pop("hidden", False) if row_fmt and row_fmt["Height"] > 0: dsl_row["height"] = row_fmt["Height"] if row_hidden: dsl_row["hidden"] = True # Separate content cells from gap-fill cells content_cells = [] gap_cells = [] for cell in rd["Cells"]: has_content = cell["Param"] or cell["HasText"] has_merge = f"{global_row},{cell['Col']}" in merge_map if has_content or has_merge: content_cells.append(cell) else: gap_cells.append(cell) # Detect rowStyle row_style_name = None row_style_key = None if len(gap_cells) > 0: gap_keys = {} for gc in gap_cells: fmt = get_format(gc["FormatIdx"]) gap_keys[get_style_key(fmt)] = True if len(gap_keys) == 1: row_style_key = list(gap_keys.keys())[0] if row_style_key in style_names: row_style_name = style_names[row_style_key] # Стиль строки и стиль ячеек — независимые вещи: платформа их часто, но не всегда # пишет одинаковыми. Один ключ с модификатором apply покрывает все три случая. own_fmt = row_style_fmt(row_fmt) own_key = get_style_key(own_fmt) if own_fmt else None own_name = style_names.get(own_key) if own_key else None cells_name = row_style_name if row_style_name and row_style_name != "default" else None if own_name and cells_name and own_key == row_style_key: dsl_row["rowStyle"] = own_name elif own_name and not cells_name: dsl_row["rowStyle"] = OrderedDict([("style", own_name), ("apply", "row")]) elif cells_name and not own_name: dsl_row["rowStyle"] = OrderedDict([("style", cells_name), ("apply", "cells")]) elif own_name and cells_name: # Стили разные — строке своё, ячейкам своё; ячейкам стиль раздаётся ниже # поячеечно, поэтому здесь пишем только строку. dsl_row["rowStyle"] = OrderedDict([("style", own_name), ("apply", "row")]) row_style_name = None row_style_key = None cells_name = None # Build cell list dsl_cells = [] for cell in sorted(content_cells, key=lambda c: c["Col"]): dsl_cell = OrderedDict() dsl_cell["col"] = cell["Col"] + 1 # Span/rowspan from merge mk = f"{global_row},{cell['Col']}" if mk in merge_map: m = merge_map[mk] if m["W"] > 0: dsl_cell["span"] = m["W"] + 1 if m["H"] > 0: dsl_cell["rowspan"] = m["H"] + 1 # Style cell_fmt = get_format(cell["FormatIdx"]) cell_style_key = get_style_key(cell_fmt) if row_style_key and cell_style_key == row_style_key: pass # Inherits rowStyle else: # Стиль пишем, только когда он отличается от того, что подставит компилятор: # без стиля у ячеек умолчание и есть "default", поэтому такой ключ — шум # (56% ячеек). А когда стиль ячейкам раздаётся, "default" писать ОБЯЗАТЕЛЬНО: # иначе ячейка при обратной сборке унаследовала бы его. # Сверяемся с cells_name — стилем, который РЕАЛЬНО раздаётся ячейкам. Раньше # здесь стоял row_style_name, а он бывает равен "default": строка такой стиль # не пишет, а ячейка получала бессмысленный ключ на несуществующий стиль. sn = get_style_name(cell["FormatIdx"]) if sn != "default" or cells_name: dsl_cell["style"] = sn # Content fill_type = cell_fmt["FillType"] if cell_fmt else "" if cell["Param"]: dsl_cell["param"] = cell["Param"] if cell["Detail"]: dsl_cell["detail"] = cell["Detail"] elif fill_type == "Template" and cell["HasText"]: dsl_cell["template"] = get_dsl_text(cell["Text"]) elif cell["HasText"]: dsl_cell["text"] = get_dsl_text(cell["Text"]) dsl_cells.append(dsl_cell) if len(dsl_cells) > 0: dsl_row["cells"] = to_positional_cells(dsl_cells) # Самая короткая из применимых форм: если у строки нет своих свойств, а список ячеек # позиционный — строка пишется просто массивом, без ключа cells. # Список позиционный, если позиция в нём НЕ записана: ни у одного элемента нет col. # Прежняя проверка искала элемент-строку, поэтому строка из одних многоязычных # текстов позиционной не признавалась и оставалась объектной. if (len(dsl_row) == 1 and "cells" in dsl_row and not any(isinstance(el, dict) and "col" in el for el in dsl_row["cells"])): area_rows.append(dsl_row["cells"]) else: area_rows.append(dsl_row) # Compress consecutive empty rows ({}) into { empty = N } compressed_rows = [] empty_run = 0 for r in area_rows: if len(r) == 0: empty_run += 1 else: if empty_run > 0: if empty_run == 1: compressed_rows.append(OrderedDict()) else: compressed_rows.append(OrderedDict([("empty", empty_run)])) empty_run = 0 compressed_rows.append(r) if empty_run > 0: if empty_run == 1: compressed_rows.append(OrderedDict()) else: compressed_rows.append(OrderedDict([("empty", empty_run)])) dsl_block = OrderedDict() # Область без имени — просто кусок сетки, ключ name у неё не пишем. if area["Name"]: dsl_block["name"] = area["Name"] # Ссылка на колоночную раскладку по имени из columnSets — как style у ячейки на styles. # Умолчание (раскладка без id) не пишем. if area.get("ColumnsId"): dsl_block["columnSet"] = area["ColumnsId"] dsl_block["rows"] = compressed_rows dsl_areas.append(dsl_block) # --- 13. Compress columnWidths --- compressed_widths = OrderedDict() if len(col_width_map) > 0: # Group columns by width width_to_cols = {} for col_str, width in col_width_map.items(): width_to_cols.setdefault(width, []).append(col_str) for width, cols in width_to_cols.items(): cols_sorted = sorted(cols, key=lambda x: int(x)) ranges = [] range_start = cols_sorted[0] range_prev = cols_sorted[0] for i in range(1, len(cols_sorted)): if int(cols_sorted[i]) == int(range_prev) + 1: range_prev = cols_sorted[i] else: if range_start == range_prev: ranges.append(range_start) else: ranges.append(f"{range_start}-{range_prev}") range_start = cols_sorted[i] range_prev = cols_sorted[i] if range_start == range_prev: ranges.append(range_start) else: ranges.append(f"{range_start}-{range_prev}") for rng in ranges: compressed_widths[rng] = width # --- 14. Build fonts output --- fonts_out = OrderedDict() for name, f in font_defs.items(): if f.get("Ref"): fonts_out[name] = OrderedDict([("ref", f["Ref"])]) continue f_out = OrderedDict() f_out["face"] = f["Face"] f_out["size"] = f["Size"] if f["Bold"]: f_out["bold"] = True if f["Italic"]: f_out["italic"] = True if f["Underline"]: f_out["underline"] = True if f["Strikeout"]: f_out["strikeout"] = True fonts_out[name] = f_out # --- 15. Assemble result --- result = OrderedDict() result["columns"] = total_columns result["defaultWidth"] = default_width if len(compressed_widths) > 0: result["columnWidths"] = compressed_widths if default_set: default_col_styles = column_styles_of(default_set) if default_col_styles: result["columnStyles"] = default_col_styles # Набор языков объявляем, только если он отличается от умолчания компилятора (один ru). if text_languages and text_languages != ['ru']: result["textLanguages"] = text_languages # Объявление языков МАКЕТА — не то же, что языки текста: почти во всех макетах ERP # объявлен один ru, а текст лежит и под ru, и под en. Пишем, только если отличается # от умолчания компилятора. ls_node = find(root, "d:languageSettings") if ls_node is not None: langs = [] for li in findall(ls_node, "d:languageInfo"): desc_node = find(li, "d:description") langs.append(OrderedDict([ ("id", text_of(find(li, "d:id")) or ""), ("code", text_of(find(li, "d:code")) or ""), ("description", text_of(desc_node) or "" if desc_node is not None else ""), ])) if langs != [OrderedDict([("id", "ru"), ("code", RU_NAME), ("description", RU_NAME)])]: result["languages"] = langs cur_node = find(ls_node, "d:currentLanguage") cur = text_of(cur_node) if cur_node is not None else None if cur != "ru": result["currentLanguage"] = cur dflt = text_of(find(ls_node, "d:defaultLanguage")) if dflt and dflt != "ru": result["defaultLanguage"] = dflt # Remove empty "default" style if "default" in style_defs and len(style_defs["default"]) == 0: del style_defs["default"] # Remove unused styles used_styles = set() for a in dsl_areas: for r in a["rows"]: # Строка может быть массивом (короткая форма) — у неё нет ключа cells, и без этой # ветки стиль, использованный только внутри такой строки, вырезался как «неиспользуемый». if isinstance(r, list): cell_list = r else: if "rowStyle" in r: rs = r["rowStyle"] used_styles.add(rs["style"] if isinstance(rs, dict) else rs) cell_list = r.get("cells") or [] # Список ячеек может быть позиционным: строки, None и маркеры стиля не несут, # стиль бывает только у объектного элемента. for c in cell_list: if isinstance(c, dict) and "style" in c: used_styles.add(c["style"]) # Стиль бывает не только у ячейки и строки: колонка — третий владелец формата. Берём # стили ИЗ САМИХ РАСКЛАДОК, а не из result: columnSets попадает в результат ПОЗЖЕ этой # проверки, поэтому стиль, на который ссылается только дополнительная раскладка, # отсекался, а ссылка на него оставалась висячей (5 макетов пилота из 40). for cs in column_sets: for name in column_styles_of(cs).values(): if name: used_styles.add(name) to_remove = [s for s in style_defs if s not in used_styles] for s in to_remove: del style_defs[s] result["fonts"] = fonts_out result["styles"] = style_defs # Колоночные раскладки помимо умолчания: ключ — идентификатор из макета, на него ссылаются # области. Содержимое раскладку не опознаёт (в корпусе полно наборов с одинаковым # содержимым и разными id), поэтому склейки по содержимому нет. extra_sets = [c for c in column_sets if c["Id"]] if extra_sets: sets_out = OrderedDict() for cs in extra_sets: entry = OrderedDict([("columns", cs["Size"])]) if cs["Widths"]: entry["columnWidths"] = cs["Widths"] styles_out = column_styles_of(cs) if styles_out: entry["columnStyles"] = styles_out sets_out[cs["Id"]] = entry result["columnSets"] = sets_out result["areas"] = dsl_areas # Именованные области, не выразимые блоком, — координатами. Тип не пишем: он выводится # из указанных осей (как в ТабличныйДокумент.Область()). DSL 1-based, XML 0-based. if overlay_areas: na_out = [] for a in overlay_areas: entry = OrderedDict([("name", a["Name"])]) if a["BeginRow"] >= 0: entry["rows"] = (f'{a["BeginRow"] + 1}-{a["EndRow"] + 1}' if a["EndRow"] > a["BeginRow"] else a["BeginRow"] + 1) if a["BeginCol"] >= 0: entry["cols"] = (f'{a["BeginCol"] + 1}-{a["EndCol"] + 1}' if a["EndCol"] > a["BeginCol"] else a["BeginCol"] + 1) na_out.append(entry) result["namedAreas"] = na_out # --- 16. Convert to JSON --- json_str = convert_to_compact_json(result) # --- 17. Output --- if output_path: abs_path = os.path.join(os.getcwd(), output_path) if not os.path.isabs(output_path) else output_path with open(abs_path, "w", encoding="utf-8", newline="") as fh: fh.write(json_str) print(f"[OK] Decompiled: {output_path}") else: print(json_str) print(f" Areas: {len(named_areas)}, Rows: {len(row_data)}, Columns: {total_columns}", file=sys.stderr) print(f" Fonts: {len(font_defs)}, Styles: {len(style_defs)}, Merges: {len(merge_map)}", file=sys.stderr) if __name__ == "__main__": main()