feat(mxl-compile,mxl-decompile): позиционный список ячеек в выводе декомпилятора

Компилятор давно принимал короткую форму, а декомпилятор всегда писал самую
развёрнутую. Теперь он выбирает самую короткую из применимых, как это делает
skd-decompile.

Короткая запись стала свойством СПИСКА ЯЧЕЕК, а не строки: cells принимает
позиционный список, поэтому строка с height или rowStyle тоже может им пользоваться.
Раньше наличие свойств строки лишало её короткой записи — искусственная связка.
Если же своих свойств у строки нет, она пишется просто массивом, без ключа cells.

Позиционный список опознаётся по наличию элемента-строки или null; список из одних
объектов читается как обычный — для простой строки обе трактовки дают один результат.

Три дефекта, вскрытых проверкой на потери (каждый раз XML переставал совпадать с
прежним, и это приводило к причине):

- компилятор двигал позицию на единицу за элемент, поэтому объектный элемент со
  span: 3 занимал одну позицию вместо трёх и всё правее него уезжало влево. У маркеров
  ">" этого не было — каждый съедает свою позицию;
- проход «удалить неиспользуемые стили» не заглядывал в строки-массивы: у массива нет
  свойства cells, и стиль, использованный только внутри такой строки, вырезался как
  неиспользуемый;
- в py тот же проход падал на позиционном списке — "style" in c для None бросает
  TypeError, а для строки делает подстрочный поиск.

Плюс ловушка PowerShell: += разворачивает вложенный массив, и строка-массив
расплющивалась в список строк — нужна запятая.

Проверено: XML на всех 40 макетах пилота совпал с прежним БАЙТ В БАЙТ, то есть
сокращение без потерь. Объём декомпилированного JSON меньше на 11%. Тесты 54/54 на
обоих рантаймах, гарды зелёные.

В WORKFLOW уточнена находка про паритет портов: декомпиляторы расходятся по JSON на
33 макетах из 40, а компиляторы на одном и том же входе — всего на 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-10 22:35:58 +03:00
co-authored by Claude Opus 5
parent a9697b613d
commit 4cceb03269
5 changed files with 173 additions and 19 deletions
@@ -1,4 +1,4 @@
# mxl-compile v1.24 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи)
# mxl-compile v1.25 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -700,6 +700,13 @@ function Expand-ShorthandRow {
$cells += $cell
$placed[$idx] = $cell
$last = $cell
# Ячейка занимает СТОЛЬКО позиций, каков её span. У строки со следующими ">" это
# получается само (каждый маркер съедает позицию), а объектный элемент несёт span
# внутри — без этого сдвига всё правее него уезжало влево.
$elSpan = if ($cell.span) { [int]$cell.span } else { 1 }
if ($elSpan -gt 1) {
for ($s = 1; $s -lt $elSpan; $s++) { $idx++; $placed[$idx] = $cell }
}
}
# Колонки, не занятые в этой строке, теряют «ячейку сверху».
@@ -709,6 +716,18 @@ function Expand-ShorthandRow {
return [PSCustomObject]@{ cells = $cells }
}
# Позиционный список ячеек опознаём по наличию хотя бы одного элемента-строки или null:
# маркеры, текст и пропуски бывают только в нём. Список из одних объектов разбирается
# как обычный — для простой строки обе трактовки дают один результат, неоднозначности нет.
function Test-PositionalCells {
param($cells)
if ($null -eq $cells -or -not ($cells -is [array])) { return $false }
foreach ($el in $cells) {
if ($null -eq $el -or $el -is [string]) { return $true }
}
return $false
}
# Карту занятых колонок ведём и по объектным строкам: "|" продолжает ту ячейку,
# которая реально стоит выше, независимо от того, какой формой её записали.
function Update-OpenByCol {
@@ -743,7 +762,14 @@ foreach ($area in $def.areas) {
foreach ($row in $area.rows) {
$rowIdx++
if ($row -is [array]) {
# Строка целиком массивом — сахар для { cells: [...] }.
$expandedRows += Expand-ShorthandRow -row $row -areaName $areaName -rowIdx $rowIdx -openByCol $openByCol -maxCols $areaMaxCols
} elseif (Test-PositionalCells $row.cells) {
# Короткая запись — свойство СПИСКА ЯЧЕЕК, а не строки: свои height и rowStyle
# строка при этом сохраняет.
$expanded = Expand-ShorthandRow -row $row.cells -areaName $areaName -rowIdx $rowIdx -openByCol $openByCol -maxCols $areaMaxCols
$row.cells = $expanded.cells
$expandedRows += $row
} else {
$expandedRows += $row
if ($row.empty) { $openByCol.Clear() } else { Update-OpenByCol -row $row -openByCol $openByCol }
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# mxl-compile v1.24 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи)
# mxl-compile v1.25 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import hashlib
@@ -674,7 +674,9 @@ def main():
extended = [] # ячейки, чей rowspan уже нарастили в этой строке (span>1 даёт несколько "|")
last = None # последняя реальная ячейка слева — цель для ">"
for idx, el in enumerate(row, start=1):
idx = 0
for el in row:
idx += 1
if idx > max_cols:
print(f'Row exceeds \'columns\' ({max_cols}): area "{area_name}",'
f' row {row_idx}', file=sys.stderr)
@@ -729,6 +731,13 @@ def main():
cells.append(cell)
placed[idx] = cell
last = cell
# Ячейка занимает СТОЛЬКО позиций, каков её span. У строки со следующими ">" это
# получается само (каждый маркер съедает позицию), а объектный элемент несёт span
# внутри — без этого сдвига всё правее него уезжало влево.
el_span = int(cell.get('span') or 1)
for _ in range(el_span - 1):
idx += 1
placed[idx] = cell
# Колонки, не занятые в этой строке, теряют «ячейку сверху».
open_by_col.clear()
@@ -738,6 +747,14 @@ def main():
out['cells'] = cells
return out
# Позиционный список ячеек опознаём по наличию хотя бы одного элемента-строки или None:
# маркеры, текст и пропуски бывают только в нём. Список из одних объектов разбирается
# как обычный — для простой строки обе трактовки дают один результат, неоднозначности нет.
def is_positional_cells(cells):
if not isinstance(cells, list):
return False
return any(el is None or isinstance(el, str) for el in cells)
# Карту занятых колонок ведём и по объектным строкам: "|" продолжает ту ячейку,
# которая реально стоит выше, независимо от того, какой формой её записали.
def update_open_by_col(row, open_by_col):
@@ -767,7 +784,14 @@ def main():
expanded_rows = []
for row_idx, row in enumerate(area.get('rows', []), start=1):
if isinstance(row, list):
# Строка целиком массивом — сахар для { cells: [...] }.
expanded_rows.append(expand_shorthand_row(row, area_name, row_idx, open_by_col, area_max_cols))
elif is_positional_cells(row.get('cells')):
# Короткая запись — свойство СПИСКА ЯЧЕЕК, а не строки: свои height и rowStyle
# строка при этом сохраняет.
expanded = expand_shorthand_row(row['cells'], area_name, row_idx, open_by_col, area_max_cols)
row['cells'] = expanded['cells']
expanded_rows.append(row)
else:
expanded_rows.append(row)
if row.get('empty'):
@@ -1,4 +1,4 @@
# mxl-decompile v1.7 — Decompile 1C spreadsheet to JSON
# mxl-decompile v1.8 — Decompile 1C spreadsheet to JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -480,6 +480,54 @@ function Get-StyleName {
return "default"
}
# Список признаётся позиционным по тому же правилу, что и в компиляторе: есть элемент-строка
# или пропуск. Список из одних объектов таковым не считаем — он читается как обычный.
function Test-PositionalList {
param($cells)
foreach ($el in $cells) {
if ($null -eq $el -or $el -is [string]) { return $true }
}
return $false
}
# Позиционная запись списка ячеек: позиция берётся из порядка, `col` не пишется.
# Применяем, когда первая ячейка стоит в колонке 1 — иначе список начнётся с череды null
# и станет длиннее объектного. Ячейка, у которой кроме текста или параметра ничего нет,
# пишется строкой; span раскрывается маркерами ">"; всё прочее — объектным элементом без col.
function ConvertTo-PositionalCells {
param($cells)
if ($cells.Count -eq 0) { return @() }
$first = $cells[0]
if ([int]$first["col"] -ne 1) { return $cells }
$out = @()
$expected = 1
foreach ($c in $cells) {
$col = [int]$c["col"]
if ($col -lt $expected) { return $cells } # перекрытие — позиционно не выразить
while ($expected -lt $col) { $out += $null; $expected++ }
$span = if ($c.Contains("span")) { [int]$c["span"] } else { 1 }
$keys = @($c.Keys | Where-Object { $_ -notin @("col", "span") })
$plainText = ($keys.Count -eq 1 -and $keys[0] -eq "text" -and $c["text"] -is [string])
$plainParam = ($keys.Count -eq 1 -and $keys[0] -eq "param")
if ($plainText) { $out += $c["text"] }
elseif ($plainParam) { $out += "{$($c["param"])}" }
else {
$obj = [ordered]@{}
foreach ($k in $c.Keys) { if ($k -ne "col") { $obj[$k] = $c[$k] } }
$out += $obj
}
# span раскрываем маркерами — кроме объектного элемента, он несёт span сам.
if (($plainText -or $plainParam) -and $span -gt 1) {
for ($i = 1; $i -lt $span; $i++) { $out += ">" }
}
$expected = $col + $span
}
return $out
}
# --- 12. Build areas ---
# Сетка нарезается на блоки: непересекающиеся области типа Rows задают границы, строки вне
@@ -664,8 +712,14 @@ foreach ($area in $blocks) {
$dslCells += $dslCell
}
if ($dslCells.Count -gt 0) { $dslRow["cells"] = [array]$dslCells }
$areaRows += $dslRow
if ($dslCells.Count -gt 0) { $dslRow["cells"] = [array](ConvertTo-PositionalCells $dslCells) }
# Самая короткая из применимых форм: если у строки нет своих свойств, а список ячеек
# позиционный — строка пишется просто массивом, без ключа cells.
if ($dslRow.Count -eq 1 -and $dslRow.Contains("cells") -and (Test-PositionalList $dslRow["cells"])) {
$areaRows += ,([array]$dslRow["cells"])
} else {
$areaRows += $dslRow
}
}
# Compress consecutive empty rows ({}) into { empty = N }
@@ -680,7 +734,8 @@ foreach ($area in $blocks) {
else { $compressedRows += [ordered]@{ empty = $emptyRun } }
$emptyRun = 0
}
$compressedRows += $r
# Запятая обязательна: строка-массив иначе развернётся в список строк.
$compressedRows += ,$r
}
}
if ($emptyRun -gt 0) {
@@ -755,8 +810,11 @@ if ($styleDefs.Contains("default") -and $styleDefs["default"].Count -eq 0) {
$usedStyles = @{}
foreach ($a in $dslAreas) {
foreach ($r in $a.rows) {
if ($r.rowStyle) { $usedStyles[$r.rowStyle] = $true }
if ($r.cells) { foreach ($c in $r.cells) { if ($c.style) { $usedStyles[$c.style] = $true } } }
# Строка может быть массивом (короткая форма) — у неё нет свойства cells, и без этой
# ветки стиль, использованный только внутри такой строки, вырезался как «неиспользуемый».
$cellList = if ($r -is [array]) { $r } else { $r.cells }
if ($r -isnot [array] -and $r.rowStyle) { $usedStyles[$r.rowStyle] = $true }
if ($cellList) { foreach ($c in $cellList) { if ($c -isnot [string] -and $c.style) { $usedStyles[$c.style] = $true } } }
}
}
$toRemove = @($styleDefs.Keys | Where-Object { -not $usedStyles.ContainsKey($_) })
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# mxl-decompile v1.7 — Decompile 1C spreadsheet to JSON
# mxl-decompile v1.8 — Decompile 1C spreadsheet to JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
@@ -667,6 +667,39 @@ def main():
return style_names[key]
return "default"
def to_positional_cells(cells):
"""Позиционная запись списка ячеек: позиция берётся из порядка, `col` не пишется.
Применяем, когда первая ячейка стоит в колонке 1 — иначе список начнётся с череды None
и станет длиннее объектного. Ячейка, у которой кроме текста или параметра ничего нет,
пишется строкой; span раскрывается маркерами ">"; прочее — объектным элементом без col."""
if not cells or int(cells[0].get("col", 0)) != 1:
return cells
out = []
expected = 1
for c in cells:
col = int(c.get("col", 0))
if col < expected:
return cells # перекрытие — позиционно не выразить
while expected < col:
out.append(None)
expected += 1
span = int(c.get("span", 1) or 1)
keys = [k for k in c if k not in ("col", "span")]
plain_text = len(keys) == 1 and keys[0] == "text" and isinstance(c["text"], str)
plain_param = len(keys) == 1 and keys[0] == "param"
if plain_text:
out.append(c["text"])
elif plain_param:
out.append("{%s}" % c["param"])
else:
obj = OrderedDict((k, v) for k, v in c.items() if k != "col")
out.append(obj)
# span раскрываем маркерами — кроме объектного элемента, он несёт span сам.
if (plain_text or plain_param) and span > 1:
out.extend([">"] * (span - 1))
expected = col + span
return out
# --- 12. Build areas ---
# Сетка нарезается на блоки: непересекающиеся области типа Rows задают границы, строки вне
@@ -831,8 +864,14 @@ def main():
dsl_cells.append(dsl_cell)
if len(dsl_cells) > 0:
dsl_row["cells"] = dsl_cells
area_rows.append(dsl_row)
dsl_row["cells"] = to_positional_cells(dsl_cells)
# Самая короткая из применимых форм: если у строки нет своих свойств, а список ячеек
# позиционный — строка пишется просто массивом, без ключа cells.
if (len(dsl_row) == 1 and "cells" in dsl_row
and any(el is None or isinstance(el, str) for el in dsl_row["cells"])):
area_rows.append(dsl_row["cells"])
else:
area_rows.append(dsl_row)
# Compress consecutive empty rows ({}) into { empty = N }
compressed_rows = []
@@ -933,12 +972,19 @@ def main():
used_styles = set()
for a in dsl_areas:
for r in a["rows"]:
if "rowStyle" in r:
used_styles.add(r["rowStyle"])
if "cells" in r:
for c in r["cells"]:
if "style" in c:
used_styles.add(c["style"])
# Строка может быть массивом (короткая форма) — у неё нет ключа cells, и без этой
# ветки стиль, использованный только внутри такой строки, вырезался как «неиспользуемый».
if isinstance(r, list):
cell_list = r
else:
if "rowStyle" in r:
used_styles.add(r["rowStyle"])
cell_list = r.get("cells") or []
# Список ячеек может быть позиционным: строки, None и маркеры стиля не несут,
# стиль бывает только у объектного элемента.
for c in cell_list:
if isinstance(c, dict) and "style" in c:
used_styles.add(c["style"])
to_remove = [s for s in style_defs if s not in used_styles]
for s in to_remove:
del style_defs[s]
@@ -1 +1 @@
{ "columns": 2, "defaultWidth": 10, "fonts": { "default": { "face": "Arial", "size": 10 } }, "styles": {}, "areas": [{ "name": "Test", "rows": [{ "cells": [{ "col": 1, "text": "A" }, { "col": 2, "text": "B" }] }] }] }
{ "columns": 2, "defaultWidth": 10, "fonts": { "default": { "face": "Arial", "size": 10 } }, "styles": {}, "areas": [{ "name": "Test", "rows": [["A", "B"]] }] }