#!/usr/bin/env python3
# form-decompile v0.150 — Decompile 1C managed Form.xml to JSON DSL (draft)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью.
#
# Зеркало form-decompile.ps1 (канон). Структура 1:1 — те же имена функций, порядок,
# комментарии. Изменения вносить сначала в .ps1, затем переносить сюда строка-в-строку.
import argparse
import os
import re
import sys
import xml.etree.ElementTree as ET
from collections import OrderedDict
from decimal import Decimal

# Регистронезависимый ввод — паритет с 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)


# --- 1. Namespaces ---
NS_LF = "http://v8.1c.ru/8.3/xcf/logform"
NS_V8 = "http://v8.1c.ru/8.1/data/core"
NS_XR = "http://v8.1c.ru/8.3/xcf/readable"
NS_XSI = "http://www.w3.org/2001/XMLSchema-instance"

NS_DCSSET = "http://v8.1c.ru/8.1/data-composition-system/settings"
NS_DCSSCH = "http://v8.1c.ru/8.1/data-composition-system/schema"
NS_DCSCOR = "http://v8.1c.ru/8.1/data-composition-system/core"
NS_V8UI = "http://v8.1c.ru/8.1/data/ui"
NS_APP = "http://v8.1c.ru/8.2/managed-application/core"

# Карта префиксов для ET.find/findall (зеркало XmlNamespaceManager).
NS = {
    'lf': NS_LF,
    'v8': NS_V8,
    'xr': NS_XR,
    'xsi': NS_XSI,
    'dcsset': NS_DCSSET,
    'dcssch': NS_DCSSCH,
    'dcscor': NS_DCSCOR,
    'v8ui': NS_V8UI,
    'app': NS_APP,
}

# Каноничные GUID пустых контейнеров ListSettings (умолчание платформы, ~90% форм).
# Если ListSettings = пустой скелет с этими GUID → декомпилятор опускает настройки вовсе,
# компилятор регенерит тот же скелет → чистый раундтрип.
CANON_FILTER_ID = 'dfcece9d-5077-440b-b6b3-45a5cb4538eb'
CANON_ORDER_ID = '88619765-ccb3-46c6-ac52-38e9c992ebd4'
CANON_CA_ID = 'b75fecce-942b-4aed-abc9-e6a02e460fb3'
CANON_ITEMS_ID = '911b6018-f537-43e8-a417-da56b22f9aec'

# Companion-элементы (авто-генерируемые компилятором) — пропускаем при обходе детей.
# Дополнения (Search*/ViewStatus) БОЛЬШЕ не companion — декомпилируются как тип-элементы
# (кастомные в AutoCommandBar/ChildItems → commandBar.children; стандартные на уровне таблицы → карта additions).
COMPANION_TAGS = ['ContextMenu', 'ExtendedTooltip', 'AutoCommandBar']

# Скрипт-скоуп состояние (зеркало $script:* канона). ROOT/NS_DOC ставятся в main().
ROOT = None
NSMAP_DOC = {}
OUTPUT_DIR = None
OUTPUT_BASENAME = None
QUERY_FILES_ACCUMULATOR = []
QUERY_FILE_NAMES_USED = {}

# Атрибут с пространством имён в формате ET ({uri}local) — зеркало GetAttribute("x", $NS_XSI).
_XSI_TYPE = '{%s}type' % NS_XSI


def _attr(node, name, ns_uri=None):
    """GetAttribute(name[, ns]) — .NET возвращает "" для отсутствующего атрибута, ET → None."""
    if node is None:
        return ''
    key = ('{%s}%s' % (ns_uri, name)) if ns_uri else name
    v = node.get(key)
    return v if v is not None else ''


# ═══════════════════════════════════════════════════════════════════════════
# JSON-сериализатор — точное зеркало ConvertTo-CompactJson/Try-InlineJson/
# Convert-StringToJsonLiteral. json.dumps НЕ подходит (иная раскладка inline/
# multiline и эскейпинг). Кириллица в выводе сырая (UTF-8 без эскейпа).
# ═══════════════════════════════════════════════════════════════════════════

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 _num_to_str(obj):
    """Зеркало [System.Convert]::ToString(.., InvariantCulture)."""
    if isinstance(obj, bool):
        return 'true' if obj else 'false'
    if isinstance(obj, int):
        return str(obj)
    # Decimal — зеркало [decimal].ToString(): сохраняет масштаб ("1.50" остаётся "1.50").
    if isinstance(obj, Decimal):
        return str(obj)
    # float — зеркало [double].ToString('R'/InvariantCulture): кратчайшее round-trip, без хвостового .0.
    r = repr(float(obj))
    if r.endswith('.0'):
        r = r[:-2]
    return r


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, Decimal)):
        return _num_to_str(obj)
    if isinstance(obj, dict):
        if len(obj) == 0:
            return '{}'
        parts = []
        for k in obj.keys():
            v = try_inline_json(obj[k])
            if v is None:
                return None
            parts.append('%s: %s' % (convert_string_to_json_literal(str(k)), v))
        return '{ ' + ', '.join(parts) + ' }'
    if isinstance(obj, (list, tuple)):
        items = list(obj)
        if len(items) == 0:
            return '[]'
        parts = []
        for it in items:
            v = try_inline_json(it)
            if v is None:
                return None
            parts.append(v)
        return '[' + ', '.join(parts) + ']'
    return None


def convert_to_compact_json(obj, depth=0, indent_unit='  ', line_limit=120):
    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, Decimal)):
        return _num_to_str(obj)
    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):
        keys = list(obj.keys())
        if len(keys) == 0:
            return '{}'
        parts = []
        for k in keys:
            val = convert_to_compact_json(obj[k], depth + 1, indent_unit, line_limit)
            parts.append('%s%s: %s' % (child_indent, convert_string_to_json_literal(str(k)), val))
        return '{\n' + ',\n'.join(parts) + '\n' + indent + '}'
    if isinstance(obj, (list, tuple)):
        items = list(obj)
        if len(items) == 0:
            return '[]'
        parts = ['%s%s' % (child_indent, convert_to_compact_json(it, depth + 1, indent_unit, line_limit)) for it in items]
        return '[\n' + ',\n'.join(parts) + '\n' + indent + ']'
    return convert_string_to_json_literal(str(obj))


# ═══════════════════════════════════════════════════════════════════════════
# Низкоуровневые читатели текста.
# .NET .InnerText = конкатенация текста всех потомков. PreserveWhitespace=false
# в каноне сворачивает whitespace-only текст-узлы в "". ET не имеет такого режима,
# поэтому два читателя на одном ws-сохраняющем дереве:
#   _text     — main-doc семантика (whitespace-only → ""), для всех чтений значений;
#   _text_ws  — сырой текст (для Resolve-WS: восстановление точного числа пробелов).
# ═══════════════════════════════════════════════════════════════════════════

def _text(node):
    if node is None:
        return None
    s = ''.join(node.itertext())
    if s != '' and s.strip() == '':
        return ''
    return s


def _text_ws(node):
    if node is None:
        return None
    return ''.join(node.itertext())


# Запрос ≥3 строк + есть outputDir → вынести в `<basename>-<listName>.sql`, вернуть "@file".
def maybe_externalize_query(query_text, list_name):
    if not query_text:
        return query_text
    if not OUTPUT_DIR:
        return query_text
    line_count = len(re.findall('\n', query_text)) + 1
    if line_count < 3:
        return query_text
    safe = re.sub(r'[^\w\-]', '_', list_name)
    if not safe:
        safe = 'query'
    prefix = (OUTPUT_BASENAME + '-') if OUTPUT_BASENAME else ''
    file_name = prefix + safe + '.sql'
    suffix = 1
    while file_name in QUERY_FILE_NAMES_USED:
        suffix += 1
        file_name = '%s%s_%d.sql' % (prefix, safe, suffix)
    QUERY_FILE_NAMES_USED[file_name] = True
    QUERY_FILES_ACCUMULATOR.append(OrderedDict([('fileName', file_name), ('text', query_text)]))
    return '@' + file_name


def save_query_files():
    if len(QUERY_FILES_ACCUMULATOR) == 0:
        return
    if not OUTPUT_DIR:
        return
    for qf in QUERY_FILES_ACCUMULATOR:
        with open(os.path.join(OUTPUT_DIR, qf['fileName']), 'w', encoding='utf-8', newline='') as f:
            f.write(qf['text'])
    sys.stderr.write("Saved %d external query file(s)\n" % len(QUERY_FILES_ACCUMULATOR))


# Есть ли в ListSettings содержательные настройки (реальные items фильтра/порядка/
# условного оформления/параметров)? Пустой скелет (только viewMode+GUID) → false.
def test_list_settings_has_content(ls_node):
    if ls_node is None:
        return False
    for cont in ('filter', 'order', 'conditionalAppearance', 'dataParameters'):
        cn = ls_node.find('dcsset:%s' % cont, NS)
        if cn is not None and cn.find('dcsset:item', NS) is not None:
            return True
    return False


# Форма ListSettings: ordered-карта present top-level элементов. $null, если форма ==
# полному каноничному скелету ИЛИ содержит неподдержанные top-level элементы → канон-fallback.
def get_list_settings_shape(ls_node, has_grouping=False):
    if ls_node is None:
        return None
    shape = OrderedDict()
    for child in list(ls_node):
        tag = _local_name(child.tag)
        if tag in ('filter', 'order', 'conditionalAppearance'):
            has_vm = child.find('dcsset:viewMode', NS) is not None
            has_us = child.find('dcsset:userSettingID', NS) is not None
            code = ('v' if has_vm else '') + ('u' if has_us else '')
            usp_node = child.find('dcsset:userSettingPresentation', NS)
            if usp_node is not None:
                usp = get_pres_by_type(usp_node)
                shape[tag] = OrderedDict([('meta', code), ('presentation', usp)])
            else:
                shape[tag] = code
        elif tag == 'itemsViewMode':
            shape['itemsViewMode'] = True
        elif tag == 'itemsUserSettingID':
            shape['itemsUserSettingID'] = True
        elif tag == 'itemsUserSettingPresentation':
            shape['itemsUserSettingPresentation'] = get_pres_by_type(child)
        elif tag == 'dataParameters':
            shape['dataParameters'] = True
        elif tag == 'item':
            if has_grouping:
                shape['structure'] = True
            else:
                return None
        else:
            return None
    if (len(shape) == 5 and shape.get('filter') == 'vu' and shape.get('order') == 'vu'
            and shape.get('conditionalAppearance') == 'vu' and shape.get('itemsViewMode') is True
            and shape.get('itemsUserSettingID') is True):
        return None
    return shape


# Группировка строк динамического списка: цепочка <dcsset:item StructureItemGroup>.
# Плоский массив уровней ИЛИ $null (не «чистая линейная цепочка одно-польных уровней»).
def build_group_level(fn):
    field = get_child(fn, 'field')
    gt = get_child(fn, 'groupType')
    pat = get_child(fn, 'periodAdditionType')
    pab_n = fn.find('dcsset:periodAdditionBegin', NS)
    pae_n = fn.find('dcsset:periodAdditionEnd', NS)
    pab = None
    pae = None
    if pab_n is not None:
        pt = _attr(pab_n, 'type', NS_XSI)
        pv = _text(pab_n)
        if re.search(r'Field$', pt) or (pv and pv != '0001-01-01T00:00:00'):
            pab = pv
    if pae_n is not None:
        pt = _attr(pae_n, 'type', NS_XSI)
        pv = _text(pae_n)
        if re.search(r'Field$', pt) or (pv and pv != '0001-01-01T00:00:00'):
            pae = pv
    is_default = ((not gt) or gt == 'Items') and ((not pat) or pat == 'None') and (not pab) and (not pae)
    if is_default:
        return field
    o = OrderedDict([('field', field)])
    if gt and gt != 'Items':
        o['groupType'] = gt
    if pat and pat != 'None':
        o['periodAdditionType'] = pat
    if pab:
        o['periodAdditionBegin'] = pab
    if pae:
        o['periodAdditionEnd'] = pae
    return o


def build_list_grouping(item_node):
    levels = []
    cur = item_node
    while cur is not None:
        if not re.search(r'StructureItemGroup$', _attr(cur, 'type', NS_XSI)):
            return None
        gi = None
        nested = []
        for ch in list(cur):
            ln = _local_name(ch.tag)
            if ln == 'groupItems':
                if gi is not None:
                    return None
                gi = ch
            elif ln == 'item':
                nested.append(ch)
            else:
                return None
        if gi is None:
            return None
        field_items = gi.findall('dcsset:item', NS)
        if len(field_items) != 1:
            return None
        fn = field_items[0]
        if not re.search(r'GroupItemField$', _attr(fn, 'type', NS_XSI)):
            return None
        levels.append(build_group_level(fn))
        if len(nested) == 0:
            break
        if len(nested) > 1:
            return None
        cur = nested[0]
    if len(levels) == 0:
        return None
    return levels


# Ring-3: конструкции вне зоны поддержки → stderr + exit 3 (см. ring3-скан в main).
def fail_ring3(kind, loc):
    sys.stderr.write("form-decompile: декомпиляция пока не поддерживает %s (path: %s)\n" % (kind, loc))
    sys.stderr.write("Для точечной работы с этой формой используй /form-edit.\n")
    sys.exit(3)


# Извлечь мультиязычный Title/Presentation → string (ru) или ordered hash {ru,en,...}
def get_lang_text(node):
    if node is None:
        return None
    items = node.findall('v8:item', NS)
    if len(items) == 0:
        return None
    m = OrderedDict()
    for it in items:
        lang = it.find('v8:lang', NS)
        content = it.find('v8:content', NS)
        if lang is not None:
            m[_text(lang)] = _text(content) if content is not None else ""
    if len(m) == 1 and 'ru' in m:
        return m['ru']
    return m


# Точное число пробелов whitespace-only <v8:content>: ET хранит .text дословно,
# второй WS-парс не нужен — читаем сырой текст того же узла (_text_ws).
def resolve_ws(content_node):
    if content_node is None:
        return None
    return _text_ws(content_node)


# Точное восстановление пробела: whitespace-only content → реальная строка пробелов.
def restore_ws_content(content_node):
    ws = resolve_ws(content_node)
    if ws and ws.strip() == '':
        return ws
    return ' '


def get_lang_text_ws(node):
    t = get_lang_text(node)
    if t is None:
        return None
    if isinstance(t, str):
        cn = node.find('v8:item/v8:content', NS)
        if t == '' and cn is not None:
            return restore_ws_content(cn)
        return t
    for it in node.findall('v8:item', NS):
        lang = it.find('v8:lang', NS)
        content = it.find('v8:content', NS)
        if lang is not None and content is not None:
            lt = _text(lang)
            if lt in t and t[lt] == '':
                t[lt] = restore_ws_content(content)
    return t


# Авто-вывод заголовка из имени — ТОЧНОЕ зеркало Title-FromName из form-compile.
def title_from_name(name):
    if not name:
        return ''
    s = re.sub(r'([А-ЯA-Z])([А-ЯA-Z][а-яa-z])', r'\1 \2', name)
    s = re.sub(r'([а-яa-z0-9])([А-ЯA-Z])', r'\1 \2', s)
    parts = s.split(' ')
    if len(parts) == 0:
        return s
    out = [parts[0]]
    for i in range(1, len(parts)):
        p = parts[i]
        if len(p) > 1 and p == p.upper():
            out.append(p)
        else:
            out.append(p.lower())
    return ' '.join(out)


# Детектор «настоящей» inline-разметки форматированного текста (идентичен form-compile!).
FMT_MARKUP_RE = r'</>|<\s*(?:link|b|i|u|s|color|colorStyle|bgColor|bgColorStyle|font|fontSize|fontStyle|img)(?:\s|>)'


def test_has_real_markup(text):
    if text is None:
        return False
    vals = list(text.values()) if isinstance(text, dict) else [str(text)]
    for v in vals:
        if re.search(FMT_MARKUP_RE, str(v)):
            return True
    return False


# Title-узел → DSL-значение ML-поля (гибрид): строка/мапа или явный {text, formatted}.
def get_ml_formatted_value(title_node):
    if title_node is None:
        return None
    text = get_lang_text_ws(title_node)
    if text is None:
        return None
    fmt_attr = (_attr(title_node, 'formatted') == 'true')
    if fmt_attr == test_has_real_markup(text):
        return text
    o = OrderedDict()
    o['text'] = text
    o['formatted'] = fmt_attr
    return o


# Прочитать дочерний скаляр (по local-name, без namespace)
def get_child(node, name):
    if node is None:
        return None
    c = node.find('{*}%s' % name)
    if c is not None:
        return _text(c)
    return None


def has_child(node, name):
    if node is None:
        return False
    return node.find('{*}%s' % name) is not None


def to_bool(v):
    return v == 'true'


# Значение с учётом xsi:type → нативный JSON-тип (число/булево/строка).
def convert_typed_value(raw, xsi_type):
    if re.search(r'decimal$', xsi_type):
        if re.match(r'^-?\d+$', raw):
            return int(raw)
        return float(raw)
    if re.search(r'boolean$', xsi_type):
        return raw == 'true'
    return raw


# Прочитать дочерний скаляр по xpath (с NS). Аналог skd Get-Text.
def get_text(node, xpath):
    if node is None:
        return None
    if xpath is None or xpath == '':
        return _text(node)
    n = node.find(xpath, NS)
    if n is not None:
        return _text(n)
    return None


# Мультиязычный текст (LocalStringType) → string (ru) или ordered hash. Алиас Get-LangText.
def get_ml_text(node):
    return get_lang_text(node)


# Презентация: либо мультиязычный LocalStringType, либо плоский xs:string.
def get_pres_text(node):
    if node is None:
        return None
    ml = get_ml_text(node)
    if ml is not None:
        return ml
    t = _text(node)
    if t:
        return t
    return None


# Presentation, сохраняющий ФОРМУ по xsi:type (ru-only LocalStringType ≠ xs:string).
def get_pres_by_type(node):
    if node is None:
        return None
    xt = _attr(node, 'type', NS_XSI)
    if re.search(r'LocalStringType$', xt):
        d = OrderedDict()
        for it in node.findall('v8:item', NS):
            lang = get_text(it, 'v8:lang')
            content = get_text(it, 'v8:content')
            if lang:
                d[lang] = content
        if len(d) > 0:
            return d
        return None
    t = _text(node)
    if t:
        return t
    return None


# Снять namespace-префикс с xsi:type ("dcsset:Foo" → "Foo")
def get_local_xsi_type(node):
    if node is None:
        return None
    t = _attr(node, 'type', NS_XSI)
    mt = re.search(r':(.+)$', t)
    if mt:
        return mt.group(1)
    return t


# Зеркало булевой коэрции PowerShell `if ($x)`: одноэлементный массив коэрсится в truthiness
# СВОЕГО элемента (а не «непустой список → true», как в Python). Нужно там, где канон пишет
# `if ($arr)` на результате билдера: напр. вырожденный FunctionalOptions @("") → falsy → дроп.
def _ps_truthy(v):
    if v is None:
        return False
    if isinstance(v, bool):
        return v
    if isinstance(v, str):
        return v != ''
    if isinstance(v, (int, float)):
        return v != 0
    if isinstance(v, dict):
        return True   # Hashtable/[ordered] — объект, всегда truthy
    if isinstance(v, (list, tuple)):
        if len(v) == 0:
            return False
        if len(v) == 1:
            return _ps_truthy(v[0])
        return True
    return True


# Зеркало PowerShell -eq/-ne для строк: регистронезависимое сравнение (PS по умолчанию ignore-case).
# Нужно там, где сравниваются произвольно-регистровые строки (заголовок vs авто-вывод из имени).
def _ps_ieq(a, b):
    return a.lower() == b.lower()


# Зеркало PowerShell "$v" (строковая интерполяция): bool→True/False, float без хвостового .0,
# $null→'', иначе str(). Используется везде, где канон делает "$value".
def _ps_str(v):
    if isinstance(v, bool):
        return 'True' if v else 'False'
    if isinstance(v, (float, Decimal)):
        return _num_to_str(v)
    if v is None:
        return ''
    return str(v)


# Шрифт оформления → объект {@type:Font, ...} (bit-perfect для compile).
def get_font_value(val_node):
    f = OrderedDict([('@type', 'Font')])
    for attr_name in ('ref', 'faceName', 'height', 'bold', 'italic', 'underline', 'strikeout', 'kind', 'scale'):
        a = val_node.get(attr_name)
        if a is not None:
            f[attr_name] = a
    return f


# Линия (граница) оформления → объект {@type:Line, width, gap, style}.
def get_line_value(val_node):
    obj = OrderedDict([('@type', 'Line')])
    w = _attr(val_node, 'width')
    g = _attr(val_node, 'gap')
    if w != '':
        obj['width'] = int(w) if re.match(r'^-?\d+$', w) else w
    if g != '':
        obj['gap'] = (g == 'true')
    style_node = val_node.find('v8ui:style', NS)
    if style_node is not None:
        obj['style'] = _text(style_node)
    return obj


# Прочитать <dcscor:value> в JSON-значение: Font/Line/Field/multilang/raw text.
def read_appearance_value_node(val_node):
    if val_node is None:
        return None
    vt = get_local_xsi_type(val_node)
    if vt == 'LocalStringType':
        # НЕ схлопываем одноязычный в строку: значение параметра оформления различает
        # xs:string (плоская строка) и LocalStringType (локализуемый текст).
        m = OrderedDict()
        for it in val_node.findall('v8:item', NS):
            lang = it.find('v8:lang', NS)
            content = it.find('v8:content', NS)
            if lang is not None:
                m[_text(lang)] = _text(content) if content is not None else ""
        return m
    if vt == 'Font':
        return get_font_value(val_node)
    if vt == 'Line':
        return get_line_value(val_node)
    if vt == 'Field':
        return OrderedDict([('field', _text(val_node))])
    return _text(val_node)


# Обратная карта comparisonType → короткий оператор фильтра (зеркало skd).
FILTER_OP_MAP = {
    'Equal': '=', 'NotEqual': '<>', 'Greater': '>', 'GreaterOrEqual': '>=',
    'Less': '<', 'LessOrEqual': '<=', 'InList': 'in', 'NotInList': 'notIn',
    'InHierarchy': 'inHierarchy', 'InListByHierarchy': 'inListByHierarchy',
    'Contains': 'contains', 'NotContains': 'notContains',
    'BeginsWith': 'beginsWith', 'NotBeginsWith': 'notBeginsWith',
    'Like': 'like', 'NotLike': 'notLike',
    'Filled': 'filled', 'NotFilled': 'notFilled',
}

# Авто-детект DTV-ссылки (Перечисление.X/Catalog.X/…): компилятор сам выводит DesignTimeValue
# для таких значений → не фиксируем явный valueType. Инлайн в каноне (дважды) — держим как константу.
_DTV_REF_RE = r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена|Catalog|Enum|Document|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.'


# Render filter value node → shorthand-acceptable scalar string
def get_filter_value(val_node):
    if val_node is None:
        return '_'
    nil = _attr(val_node, 'nil', NS_XSI)
    if nil == 'true':
        return '_'
    v_type = get_local_xsi_type(val_node)
    if v_type == 'DesignTimeValue':
        return _text(val_node)
    if v_type == 'LocalStringType':
        return get_ml_text(val_node)
    txt = _text(val_node)
    if not txt:
        return '_'
    return txt


# Get-FilterValue + xsi:type значения (для valueType, например dcscor:Field).
def get_filter_value_with_type(val_node):
    if val_node is None:
        return {'value': '_', 'type': None}
    raw_type = _attr(val_node, 'type', NS_XSI)
    nil = _attr(val_node, 'nil', NS_XSI)
    if nil == 'true':
        return {'value': '_', 'type': None}
    v_type = get_local_xsi_type(val_node)
    if v_type == 'LocalStringType':
        return {'value': get_ml_text(val_node), 'type': raw_type}
    # Стандартная дата начала/окончания: SBD Custom+date → голая ISO-дата без valueType;
    # именованный вариант → строка + valueType; SED Custom/нетипичное → {variant, date} + valueType.
    if v_type == 'StandardBeginningDate' or v_type == 'StandardEndDate':
        variant_n = val_node.find('v8:variant', NS)
        date_n = val_node.find('v8:date', NS)
        variant_str = _text(variant_n) if variant_n is not None else ''
        if date_n is not None:
            if v_type == 'StandardBeginningDate' and variant_str == 'Custom':
                return {'value': _text(date_n), 'type': None}
            return {'value': OrderedDict([('variant', variant_str), ('date', _text(date_n))]), 'type': raw_type}
        return {'value': variant_str, 'type': raw_type}
    txt = _text(val_node)
    if not txt:
        return {'value': '_', 'type': raw_type}
    if v_type == 'boolean':
        return {'value': (txt == 'true'), 'type': raw_type}
    if v_type == 'decimal':
        if re.match(r'^-?\d+$', txt):
            return {'value': int(txt), 'type': raw_type}
        return {'value': float(txt), 'type': raw_type}
    return {'value': txt, 'type': raw_type}


# Convert filter item node → shorthand string или object form (рекурсивно для групп).
def build_filter_item(item_node, loc):
    xtype = get_local_xsi_type(item_node)
    if xtype == 'FilterItemGroup':
        gt = get_text(item_node, 'dcsset:groupType')
        group_name = {'OrGroup': 'Or', 'NotGroup': 'Not'}.get(gt, 'And')
        items = []
        for c in item_node.findall('dcsset:item', NS):
            bi = build_filter_item(c, '%s/item' % loc)
            if bi is not None:
                items.append(bi)
        g_obj = OrderedDict([('group', group_name), ('items', items)])
        if get_text(item_node, 'dcsset:use') == 'false':
            g_obj['use'] = False   # группа отключена (@off)
        g_pres_node = item_node.find('dcsset:presentation', NS)
        if g_pres_node is not None:
            g_pres = get_pres_by_type(g_pres_node)
            if g_pres is not None and g_pres != '':
                g_obj['presentation'] = g_pres
        g_vm_node = item_node.find('dcsset:viewMode', NS)
        if g_vm_node is not None:
            g_obj['viewMode'] = _text(g_vm_node)
        g_usid = get_text(item_node, 'dcsset:userSettingID')
        if g_usid:
            g_obj['userSettingID'] = 'auto'
        g_uspn = item_node.find('dcsset:userSettingPresentation', NS)
        if g_uspn is not None:
            g_usp = get_pres_text(g_uspn)
            if g_usp:
                g_obj['userSettingPresentation'] = g_usp
        return g_obj
    if xtype != 'FilterItemComparison':
        sys.stderr.write("form-decompile: пропущен фильтр неизвестного типа '%s' (path: %s)\n" % (xtype, loc))
        return None
    left_node = item_node.find('dcsset:left', NS)
    field = _text(left_node) if left_node is not None else None
    ct = get_text(item_node, 'dcsset:comparisonType')
    op = FILTER_OP_MAP.get(ct)
    if not op:
        op = ct

    right_nodes = item_node.findall('dcsset:right', NS)
    value = None
    value_is_array_flag = False
    value_type_attr = None
    if len(right_nodes) == 1:
        rn = right_nodes[0]
        if get_local_xsi_type(rn) == 'ValueListType':
            value = []
            value_is_array_flag = True
        else:
            vt = get_filter_value_with_type(rn)
            value = vt['value']
            auto_detects_dtv = (vt['type'] == 'dcscor:DesignTimeValue') and \
                bool(re.search(_DTV_REF_RE, _ps_str(vt['value'])))
            if vt['type'] and not re.match(r'^xs:', vt['type']) and not auto_detects_dtv:
                value_type_attr = vt['type']
            elif vt['type'] == 'xs:string' and isinstance(value, str) and re.search(r'^(-?\d+(\.\d+)?|\d{4}-\d{2}-\d{2}T)', value):
                # Значение-строка "1"/"2020-..." с xs:string: компилятор авто-детектит число/дату → фиксируем явный valueType.
                value_type_attr = 'xs:string'
    elif len(right_nodes) > 1:
        arr = []
        raw_types = []
        for rn in right_nodes:
            arr.append(get_filter_value(rn))
            raw_types.append(_attr(rn, 'type', NS_XSI))
        value = arr
        value_is_array_flag = True
        uniq_types = sorted(set(raw_types))
        if len(uniq_types) == 1 and uniq_types[0]:
            auto_detects_dtv = (uniq_types[0] == 'dcscor:DesignTimeValue') and \
                (len(arr) > 0) and \
                (len([x for x in arr if not re.search(_DTV_REF_RE, _ps_str(x))]) == 0)
            if not auto_detects_dtv:
                value_type_attr = uniq_types[0]

    use = get_text(item_node, 'dcsset:use')
    user_id = get_text(item_node, 'dcsset:userSettingID')
    vm_node = item_node.find('dcsset:viewMode', NS)
    view_mode = _text(vm_node) if vm_node is not None else None
    user_pres_node = item_node.find('dcsset:userSettingPresentation', NS)
    fi_pres_node = item_node.find('dcsset:presentation', NS)
    fi_pres = None
    if fi_pres_node is not None:
        fi_pres = get_pres_by_type(fi_pres_node)

    flags = []
    if use == 'false':
        flags.append('@off')
    if user_id:
        flags.append('@user')
    if view_mode == 'QuickAccess':
        flags.append('@quickAccess')
    elif view_mode == 'Inaccessible':
        flags.append('@inaccessible')
    elif view_mode == 'Normal':
        flags.append('@normal')

    no_value_ops = ('filled', 'notFilled')

    # Пустой xs:string right ≠ отсутствие <right>; пробельные значения рвут shorthand → форсим объект.
    val_needs_obj = False
    if len(right_nodes) == 1 and not value_is_array_flag and op not in no_value_ops:
        if _ps_str(value) == '_':
            ws = resolve_ws(right_nodes[0])
            if ws and len(ws) > 0 and ws.strip() == '':
                value = ws
            val_needs_obj = True
        elif value is not None and re.search(r'\s', _ps_str(value)):
            val_needs_obj = True

    if user_pres_node is not None or value_is_array_flag or value_type_attr or fi_pres or val_needs_obj:
        obj = OrderedDict([('field', field), ('op', op)])
        if op not in no_value_ops and value is not None:
            if value_is_array_flag:
                arr_as_list = []
                for vv in (value if isinstance(value, (list, tuple)) else [value]):
                    arr_as_list.append(vv)
                obj['value'] = arr_as_list
            else:
                obj['value'] = value
        if value_type_attr:
            obj['valueType'] = value_type_attr
        if use == 'false':
            obj['use'] = False
        if user_id:
            obj['userSettingID'] = 'auto'
        if fi_pres:
            obj['presentation'] = fi_pres
        if view_mode:
            obj['viewMode'] = view_mode
        if user_pres_node is not None:
            obj['userSettingPresentation'] = get_pres_text(user_pres_node)
        return obj

    s = field if field is not None else ''   # $null + строка в PS → '' (избегаем TypeError)
    if op in no_value_ops:
        s += ' ' + op
    else:
        v_display = '_'
        if value is not None:
            if isinstance(value, bool):
                v_display = 'true' if value else 'false'
            elif _ps_str(value) != '':
                v_display = _ps_str(value)
        s += ' ' + op + ' ' + v_display
    if flags:
        s += ' ' + ' '.join(flags)
    return s


# Рекурсивный хелпер одного элемента selection (для conditionalAppearance).
def build_selection_item(item, loc):
    xt = get_local_xsi_type(item)
    if not xt:
        f_name = get_text(item, 'dcsset:field')
        if f_name:
            return f_name
        field_el = item.find('dcsset:field', NS)
        if field_el is not None:
            return 'Auto'
    if xt == 'SelectedItemAuto':
        use_v = get_text(item, 'dcsset:use')
        if use_v == 'false':
            return OrderedDict([('auto', True), ('use', False)])
        return 'Auto'
    if xt == 'SelectedItemField':
        f_name = get_text(item, 'dcsset:field')
        title_node = item.find('dcsset:lwsTitle', NS)
        title = get_ml_text(title_node)
        vm_n = item.find('dcsset:viewMode', NS)
        use_v = get_text(item, 'dcsset:use')
        use_false = (use_v == 'false')
        if title or vm_n is not None or use_false:
            obj = OrderedDict([('field', f_name)])
            if use_false:
                obj['use'] = False
            if title:
                obj['title'] = title
            if vm_n is not None:
                obj['viewMode'] = _text(vm_n)
            return obj
        return f_name
    if xt == 'SelectedItemFolder':
        title_node = item.find('dcsset:lwsTitle', NS)
        folder_title = get_ml_text(title_node)
        inner = []
        for sub in item.findall('dcsset:item', NS):
            bi = build_selection_item(sub, '%s/folder' % loc)
            if bi is not None:
                inner.append(bi)
        entry = OrderedDict([('folder', folder_title), ('items', inner)])
        folder_field = get_text(item, 'dcsset:field')
        if folder_field:
            entry['field'] = folder_field
        pl_n = item.find('dcsset:placement', NS)
        if pl_n is not None and _text(pl_n) and _text(pl_n) != 'Auto':
            entry['placement'] = _text(pl_n)
        return entry
    sys.stderr.write("form-decompile: пропущен элемент selection неизвестного типа '%s' (path: %s)\n" % (xt, loc))
    return None


# Build selection items array (для conditionalAppearance).
def build_selection(sel_node, loc):
    if sel_node is None:
        return []
    out = []
    for it in sel_node.findall('dcsset:item', NS):
        bi = build_selection_item(it, loc)
        if bi is not None:
            out.append(bi)
    return out


# Build order items array.
def build_order(ord_node, loc):
    if ord_node is None:
        return []
    out = []
    for it in ord_node.findall('dcsset:item', NS):
        xt = get_local_xsi_type(it)
        if xt == 'OrderItemAuto':
            out.append('Auto')
        elif xt == 'OrderItemField':
            fn = get_text(it, 'dcsset:field')
            ot = get_text(it, 'dcsset:orderType')
            vm_n = it.find('dcsset:viewMode', NS)
            use_v = get_text(it, 'dcsset:use')
            use_false = (use_v == 'false')
            if vm_n is not None or use_false:
                obj = OrderedDict([('field', fn)])
                if use_false:
                    obj['use'] = False
                if ot == 'Desc':
                    obj['direction'] = 'desc'
                if vm_n is not None:
                    obj['viewMode'] = _text(vm_n)
                out.append(obj)
            else:
                if ot == 'Desc':
                    out.append('%s desc' % fn)
                else:
                    out.append(fn)
        else:
            sys.stderr.write("form-decompile: пропущен элемент сортировки неизвестного типа '%s' (path: %s)\n" % (xt, loc))
    return out


# Build appearance dict из <dcsset:appearance> (Line/Font/multilang/nested items).
def get_settings_appearance(app_node):
    if app_node is None:
        return None
    d = OrderedDict()
    for it in app_node.findall('dcscor:item', NS):
        p_name = get_text(it, 'dcscor:parameter')
        val = it.find('dcscor:value', NS)
        if not p_name or val is None:
            continue
        raw_val = read_appearance_value_node(val)
        use_v = get_text(it, 'dcscor:use')
        nested_items = OrderedDict()
        for sub in it.findall('dcscor:item', NS):
            sub_name = get_text(sub, 'dcscor:parameter')
            sub_val = sub.find('dcscor:value', NS)
            if not sub_name:
                continue
            sub_raw = read_appearance_value_node(sub_val)
            sub_use = get_text(sub, 'dcscor:use')
            sub_entry = OrderedDict([('value', sub_raw)])
            if sub_use == 'false':
                sub_entry['use'] = False
            nested_items[sub_name] = sub_entry
        val_is_line = isinstance(raw_val, dict) and ('@type' in raw_val) and (raw_val['@type'] == 'Line')
        if val_is_line:
            if use_v == 'false':
                raw_val['use'] = False
            if len(nested_items) > 0:
                raw_val['items'] = nested_items
            d[p_name] = raw_val
        elif (use_v == 'false') or (len(nested_items) > 0):
            wrap = OrderedDict([('value', raw_val)])
            if use_v == 'false':
                wrap['use'] = False
            if len(nested_items) > 0:
                wrap['items'] = nested_items
            d[p_name] = wrap
        else:
            d[p_name] = raw_val
    return d


# Build conditionalAppearance array.
def build_conditional_appearance(ca_node, loc):
    if ca_node is None:
        return []
    out = []
    i = 0
    for it in ca_node.findall('dcsset:item', NS):
        entry = OrderedDict()
        scope_node = it.find('dcsset:scope', NS)
        if scope_node is not None and (len(scope_node) > 0 or bool(_text(scope_node))):
            sys.stderr.write("form-decompile: conditionalAppearance item имеет scope — не воспроизводится в DSL (path: %s/%d/scope)\n" % (loc, i))
        sel_node = it.find('dcsset:selection', NS)
        if sel_node is not None and len(sel_node.findall('dcsset:item', NS)) > 0:
            entry['selection'] = build_selection(sel_node, '%s/%d/selection' % (loc, i))
        filter_node = it.find('dcsset:filter', NS)
        if filter_node is not None and len(filter_node.findall('dcsset:item', NS)) > 0:
            f = []
            for fc in filter_node.findall('dcsset:item', NS):
                bi = build_filter_item(fc, '%s/%d/filter' % (loc, i))
                if bi is not None:
                    f.append(bi)
            entry['filter'] = f
        app_node = it.find('dcsset:appearance', NS)
        ap = get_settings_appearance(app_node)
        if ap and len(ap) > 0:
            entry['appearance'] = ap
        pres_node = it.find('dcsset:presentation', NS)
        if pres_node is not None:
            pres = get_pres_by_type(pres_node)
            if pres is not None and pres != '':
                entry['presentation'] = pres
        vm_n = it.find('dcsset:viewMode', NS)
        if vm_n is not None:
            entry['viewMode'] = _text(vm_n)
        usid = get_text(it, 'dcsset:userSettingID')
        if usid:
            entry['userSettingID'] = 'auto'
        usp_n = it.find('dcsset:userSettingPresentation', NS)
        if usp_n is not None:
            usp = get_pres_text(usp_n)
            if usp:
                entry['userSettingPresentation'] = usp
        use_v = get_text(it, 'dcsset:use')
        if use_v == 'false':
            entry['use'] = False
        use_in_dont_use = []
        for ch in list(it):
            if not ch.tag.startswith('{%s}' % NS_DCSSET):
                continue
            ln = _local_name(ch.tag)
            mt = re.match(r'^useIn(.+)$', ln)
            if mt and _text(ch) == 'DontUse':
                short_name = mt.group(1)[0:1].lower() + mt.group(1)[1:]
                use_in_dont_use.append(short_name)
        if len(use_in_dont_use) > 0:
            entry['useInDontUse'] = use_in_dont_use
        out.append(entry)
        i += 1
    return out


# Общие layout-свойства → в obj (симметрично Emit-Layout компилятора).
def add_layout(obj, node):
    if get_child(node, 'DefaultItem') == 'true':
        obj['defaultItem'] = True
    soi = get_child(node, 'SkipOnInput')
    if soi is not None:
        obj['skipOnInput'] = (soi == 'true')
    esd = get_child(node, 'EnableStartDrag')
    if esd is not None:
        obj['enableStartDrag'] = (esd == 'true')
    edr = get_child(node, 'EnableDrag')
    if edr is not None:
        obj['enableDrag'] = (edr == 'true')
    fdm = get_child(node, 'FileDragMode')
    if fdm:
        obj['fileDragMode'] = fdm
    # AutoMaxWidth: компилятор додумывает false для multiLine-input → захват факт. значения.
    amw_node = get_child(node, 'AutoMaxWidth')
    if amw_node == 'false':
        obj['autoMaxWidth'] = False
    elif amw_node == 'true':
        obj['autoMaxWidth'] = True
    elif get_child(node, 'MultiLine') == 'true':
        obj['autoMaxWidth'] = True
    mw = get_child(node, 'MaxWidth')
    if mw:
        obj['maxWidth'] = int(mw)
    if get_child(node, 'AutoMaxHeight') == 'false':
        obj['autoMaxHeight'] = False
    mh = get_child(node, 'MaxHeight')
    if mh:
        obj['maxHeight'] = int(mh)
    w = get_child(node, 'Width')
    if w:
        obj['width'] = int(w)
    h = get_child(node, 'Height')
    if h:
        obj['height'] = int(h)
    hs = get_child(node, 'HorizontalStretch')
    if hs is not None:
        obj['horizontalStretch'] = (hs == 'true')
    vs = get_child(node, 'VerticalStretch')
    if vs is not None:
        obj['verticalStretch'] = (vs == 'true')
    gha = get_child(node, 'GroupHorizontalAlign')
    if gha:
        obj['groupHorizontalAlign'] = gha
    gva = get_child(node, 'GroupVerticalAlign')
    if gva:
        obj['groupVerticalAlign'] = gva
    ha = get_child(node, 'HorizontalAlign')
    if ha:
        obj['horizontalAlign'] = ha
    for p in ('ShowInHeader', 'ShowInFooter', 'AutoCellHeight'):
        v = get_child(node, p)
        if v is not None:
            obj[p[0:1].lower() + p[1:]] = (v == 'true')
    fha = get_child(node, 'FooterHorizontalAlign')
    if fha:
        obj['footerHorizontalAlign'] = fha
    hha = get_child(node, 'HeaderHorizontalAlign')
    if hha:
        obj['headerHorizontalAlign'] = hha
    hdp = get_child(node, 'HeaderDataPath')
    if hdp:
        obj['headerDataPath'] = hdp
    hf_node = node.find('lf:HeaderFormat', NS)
    if hf_node is not None:
        hf = get_lang_text(hf_node)
        if hf is not None:
            obj['headerFormat'] = hf


# TitleLocation у check/radio: тега нет → ""; значение = умный дефолт → опускаем; иначе пишем.
def add_title_location(obj, node, smart_default):
    tl = get_child(node, 'TitleLocation')
    if tl is None:
        obj['titleLocation'] = ''
    elif tl != smart_default:
        obj['titleLocation'] = tl.lower()


# Разобрать <Events> элемента → упорядоченная мапа { ИмяСобытия: ИмяОбработчика }.
def get_events(node, el_name):
    ev = node.find('lf:Events', NS)
    if ev is None:
        return None
    events = OrderedDict()
    for e in ev.findall('lf:Event', NS):
        events[_attr(e, 'name')] = _text(e)
    if len(events) == 0:
        return None
    return events


# Инверсия Emit-XrFlag: role-adjustable boolean (UserVisible/View/Edit/Use).
def decompile_xr_flag(node, tag):
    el = node.find('{*}%s' % tag)
    if el is None:
        return None
    common_node = el.find('{*}Common')
    common = (common_node is not None and _text(common_node) == 'true')
    val_nodes = el.findall('{*}Value')
    if len(val_nodes) == 0:
        return common
    roles = OrderedDict()
    for v in val_nodes:
        rn = _attr(v, 'name')
        if re.match(r'^Role\.', rn):
            rn = rn[5:]
        roles[rn] = (_text(v) == 'true')
    o = OrderedDict()
    o['common'] = common
    o['roles'] = roles
    return o


# Командный интерфейс формы (<CommandInterface>): панели CommandBar + NavigationPanel.
def decompile_command_interface():
    ci_node = ROOT.find('lf:CommandInterface', NS)
    if ci_node is None:
        return None
    ci = OrderedDict()
    for panel in (('CommandBar', 'commandBar'), ('NavigationPanel', 'navigationPanel')):
        pn = ci_node.find('lf:%s' % panel[0], NS)
        if pn is None:
            continue
        items = []
        for it in pn.findall('lf:Item', NS):
            o = OrderedDict()
            cmd = get_child(it, 'Command')
            o['command'] = _ps_str(cmd)
            ty = get_child(it, 'Type')
            if ty and ty != 'Auto':
                o['type'] = ty
            at = get_child(it, 'Attribute')
            if at:
                o['attribute'] = at
            cg = get_child(it, 'CommandGroup')
            if cg:
                o['group'] = cg
            idx = get_child(it, 'Index')
            if idx is not None:
                o['index'] = int(idx)
            dv = get_child(it, 'DefaultVisible')
            if dv is not None:
                o['defaultVisible'] = (dv == 'true')
            vis = decompile_xr_flag(it, 'Visible')
            if vis is not None:
                o['visible'] = vis
            # Голый элемент (только command) → строка-shorthand; иначе объект
            if len(o) == 1:
                items.append(_ps_str(cmd))
            else:
                items.append(o)
        if len(items) > 0:
            ci[panel[1]] = items
    if len(ci) > 0:
        return ci
    return None


# <FunctionalOptions><Item>FunctionalOption.X</Item>…> → массив строк (префикс снят; GUID — как есть).
def decompile_functional_options(node):
    fo_node = node.find('lf:FunctionalOptions', NS)
    if fo_node is None:
        return None
    opts = []
    for it in fo_node.findall('lf:Item', NS):
        t = re.sub(r'^FunctionalOption\.', '', (_text(it) or '').strip())
        opts.append(t)
    if len(opts) > 0:
        return opts
    return None


# Колонка реквизита (прямая или внутри AdditionalColumns): name/type/title/functionalOptions.
def decompile_attr_column(c):
    co = OrderedDict()
    co['name'] = _attr(c, 'name')
    cty = decompile_type(c.find('lf:Type', NS))
    if cty:
        co['type'] = cty
    ct_node = c.find('lf:Title', NS)
    if ct_node is not None:
        t = get_lang_text_ws(ct_node)
        if t is not None:
            co['title'] = t
    cfc = get_child(c, 'FillCheck')
    if cfc:
        co['fillCheck'] = cfc
    cfo = decompile_functional_options(c)
    if _ps_truthy(cfo):
        co['functionalOptions'] = cfo
    cv = decompile_xr_flag(c, 'View')
    if cv is not None:
        co['view'] = cv
    ce = decompile_xr_flag(c, 'Edit')
    if ce is not None:
        co['edit'] = ce
    return co


# Картинка-ссылка с прозрачностью (HeaderPicture/FooterPicture/…). Дефолт loadTransparent=false.
def get_picture_ref(node, pic_tag):
    ref = node.find('lf:%s/xr:Ref' % pic_tag, NS)
    abs_ = node.find('lf:%s/xr:Abs' % pic_tag, NS)
    if ref is None and abs_ is None:
        return None
    src = _text(ref) if ref is not None else 'abs:%s' % _text(abs_)
    lt = node.find('lf:%s/xr:LoadTransparent' % pic_tag, NS)
    lt_true = (lt is not None and _text(lt) == 'true')
    tpx = node.find('lf:%s/xr:TransparentPixel' % pic_tag, NS)
    if not lt_true and tpx is None:
        return src
    o = OrderedDict([('src', src)])
    if lt_true:
        o['loadTransparent'] = True
    if tpx is not None:
        o['transparentPixel'] = OrderedDict([('x', int(_attr(tpx, 'x'))), ('y', int(_attr(tpx, 'y')))])
    return o


# <Picture> кнопки/попапа/команды. Дефолт LoadTransparent=true (обратная конвенция).
def set_command_picture(obj, node):
    ref = node.find('lf:Picture/xr:Ref', NS)
    abs_ = node.find('lf:Picture/xr:Abs', NS)
    if ref is None and abs_ is None:
        return
    src = _text(ref) if ref is not None else 'abs:%s' % _text(abs_)
    lt = node.find('lf:Picture/xr:LoadTransparent', NS)
    lt_false = (lt is not None and _text(lt) == 'false')
    tpx = node.find('lf:Picture/xr:TransparentPixel', NS)
    if tpx is not None:
        o = OrderedDict([('src', src)])
        if lt_false:
            o['loadTransparent'] = False
        o['transparentPixel'] = OrderedDict([('x', int(_attr(tpx, 'x'))), ('y', int(_attr(tpx, 'y')))])
        obj['picture'] = o
    else:
        obj['picture'] = src
        if lt_false:
            obj['loadTransparent'] = False


# Шрифт <Font ...> → строка-ref (если только ref+kind=StyleItem) или объект-атрибуты.
def build_font_value(f):
    present = []
    for a in ('ref', 'faceName', 'height', 'bold', 'italic', 'underline', 'strikeout', 'kind', 'scale'):
        if a in f.attrib:
            present.append(a)
    if len(present) == 2 and ('ref' in present) and _attr(f, 'kind') == 'StyleItem':
        return _attr(f, 'ref')
    o = OrderedDict()
    for k in present:
        v = _attr(f, k)
        if k in ('height', 'scale') and re.match(r'^-?\d+$', v):
            o[k] = int(v)
        elif k in ('bold', 'italic', 'underline', 'strikeout'):
            o[k] = (v == 'true')
        else:
            o[k] = v
    return o


# Граница <Border> → строка-ref (из стиля) или объект {width, style}.
def build_border_value(b):
    if 'ref' in b.attrib:
        return _attr(b, 'ref')
    o = OrderedDict()
    if 'width' in b.attrib:
        w = _attr(b, 'width')
        o['width'] = int(w) if re.match(r'^-?\d+$', w) else w
    st = b.find('v8ui:style', NS)
    if st is not None:
        o['style'] = _text(st)
    return o


# Порядок ключей цвета = .NET Hashtable enumeration (захвачено из PS 5.1), НЕ порядок литерала.
COLOR_MAP = [
    ('BackColor', 'backColor'),
    ('TitleBackColor', 'titleBackColor'),
    ('TitleTextColor', 'titleTextColor'),
    ('TextColor', 'textColor'),
    ('FooterTextColor', 'footerTextColor'),
    ('FooterBackColor', 'footerBackColor'),
    ('BorderColor', 'borderColor'),
]


# Оформление элемента (цвета/шрифты/граница) → canonical DSL-ключи. Цвет — verbatim-строка.
def add_appearance(obj, node):
    for tag, key in COLOR_MAP:
        c = node.find('lf:%s' % tag, NS)
        if c is not None:
            obj[key] = _text(c)
    for pair in (('Font', 'font'), ('TitleFont', 'titleFont'), ('FooterFont', 'footerFont')):
        f = node.find('lf:%s' % pair[0], NS)
        if f is not None:
            obj[pair[1]] = build_font_value(f)
    b = node.find('lf:Border', NS)
    if b is not None:
        obj['border'] = build_border_value(b)


def add_common_props(obj, node, el_name):
    add_appearance(obj, node)
    if get_child(node, 'Visible') == 'false':
        obj['hidden'] = True
    if get_child(node, 'Enabled') == 'false':
        obj['disabled'] = True
    if get_child(node, 'ReadOnly') == 'true':
        obj['readOnly'] = True
    uv = decompile_xr_flag(node, 'UserVisible')
    if uv is not None:
        obj['userVisible'] = uv
    title_node = node.find('lf:Title', NS)
    if title_node is not None:
        t = get_lang_text_ws(title_node)
        if t is not None:
            obj['title'] = t
    tt_node = node.find('lf:ToolTip', NS)
    if tt_node is not None:
        tt = get_lang_text_ws(tt_node)
        if tt is not None:
            obj['tooltip'] = tt
    ttr = get_child(node, 'ToolTipRepresentation')
    if ttr:
        obj['tooltipRepresentation'] = ttr
    hp = get_picture_ref(node, 'HeaderPicture')
    if hp is not None:
        obj['headerPicture'] = hp
    fp = get_picture_ref(node, 'FooterPicture')
    if fp is not None:
        obj['footerPicture'] = fp
    ev = get_events(node, el_name)
    if ev:
        obj['events'] = ev
    # CommandSet — список отключённых команд редактора (только <ExcludedCommand>).
    cs_node = node.find('lf:CommandSet', NS)
    if cs_node is not None:
        exc = []
        for ec in cs_node.findall('lf:ExcludedCommand', NS):
            exc.append(_text(ec))
        if len(exc) > 0:
            obj['excludedCommands'] = exc


# --- 3. Type decompile (inverse of Emit-Type) ---
def decompile_type(type_node):
    if type_node is None:
        return None
    parts = []
    for vt in type_node.findall('v8:Type', NS):
        raw = (_text(vt) or '').strip()
        short = raw
        # break-эквивалент: ветви взаимоисключающи (общий v8|v8ui не перетирает специфичные).
        if re.match(r'^xs:string$', raw):
            ln = type_node.find('v8:StringQualifiers/v8:Length', NS)
            al = type_node.find('v8:StringQualifiers/v8:AllowedLength', NS)
            fixed = (al is not None and _text(al) == 'Fixed')   # Variable = дефолт; Fixed — явно
            if ln is not None and int(_text(ln)) > 0:
                short = ('string(%s,fixed)' % _text(ln)) if fixed else ('string(%s)' % _text(ln))
            else:
                short = 'string'   # Length=0 → всегда Variable (корпус)
        elif re.match(r'^xs:decimal$', raw):
            d = type_node.find('v8:NumberQualifiers/v8:Digits', NS)
            f = type_node.find('v8:NumberQualifiers/v8:FractionDigits', NS)
            sgn = type_node.find('v8:NumberQualifiers/v8:AllowedSign', NS)
            dd = _text(d) if d is not None else '0'
            ff = _text(f) if f is not None else '0'
            if sgn is not None and _text(sgn) == 'Nonnegative':
                short = 'decimal(%s,%s,nonneg)' % (dd, ff)
            else:
                short = 'decimal(%s,%s)' % (dd, ff)
        elif re.match(r'^xs:boolean$', raw):
            short = 'boolean'
        elif re.match(r'^xs:dateTime$', raw):
            df = type_node.find('v8:DateQualifiers/v8:DateFractions', NS)
            dfv = _text(df) if df is not None else 'DateTime'
            if dfv == 'Date':
                short = 'date'
            elif dfv == 'Time':
                short = 'time'
            else:
                short = 'dateTime'
        else:
            m_cfg = re.match(r'^cfg:(.+)$', raw)
            if m_cfg:
                short = m_cfg.group(1)
            elif re.match(r'^(v8|v8ui):', raw):
                # Платформенный тип: friendly-шорткат если есть, иначе verbatim (не теряем v8:UUID и т.п.).
                rev = {
                    'v8:ValueTable': 'ValueTable', 'v8:ValueTree': 'ValueTree', 'v8:ValueListType': 'ValueList',
                    'v8:TypeDescription': 'TypeDescription', 'v8:Universal': 'Universal',
                    'v8:FixedArray': 'FixedArray', 'v8:FixedStructure': 'FixedStructure',
                    'v8ui:FormattedString': 'FormattedString', 'v8ui:Picture': 'Picture', 'v8ui:Color': 'Color', 'v8ui:Font': 'Font',
                }
                short = rev.get(raw, raw)
            else:
                short = raw
        parts.append(short)
    # TypeSet (набор типов): префикс cfg:/v8: снимаем — обратный роутинг в компиляторе по форме токена.
    for ts in type_node.findall('v8:TypeSet', NS):
        raw = (_text(ts) or '').strip()
        short = re.sub(r'^(v8ui|v8|cfg):', '', raw)
        parts.append(short)
    # TypeId — тип по глобальному стабильному GUID → маркер 'typeid:GUID' (компилятор разворачивает).
    for ti in type_node.findall('v8:TypeId', NS):
        parts.append('typeid:' + (_text(ti) or '').strip())
    if len(parts) == 0:
        return None
    if len(parts) == 1:
        return parts[0]
    return ' | '.join(parts)


# Ограничения использования (useRestriction/attributeUseRestriction) → объект {field?,condition?,group?,order?}.
def build_restrict_obj(node):
    r = OrderedDict()
    for k in ('field', 'condition', 'group', 'order'):
        if get_child(node, k) == 'true':
            r[k] = True
    return r


# Вычисляемое поле DataSet динамического списка (<CalculatedField>) → объектная модель.
def build_calc_field(cf_node):
    o = OrderedDict()
    o['dataPath'] = get_child(cf_node, 'dataPath')
    o['expression'] = get_child(cf_node, 'expression')
    tn = cf_node.find('dcssch:title', NS)
    if tn is not None:
        t = get_lang_text(tn)
        if t is not None:
            o['title'] = t
    vt = cf_node.find('dcssch:valueType', NS)
    if vt is not None:
        v = decompile_type(vt)
        if v:
            o['valueType'] = v
    ur = cf_node.find('dcssch:useRestriction', NS)
    if ur is not None:
        r = OrderedDict()
        for k in ('field', 'condition', 'group', 'order'):
            if get_child(ur, k) == 'true':
                r[k] = True
        if len(r) > 0:
            o['useRestriction'] = r
    pe = get_child(cf_node, 'presentationExpression')
    if pe is not None and pe != '':
        o['presentationExpression'] = pe
    oe_nodes = cf_node.findall('dcssch:orderExpression', NS)
    if len(oe_nodes) > 0:
        oes = []
        for oen in oe_nodes:
            eo = OrderedDict()
            expr_n = oen.find('{*}expression')
            ot_n = oen.find('{*}orderType')
            ao_n = oen.find('{*}autoOrder')
            eo['expression'] = _text(expr_n) if expr_n is not None else ''
            if ot_n is not None and _text(ot_n) != 'Asc':
                eo['orderType'] = _text(ot_n)
            if ao_n is not None and _text(ao_n) == 'true':
                eo['autoOrder'] = True
            oes.append(eo)
        o['orderExpression'] = oes
    return o


# Параметры выбора входа дин-списка (<inputParameters>) → массив. Зеркало Emit-DLInputParameters.
def build_dl_input_parameters(ip_node):
    items = []
    for it in ip_node.findall('dcscor:item', NS):
        io = OrderedDict()
        io['parameter'] = get_child(it, 'parameter')
        use_n = it.find('dcscor:use', NS)
        if use_n is not None and _text(use_n) == 'false':
            io['use'] = False
        val_n = it.find('dcscor:value', NS)
        if val_n is not None:
            vt = _attr(val_n, 'type', NS_XSI)
            if re.search(r'ChoiceParameters$', vt):
                cps = []
                for cpi in val_n.findall('dcscor:item', NS):
                    cpo = OrderedDict()
                    cpo['name'] = get_child(cpi, 'choiceParameter')
                    vals = []
                    for cv in cpi.findall('dcscor:value', NS):
                        vals.append(convert_typed_value(_text(cv), _attr(cv, 'type', NS_XSI)))
                    cpo['values'] = vals
                    cps.append(cpo)
                io['choiceParameters'] = cps
            elif re.search(r'ChoiceParameterLinks$', vt):
                cpls = []
                for cpi in val_n.findall('dcscor:item', NS):
                    cpo = OrderedDict()
                    cpo['name'] = get_child(cpi, 'choiceParameter')
                    cpo['value'] = get_child(cpi, 'value')
                    md = get_child(cpi, 'mode')
                    if md and md != 'Auto':
                        cpo['mode'] = md
                    cpls.append(cpo)
                io['choiceParameterLinks'] = cpls
            elif re.search(r'TypeLink$', vt):
                # Связь по типу: field + linkItem — структурное значение, НЕ склеивать InnerText в строку.
                tlo = OrderedDict()
                tlf = get_child(val_n, 'field')
                if tlf is not None:
                    tlo['field'] = tlf
                tli = get_child(val_n, 'linkItem')
                if tli is not None:
                    tlo['linkItem'] = int(tli) if re.match(r'^-?\d+$', tli) else tli
                io['typeLink'] = tlo
            else:
                if _attr(val_n, 'nil', NS_XSI) != 'true':
                    io['value'] = convert_typed_value(_text(val_n), vt)
        items.append(io)
    return items


# dcsset:dataParameters → массив (shorthand "Имя @off" / объект для типизированного значения).
def build_form_data_parameters(dp_node):
    entries = []
    for it in dp_node.findall('dcscor:item', NS):
        pn = get_text(it, 'dcscor:parameter')
        use = get_text(it, 'dcscor:use')
        val_nodes = it.findall('dcscor:value', NS)
        val_node = val_nodes[0] if len(val_nodes) >= 1 else None
        usid_n = it.find('dcsset:userSettingID', NS)
        vm_n = it.find('dcsset:viewMode', NS)
        usp_n = it.find('dcsset:userSettingPresentation', NS)
        if val_node is not None or usid_n is not None or vm_n is not None or usp_n is not None:
            obj = OrderedDict([('parameter', pn)])
            if len(val_nodes) > 1:
                # Список значений параметра (valueListAllowed) — все <dcscor:value> массивом
                obj['value'] = [_text(v) for v in val_nodes]
                vt0 = _attr(val_nodes[0], 'type', NS_XSI)
                if vt0:
                    obj['valueType'] = vt0
            elif val_node is not None:
                if _attr(val_node, 'nil', NS_XSI) == 'true':
                    obj['nilValue'] = True
                else:
                    v_type = _attr(val_node, 'type', NS_XSI)
                    v_val = _text(val_node)
                    if re.search(r'decimal$', v_type) and re.match(r'^-?\d+$', v_val):
                        obj['value'] = int(v_val)
                    elif re.search(r'boolean$', v_type):
                        obj['value'] = (v_val == 'true')
                    else:
                        obj['value'] = v_val
                    if v_type:
                        obj['valueType'] = v_type
            if use == 'false':
                obj['use'] = False
            if usid_n is not None:
                obj['userSettingID'] = 'auto'
            if vm_n is not None:
                obj['viewMode'] = _text(vm_n)
            if usp_n is not None:
                usp = get_pres_text(usp_n)
                if usp is not None:
                    obj['userSettingPresentation'] = usp
            entries.append(obj)
        else:
            s = pn
            if use == 'false':
                s += ' @off'
            entries.append(s)
    return entries


def build_dl_parameter(p_node):
    name = get_child(p_node, 'name')
    o = OrderedDict()
    o['name'] = name
    # title — опускаем, если совпадает с авто-выводом из имени (ru-only)
    title_node = p_node.find('dcssch:title', NS)
    if title_node is not None:
        t = get_lang_text(title_node)
        if t is not None:
            auto = title_from_name(name)
            if not (isinstance(t, str) and _ps_ieq(t, auto)):
                o['title'] = t
    vt_node = p_node.find('dcssch:valueType', NS)
    type_val = None
    if vt_node is not None:
        type_val = decompile_type(vt_node)
        if type_val:
            o['type'] = type_val
    # value — опускаем nil (дефолт), КРОМЕ valueListAllowed+nil (явный маркер value:null).
    v_nodes = p_node.findall('dcssch:value', NS)
    if len(v_nodes) > 1:
        o['value'] = [convert_typed_value(_text(v), _attr(v, 'type', NS_XSI)) for v in v_nodes]
    elif len(v_nodes) == 1:
        v_node = v_nodes[0]
        if _attr(v_node, 'nil', NS_XSI) != 'true':
            o['value'] = convert_typed_value(_text(v_node), _attr(v_node, 'type', NS_XSI))
        elif get_child(p_node, 'valueListAllowed') == 'true':
            o['value'] = None
    if get_child(p_node, 'useRestriction') == 'false':
        o['useRestriction'] = False
    expr = get_child(p_node, 'expression')
    if expr is not None and expr != '':
        o['expression'] = expr
    av_nodes = p_node.findall('dcssch:availableValue', NS)
    if len(av_nodes) > 0:
        avs = []
        for avn in av_nodes:
            avo = OrderedDict()
            avv = avn.find('dcssch:value', NS)
            if avv is not None and _attr(avv, 'nil', NS_XSI) != 'true':
                avo['value'] = convert_typed_value(_text(avv), _attr(avv, 'type', NS_XSI))
            else:
                avo['value'] = None
            avp = avn.find('dcssch:presentation', NS)
            if avp is not None:
                pres = get_lang_text(avp)
                if pres is not None:
                    avo['presentation'] = pres
            avs.append(avo)
        o['availableValues'] = avs
    if get_child(p_node, 'valueListAllowed') == 'true':
        o['valueListAllowed'] = True
    if get_child(p_node, 'availableAsField') == 'false':
        o['availableAsField'] = False
    ip_node = p_node.find('dcssch:inputParameters', NS)
    if ip_node is not None:
        ip = build_dl_input_parameters(ip_node)
        if len(ip) > 0:
            o['inputParameters'] = ip[0] if len(ip) == 1 else ip   # PS unwrap @() одноэлементного → объект
    if get_child(p_node, 'denyIncompleteValues') == 'true':
        o['denyIncompleteValues'] = True
    use = get_child(p_node, 'use')
    if use is not None and use != '':
        o['use'] = use

    # Компактизация: {name} → "name"; {name, type} → "name: type"; иначе объект.
    keys = list(o.keys())
    if len(keys) == 1:
        return name
    if len(keys) == 2 and 'type' in o and isinstance(type_val, str):
        return '%s: %s' % (name, type_val)
    return o


# --- 4. Element dispatch ---
# Lookup-карта (диспетчер по тегу → DSL-ключ); порядок не влияет на вывод.
ELEMENT_KEY = {
    'UsualGroup': 'group', 'ColumnGroup': 'columnGroup', 'ButtonGroup': 'buttonGroup', 'InputField': 'input', 'CheckBoxField': 'check',
    'RadioButtonField': 'radio', 'LabelDecoration': 'label', 'LabelField': 'labelField',
    'PictureDecoration': 'picture', 'PictureField': 'picField', 'CalendarField': 'calendar',
    'Table': 'table', 'Pages': 'pages', 'Page': 'page', 'Button': 'button', 'CommandBar': 'cmdBar', 'Popup': 'popup',
    'SearchStringAddition': 'searchString', 'ViewStatusAddition': 'viewStatus', 'SearchControlAddition': 'searchControl',
    'SpreadSheetDocumentField': 'spreadsheet', 'HTMLDocumentField': 'html', 'TextDocumentField': 'textDoc',
    'FormattedDocumentField': 'formattedDoc', 'ProgressBarField': 'progressBar', 'TrackBarField': 'trackBar',
    'ChartField': 'chart', 'GraphicalSchemaField': 'graphicalSchema', 'PlannerField': 'planner',
    'PeriodField': 'periodField', 'DendrogramField': 'dendrogram', 'GanttChartField': 'ganttChart',
}

# Простые скаляры элемента (pass-through, зеркало $script:genericScalars). ПОРЯДОК значим (ordered).
GENERIC_SCALARS = [
    ('VerticalAlign', 'verticalAlign', 'value'),
    ('ThroughAlign', 'throughAlign', 'value'),
    ('EnableContentChange', 'enableContentChange', 'bool'),
    ('PictureSize', 'pictureSize', 'value'),
    ('TitleHeight', 'titleHeight', 'value'),
    ('ChildItemsWidth', 'childItemsWidth', 'value'),
    ('ShowLeftMargin', 'showLeftMargin', 'bool'),
    ('CellHyperlink', 'cellHyperlink', 'bool'),
    ('ViewMode', 'viewMode', 'value'),
    ('VerticalScrollBar', 'verticalScrollBar', 'value'),
    ('RowInputMode', 'rowInputMode', 'value'),
    ('Mask', 'mask', 'value'),
    ('CreateButton', 'createButton', 'bool'),
    ('FixingInTable', 'fixingInTable', 'value'),
    ('VerticalSpacing', 'verticalSpacing', 'value'),
    ('HorizontalScrollBar', 'horizontalScrollBar', 'value'),
    ('ViewScalingMode', 'viewScalingMode', 'value'),
    ('Output', 'output', 'value'),
    ('SelectionShowMode', 'selectionShowMode', 'value'),
    ('PointerType', 'pointerType', 'value'),
    ('DrawingSelectionShowMode', 'drawingSelectionShowMode', 'value'),
    ('WarningOnEditRepresentation', 'warningOnEditRepresentation', 'value'),
    ('MarkingAppearance', 'markingAppearance', 'value'),
    ('Protection', 'protection', 'bool'),
    ('Edit', 'edit', 'bool'),
    ('ShowGrid', 'showGrid', 'bool'),
    ('ShowGroups', 'showGroups', 'bool'),
    ('ShowHeaders', 'showHeaders', 'bool'),
    ('ShowRowAndColumnNames', 'showRowAndColumnNames', 'bool'),
    ('ShowCellNames', 'showCellNames', 'bool'),
    ('ShowPercent', 'showPercent', 'bool'),
    ('HorizontalSpacing', 'horizontalSpacing', 'value'),
    ('RepresentationInContextMenu', 'representationInContextMenu', 'value'),
    ('SettingsNamedItemDetailedRepresentation', 'settingsNamedItemDetailedRepresentation', 'bool'),
    ('ItemHeight', 'itemHeight', 'value'),
    ('DropListWidth', 'dropListWidth', 'value'),
    ('TitleDataPath', 'titleDataPath', 'value'),
    ('ExtendedEdit', 'extendedEdit', 'bool'),
    ('MaxRowsCount', 'maxRowsCount', 'value'),
    ('AutoMaxRowsCount', 'autoMaxRowsCount', 'bool'),
    ('HeightControlVariant', 'heightControlVariant', 'value'),
    ('EditTextUpdate', 'editTextUpdate', 'value'),
    ('ControlRepresentation', 'controlRepresentation', 'value'),
    ('ShapeRepresentation', 'shapeRepresentation', 'value'),
    ('AutoAddIncomplete', 'autoAddIncomplete', 'bool'),
    ('MarkNegatives', 'markNegatives', 'bool'),
    ('InitialListView', 'initialListView', 'value'),
    ('ChoiceListHeight', 'choiceListHeight', 'value'),
    ('ThreeState', 'threeState', 'bool'),
    ('ScrollOnCompress', 'scrollOnCompress', 'bool'),
    ('Shortcut', 'shortcut', 'value'),
    ('IncompleteChoiceMode', 'incompleteChoiceMode', 'value'),
    ('EqualColumnsWidth', 'equalColumnsWidth', 'bool'),
    ('ChildrenAlign', 'childrenAlign', 'value'),
    ('ImageScale', 'imageScale', 'value'),
    ('Zoomable', 'zoomable', 'bool'),
    ('Shape', 'shape', 'value'),
    ('PictureLocation', 'pictureLocation', 'value'),
    ('EqualItemsWidth', 'equalItemsWidth', 'bool'),
    ('ItemTitleHeight', 'itemTitleHeight', 'value'),
    ('SpecialTextInputMode', 'specialTextInputMode', 'value'),
    ('ItemWidth', 'itemWidth', 'value'),
    ('ShowCheckBoxesInDropList', 'showCheckBoxesInDropList', 'bool'),
    ('MultipleValueDataPath', 'multipleValueDataPath', 'value'),
    ('MultipleValuePresentDataPath', 'multipleValuePresentDataPath', 'value'),
    ('AutoShowOpenButtonMode', 'autoShowOpenButtonMode', 'value'),
    ('AutoShowClearButtonMode', 'autoShowClearButtonMode', 'value'),
    ('MultipleValuesTextColor', 'multipleValuesTextColor', 'value'),
    ('MultipleValuesBackColor', 'multipleValuesBackColor', 'value'),
    ('MultipleValuePictureShape', 'multipleValuePictureShape', 'value'),
    ('MultipleValuePictureDataPath', 'multipleValuePictureDataPath', 'value'),
    ('AutoCorrectionOnTextInput', 'autoCorrectionOnTextInput', 'value'),
    ('SpellCheckingOnTextInput', 'spellCheckingOnTextInput', 'value'),
    ('CommandUniqueness', 'commandUniqueness', 'bool'),
    ('AllowInputEmptyMultipleValues', 'allowInputEmptyMultipleValues', 'bool'),
    ('BehaviorOnHorizontalCompression', 'behaviorOnHorizontalCompression', 'value'),
]


# Захват generic-скаляров. Специфичная обработка (если ключ уже задан) — побеждает.
def add_generic_scalars(obj, node):
    for tag, key, kind in GENERIC_SCALARS:
        if key in obj:
            continue
        v = get_child(node, tag)
        if v is None:
            continue
        if kind == 'bool':
            obj[key] = (v == 'true')
        else:
            obj[key] = v


def decompile_children(parent_node, child_container='ChildItems'):
    container = parent_node.find('lf:%s' % child_container, NS)
    if container is None:
        return None
    lst = []
    for child in list(container):
        if _local_name(child.tag) in COMPANION_TAGS:
            continue
        el = decompile_element(child)
        if el is not None:
            lst.append(el)
    if len(lst) == 0:
        return None
    return lst


# Инверсия Emit-CompanionPanel: companion-панель (ContextMenu/AutoCommandBar) с контентом → объект либо None.
def decompile_companion_panel(node, tag, is_dyn_list_table=False):
    p = node.find('lf:%s' % tag, NS)
    if p is None:
        return None
    autofill_raw = get_child(p, 'Autofill')
    halign = get_child(p, 'HorizontalAlign')
    kids = decompile_children(p)
    has_kids = kids is not None and len(kids) > 0
    if is_dyn_list_table and tag == 'AutoCommandBar' and not has_kids and not halign:
        if autofill_raw == 'false':
            return None                                  # = дефолт эвристики → молчим
        return OrderedDict([('autofill', True)])         # голая панель → отклонение
    if not has_kids and autofill_raw is None and not halign:
        return None
    o = OrderedDict()
    if halign:
        o['horizontalAlign'] = halign
    if autofill_raw == 'false':
        o['autofill'] = False
    elif autofill_raw == 'true':
        o['autofill'] = True
    if has_kids:
        o['children'] = kids
    return o


# Инверсия Emit-ChoiceList: <ChoiceList><xr:Item>… → [ { value, presentation? } ] либо None.
def decompile_choice_list(node):
    cl = node.find('lf:ChoiceList', NS)
    if cl is None:
        return None
    items = []
    for it in cl.findall('xr:Item', NS):
        val_node = it.find('xr:Value/lf:Value', NS)
        pres_node = it.find('xr:Value/lf:Presentation', NS)
        ci = OrderedDict()
        if val_node is not None:
            if _attr(val_node, 'nil', NS_XSI) == 'true':
                # nil-значение — компилятор эмитит <Value xsi:nil="true"/>.
                ci['valueType'] = 'nil'
            else:
                xsi_type = _attr(val_node, 'type', NS_XSI)
                ci['value'] = convert_typed_value(_text(val_node), xsi_type)
                # Не-примитивный тип / raw-ссылка по GUID → сохраняем valueType.
                if xsi_type and not re.match(r'^xs:(string|decimal|boolean|dateTime)$', xsi_type) and \
                        (xsi_type != 'xr:DesignTimeRef' or re.match(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-', _text(val_node) or '')):
                    ci['valueType'] = xsi_type
        # Presentation: непустой → текст; пустой <Presentation/> → "" суппресс-маркер.
        if pres_node is not None:
            p = get_lang_text_ws(pres_node)
            if p is not None and p != '':
                ci['presentation'] = p
            else:
                ci['presentation'] = ''
        items.append(ci)
    if len(items) > 0:
        return items
    return None


# Значение Параметра выбора (<lf:Value>): скаляр или v8:FixedArray → массив скаляров.
def convert_choice_param_value(val_node):
    vt = _attr(val_node, 'type', NS_XSI)
    if re.search(r'FixedArray$', vt):
        arr = []
        for it in val_node.findall('v8:Value', NS):
            inner = it.find('lf:Value', NS)
            if inner is not None:
                arr.append(convert_typed_value(_text(inner), _attr(inner, 'type', NS_XSI)))
        return arr
    return convert_typed_value(_text(val_node), vt)


# Инверсия Emit-ChoiceParameters: <ChoiceParameters><app:item name="X"><app:value><Value…> → [{name, value}].
def decompile_choice_parameters(node):
    cpn = node.find('lf:ChoiceParameters', NS)
    if cpn is None:
        return None
    items = []
    for it in cpn.findall('app:item', NS):
        o = OrderedDict()
        o['name'] = _attr(it, 'name')
        val_node = it.find('app:value/lf:Value', NS)
        if val_node is not None:
            o['value'] = convert_choice_param_value(val_node)
        items.append(o)
    if len(items) > 0:
        return items
    return None


# Инверсия Emit-ChoiceParameterLinks → [{name, dataPath, valueChange?}].
def decompile_choice_parameter_links(node):
    cln = node.find('lf:ChoiceParameterLinks', NS)
    if cln is None:
        return None
    items = []
    for lk in cln.findall('xr:Link', NS):
        o = OrderedDict()
        o['name'] = get_text(lk, 'xr:Name')
        o['dataPath'] = get_text(lk, 'xr:DataPath')
        vc = get_text(lk, 'xr:ValueChange')
        if vc and vc != 'Clear':
            o['valueChange'] = vc
        items.append(o)
    if len(items) > 0:
        return items
    return None


# Инверсия Emit-TypeLink: <TypeLink><xr:DataPath><xr:LinkItem> → {dataPath, linkItem}.
def decompile_type_link(node):
    tn = node.find('lf:TypeLink', NS)
    if tn is None:
        return None
    o = OrderedDict()
    o['dataPath'] = get_text(tn, 'xr:DataPath')
    li = get_text(tn, 'xr:LinkItem')
    if li is not None and li != '':
        o['linkItem'] = int(li)
    return o


# Захват <Format>/<EditFormat> (LocalStringType) → format/editFormat.
def add_format_props(obj, node):
    fmt = node.find('lf:Format', NS)
    if fmt is not None:
        t = get_lang_text(fmt)
        if t is not None and t != '':
            obj['format'] = t
    efmt = node.find('lf:EditFormat', NS)
    if efmt is not None:
        t = get_lang_text(efmt)
        if t is not None and t != '':
            obj['editFormat'] = t


# Ядро дополнения: source + Add-CommonProps + horizontalLocation. Layout добавляется отдельно.
def add_addition_core(obj, node, el_name):
    src = node.find('lf:AdditionSource/lf:Item', NS)
    if src is not None:
        obj['source'] = _text(src)
    add_common_props(obj, node, el_name)
    hl = get_child(node, 'HorizontalLocation')
    if hl:
        obj['horizontalLocation'] = hl.lower()


# Стандартные дополнения уровня таблицы → карта { тип: {отклонения} }.
def decompile_table_additions(table_node, table_name):
    tag_to_key = {'SearchStringAddition': 'searchString', 'ViewStatusAddition': 'viewStatus', 'SearchControlAddition': 'searchControl'}
    m = OrderedDict()
    for child in list(table_node):
        ln = _local_name(child.tag)
        if ln not in tag_to_key:
            continue
        key = tag_to_key[ln]
        nm = _attr(child, 'name')
        o = OrderedDict()
        o[key] = nm
        add_addition_core(o, child, nm)
        add_layout(o, child)
        del o[key]                                                          # имя авто
        if 'source' in o and o['source'] == table_name:
            del o['source']                                                 # source=таблица дефолт
        if len(o) > 0:
            m[key] = o
    if len(m) > 0:
        return m
    return None


# Спец-поля «документ/датчик» — общий скелет поля.
def decompile_simple_field(obj, node, name, key):
    obj[key] = name
    dp = get_child(node, 'DataPath')
    if dp:
        obj['path'] = dp
    add_common_props(obj, node, name)
    tl = get_child(node, 'TitleLocation')
    if tl:
        obj['titleLocation'] = tl.lower()
    em = get_child(node, 'EditMode')
    if em:
        obj['editMode'] = em


# Числовые скаляры датчиков (ProgressBar/TrackBar) — без xsi:type.
def add_gauge_scalars(obj, node, tags):
    for p in tags:
        v = get_child(node, p)
        if v is None:
            continue
        key = p[0:1].lower() + p[1:]
        if re.match(r'^-?\d+$', v):
            obj[key] = int(v)
        else:
            obj[key] = v


def decompile_element(node):
    tag = _local_name(node.tag)
    if tag not in ELEMENT_KEY:
        fail_ring3("элемент <%s>" % tag, "ChildItems/%s" % tag)
    key = ELEMENT_KEY[tag]
    name = _attr(node, 'name')
    obj = OrderedDict()

    if tag == 'UsualGroup':
        g = get_child(node, 'Group')
        gmap = {'Horizontal': 'horizontal', 'Vertical': 'vertical', 'AlwaysHorizontal': 'alwaysHorizontal', 'AlwaysVertical': 'alwaysVertical', 'HorizontalIfPossible': 'horizontalIfPossible'}
        if g and g in gmap:
            obj[key] = gmap[g]
        else:
            obj[key] = ''
        behavior = get_child(node, 'Behavior')
        if behavior:
            bmap = {'Usual': 'usual', 'Collapsible': 'collapsible', 'PopUp': 'popup'}
            obj['behavior'] = bmap[behavior] if behavior in bmap else behavior
        obj['name'] = name
        add_common_props(obj, node, name)
        rep = get_child(node, 'Representation')
        if rep:
            repmap = {'None': 'none', 'NormalSeparation': 'normal', 'WeakSeparation': 'weak', 'StrongSeparation': 'strong'}
            obj['representation'] = repmap[rep] if rep in repmap else rep
        st = get_child(node, 'ShowTitle')
        if st is not None:
            obj['showTitle'] = (st == 'true')
        cru = get_child(node, 'CurrentRowUse')
        if cru:
            obj['currentRowUse'] = cru
        crt = node.find('lf:CollapsedRepresentationTitle', NS)
        if crt is not None:
            ct = get_lang_text(crt)
            if ct is not None and ct != '':
                obj['collapsedTitle'] = ct
        if get_child(node, 'United') == 'false':
            obj['united'] = False
        if get_child(node, 'Collapsed') == 'true':
            obj['collapsed'] = True
        add_format_props(obj, node)
        kids = decompile_children(node)
        if kids:
            obj['children'] = kids
    elif tag == 'ColumnGroup':
        g = get_child(node, 'Group')
        gmap = {'Horizontal': 'horizontal', 'Vertical': 'vertical', 'InCell': 'inCell'}
        if g and g in gmap:
            obj[key] = gmap[g]
        else:
            obj[key] = ''
        obj['name'] = name
        add_common_props(obj, node, name)
        st = get_child(node, 'ShowTitle')
        if st is not None:
            obj['showTitle'] = (st == 'true')
        sih = get_child(node, 'ShowInHeader')
        if sih is not None:
            obj['showInHeader'] = to_bool(sih)
        kids = decompile_children(node)
        if kids:
            obj['children'] = kids
    elif tag == 'InputField':
        obj[key] = name
        dp = get_child(node, 'DataPath')
        if dp:
            obj['path'] = dp
        add_common_props(obj, node, name)
        ml_in = get_child(node, 'MultiLine')
        if ml_in is not None:
            obj['multiLine'] = (ml_in == 'true')
        pm_in = get_child(node, 'PasswordMode')
        if pm_in is not None:
            obj['passwordMode'] = (pm_in == 'true')
        mi = get_child(node, 'AutoMarkIncomplete')
        if mi is not None:
            obj['markIncomplete'] = (mi == 'true')
        em = get_child(node, 'EditMode')
        if em:
            obj['editMode'] = em
        tl = get_child(node, 'TitleLocation')
        if tl:
            obj['titleLocation'] = tl.lower()
        ih = node.find('lf:InputHint', NS)
        if ih is not None:
            t = get_lang_text_ws(ih)
            if t:
                obj['inputHint'] = t
        woe = node.find('lf:WarningOnEdit', NS)
        if woe is not None:
            t = get_lang_text_ws(woe)
            if t is not None:
                obj['warningOnEdit'] = t
        ftxt = node.find('lf:FooterText', NS)
        if ftxt is not None:
            t = get_lang_text_ws(ftxt)
            if t is not None:
                obj['footerText'] = t
        for p in ('ChoiceButton', 'ClearButton', 'SpinButton', 'DropListButton', 'ChoiceListButton'):
            v = get_child(node, p)
            if v is not None:
                obj[p[0:1].lower() + p[1:]] = to_bool(v)
        for p in ('Wrap', 'OpenButton', 'ListChoiceMode', 'ExtendedEditMultipleValues', 'ChooseType', 'QuickChoice', 'AutoChoiceIncomplete'):
            v = get_child(node, p)
            if v is not None:
                obj[p[0:1].lower() + p[1:]] = to_bool(v)
        for p in ('ChoiceForm', 'ChoiceHistoryOnInput', 'ChoiceFoldersAndItems', 'FooterDataPath'):
            v = get_child(node, p)
            if v is not None:
                obj[p[0:1].lower() + p[1:]] = v
        for p in ('MinValue', 'MaxValue'):
            mn = node.find('lf:%s' % p, NS)
            if mn is not None:
                xt = _attr(mn, 'type', NS_XSI)
                txt = _text(mn)
                k = p[0:1].lower() + p[1:]
                if re.search(r'decimal|int', xt):
                    if re.match(r'^-?\d+$', txt):
                        obj[k] = int(txt)
                    elif re.match(r'^-?\d+\.\d+$', txt):
                        obj[k] = Decimal(txt)
                    else:
                        obj[k] = txt
                else:
                    obj[k] = txt
        tde = get_child(node, 'TypeDomainEnabled')
        if tde is not None:
            obj['typeDomainEnabled'] = to_bool(tde)
        at_node = node.find('lf:AvailableTypes', NS)
        if at_node is not None:
            at = decompile_type(at_node)
            if at:
                obj['availableTypes'] = at
        cbr = get_child(node, 'ChoiceButtonRepresentation')
        if cbr:
            obj['choiceButtonRepresentation'] = cbr
        cbp = get_picture_ref(node, 'ChoiceButtonPicture')
        if cbp is not None:
            obj['choiceButtonPicture'] = cbp
        if get_child(node, 'TextEdit') == 'false':
            obj['textEdit'] = False
        cl = decompile_choice_list(node)
        if cl:
            obj['choiceList'] = cl
        add_format_props(obj, node)
        cp = decompile_choice_parameters(node)
        if cp:
            obj['choiceParameters'] = cp
        cpl = decompile_choice_parameter_links(node)
        if cpl:
            obj['choiceParameterLinks'] = cpl
        tlk = decompile_type_link(node)
        if tlk:
            obj['typeLink'] = tlk
    elif tag == 'CheckBoxField':
        obj[key] = name
        dp = get_child(node, 'DataPath')
        if dp:
            obj['path'] = dp
        add_common_props(obj, node, name)
        em = get_child(node, 'EditMode')
        if em:
            obj['editMode'] = em
        cbt = get_child(node, 'CheckBoxType')
        if cbt is None:
            obj['checkBoxType'] = ''
        elif cbt != 'Auto':
            obj['checkBoxType'] = cbt[0:1].lower() + cbt[1:]
        add_title_location(obj, node, 'Right')
        woe = node.find('lf:WarningOnEdit', NS)
        if woe is not None:
            t = get_lang_text_ws(woe)
            if t is not None:
                obj['warningOnEdit'] = t
        fdp = get_child(node, 'FooterDataPath')
        if fdp:
            obj['footerDataPath'] = fdp
        ftxt = node.find('lf:FooterText', NS)
        if ftxt is not None:
            t = get_lang_text_ws(ftxt)
            if t is not None:
                obj['footerText'] = t
        add_format_props(obj, node)
    elif tag == 'RadioButtonField':
        obj[key] = name
        dp = get_child(node, 'DataPath')
        if dp:
            obj['path'] = dp
        add_common_props(obj, node, name)
        add_title_location(obj, node, 'None')
        em = get_child(node, 'EditMode')
        if em:
            obj['editMode'] = em
        rbt = get_child(node, 'RadioButtonType')
        if rbt:
            obj['radioButtonType'] = rbt
        cc = get_child(node, 'ColumnsCount')
        if cc:
            obj['columnsCount'] = int(cc)
        woe = node.find('lf:WarningOnEdit', NS)
        if woe is not None:
            t = get_lang_text_ws(woe)
            if t is not None:
                obj['warningOnEdit'] = t
        cl = decompile_choice_list(node)
        if cl:
            obj['choiceList'] = cl
    elif tag == 'LabelDecoration':
        obj[key] = name
        add_common_props(obj, node, name)
        if get_child(node, 'Hyperlink') == 'true':
            obj['hyperlink'] = True
        ti_node = node.find('lf:Title', NS)
        if ti_node is not None:
            tv = get_ml_formatted_value(ti_node)
            if tv is not None:
                obj['title'] = tv
    elif tag == 'LabelField':
        obj[key] = name
        dp = get_child(node, 'DataPath')
        if dp:
            obj['path'] = dp
        add_common_props(obj, node, name)
        tl = get_child(node, 'TitleLocation')
        if tl:
            obj['titleLocation'] = tl.lower()
        em = get_child(node, 'EditMode')
        if em:
            obj['editMode'] = em
        if get_child(node, 'Hiperlink') == 'true':   # тег <Hiperlink> (опечатка платформы)
            obj['hyperlink'] = True
        pm = get_child(node, 'PasswordMode')
        if pm is not None:
            obj['passwordMode'] = (pm == 'true')
        woe = node.find('lf:WarningOnEdit', NS)
        if woe is not None:
            t = get_lang_text_ws(woe)
            if t is not None:
                obj['warningOnEdit'] = t
        fdp = get_child(node, 'FooterDataPath')
        if fdp:
            obj['footerDataPath'] = fdp
        ftxt = node.find('lf:FooterText', NS)
        if ftxt is not None:
            t = get_lang_text_ws(ftxt)
            if t is not None:
                obj['footerText'] = t
        add_format_props(obj, node)
    elif tag == 'PictureDecoration':
        obj[key] = name
        add_common_props(obj, node, name)
        ti_node = node.find('lf:Title', NS)
        if ti_node is not None:
            tv = get_ml_formatted_value(ti_node)
            if tv is not None:
                obj['title'] = tv
        npt = node.find('lf:NonselectedPictureText', NS)
        if npt is not None:
            t = get_lang_text_ws(npt)
            if t is not None:
                obj['nonselectedPictureText'] = t
        ref = node.find('lf:Picture/xr:Ref', NS)
        abs_ = node.find('lf:Picture/xr:Abs', NS)
        if ref is not None:
            obj['src'] = _text(ref)
        elif abs_ is not None:
            obj['src'] = 'abs:%s' % _text(abs_)
        lt = node.find('lf:Picture/xr:LoadTransparent', NS)
        if lt is not None and _text(lt) == 'true':
            obj['loadTransparent'] = True
        tpx = node.find('lf:Picture/xr:TransparentPixel', NS)
        if tpx is not None:
            obj['transparentPixel'] = OrderedDict([('x', int(_attr(tpx, 'x'))), ('y', int(_attr(tpx, 'y')))])
        if get_child(node, 'Hyperlink') == 'true':
            obj['hyperlink'] = True
    elif tag == 'PictureField':
        obj[key] = name
        dp = get_child(node, 'DataPath')
        if dp:
            obj['path'] = dp
        add_common_props(obj, node, name)
        em = get_child(node, 'EditMode')
        if em:
            obj['editMode'] = em
        tl = get_child(node, 'TitleLocation')
        if tl:
            obj['titleLocation'] = tl.lower()
        if get_child(node, 'Hyperlink') == 'true':
            obj['hyperlink'] = True
        vp = get_picture_ref(node, 'ValuesPicture')
        if vp is not None:
            obj['valuesPicture'] = vp
        npt = node.find('lf:NonselectedPictureText', NS)
        if npt is not None:
            t = get_lang_text_ws(npt)
            if t is not None:
                obj['nonselectedPictureText'] = t
        fdp = get_child(node, 'FooterDataPath')
        if fdp:
            obj['footerDataPath'] = fdp
        ftxt = node.find('lf:FooterText', NS)
        if ftxt is not None:
            t = get_lang_text_ws(ftxt)
            if t is not None:
                obj['footerText'] = t
    elif tag == 'CalendarField':
        obj[key] = name
        dp = get_child(node, 'DataPath')
        if dp:
            obj['path'] = dp
        add_common_props(obj, node, name)
        tl = get_child(node, 'TitleLocation')
        if tl:
            obj['titleLocation'] = tl.lower()
        sm = get_child(node, 'SelectionMode')
        if sm:
            obj['selectionMode'] = sm
        scd = get_child(node, 'ShowCurrentDate')
        if scd is not None:
            obj['showCurrentDate'] = (scd == 'true')
        wim = get_child(node, 'WidthInMonths')
        if wim is not None:
            obj['widthInMonths'] = int(wim)
        him = get_child(node, 'HeightInMonths')
        if him is not None:
            obj['heightInMonths'] = int(him)
        smp = get_child(node, 'ShowMonthsPanel')
        if smp is not None:
            obj['showMonthsPanel'] = (smp == 'true')
    elif tag == 'Table':
        obj[key] = name
        dp = get_child(node, 'DataPath')
        if dp:
            obj['path'] = dp
        add_common_props(obj, node, name)
        tl = get_child(node, 'TitleLocation')
        if tl:
            obj['titleLocation'] = tl.lower()
        rep = get_child(node, 'Representation')
        if rep:
            obj['representation'] = rep
        crs = get_child(node, 'ChangeRowSet')
        if crs is not None:
            obj['changeRowSet'] = (crs == 'true')
        cro = get_child(node, 'ChangeRowOrder')
        if cro is not None:
            obj['changeRowOrder'] = (cro == 'true')
        if get_child(node, 'AutoInsertNewRow') == 'true':
            obj['autoInsertNewRow'] = True
        if node.find('lf:RowFilter', NS) is not None:
            obj['rowFilter'] = None
        if get_child(node, 'Header') == 'false':
            obj['header'] = False
        if get_child(node, 'Footer') == 'true':
            obj['footer'] = True
        htr = get_child(node, 'HeightInTableRows')
        if htr:
            obj['heightInTableRows'] = int(htr)
        hh = get_child(node, 'HeaderHeight')
        if hh is not None:
            obj['headerHeight'] = int(hh)
        fh = get_child(node, 'FooterHeight')
        if fh is not None:
            obj['footerHeight'] = int(fh)
        cru = get_child(node, 'CurrentRowUse')
        if cru:
            obj['currentRowUse'] = cru
        rr = get_child(node, 'RefreshRequest')
        if rr:
            obj['refreshRequest'] = rr
        # CommandBarLocation: дин-список-таблица авто-инжектит "None" → инвертируем суппресс-маркером.
        cbl = get_child(node, 'CommandBarLocation')
        if has_child(node, 'UpdateOnDataChange'):
            if cbl is None:
                obj['commandBarLocation'] = ''
            elif cbl != 'None':
                obj['commandBarLocation'] = cbl
        elif cbl:
            obj['commandBarLocation'] = cbl
        ssl = get_child(node, 'SearchStringLocation')
        if ssl:
            obj['searchStringLocation'] = ssl
        vsl = get_child(node, 'ViewStatusLocation')
        if vsl:
            obj['viewStatusLocation'] = vsl
        scl = get_child(node, 'SearchControlLocation')
        if scl:
            obj['searchControlLocation'] = scl
        if get_child(node, 'ChoiceMode') == 'true':
            obj['choiceMode'] = True
        selm = get_child(node, 'SelectionMode')
        if selm:
            obj['selectionMode'] = selm
        rsm = get_child(node, 'RowSelectionMode')
        if rsm:
            obj['rowSelectionMode'] = rsm
        if get_child(node, 'VerticalLines') == 'false':
            obj['verticalLines'] = False
        if get_child(node, 'HorizontalLines') == 'false':
            obj['horizontalLines'] = False
        if get_child(node, 'UseAlternationRowColor') == 'true':
            obj['useAlternationRowColor'] = True
        taf = get_child(node, 'Autofill')
        if taf is not None:
            obj['autofill'] = (taf == 'true')
        if get_child(node, 'MultipleChoice') == 'true':
            obj['multipleChoice'] = True
        soin = get_child(node, 'SearchOnInput')
        if soin:
            obj['searchOnInput'] = soin
        mi = get_child(node, 'AutoMarkIncomplete')
        if mi is not None:
            obj['markIncomplete'] = (mi == 'true')
        itv = get_child(node, 'InitialTreeView')
        if itv:
            obj['initialTreeView'] = itv
        rp = get_picture_ref(node, 'RowsPicture')
        if rp is not None:
            obj['rowsPicture'] = rp
        rpdp = get_child(node, 'RowPictureDataPath')
        if has_child(node, 'UpdateOnDataChange'):
            if get_child(node, 'AutoRefresh') == 'true':
                obj['autoRefresh'] = True
            arp = get_child(node, 'AutoRefreshPeriod')
            if arp and arp != '60':
                obj['autoRefreshPeriod'] = int(arp)
            cfi = get_child(node, 'ChoiceFoldersAndItems')
            if cfi and cfi != 'Items':
                obj['choiceFoldersAndItems'] = cfi
            if get_child(node, 'RestoreCurrentRow') == 'true':
                obj['restoreCurrentRow'] = True
            if get_child(node, 'ShowRoot') == 'false':
                obj['showRoot'] = False
            if get_child(node, 'AllowRootChoice') == 'true':
                obj['allowRootChoice'] = True
            uodc = get_child(node, 'UpdateOnDataChange')
            if uodc and uodc != 'Auto':
                obj['updateOnDataChange'] = uodc
            if get_child(node, 'AllowGettingCurrentRowURL') == 'false':
                obj['allowGettingCurrentRowURL'] = False
            if rpdp is None:
                obj['rowPictureDataPath'] = ''
            elif rpdp != ('%s.DefaultPicture' % _ps_str(obj.get('path'))):
                obj['rowPictureDataPath'] = rpdp
            usg = get_child(node, 'UserSettingsGroup')
            if usg:
                if re.match(r'^\d+:[0-9a-fA-F]{8}-', usg):
                    sys.stderr.write("form-decompile: UserSettingsGroup '%s' (%s) — ссылка по id, не воспроизводима, опущена\n" % (usg, name))
                else:
                    obj['userSettingsGroup'] = usg
        elif rpdp:
            obj['rowPictureDataPath'] = rpdp
        cs_node = node.find('lf:CommandSet', NS)
        if cs_node is not None:
            exc = []
            for ec in cs_node.findall('lf:ExcludedCommand', NS):
                exc.append(_text(ec))
            if len(exc) > 0:
                obj['excludedCommands'] = exc
        cols = decompile_children(node)
        if cols:
            obj['columns'] = cols
        add_map = decompile_table_additions(node, name)
        if add_map:
            obj['additions'] = add_map
    elif tag == 'Pages':
        obj[key] = name
        add_common_props(obj, node, name)
        pr = get_child(node, 'PagesRepresentation')
        if pr:
            obj['pagesRepresentation'] = pr
        cru = get_child(node, 'CurrentRowUse')
        if cru:
            obj['currentRowUse'] = cru
        kids = decompile_children(node)
        if kids:
            obj['children'] = kids
    elif tag == 'Page':
        obj[key] = name
        add_common_props(obj, node, name)
        g = get_child(node, 'Group')
        gmap = {'Horizontal': 'horizontal', 'Vertical': 'vertical', 'AlwaysHorizontal': 'alwaysHorizontal', 'AlwaysVertical': 'alwaysVertical', 'HorizontalIfPossible': 'horizontalIfPossible'}
        if g and g in gmap:
            obj['group'] = gmap[g]
        pp = get_picture_ref(node, 'Picture')
        if pp is not None:
            obj['picture'] = pp
        st = get_child(node, 'ShowTitle')
        if st is not None:
            obj['showTitle'] = (st == 'true')
        add_format_props(obj, node)
        kids = decompile_children(node)
        if kids:
            obj['children'] = kids
    elif tag == 'Button':
        obj[key] = name
        cmd = get_child(node, 'CommandName')
        if cmd:
            m1 = re.match(r'^Form\.Command\.(.+)$', cmd)
            m2 = re.match(r'^Form\.StandardCommand\.(.+)$', cmd)
            m3 = re.match(r'^Form\.Item\.(.+)\.StandardCommand\.(.+)$', cmd)
            if m1:
                obj['command'] = m1.group(1)
            elif m2:
                obj['stdCommand'] = m2.group(1)
            elif m3:
                obj['stdCommand'] = '%s.%s' % (m3.group(1), m3.group(2))
            else:
                obj['commandName'] = cmd
        dp = get_child(node, 'DataPath')
        if dp:
            obj['path'] = dp
        btn_param = node.find('lf:Parameter', NS)
        if btn_param is not None:
            pxt = _attr(btn_param, 'type', NS_XSI)
            if re.search(r'TypeDescription$', pxt):
                pt = decompile_type(btn_param)
                if pt:
                    obj['parameter'] = OrderedDict([('type', pt)])
            elif _text(btn_param):
                obj['parameter'] = _text(btn_param)
        add_common_props(obj, node, name)
        type_ = get_child(node, 'Type')
        if type_:
            tmap = {'CommandBarButton': 'commandBar', 'UsualButton': 'usual', 'Hyperlink': 'hyperlink', 'CommandBarHyperlink': 'hyperlink'}
            obj['type'] = tmap[type_] if type_ in tmap else type_
        if get_child(node, 'DefaultButton') == 'true':
            obj['defaultButton'] = True
        if get_child(node, 'Check') == 'true':
            obj['checked'] = True
        set_command_picture(obj, node)
        rep = get_child(node, 'Representation')
        if rep:
            obj['representation'] = rep
        lic = get_child(node, 'LocationInCommandBar')
        if lic:
            obj['locationInCommandBar'] = lic
    elif tag == 'ButtonGroup':
        obj[key] = name
        add_common_props(obj, node, name)
        cs = get_child(node, 'CommandSource')
        if cs:
            if re.match(r'^\d+:[0-9a-fA-F]{8}-', cs):
                sys.stderr.write("form-decompile: CommandSource '%s' (%s) — ссылка по id, не воспроизводима, опущена\n" % (cs, name))
            else:
                obj['commandSource'] = cs
        rep = get_child(node, 'Representation')
        if rep:
            obj['representation'] = rep
        kids = decompile_children(node)
        if kids:
            obj['children'] = kids
    elif tag == 'CommandBar':
        obj[key] = name
        add_common_props(obj, node, name)
        cs = get_child(node, 'CommandSource')
        if cs:
            if re.match(r'^\d+:[0-9a-fA-F]{8}-', cs):
                sys.stderr.write("form-decompile: CommandSource '%s' (%s) — ссылка по id, не воспроизводима, опущена\n" % (cs, name))
            else:
                obj['commandSource'] = cs
        hl = get_child(node, 'HorizontalLocation')
        if hl:
            obj['horizontalLocation'] = hl.lower()
        if get_child(node, 'Autofill') == 'true':
            obj['autofill'] = True
        kids = decompile_children(node)
        if kids:
            obj['children'] = kids
    elif tag == 'Popup':
        obj[key] = name
        add_common_props(obj, node, name)
        set_command_picture(obj, node)
        rep = get_child(node, 'Representation')
        if rep:
            obj['representation'] = rep
        cs = get_child(node, 'CommandSource')
        if cs:
            if re.match(r'^\d+:[0-9a-fA-F]{8}-', cs):
                sys.stderr.write("form-decompile: CommandSource '%s' (%s) — ссылка по id, не воспроизводима, опущена\n" % (cs, name))
            else:
                obj['commandSource'] = cs
        kids = decompile_children(node)
        if kids:
            obj['children'] = kids
    elif tag == 'SearchStringAddition':
        obj[key] = name
        add_addition_core(obj, node, name)
    elif tag == 'ViewStatusAddition':
        obj[key] = name
        add_addition_core(obj, node, name)
    elif tag == 'SearchControlAddition':
        obj[key] = name
        add_addition_core(obj, node, name)
    elif tag == 'SpreadSheetDocumentField':
        decompile_simple_field(obj, node, name, key)
    elif tag == 'HTMLDocumentField':
        decompile_simple_field(obj, node, name, key)
    elif tag == 'TextDocumentField':
        decompile_simple_field(obj, node, name, key)
    elif tag == 'FormattedDocumentField':
        decompile_simple_field(obj, node, name, key)
    elif tag == 'ProgressBarField':
        decompile_simple_field(obj, node, name, key)
        add_gauge_scalars(obj, node, ('MinValue', 'MaxValue'))
    elif tag == 'TrackBarField':
        decompile_simple_field(obj, node, name, key)
        add_gauge_scalars(obj, node, ('MinValue', 'MaxValue', 'LargeStep', 'MarkingStep', 'Step'))
    elif tag == 'ChartField':
        decompile_simple_field(obj, node, name, key)
    elif tag == 'GraphicalSchemaField':
        decompile_simple_field(obj, node, name, key)
    elif tag == 'PlannerField':
        decompile_simple_field(obj, node, name, key)
    elif tag == 'PeriodField':
        decompile_simple_field(obj, node, name, key)
    elif tag == 'DendrogramField':
        decompile_simple_field(obj, node, name, key)
    elif tag == 'GanttChartField':
        decompile_simple_field(obj, node, name, key)
        tbl_node = node.find('lf:Table', NS)
        if tbl_node is not None:
            obj['ganttTable'] = decompile_element(tbl_node)

    # DisplayImportance — атрибут открывающего тега, захват «как есть».
    di = _attr(node, 'DisplayImportance')
    if di:
        obj['displayImportance'] = di
    # title: "" — подавление авто-вывода компилятора там, где <Title> отсутствует.
    if 'title' not in obj:
        auto_title = False
        if tag in ('LabelDecoration', 'Page', 'Popup'):
            auto_title = True
        elif tag == 'Button':
            auto_title = not ('command' in obj or 'commandName' in obj or 'stdCommand' in obj)
        elif tag in ('InputField', 'CheckBoxField', 'RadioButtonField', 'LabelField', 'Table', 'CalendarField'):
            auto_title = 'path' not in obj
        if auto_title:
            obj['title'] = ''
    add_layout(obj, node)
    add_generic_scalars(obj, node)
    # extendedTooltip: companion <ExtendedTooltip> (LabelDecoration). Текст-форма или own-content объект.
    et_node = node.find('lf:ExtendedTooltip', NS)
    if et_node is not None:
        et_title = et_node.find('lf:Title', NS)
        text_val = get_ml_formatted_value(et_title) if et_title is not None else None
        et_obj = OrderedDict()
        add_layout(et_obj, et_node)
        add_generic_scalars(et_obj, et_node)
        add_appearance(et_obj, et_node)
        et_tt = et_node.find('lf:ToolTip', NS)
        if et_tt is not None:
            tt_val = get_lang_text(et_tt)
            if tt_val is not None:
                et_obj['tooltip'] = tt_val
        if get_child(et_node, 'Visible') == 'false':
            et_obj['hidden'] = True
        if get_child(et_node, 'Enabled') == 'false':
            et_obj['disabled'] = True
        if get_child(et_node, 'Hyperlink') == 'true':
            et_obj['hyperlink'] = True
        et_ev = get_events(et_node, name)
        if et_ev:
            et_obj['events'] = et_ev
        if len(et_obj) > 0:
            if text_val is not None:
                if isinstance(text_val, dict) and 'text' in text_val:
                    et_obj['text'] = text_val['text']
                    if text_val['formatted']:
                        et_obj['formatted'] = True
                else:
                    et_obj['text'] = text_val
                if 'formatted' not in et_obj and et_title is not None and _attr(et_title, 'formatted') == 'true':
                    et_obj['formatted'] = True
            obj['extendedTooltip'] = et_obj
        elif text_val is not None:
            obj['extendedTooltip'] = text_val
    # companion-панели с контентом: AutoCommandBar → commandBar, ContextMenu → contextMenu
    is_dyn_list_table = (tag == 'Table') and has_child(node, 'UpdateOnDataChange')
    cb = decompile_companion_panel(node, 'AutoCommandBar', is_dyn_list_table)
    if cb is not None:
        obj['commandBar'] = cb
    cm = decompile_companion_panel(node, 'ContextMenu')
    if cm is not None:
        obj['contextMenu'] = cm
    return obj


# ─────────────────────────────────────────────────────────────────────────────
# Planner design-time <Settings xsi:type="pl:Planner"> → объект planner на реквизите.
def pld_bool(v):
    if v is None:
        return None
    return v == 'true'


def pld_int(v):
    if v is None:
        return None
    return int(v)


def get_planner_value(node):
    if node is None:
        return None
    if _attr(node, 'nil', NS_XSI) == 'true':
        return None
    t = _text(node)
    if t:
        return t
    return None


def build_planner_font(node):
    if node is None:
        return None
    o = OrderedDict()
    for a in ('ref', 'faceName', 'height', 'bold', 'italic', 'underline', 'strikeout', 'kind', 'scale'):
        av = _attr(node, a)
        if av != '':
            o[a] = av
    if len(o) == 0:
        return None
    return o


def build_planner_border(node):
    if node is None:
        return None
    o = OrderedDict()
    w = _attr(node, 'width')
    if w != '':
        o['width'] = int(w)
    st = node.find('{*}style')
    if st is not None:
        o['style'] = _text(st)
    return o


def build_planner_item(itn):
    o = OrderedDict()
    val_node = itn.find('{*}value')
    if val_node is not None and _attr(val_node, 'nil', NS_XSI) != 'true' and _text(val_node):
        o['value'] = _text(val_node)
    o['text'] = get_child(itn, 'text')
    tt = get_child(itn, 'tooltip')
    if tt:
        o['tooltip'] = tt
    o['begin'] = get_child(itn, 'begin')
    o['end'] = get_child(itn, 'end')
    o['borderColor'] = get_child(itn, 'borderColor')
    o['backColor'] = get_child(itn, 'backColor')
    o['textColor'] = get_child(itn, 'textColor')
    fnt = build_planner_font(itn.find('{*}font'))
    if fnt:
        o['font'] = fnt
    o['replacementDate'] = get_child(itn, 'replacementDate')
    o['deleted'] = pld_bool(get_child(itn, 'deleted'))
    o['id'] = get_child(itn, 'id')
    o['textFormatted'] = pld_bool(get_child(itn, 'textFormatted'))
    brd = build_planner_border(itn.find('{*}border'))
    if brd:
        o['border'] = brd
    o['editMode'] = get_child(itn, 'editMode')
    return o


def build_planner_dim_element(eln):
    o = OrderedDict()
    v = get_planner_value(eln.find('{*}value'))
    if v is not None:
        o['value'] = v
    o['text'] = get_child(eln, 'text')
    o['borderColor'] = get_child(eln, 'borderColor')
    o['backColor'] = get_child(eln, 'backColor')
    o['textColor'] = get_child(eln, 'textColor')
    fnt = build_planner_font(eln.find('{*}font'))
    if fnt:
        o['font'] = fnt
    subs = []
    for s in eln.findall('{*}item'):
        subs.append(build_planner_dim_element(s))
    if len(subs) > 0:
        o['elements'] = subs
    sos = get_child(eln, 'showOnlySubordinatesAreas')
    if sos is not None:
        o['showOnlySubordinatesAreas'] = (sos == 'true')
    o['textFormatted'] = pld_bool(get_child(eln, 'textFormatted'))
    return o


def build_planner_dimension(dn):
    o = OrderedDict()
    v = get_planner_value(dn.find('{*}value'))
    if v is not None:
        o['value'] = v
    o['text'] = get_child(dn, 'text')
    o['borderColor'] = get_child(dn, 'borderColor')
    o['backColor'] = get_child(dn, 'backColor')
    o['textColor'] = get_child(dn, 'textColor')
    fnt = build_planner_font(dn.find('{*}font'))
    if fnt:
        o['font'] = fnt
    els = []
    for e in dn.findall('{*}item'):
        els.append(build_planner_dim_element(e))
    if len(els) > 0:
        o['elements'] = els
    o['textFormatted'] = pld_bool(get_child(dn, 'textFormatted'))
    return o


def build_planner_level(lvn):
    o = OrderedDict()
    o['measure'] = get_child(lvn, 'measure')
    o['interval'] = pld_int(get_child(lvn, 'interval'))
    o['show'] = pld_bool(get_child(lvn, 'show'))
    line_node = lvn.find('{*}line')
    if line_node is not None:
        ln = OrderedDict()
        w = _attr(line_node, 'width')
        if w != '':
            ln['width'] = int(w)
        g = _attr(line_node, 'gap')
        if g != '':
            ln['gap'] = (g == 'true')
        st = line_node.find('{*}style')
        if st is not None:
            ln['style'] = _text(st)
        o['line'] = ln
    o['scaleColor'] = get_child(lvn, 'scaleColor')
    o['dayFormatRule'] = get_child(lvn, 'dayFormatRule')
    fmt_node = lvn.find('{*}format')
    if fmt_node is not None:
        f = get_lang_text(fmt_node)
        if f is not None:
            o['format'] = f
    labels_node = lvn.find('{*}labels')
    if labels_node is not None:
        o['labels'] = OrderedDict([('ticks', pld_int(get_child(labels_node, 'ticks')))])
    o['backColor'] = get_child(lvn, 'backColor')
    o['textColor'] = get_child(lvn, 'textColor')
    o['showPereodicalLabels'] = pld_bool(get_child(lvn, 'showPereodicalLabels'))
    return o


def build_planner_time_scale(tsn):
    o = OrderedDict()
    o['placement'] = get_child(tsn, 'placement')
    levels = []
    for lvn in tsn.findall('{*}level'):
        levels.append(build_planner_level(lvn))
    o['levels'] = levels
    o['transparent'] = pld_bool(get_child(tsn, 'transparent'))
    o['backColor'] = get_child(tsn, 'backColor')
    o['textColor'] = get_child(tsn, 'textColor')
    o['currentLevel'] = pld_int(get_child(tsn, 'currentLevel'))
    return o


def build_planner_settings(set_node):
    pl = OrderedDict()
    item_nodes = set_node.findall('{*}item')
    if len(item_nodes) > 0:
        items = []
        for itn in item_nodes:
            items.append(build_planner_item(itn))
        pl['items'] = items
    dim_nodes = set_node.findall('{*}dimension')
    if len(dim_nodes) > 0:
        dims = []
        for dn in dim_nodes:
            dims.append(build_planner_dimension(dn))
        pl['dimensions'] = dims
    pl['borderColor'] = get_child(set_node, 'borderColor')
    pl['backColor'] = get_child(set_node, 'backColor')
    pl['textColor'] = get_child(set_node, 'textColor')
    pl['lineColor'] = get_child(set_node, 'lineColor')
    fnt = build_planner_font(set_node.find('{*}font'))
    if fnt:
        pl['font'] = fnt
    pl['beginOfRepresentationPeriod'] = get_child(set_node, 'beginOfRepresentationPeriod')
    pl['endOfRepresentationPeriod'] = get_child(set_node, 'endOfRepresentationPeriod')
    pl['alignElementsOfTimeScale'] = pld_bool(get_child(set_node, 'alignElementsOfTimeScale'))
    pl['displayTimeScaleWrapHeaders'] = pld_bool(get_child(set_node, 'displayTimeScaleWrapHeaders'))
    pl['displayWrapHeaders'] = pld_bool(get_child(set_node, 'displayWrapHeaders'))
    wf_node = set_node.find('{*}timeScaleWrapHeadersFormat')
    if wf_node is not None:
        wf = get_lang_text(wf_node)
        if wf is not None:
            pl['timeScaleWrapHeadersFormat'] = wf
    pl['periodicVariantUnit'] = get_child(set_node, 'periodicVariantUnit')
    pl['periodicVariantRepetition'] = pld_int(get_child(set_node, 'periodicVariantRepetition'))
    pl['timeScaleWrapBeginIndent'] = pld_int(get_child(set_node, 'timeScaleWrapBeginIndent'))
    pl['timeScaleWrapEndIndent'] = pld_int(get_child(set_node, 'timeScaleWrapEndIndent'))
    ts_node = set_node.find('{*}timeScale')
    if ts_node is not None:
        pl['timeScale'] = build_planner_time_scale(ts_node)
    per_node = set_node.find('{*}period')
    if per_node is not None:
        pl['period'] = OrderedDict([('begin', get_child(per_node, 'begin')), ('end', get_child(per_node, 'end'))])
    pl['displayCurrentDate'] = pld_bool(get_child(set_node, 'displayCurrentDate'))
    pl['itemsTimeRepresentation'] = get_child(set_node, 'itemsTimeRepresentation')
    pl['itemsBehaviorWhenSpaceInsufficient'] = get_child(set_node, 'itemsBehaviorWhenSpaceInsufficient')
    pl['autoMinColumnWidth'] = pld_bool(get_child(set_node, 'autoMinColumnWidth'))
    pl['autoMinRowHeight'] = pld_bool(get_child(set_node, 'autoMinRowHeight'))
    pl['minColumnWidth'] = pld_int(get_child(set_node, 'minColumnWidth'))
    pl['minRowHeight'] = pld_int(get_child(set_node, 'minRowHeight'))
    pl['fixDimensionsHeader'] = get_child(set_node, 'fixDimensionsHeader')
    pl['fixTimeScaleHeader'] = get_child(set_node, 'fixTimeScaleHeader')
    brd = build_planner_border(set_node.find('{*}border'))
    if brd:
        pl['border'] = brd
    pl['newItemsTextType'] = get_child(set_node, 'newItemsTextType')
    return pl


# ─────────────────────────────────────────────────────────────────────────────
# Chart design-time <Settings xsi:type="d4p1:Chart"> → объект chart (генерик-движок).
CHART_ML_FIELDS = {'title', 'lbFormat', 'lbpFormat', 'vsFormat', 'dtFormat', 'dataSourceDescription', 'labelFormat', 'text'}
CHART_SERIES_FIELDS = {'realSeriesData', 'realExSeriesData', 'realPointData', 'realDataItems'}
CHART_ATTR_FIELDS = {'gaugeQualityBands'}


def conv_chart_scalar(v):
    if v == 'true':
        return True
    if v == 'false':
        return False
    return v


def build_chart_node(n, name):
    # ML-поле → строка/мапа/"" (даже ru-only форсим в ML на эмите по имени)
    if name in CHART_ML_FIELDS:
        ml = get_lang_text(n)
        if ml is None:
            return ''
        return ml
    kids = list(n)
    if len(kids) == 0:
        # лист: attrs-only (шрифт/gaugeQualityBands) или текст
        attrs = [(k, v) for k, v in n.attrib.items() if k != _XSI_TYPE and k != ('{%s}nil' % NS_XSI)]
        if len(attrs) > 0:
            o = OrderedDict()
            for k, v in attrs:
                o[k] = conv_chart_scalar(v)
            return o
        return conv_chart_scalar(_text(n))
    # line/border: дочерний v8ui:style (+ width[/gap])
    style_child = n.find('{*}style')
    if style_child is not None:
        o = OrderedDict()
        w = _attr(n, 'width')
        if w != '':
            o['width'] = int(w)
        g = _attr(n, 'gap')
        if g != '':
            o['gap'] = (g == 'true')
        o['style'] = _text(style_child)
        return o
    # вложенный объект d4p1: группируем детей по имени
    o = OrderedDict()
    for c in kids:
        ln = _local_name(c.tag)
        val = build_chart_node(c, ln)
        if ln in CHART_SERIES_FIELDS:
            if ln not in o:
                o[ln] = []
            o[ln].append(val)
        elif ln in o:
            if not isinstance(o[ln], list):
                o[ln] = [o[ln]]
            o[ln].append(val)
        else:
            o[ln] = val
    return o


def build_chart_settings(set_node):
    return build_chart_node(set_node, '')


# Зеркало компилятор-эвристики B3: есть ли cmdBar-элемент где-либо в дереве.
def test_any_cmd_bar(lst):
    if not lst:
        return False
    for e in lst:
        if isinstance(e, dict) and 'cmdBar' in e:
            return True
        if isinstance(e, dict):
            if 'children' in e and test_any_cmd_bar(e['children']):
                return True
            if 'columns' in e and test_any_cmd_bar(e['columns']):
                return True
    return False


# Объектный тип (зеркало Test-IsObjectLikeType) — кандидат на авто-main эвристики 11b.3.
def test_is_object_like_type_dec(type_):
    if not type_:
        return False
    if type_ == 'DynamicList' or type_ == 'ConstantsSet':
        return True
    return bool(re.match(r'^(CatalogObject|DocumentObject|DataProcessorObject|ReportObject|ExternalDataProcessorObject|ExternalReportObject|BusinessProcessObject|TaskObject|ChartOfAccountsObject|ChartOfCharacteristicTypesObject|ChartOfCalculationTypesObject|ExchangePlanObject|InformationRegisterRecordSet|AccumulationRegisterRecordSet|AccountingRegisterRecordSet|CalculationRegisterRecordSet|InformationRegisterRecordManager)\.', type_))


# Ring 3: конструкции вне зоны поддержки (зеркало inline-скана канона 219-235).
def _ring3_scan(root, form_path):
    # ConditionalAppearance со scope (привязка к области) пока не воспроизводим.
    for ca in root.iter():
        if _local_name(ca.tag) != 'ConditionalAppearance':
            continue
        for item in list(ca):
            if _local_name(item.tag) != 'item':
                continue
            for scope in list(item):
                if _local_name(scope.tag) != 'scope':
                    continue
                if len(scope) > 0 or (scope.text or '').strip():
                    fail_ring3("ConditionalAppearance со scope", "form/ConditionalAppearance/item/scope")
    # Реквизит с design-time <Settings> chart-типа (кроме TypeDescription/DynamicList/Planner/Chart).
    for attr in root.iter():
        if _local_name(attr.tag) != 'Attribute':
            continue
        for s in list(attr):
            if _local_name(s.tag) != 'Settings':
                continue
            st = _attr(s, 'type', NS_XSI)
            if st and not re.search(r'TypeDescription$', st) and not re.search(r'DynamicList$', st) and not re.search(r'Planner$', st) and not re.search(r'd4p1:(Gantt)?Chart$', st):
                fail_ring3("Attribute>Settings типа '%s' (design-time конфигурация, напр. диаграмма)" % st, "Attribute/Settings")
            elif re.search(r'd4p1:(Gantt)?Chart$', st):
                # Chart/GanttChart с типизированными значениями/осями/точками (d4p1 xsi:type/nil/item).
                chart_uri = NSMAP_DOC.get('d4p1')
                typed = False
                if chart_uri:
                    pref = '{%s}' % chart_uri
                    nil_key = '{%s}nil' % NS_XSI
                    for d in s.iter():
                        if not d.tag.startswith(pref):
                            continue
                        if _XSI_TYPE in d.attrib or nil_key in d.attrib or _local_name(d.tag) == 'item':
                            typed = True
                            break
                if typed:
                    fail_ring3("Attribute>Settings %s с точками/осями (типизированные значения/d4p1-ML)" % st, "Attribute/Settings")


# ═══════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════

def main():
    global ROOT, NSMAP_DOC, OUTPUT_DIR, OUTPUT_BASENAME, QUERY_FILES_ACCUMULATOR, QUERY_FILE_NAMES_USED
    parser = argparse.ArgumentParser(description='Decompile 1C managed Form.xml to JSON DSL', allow_abbrev=False)
    parser.add_argument('-FormPath', '-Path', dest='FormPath', type=str, required=True)
    parser.add_argument('-OutputPath', dest='OutputPath', type=str, default=None)
    args = ci_parse_args(parser)

    form_path = args.FormPath
    output_path = args.OutputPath

    # --- 0. Resolve and validate input ---
    if not os.path.exists(form_path):
        sys.stderr.write("Form not found: %s\n" % form_path)
        sys.exit(1)
    form_path = os.path.abspath(form_path)

    # Префикс→URI (для ring3-проверки d4p1-чартов). Первое связывание на префикс.
    NSMAP_DOC = {}
    for _ev, (_pfx, _uri) in ET.iterparse(form_path, events=['start-ns']):
        if _pfx not in NSMAP_DOC:
            NSMAP_DOC[_pfx] = _uri

    # .NET XmlDocument сохраняет литеральные \r\n в InnerText; ET (expat) нормализует CRLF→LF
    # по XML-спеке. Чтобы совпасть с каноном, превращаем \r\n→&#13;\n (символьные ссылки не
    # подлежат EOL-нормализации) в содержимом документа — но только от <Form (в прологе char-ref
    # невалиден). Покрывает многострочные title/tooltip/content/QueryText.
    raw = open(form_path, 'rb').read()
    rs = raw.find(b'<Form')
    re_end = raw.rfind(b'</Form>')
    if rs >= 0 and re_end >= 0:
        end = re_end + len(b'</Form>')
        # Только ВНУТРИ корневого элемента: в прологе/эпилоге char-ref невалиден (хвостовой </Form>\r\n).
        raw = raw[:rs] + raw[rs:end].replace(b'\r\n', b'&#13;\n') + raw[end:]
    root = ET.fromstring(raw)
    ROOT = root

    # Ring 2: not a managed Form
    if _local_name(root.tag) != 'Form':
        sys.stderr.write("form-decompile: корневой элемент <%s> не <Form> — это не управляемая форма.\n" % _local_name(root.tag))
        sys.exit(2)

    # --- Вынос запроса динсписка в .sql рядом с output ---
    QUERY_FILES_ACCUMULATOR = []
    QUERY_FILE_NAMES_USED = {}
    if output_path:
        od = os.path.dirname(output_path)
        if not od:
            od = os.getcwd()
        OUTPUT_DIR = od
        OUTPUT_BASENAME = os.path.splitext(os.path.basename(output_path))[0]

    # --- 1b. Ring-3 scan ---
    _ring3_scan(root, form_path)

    # --- 5. Form-level assembly ---
    dsl = OrderedDict()

    title_node = root.find('lf:Title', NS)
    if title_node is not None:
        t = get_lang_text(title_node)
        if t is not None:
            dsl['title'] = t

    # properties (прямые скаляры под <Form>, PascalCase → camelCase)
    known_form_props = ['AutoTitle', 'ReportResult', 'DetailsData', 'ReportFormType', 'AutoShowState', 'ReportResultViewMode', 'ViewModeApplicationOnSetReportResult', 'WindowOpeningMode', 'CommandBarLocation', 'SaveDataInSettings', 'AutoSaveDataInSettings', 'AutoTime', 'UsePostingMode', 'RepostOnWrite', 'AutoURL', 'AutoFillCheck', 'Customizable', 'EnterKeyBehavior', 'VerticalScroll', 'Width', 'Height', 'Group', 'UseForFoldersAndItems', 'SaveWindowSettings', 'ScalingMode', 'VerticalSpacing', 'VariantAppearance', 'ShowCloseButton', 'HorizontalAlign', 'ChildrenAlign', 'ShowTitle', 'ConversationsRepresentation', 'CollapseItemsByImportanceVariant', 'GroupList', 'ChildItemsWidth', 'VerticalAlign', 'HorizontalSpacing', 'CustomSettingsFolder', 'SettingsStorage', 'Enabled', 'Scale']
    props = OrderedDict()
    for pn in known_form_props:
        v = get_child(root, pn)
        if v is not None:
            camel = pn[0:1].lower() + pn[1:]
            if v == 'true':
                props[camel] = True
            elif v == 'false':
                props[camel] = False
            elif re.match(r'^\d+$', v):
                props[camel] = int(v)
            else:
                props[camel] = v
    # Ссылка на члена формы по id ("N:uuid") в groupList/customSettingsFolder НЕ воспроизводима.
    for ref_key in ('groupList', 'customSettingsFolder'):
        if ref_key in props and re.match(r'^\d+:[0-9a-fA-F]{8}-', _ps_str(props[ref_key])):
            sys.stderr.write("form-decompile: %s = '%s' — ссылка на члена формы по id, не воспроизводима (id переназначаются), опущена. Задайте по имени через form-edit.\n" % (ref_key, props[ref_key]))
            del props[ref_key]
    # AutoTitle при наличии title: компилятор инъектит false (~95%). Зеркалим.
    if 'title' in dsl:
        if 'autoTitle' not in props:
            props['autoTitle'] = ''
        elif props['autoTitle'] is False:
            del props['autoTitle']
    if len(props) > 0:
        dsl['properties'] = props

    # MobileDeviceCommandBarContent (form-level) → список имён
    mdcb = root.find('lf:MobileDeviceCommandBarContent', NS)
    if mdcb is not None:
        names = []
        for it in mdcb.findall('xr:Item', NS):
            v = it.find('xr:Value', NS)
            if v is not None:
                names.append(_text(v))
        if len(names) > 0:
            dsl['mobileCommandBarContent'] = names

    # excludedCommands (form-level <CommandSet>)
    cs_form = root.find('lf:CommandSet', NS)
    if cs_form is not None:
        exc_form = []
        for ec in cs_form.findall('lf:ExcludedCommand', NS):
            exc_form.append(_text(ec))
        if len(exc_form) > 0:
            dsl['excludedCommands'] = exc_form

    # events (form-level) → {Event: handler} напрямую
    ev_form = get_events(root, None)
    if ev_form:
        ev_map = OrderedDict()
        ev_node = root.find('lf:Events', NS)
        for e in ev_node.findall('lf:Event', NS):
            ev_map[_attr(e, 'name')] = _text(e)
        if len(ev_map) > 0:
            dsl['events'] = ev_map

    # elements (+ форменный AutoCommandBar как autoCmdBar-элемент)
    elem_list = []
    elements = decompile_children(root)
    form_has_cmd_bar = test_any_cmd_bar(elements)
    acb = root.find('lf:AutoCommandBar', NS)
    if acb is not None:
        haln = get_child(acb, 'HorizontalAlign')
        acb_autofill = get_child(acb, 'Autofill')
        acb_di = _attr(acb, 'DisplayImportance')
        acb_kids = decompile_children(acb)
        acb_obj = None
        if haln or (acb_autofill == 'false') or acb_kids or acb_di:
            acb_obj = OrderedDict()
            acb_obj['autoCmdBar'] = _attr(acb, 'name')
            if acb_di:
                acb_obj['displayImportance'] = acb_di
            if haln:
                acb_obj['horizontalAlign'] = haln
            if acb_autofill == 'false':
                acb_obj['autofill'] = False
            if acb_kids:
                acb_obj['children'] = acb_kids
        elif form_has_cmd_bar and acb_autofill is None:
            acb_obj = OrderedDict()
            acb_obj['autoCmdBar'] = _attr(acb, 'name')
            acb_obj['autofill'] = True
        if acb_obj is not None:
            elem_list.append(acb_obj)
    if elements:
        for e in elements:
            elem_list.append(e)
    if len(elem_list) > 0:
        dsl['elements'] = elem_list

    # attributes
    attrs_node = root.find('lf:Attributes', NS)
    if attrs_node is not None:
        attrs = []
        # Подавление авто-main (эвристика 11b.3): нет <MainAttribute> И ровно 1 объектный реквизит.
        all_attr_nodes = attrs_node.findall('lf:Attribute', NS)
        any_main_attr = False
        obj_like_nodes = []
        for an in all_attr_nodes:
            if get_child(an, 'MainAttribute') == 'true':
                any_main_attr = True
            atype = decompile_type(an.find('lf:Type', NS))
            if test_is_object_like_type_dec(_ps_str(atype)):
                obj_like_nodes.append(an)
        suppress_main_name = _attr(obj_like_nodes[0], 'name') if (not any_main_attr and len(obj_like_nodes) == 1) else None
        for a in all_attr_nodes:
            ao = OrderedDict()
            ao['name'] = _attr(a, 'name')
            ty = decompile_type(a.find('lf:Type', NS))
            if ty:
                ao['type'] = ty
            # valueType: <Settings xsi:type="v8:TypeDescription">; Planner/Chart — отдельно.
            set_node = a.find('lf:Settings', NS)
            if set_node is not None and re.search(r'TypeDescription$', _attr(set_node, 'type', NS_XSI)):
                vt = decompile_type(set_node)
                ao['valueType'] = vt if vt else ''
            elif set_node is not None and re.search(r'Planner$', _attr(set_node, 'type', NS_XSI)):
                ao['planner'] = build_planner_settings(set_node)
            elif set_node is not None and re.search(r'd4p1:(Gantt)?Chart$', _attr(set_node, 'type', NS_XSI)):
                ao['chart'] = build_chart_settings(set_node)
            if get_child(a, 'MainAttribute') == 'true':
                ao['main'] = True
            elif suppress_main_name and ao['name'] == suppress_main_name:
                ao['main'] = False
            vw = decompile_xr_flag(a, 'View')
            if vw is not None:
                ao['view'] = vw
            ed = decompile_xr_flag(a, 'Edit')
            if ed is not None:
                ao['edit'] = ed
            is_main = (ao.get('main') is True)
            t_node = a.find('lf:Title', NS)
            if t_node is not None:
                t = get_lang_text_ws(t_node)
                if t is not None:
                    if is_main or not isinstance(t, str) or not _ps_ieq(t, title_from_name(ao['name'])):
                        ao['title'] = t
            elif not is_main:
                ao['title'] = ''
            if get_child(a, 'SavedData') == 'true':
                ao['savedData'] = True
            elif ao.get('main') is True and re.search(r'^(CatalogObject|DocumentObject|ChartOfAccountsObject|ChartOfCalculationTypesObject|ChartOfCharacteristicTypesObject|ExchangePlanObject|BusinessProcessObject|TaskObject)\.|RecordManager\.', _ps_str(ao.get('type'))):
                ao['savedData'] = False
            save_node = a.find('lf:Save', NS)
            if save_node is not None:
                nm = _ps_str(ao['name'])
                flds = [_text(f) for f in save_node.findall('lf:Field', NS)]
                if len(flds) == 1 and flds[0] == nm:
                    ao['save'] = True
                elif len(flds) > 0:
                    stripped = []
                    for f in flds:
                        m = re.match('^' + re.escape(nm) + r'\.([^.]+)$', f)
                        if m and not re.match(r'^\d+/\d+', m.group(1)):
                            stripped.append(m.group(1))
                        else:
                            stripped.append(f)
                    ao['save'] = stripped[0] if len(stripped) == 1 else stripped
            fc = get_child(a, 'FillCheck')
            if fc:
                ao['fillCheck'] = fc
            afo = decompile_functional_options(a)
            if _ps_truthy(afo):
                ao['functionalOptions'] = afo
            cols_node = a.find('lf:Columns', NS)
            if cols_node is not None:
                cols = []
                for c in cols_node.findall('lf:Column', NS):
                    cols.append(decompile_attr_column(c))
                if len(cols) > 0:
                    ao['columns'] = cols
                add_nodes = cols_node.findall('lf:AdditionalColumns', NS)
                if len(add_nodes) > 0:
                    add_list = []
                    for an2 in add_nodes:
                        ac_obj = OrderedDict()
                        ac_obj['table'] = _attr(an2, 'table')
                        ac_cols = []
                        for c in an2.findall('lf:Column', NS):
                            ac_cols.append(decompile_attr_column(c))
                        ac_obj['columns'] = ac_cols
                        add_list.append(ac_obj)
                    ao['additionalColumns'] = add_list
            # UseAlways: префикс "ИмяРеквизита." снимаем; маркер "~" сохраняем (префикс после него).
            ua_node = a.find('lf:UseAlways', NS)
            if ua_node is not None:
                prefix = _ps_str(ao['name']) + '.'
                shorts = []
                for fn in ua_node.findall('lf:Field', NS):
                    t = (_text(fn) or '').strip()
                    if t.startswith('~'):
                        rest = t[1:]
                        if rest.startswith(prefix):
                            rest = rest[len(prefix):]
                        t = '~' + rest
                    elif t.startswith(prefix):
                        t = t[len(prefix):]
                    shorts.append(t)
                if 'columns' in ao:
                    rest_list = []
                    for s in shorts:
                        col = None
                        for cc in ao['columns']:
                            if cc.get('name') == s:
                                col = cc
                                break
                        if col is not None:
                            col['useAlways'] = True
                        else:
                            rest_list.append(s)
                    if len(rest_list) > 0:
                        ao['useAlways'] = rest_list
                elif len(shorts) > 0:
                    ao['useAlways'] = shorts
            # Settings динамического списка (xsi:type=DynamicList)
            set_node = a.find('lf:Settings', NS)
            if set_node is not None and re.search(r'DynamicList$', _attr(set_node, 'type', NS_XSI)):
                so = OrderedDict()
                afaf = get_child(set_node, 'AutoFillAvailableFields')
                if afaf is not None:
                    so['autoFillAvailableFields'] = (afaf == 'true')
                mt = get_child(set_node, 'MainTable')
                if mt:
                    so['mainTable'] = mt
                gifp = get_child(set_node, 'GetInvisibleFieldPresentations')
                if gifp is not None:
                    so['getInvisibleFieldPresentations'] = (gifp == 'true')
                kt = get_child(set_node, 'KeyType')
                if kt:
                    so['keyType'] = kt
                kf_nodes = [_text(x) for x in set_node.findall('lf:KeyField', NS)]
                if len(kf_nodes) > 0:
                    so['keyFields'] = kf_nodes
                asus = get_child(set_node, 'AutoSaveUserSettings')
                if asus is not None:
                    so['autoSaveUserSettings'] = (asus == 'true')
                qt_node = set_node.find('lf:QueryText', NS)
                has_q = bool(qt_node is not None and _text(qt_node))
                if has_q:
                    so['query'] = maybe_externalize_query(_text(qt_node), _ps_str(ao['name']))
                mq_v = get_child(set_node, 'ManualQuery')
                if mq_v is not None:
                    mq_actual = (mq_v == 'true')
                    if mq_actual != has_q:
                        so['manualQuery'] = mq_actual
                if get_child(set_node, 'DynamicDataRead') == 'false':
                    so['dynamicDataRead'] = False
                field_nodes = set_node.findall('lf:Field', NS)
                if len(field_nodes) > 0:
                    fields = []
                    for fn in field_nodes:
                        fo = OrderedDict()
                        fld = get_child(fn, 'field')
                        dp = get_child(fn, 'dataPath')
                        if fld:
                            fo['field'] = fld
                        if has_child(fn, 'dataPath') and dp != fld:
                            fo['dataPath'] = dp
                        f_type_attr = _attr(fn, 'type', NS_XSI)
                        if re.search(r'NestedDataSet$', f_type_attr):
                            fo['nested'] = True
                        elif re.search(r'Folder$', f_type_attr):
                            fo['folder'] = True
                        ftn = fn.find('dcssch:title', NS)
                        if ftn is not None:
                            t = get_lang_text(ftn)
                            if t is not None:
                                fo['title'] = t
                        fvt = fn.find('dcssch:valueType', NS)
                        if fvt is not None:
                            fvt_val = decompile_type(fvt)
                            if fvt_val:
                                fo['valueType'] = fvt_val
                        fpe = get_child(fn, 'presentationExpression')
                        if fpe is not None and fpe != '':
                            fo['presentationExpression'] = fpe
                        fapp_node = fn.find('dcssch:appearance', NS)
                        if fapp_node is not None:
                            fap = get_settings_appearance(fapp_node)
                            if fap and len(fap) > 0:
                                fo['appearance'] = fap
                        fur_node = fn.find('dcssch:useRestriction', NS)
                        if fur_node is not None:
                            fur = build_restrict_obj(fur_node)
                            if len(fur) > 0:
                                fo['useRestriction'] = fur
                        faur_node = fn.find('dcssch:attributeUseRestriction', NS)
                        if faur_node is not None:
                            faur = build_restrict_obj(faur_node)
                            if len(faur) > 0:
                                fo['attributeUseRestriction'] = faur
                        fip_node = fn.find('dcssch:inputParameters', NS)
                        if fip_node is not None:
                            fip = build_dl_input_parameters(fip_node)
                            if len(fip) > 0:
                                fo['inputParameters'] = fip[0] if len(fip) == 1 else fip   # PS unwrap
                        fields.append(fo)
                    so['fields'] = fields
                calc_nodes = set_node.findall('lf:CalculatedField', NS)
                if len(calc_nodes) > 0:
                    cfs = []
                    for cn in calc_nodes:
                        cfs.append(build_calc_field(cn))
                    so['calculatedFields'] = cfs
                param_nodes = set_node.findall('lf:Parameter', NS)
                if len(param_nodes) > 0:
                    dl_pars = []
                    for pn in param_nodes:
                        dl_pars.append(build_dl_parameter(pn))
                    so['parameters'] = dl_pars
                ls_node = set_node.find('lf:ListSettings', NS)
                if ls_node is not None:
                    f_node = ls_node.find('dcsset:filter', NS)
                    if f_node is not None and f_node.find('dcsset:item', NS) is not None:
                        flt = []
                        for fc in f_node.findall('dcsset:item', NS):
                            bi = build_filter_item(fc, 'settings/filter')
                            if bi is not None:
                                flt.append(bi)
                        if len(flt) > 0:
                            so['filter'] = flt
                    o_node = ls_node.find('dcsset:order', NS)
                    if o_node is not None and o_node.find('dcsset:item', NS) is not None:
                        ordr = build_order(o_node, 'settings/order')
                        if len(ordr) > 0:
                            so['order'] = ordr
                    ca_node = ls_node.find('dcsset:conditionalAppearance', NS)
                    if ca_node is not None and ca_node.find('dcsset:item', NS) is not None:
                        ca = build_conditional_appearance(ca_node, 'settings/conditionalAppearance')
                        if len(ca) > 0:
                            so['conditionalAppearance'] = ca
                    dp_node = ls_node.find('dcsset:dataParameters', NS)
                    if dp_node is not None and dp_node.find('dcscor:item', NS) is not None:
                        dp = build_form_data_parameters(dp_node)
                        if len(dp) > 0:
                            so['dataParameters'] = dp
                    grp_item_node = ls_node.find('dcsset:item', NS)
                    grouping = None
                    if grp_item_node is not None:
                        grouping = build_list_grouping(grp_item_node)
                    if grouping is not None:
                        so['grouping'] = grouping
                    ls_shape = get_list_settings_shape(ls_node, grouping is not None)
                    if ls_shape is not None:
                        so['listSettings'] = ls_shape
                if len(so) > 0:
                    ao['settings'] = so
            attrs.append(ao)
        if len(attrs) > 0:
            dsl['attributes'] = attrs

    # conditionalAppearance формы (<ConditionalAppearance> — последний child <Attributes>)
    if attrs_node is not None:
        ca_node = attrs_node.find('lf:ConditionalAppearance', NS)
        if ca_node is not None:
            ca = build_conditional_appearance(ca_node, 'form/conditionalAppearance')
            if len(ca) > 0:
                dsl['conditionalAppearance'] = ca

    # parameters
    pars_node = root.find('lf:Parameters', NS)
    if pars_node is not None:
        pars = []
        for p in pars_node.findall('lf:Parameter', NS):
            po = OrderedDict()
            po['name'] = _attr(p, 'name')
            ty = decompile_type(p.find('lf:Type', NS))
            if ty:
                po['type'] = ty
            if get_child(p, 'KeyParameter') == 'true':
                po['key'] = True
            pars.append(po)
        if len(pars) > 0:
            dsl['parameters'] = pars

    # commands
    cmds_node = root.find('lf:Commands', NS)
    if cmds_node is not None:
        cmds = []
        for c in cmds_node.findall('lf:Command', NS):
            co = OrderedDict()
            co['name'] = _attr(c, 'name')
            act = get_child(c, 'Action')
            if act:
                co['action'] = act
            if get_child(c, 'ModifiesSavedData') == 'true':
                co['modifiesSavedData'] = True
            t_node = c.find('lf:Title', NS)
            if t_node is not None:
                t = get_lang_text(t_node)
                if t is not None:
                    co['title'] = t
            else:
                co['title'] = ''
            tt_node = c.find('lf:ToolTip', NS)
            if tt_node is not None:
                t = get_lang_text(tt_node)
                if t is not None:
                    co['tooltip'] = t
            us = decompile_xr_flag(c, 'Use')
            if us is not None:
                co['use'] = us
            cfo = decompile_functional_options(c)
            if _ps_truthy(cfo):
                co['functionalOptions'] = cfo
            cru = get_child(c, 'CurrentRowUse')
            if cru:
                co['currentRowUse'] = cru
            ate = get_child(c, 'AssociatedTableElementId')
            if ate:
                co['table'] = ate
            sc = get_child(c, 'Shortcut')
            if sc:
                co['shortcut'] = sc
            set_command_picture(co, c)
            rep = get_child(c, 'Representation')
            if rep:
                co['representation'] = rep
            cmds.append(co)
        if len(cmds) > 0:
            dsl['commands'] = cmds

    # commandInterface (форменный <CommandInterface>)
    ci = decompile_command_interface()
    if ci is not None:
        dsl['commandInterface'] = ci

    # --- 6. Output ---
    js = convert_to_compact_json(dsl)
    if output_path:
        with open(output_path, 'w', encoding='utf-8', newline='') as f:
            f.write(js)
        save_query_files()
        sys.stdout.write("form-decompile: %s\n" % output_path)
    else:
        sys.stdout.write(js + "\n")


def _local_name(tag):
    """{uri}local → local."""
    return tag.rsplit('}', 1)[-1] if '}' in tag else tag


if __name__ == '__main__':
    main()
