feat(mxl-compile): короткая форма строки — массив ячеек

Строка, записанная массивом ("rows": [["А","Б","В"]]), молча превращалась в
пустую строку в обоих портах. Форма не выдумана: ровно так документированы
макеты в skd-compile, и привычка естественно переносится на табличный документ.

Масштаб потери был виден в самом репозитории — ПЯТЬ кейсов семейства mxl-*
написаны в этой форме и потому проверяли пустые макеты: снэпшот
lenient-key-case содержал единственную строку <empty>true</empty>. После правки
все пять снэпшотов наполнились ячейками, которые терялись.

Семантика повторяет skd-compile: позиция ячейки — индекс в массиве, ">"
продолжает ячейку слева (span), "|" — сверху (rowspan), null пропускает
колонку, "{Имя}" даёт параметр. Элемент можно записать и объектом, когда нужен
style или detail; col в нём запрещён — это смешение двух способов адресации.
Разворот идёт отдельным пред-проходом в обычные строки с явными col/span/rowspan,
поэтому остальной компилятор о короткой форме не знает.

Форма документирована — в отличие от прощающего пропуска col: модель пишет канон
соседнего навыка, и выравнивание двух табличных DSL убирает развилку. По каскаду
в SKILL.md только правило и пример, подробности и таблица ошибок — в спеке
(обе копии: docs/ и reference/ навыка).

Правка на ps1, зазеркалена в py: вывод портов совпадает байт в байт, decompile →
compile возвращает исходный XML байт в байт.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-10 14:07:51 +03:00
co-authored by Claude Opus 5
parent ccbbecbcf2
commit 6719d7e647
17 changed files with 908 additions and 15 deletions
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# mxl-compile v1.15 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи)
# mxl-compile v1.16 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import json
@@ -585,12 +585,117 @@ def main():
}
return register_format(key, props)
# --- 5.5. Шорткат строк: строка-массив ячеек ---
# Та же форма, что у макетов СКД (skd-compile): позиция ячейки = индекс в массиве,
# ">" продолжает ячейку слева, "|" — ячейку сверху, null — пустая колонка,
# "{Имя}" — параметр. Разворачиваем в обычную строку с явными col/span/rowspan,
# поэтому весь код ниже про шорткат не знает.
def expand_shorthand_row(row, area_name, row_idx, open_by_col):
cells = []
placed = {} # 1-based col -> ячейка, занимающая колонку в ЭТОЙ строке
extended = [] # ячейки, чей rowspan уже нарастили в этой строке (span>1 даёт несколько "|")
last = None # последняя реальная ячейка слева — цель для ">"
for idx, el in enumerate(row, start=1):
if idx > total_columns:
print(f'Row exceeds \'columns\' ({total_columns}): area "{area_name}",'
f' row {row_idx}', file=sys.stderr)
sys.exit(1)
if el is None:
last = None
continue
if isinstance(el, str) and el == '>':
if last is None:
print(f'Row shorthand: \'>\' has no cell to the left: area "{area_name}",'
f' row {row_idx}, cell {idx}', file=sys.stderr)
sys.exit(1)
last['span'] = int(last.get('span', 1)) + 1
placed[idx] = last
continue
if isinstance(el, str) and el == '|':
above = open_by_col.get(idx)
if above is None:
print(f'Row shorthand: \'|\' has no cell above: area "{area_name}",'
f' row {row_idx}, cell {idx}', file=sys.stderr)
sys.exit(1)
if not any(above is e for e in extended):
above['rowspan'] = int(above.get('rowspan', 1)) + 1
extended.append(above)
placed[idx] = above
last = None
continue
if isinstance(el, str):
cell = CIDict()
cell['col'] = idx
cell['span'] = 1
m = re.match(r'^\{(.+)\}$', el)
if m:
cell['param'] = m.group(1)
else:
cell['text'] = el
else:
# Объектный элемент — обычная ячейка mxl, позиция берётся из индекса.
if 'col' in el:
print(f'Row shorthand: cell object must not carry \'col\': area "{area_name}",'
f' row {row_idx}, cell {idx}', file=sys.stderr)
sys.exit(1)
cell = el
cell['col'] = idx
if not cell.get('span'):
cell['span'] = 1
cells.append(cell)
placed[idx] = cell
last = cell
# Колонки, не занятые в этой строке, теряют «ячейку сверху».
open_by_col.clear()
open_by_col.update(placed)
out = CIDict()
out['cells'] = cells
return out
# Карту занятых колонок ведём и по объектным строкам: "|" продолжает ту ячейку,
# которая реально стоит выше, независимо от того, какой формой её записали.
def update_open_by_col(row, open_by_col):
placed = {}
for cell in (row.get('cells') or []):
# Ячейки без col (строка с автопотоком) на этом шаге ещё не разложены — позиция
# станет известна позже, поэтому «ячейку сверху» они не дают, и "|" под такой
# строкой честно скажет, что сверху ничего нет.
if cell.get('col') is None:
continue
col = int(cell['col'])
span = int(cell.get('span', 1))
for c in range(col, col + span):
placed[c] = cell
open_by_col.clear()
open_by_col.update(placed)
for area in defn['areas']:
area_name = area.get('name', '')
open_by_col = {}
expanded_rows = []
for row_idx, row in enumerate(area.get('rows', []), start=1):
if isinstance(row, list):
expanded_rows.append(expand_shorthand_row(row, area_name, row_idx, open_by_col))
else:
expanded_rows.append(row)
if row.get('empty'):
open_by_col.clear()
else:
update_open_by_col(row, open_by_col)
area['rows'] = expanded_rows
# Pre-register all formats from areas
for area in defn['areas']:
for row in area.get('rows', []):
# Skip list-of-values shorthand rows (treated as empty rows like PS1)
if isinstance(row, list):
continue
# Skip empty row placeholder
if row.get('empty'):
continue
@@ -668,9 +773,6 @@ def main():
local_row = 0
for row in area.get('rows', []):
# List-of-values shorthand: treat as row with no properties (like PS1)
if isinstance(row, list):
row = {}
# Empty row placeholder: emit N empty rows
if row.get('empty'):
count = int(row['empty'])