feat(mxl-compile,mxl-decompile): колоночные раскладки

Инкремент B кампании mxl-roundtrip. Табличный документ может задавать индивидуальные
ширины колонок для группы строк — в XML это несколько элементов <columns>, из которых
раскладка БЕЗ <id> является умолчанием (ровно одна в каждом макете корпуса), а остальные
адресуются GUID, на который ссылаются строки и области. Декомпилятор читал только первый
<columns>: ширины прочих раскладок терялись, а на макетах с пустым умолчанием он отдавал
columns: 0. Несколько раскладок есть у 63% макетов ERP.

DSL: документные columns/columnWidths — раскладка по умолчанию, дополнительные объявляются
в columnSets (ключ — идентификатор), область ссылается ключом columnSet. Форма повторяет
уже принятую в этом DSL пару «словарь именованных штук наверху — ссылка по имени на
потребителе», как styles/style и fonts/font.

Склейки раскладок по содержимому НЕТ, и это исправление собственной ошибки: замер, на
котором строилось прежнее решение, вырезал идентификатор через findtext('columnsID'),
тогда как у набора он называется <id> (columnsID — имя только у ссылок). Вырезание не
срабатывало, и наборы «различались» просто из-за разных GUID. Правильный замер: в 337
макетах из 517 есть наборы с полностью одинаковым содержимым, до 59 дублей в одном
макете. Содержимое раскладку не опознаёт — опознаёт идентификатор.

Попутно закрыты два дефекта, вскрытые корпусом:

- проверка обязательных полей смотрела на истинность значения, а не на наличие ключа:
  columns: 0 — осмысленная величина (умолчание пустое, все строки в именованных
  раскладках), и компилятор объявлял её отсутствующей. Тот же класс, что был с col;

- позиции колонок сверялись с документным columns, тогда как ширина сетки у каждой
  раскладки своя. Проверки диапазона col, автопотока и короткой формы переведены на
  ширину раскладки области.

На пилоте из 40 макетов ERP отказов больше нет ни одного (было 26): цикл проходят все 40
на обоих рантаймах. Скомпилированный XML портов совпадает байт в байт.

Записана находка (debug/mxl-roundtrip/WORKFLOW.md): порты mxl-decompile расходятся по
выходному JSON на 11 макетах из 12 — порядок стилей в словаре, при идентичном XML.
Не ловилось потому, что кейсы снэпшотят входной Template.xml, а не выходной JSON.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-10 19:01:47 +03:00
co-authored by Claude Opus 5
parent d002de3a26
commit a1642cbe60
4 changed files with 375 additions and 128 deletions
@@ -1,4 +1,4 @@
# mxl-compile v1.18 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи)
# mxl-compile v1.19 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -192,11 +192,15 @@ if (-not (Test-Path $JsonPath)) {
$json = Get-Content -Raw -Encoding UTF8 $JsonPath
$def = $json | ConvertFrom-Json
if (-not $def.columns) {
# Проверяем НАЛИЧИЕ ключа, а не истинность значения: `columns: 0` — осмысленная величина
# (раскладка по умолчанию пустая, все строки живут в именованных раскладках), а пустой
# список областей встречается у макета без строк. Прежняя проверка `-not` объявляла и то
# и другое отсутствующим.
if (-not $def.PSObject.Properties['columns'] -or $null -eq $def.columns) {
Write-Error "Required field 'columns' is missing"
exit 1
}
if (-not $def.areas) {
if (-not $def.PSObject.Properties['areas'] -or $null -eq $def.areas) {
Write-Error "Required field 'areas' is missing"
exit 1
}
@@ -363,18 +367,40 @@ if ($def.page) {
}
# Build column width map: 1-based col -> width
$colWidthMap = @{}
if ($def.columnWidths) {
foreach ($prop in $def.columnWidths.PSObject.Properties) {
$val = "$($prop.Value)"
if ($val -match '^([0-9.]+)x$') {
$width = [int][math]::Round([double]$Matches[1] * $defaultWidth)
} else {
$width = [int]$val
function Build-ColWidthMap {
param($widths)
$map = @{}
if ($widths) {
foreach ($prop in $widths.PSObject.Properties) {
$val = "$($prop.Value)"
if ($val -match '^([0-9.]+)x$') {
$width = [int][math]::Round([double]$Matches[1] * $defaultWidth)
} else {
$width = [int]$val
}
foreach ($c in (Parse-ColumnSpec $prop.Name)) { $map[$c] = $width }
}
$columns = Parse-ColumnSpec $prop.Name
foreach ($c in $columns) {
$colWidthMap[$c] = $width
}
return $map
}
$colWidthMap = Build-ColWidthMap $def.columnWidths
# Колоночные раскладки: документные columns/columnWidths — раскладка по умолчанию (в XML
# элемент <columns> БЕЗ <id>, он всегда идёт первым). Дополнительные объявляются в
# columnSets, ключ — идентификатор, на него ссылается область ключом columnSet.
# Склейки по содержимому нет: в корпусе полно раскладок с одинаковым содержимым и разными
# идентификаторами, поэтому опознаёт раскладку только идентификатор.
$columnLayouts = @()
$columnLayouts += @{ Id = $null; Size = $totalColumns; Widths = $colWidthMap }
if ($def.columnSets) {
foreach ($prop in $def.columnSets.PSObject.Properties) {
$cs = $prop.Value
$size = if ($cs.columns) { [int]$cs.columns } else { $totalColumns }
$columnLayouts += @{
Id = $prop.Name
Size = $size
Widths = Build-ColWidthMap $cs.columnWidths
}
}
}
@@ -482,14 +508,17 @@ function Register-Format {
$defaultFormatKey = Get-FormatKey -width $defaultWidth
$defaultFormatIndex = Register-Format -key $defaultFormatKey -props @{ Width = $defaultWidth }
# 6b. Column width formats
$colFormatMap = @{} # 1-based col -> format index
foreach ($col in ($colWidthMap.Keys | Sort-Object)) {
$w = $colWidthMap[$col]
$key = Get-FormatKey -width $w
$idx = Register-Format -key $key -props @{ Width = $w }
$colFormatMap[[int]$col] = $idx
# 6b. Column width formats — по одной карте на каждую колоночную раскладку
foreach ($layout in $columnLayouts) {
$map = @{} # 1-based col -> format index
foreach ($col in ($layout.Widths.Keys | Sort-Object)) {
$w = $layout.Widths[$col]
$key = Get-FormatKey -width $w
$map[[int]$col] = Register-Format -key $key -props @{ Width = $w }
}
$layout.FormatMap = $map
}
$colFormatMap = $columnLayouts[0].FormatMap
# 6c. Scan areas for row heights and cell formats
# We need to do two passes: first collect all formats, then generate XML
@@ -551,7 +580,7 @@ function Set-CellProp {
}
function Expand-ShorthandRow {
param($row, [string]$areaName, [int]$rowIdx, $openByCol)
param($row, [string]$areaName, [int]$rowIdx, $openByCol, [int]$maxCols)
$cells = @()
$placed = @{} # 1-based col -> ячейка, занимающая колонку в ЭТОЙ строке
@@ -563,8 +592,8 @@ function Expand-ShorthandRow {
$idx++
# Внутри функции пишем в stderr напрямую: Write-Error приписал бы к сообщению имя
# функции, и текст перестал бы совпадать с py-портом.
if ($idx -gt $totalColumns) {
[Console]::Error.WriteLine("Row exceeds 'columns' ($totalColumns): area `"$areaName`", row $rowIdx")
if ($idx -gt $maxCols) {
[Console]::Error.WriteLine("Row exceeds 'columns' ($maxCols): area `"$areaName`", row $rowIdx")
exit 1
}
@@ -647,13 +676,19 @@ function Update-OpenByCol {
foreach ($area in $def.areas) {
$areaName = $area.name
# Ширина сетки берётся из раскладки области: у каждой она своя.
$areaMaxCols = $totalColumns
if ($area.PSObject.Properties['columnSet'] -and "$($area.columnSet)" -ne '') {
$lay = @($columnLayouts | Where-Object { $_.Id -eq "$($area.columnSet)" })[0]
if ($lay) { $areaMaxCols = [int]$lay.Size }
}
$openByCol = @{}
$rowIdx = 0
$expandedRows = @()
foreach ($row in $area.rows) {
$rowIdx++
if ($row -is [array]) {
$expandedRows += Expand-ShorthandRow -row $row -areaName $areaName -rowIdx $rowIdx -openByCol $openByCol
$expandedRows += Expand-ShorthandRow -row $row -areaName $areaName -rowIdx $rowIdx -openByCol $openByCol -maxCols $areaMaxCols
} else {
$expandedRows += $row
if ($row.empty) { $openByCol.Clear() } else { Update-OpenByCol -row $row -openByCol $openByCol }
@@ -721,23 +756,27 @@ X "`t`t</languageInfo>"
X "`t</languageSettings>"
# 7c. Columns
X "`t<columns>"
X "`t`t<size>$totalColumns</size>"
# Раскладка по умолчанию идёт первой и без <id> — так их хранит платформа.
foreach ($layout in $columnLayouts) {
X "`t<columns>"
if ($layout.Id) { X "`t`t<id>$($layout.Id)</id>" }
X "`t`t<size>$($layout.Size)</size>"
# Emit columnsItem for columns with non-default widths
foreach ($col in ($colFormatMap.Keys | Sort-Object)) {
$fmtIdx = $colFormatMap[$col]
$colIdx = $col - 1 # Convert to 0-based
X "`t`t<columnsItem>"
X "`t`t`t<index>$colIdx</index>"
X "`t`t`t<column>"
X "`t`t`t`t<formatIndex>$fmtIdx</formatIndex>"
X "`t`t`t</column>"
X "`t`t</columnsItem>"
# Emit columnsItem for columns with non-default widths
foreach ($col in ($layout.FormatMap.Keys | Sort-Object)) {
$fmtIdx = $layout.FormatMap[$col]
$colIdx = $col - 1 # Convert to 0-based
X "`t`t<columnsItem>"
X "`t`t`t<index>$colIdx</index>"
X "`t`t`t<column>"
X "`t`t`t`t<formatIndex>$fmtIdx</formatIndex>"
X "`t`t`t</column>"
X "`t`t</columnsItem>"
}
X "`t</columns>"
}
X "`t</columns>"
# 7d. Rows — main generation loop
$globalRow = 0
$merges = @()
@@ -749,6 +788,19 @@ foreach ($area in $def.areas) {
$areaName = $area.name
$activeRowspans = @() # @{ColStart=1-based; ColEnd=1-based; EndLocalRow=int}
$localRow = 0
# Ссылка области на колоночную раскладку — её получают все строки области.
$areaColumnSet = if ($area.PSObject.Properties['columnSet']) { "$($area.columnSet)" } else { '' }
$areaLayout = $columnLayouts[0]
if ($areaColumnSet) {
$areaLayout = @($columnLayouts | Where-Object { $_.Id -eq $areaColumnSet })[0]
if (-not $areaLayout) {
[Console]::Error.WriteLine("Unknown 'columnSet': `"$areaColumnSet`" is not declared in columnSets")
exit 1
}
}
# Ширина сетки — у КАЖДОЙ раскладки своя, поэтому позиции колонок сверяем с ней,
# а не с документным columns (у макетов с раскладками умолчание бывает и пустым).
$areaColumns = [int]$areaLayout.Size
foreach ($row in $area.rows) {
# Empty row placeholder: emit N empty rows
@@ -813,8 +865,8 @@ foreach ($area in $def.areas) {
if ($isFree) { break }
$cursor++
}
if (($cursor + $colSpan - 1) -gt $totalColumns) {
Write-Error "Row exceeds 'columns' ($totalColumns): area `"$areaName`", row $($localRow + 1)"
if (($cursor + $colSpan - 1) -gt $areaColumns) {
Write-Error "Row exceeds 'columns' ($areaColumns): area `"$areaName`", row $($localRow + 1)"
exit 1
}
$cell | Add-Member -NotePropertyName col -NotePropertyValue $cursor -Force
@@ -831,7 +883,7 @@ foreach ($area in $def.areas) {
foreach ($cell in $row.cells) {
$cellIdx++
$colParsed = 0
if (-not [int]::TryParse("$($cell.col)", [ref]$colParsed) -or $colParsed -lt 1 -or $colParsed -gt $totalColumns) {
if (-not [int]::TryParse("$($cell.col)", [ref]$colParsed) -or $colParsed -lt 1 -or $colParsed -gt $areaColumns) {
Write-Error "Invalid 'col' value `"$($cell.col)`": area `"$areaName`", row $($localRow + 1), cell $cellIdx"
exit 1
}
@@ -927,6 +979,10 @@ foreach ($area in $def.areas) {
X "`t`t<index>$globalRow</index>"
X "`t`t<row>"
if ($areaColumnSet) {
X "`t`t`t<columnsID>$areaColumnSet</columnsID>"
}
if ($rowFormatIdx -gt 0) {
X "`t`t`t<formatIndex>$rowFormatIdx</formatIndex>"
}