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>"
}
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# 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
import argparse
import json
@@ -348,10 +348,14 @@ def main():
with open(json_path, 'r', encoding='utf-8-sig') as f:
defn = ci_json(json.load(f))
if not defn.get('columns'):
# Проверяем НАЛИЧИЕ ключа, а не истинность значения: `columns: 0` — осмысленная величина
# (раскладка по умолчанию пустая, все строки живут в именованных раскладках), а пустой
# список областей встречается у макета без строк. Прежняя проверка объявляла и то
# и другое отсутствующим.
if 'columns' not in defn or defn.get('columns') is None:
print("Required field 'columns' is missing", file=sys.stderr)
sys.exit(1)
if not defn.get('areas'):
if 'areas' not in defn or defn.get('areas') is None:
print("Required field 'areas' is missing", file=sys.stderr)
sys.exit(1)
@@ -473,18 +477,32 @@ def main():
default_width = round((target_width - absolute_sum) / total_units)
# Build column width map: 1-based col -> width
col_width_map = {}
if defn.get('columnWidths'):
for prop_name, prop_value in defn['columnWidths'].items():
val = str(prop_value)
m = re.match(r'^([0-9.]+)x$', val)
if m:
width = round(float(m.group(1)) * default_width)
else:
width = int(val)
columns = parse_column_spec(prop_name)
for c in columns:
col_width_map[c] = width
def build_col_width_map(widths):
out = {}
if widths:
for prop_name, prop_value in widths.items():
val = str(prop_value)
m = re.match(r'^([0-9.]+)x$', val)
width = round(float(m.group(1)) * default_width) if m else int(val)
for c in parse_column_spec(prop_name):
out[c] = width
return out
col_width_map = build_col_width_map(defn.get('columnWidths'))
# Колоночные раскладки: документные columns/columnWidths — раскладка по умолчанию (в XML
# элемент <columns> БЕЗ <id>, он всегда идёт первым). Дополнительные объявляются в
# columnSets, ключ — идентификатор, на него ссылается область ключом columnSet.
# Склейки по содержимому нет: в корпусе полно раскладок с одинаковым содержимым и разными
# идентификаторами, поэтому опознаёт раскладку только идентификатор.
column_layouts = [{'Id': None, 'Size': total_columns, 'Widths': col_width_map}]
for set_id, cs in (defn.get('columnSets') or {}).items():
size = int(cs['columns']) if cs.get('columns') is not None else total_columns
column_layouts.append({
'Id': set_id,
'Size': size,
'Widths': build_col_width_map(cs.get('columnWidths')),
})
# --- 5. Style resolver ---
def resolve_style(style_name, fill_type):
@@ -560,13 +578,14 @@ def main():
default_format_key = get_format_key(width=default_width)
default_format_index = register_format(default_format_key, {'Width': default_width})
# 6b. Column width formats
col_format_map = {} # 1-based col -> format index
for col in sorted(col_width_map):
w = col_width_map[col]
key = get_format_key(width=w)
idx = register_format(key, {'Width': w})
col_format_map[int(col)] = idx
# 6b. Column width formats — по одной карте на каждую колоночную раскладку
for layout in column_layouts:
fmap = {} # 1-based col -> format index
for col in sorted(layout['Widths']):
w = layout['Widths'][col]
fmap[int(col)] = register_format(get_format_key(width=w), {'Width': w})
layout['FormatMap'] = fmap
col_format_map = column_layouts[0]['FormatMap']
# 6c. Helper: determine fillType from cell content
def get_fill_type(cell):
@@ -604,15 +623,15 @@ def main():
# "{Имя}" — параметр. Разворачиваем в обычную строку с явными col/span/rowspan,
# поэтому весь код ниже про шорткат не знает.
def expand_shorthand_row(row, area_name, row_idx, open_by_col):
def expand_shorthand_row(row, area_name, row_idx, open_by_col, max_cols):
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}",'
if idx > max_cols:
print(f'Row exceeds \'columns\' ({max_cols}): area "{area_name}",'
f' row {row_idx}', file=sys.stderr)
sys.exit(1)
@@ -693,11 +712,17 @@ def main():
for area in defn['areas']:
area_name = area.get('name', '')
# Ширина сетки берётся из раскладки области: у каждой она своя.
area_max_cols = total_columns
if area.get('columnSet'):
lay = next((x for x in column_layouts if x['Id'] == str(area['columnSet'])), None)
if lay:
area_max_cols = int(lay['Size'])
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))
expanded_rows.append(expand_shorthand_row(row, area_name, row_idx, open_by_col, area_max_cols))
else:
expanded_rows.append(row)
if row.get('empty'):
@@ -757,21 +782,25 @@ def main():
lines.append('\t</languageSettings>')
# 7c. Columns
lines.append('\t<columns>')
lines.append(f'\t\t<size>{total_columns}</size>')
# Раскладка по умолчанию идёт первой и без <id> — так их хранит платформа.
for layout in column_layouts:
lines.append('\t<columns>')
if layout['Id']:
lines.append(f'\t\t<id>{layout["Id"]}</id>')
lines.append(f'\t\t<size>{layout["Size"]}</size>')
# Emit columnsItem for columns with non-default widths
for col in sorted(col_format_map.keys()):
fmt_idx = col_format_map[col]
col_idx = col - 1 # Convert to 0-based
lines.append('\t\t<columnsItem>')
lines.append(f'\t\t\t<index>{col_idx}</index>')
lines.append('\t\t\t<column>')
lines.append(f'\t\t\t\t<formatIndex>{fmt_idx}</formatIndex>')
lines.append('\t\t\t</column>')
lines.append('\t\t</columnsItem>')
# Emit columnsItem for columns with non-default widths
for col in sorted(layout['FormatMap'].keys()):
fmt_idx = layout['FormatMap'][col]
col_idx = col - 1 # Convert to 0-based
lines.append('\t\t<columnsItem>')
lines.append(f'\t\t\t<index>{col_idx}</index>')
lines.append('\t\t\t<column>')
lines.append(f'\t\t\t\t<formatIndex>{fmt_idx}</formatIndex>')
lines.append('\t\t\t</column>')
lines.append('\t\t</columnsItem>')
lines.append('\t</columns>')
lines.append('\t</columns>')
# 7d. Rows -- main generation loop
global_row = 0
@@ -784,6 +813,18 @@ def main():
area_name = area.get('name', '')
active_rowspans = []
local_row = 0
# Ссылка области на колоночную раскладку — её получают все строки области.
area_column_set = str(area.get('columnSet') or '')
area_layout = column_layouts[0]
if area_column_set:
area_layout = next((x for x in column_layouts if x['Id'] == area_column_set), None)
if area_layout is None:
print(f'Unknown \'columnSet\': "{area_column_set}" is not declared in columnSets',
file=sys.stderr)
sys.exit(1)
# Ширина сетки — у КАЖДОЙ раскладки своя, поэтому позиции колонок сверяем с ней,
# а не с документным columns (у макетов с раскладками умолчание бывает и пустым).
area_columns = int(area_layout['Size'])
for row in area.get('rows', []):
# Empty row placeholder: emit N empty rows
@@ -832,8 +873,8 @@ def main():
col_span = int(cell.get('span', 1))
while any(c in rowspan_occupied for c in range(cursor, cursor + col_span)):
cursor += 1
if cursor + col_span - 1 > total_columns:
print(f'Row exceeds \'columns\' ({total_columns}): area "{area_name}",'
if cursor + col_span - 1 > area_columns:
print(f'Row exceeds \'columns\' ({area_columns}): area "{area_name}",'
f' row {local_row + 1}', file=sys.stderr)
sys.exit(1)
cell['col'] = cursor
@@ -847,7 +888,7 @@ def main():
# ронял py голым KeyError/ValueError, а ps1 молча писал Col = -1.
for cell_idx, cell in enumerate(row['cells'], start=1):
col_parsed = parse_col_value(cell.get('col'))
if col_parsed is None or col_parsed < 1 or col_parsed > total_columns:
if col_parsed is None or col_parsed < 1 or col_parsed > area_columns:
print(f'Invalid \'col\' value "{cell.get("col")}": area "{area_name}",'
f' row {local_row + 1}, cell {cell_idx}', file=sys.stderr)
sys.exit(1)
@@ -933,6 +974,9 @@ def main():
lines.append(f'\t\t<index>{global_row}</index>')
lines.append('\t\t<row>')
if area_column_set:
lines.append(f'\t\t\t<columnsID>{area_column_set}</columnsID>')
if row_format_idx > 0:
lines.append(f'\t\t\t<formatIndex>{row_format_idx}</formatIndex>')
@@ -1,4 +1,4 @@
# mxl-decompile v1.4 — Decompile 1C spreadsheet to JSON
# mxl-decompile v1.5 — Decompile 1C spreadsheet to JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -117,15 +117,11 @@ function Get-Format {
# --- 5. Extract columns and default width ---
$colNode = $root.SelectSingleNode("d:columns", $ns)
$totalColumns = [int]$colNode.SelectSingleNode("d:size", $ns).InnerText
$colFormatIndices = @{}
foreach ($ci in $colNode.SelectNodes("d:columnsItem", $ns)) {
$colIdx = [int]$ci.SelectSingleNode("d:index", $ns).InnerText
$fmtIdx = [int]$ci.SelectSingleNode("d:column/d:formatIndex", $ns).InnerText
$colFormatIndices[$colIdx] = $fmtIdx
}
# Колоночная раскладка («индивидуальная ширина колонок» для группы строк) — элемент <columns>.
# Их бывает несколько: раскладка БЕЗ <id> — умолчание (ровно одна в каждом макете корпуса),
# остальные адресуются GUID из <id>, на который ссылаются строки (<row><columnsID>) и
# области (<area><columnsID>). Раньше читался только первый <columns> — отсюда терялись
# ширины и вылезал columns: 0.
$defaultFmtIdx = 0
$n = $root.SelectSingleNode("d:defaultFormatIndex", $ns)
@@ -137,16 +133,39 @@ if ($defaultFmtIdx -gt 0) {
if ($defFmt -and $defFmt.Width -gt 0) { $defaultWidth = $defFmt.Width }
}
# Build column width map (1-based col → width), only non-default
$colWidthMap = [ordered]@{}
foreach ($col0 in ($colFormatIndices.Keys | Sort-Object)) {
$fmt = Get-Format $colFormatIndices[$col0]
if ($fmt -and $fmt.Width -gt 0 -and $fmt.Width -ne $defaultWidth) {
$col1 = [string]($col0 + 1)
$colWidthMap.Add($col1, $fmt.Width)
function Read-ColumnSet {
param($node)
$byIdx = @{}
foreach ($ci in $node.SelectNodes("d:columnsItem", $ns)) {
$colIdx = [int]$ci.SelectSingleNode("d:index", $ns).InnerText
$fmtIdx = [int]$ci.SelectSingleNode("d:column/d:formatIndex", $ns).InnerText
$byIdx[$colIdx] = $fmtIdx
}
# Карта ширин (1-based колонка → ширина), только отличные от умолчания.
$widths = [ordered]@{}
foreach ($col0 in ($byIdx.Keys | Sort-Object)) {
$fmt = Get-Format $byIdx[$col0]
if ($fmt -and $fmt.Width -gt 0 -and $fmt.Width -ne $defaultWidth) {
$widths.Add([string]($col0 + 1), $fmt.Width)
}
}
$sizeNode = $node.SelectSingleNode("d:size", $ns)
$idNode = $node.SelectSingleNode("d:id", $ns)
return @{
Id = if ($idNode) { $idNode.InnerText } else { $null }
Size = if ($sizeNode) { [int]$sizeNode.InnerText } else { 0 }
Widths = $widths
}
}
$columnSets = @()
foreach ($cn in $root.SelectNodes("d:columns", $ns)) { $columnSets += Read-ColumnSet $cn }
$defaultSet = @($columnSets | Where-Object { -not $_.Id })[0]
if (-not $defaultSet -and $columnSets.Count -gt 0) { $defaultSet = $columnSets[0] }
$totalColumns = if ($defaultSet) { $defaultSet.Size } else { 0 }
$colWidthMap = if ($defaultSet) { $defaultSet.Widths } else { [ordered]@{} }
# --- 6. Extract merges ---
$mergeMap = @{}
@@ -243,11 +262,17 @@ foreach ($riNode in $root.SelectNodes("d:rowsItem", $ns)) {
}
}
# Ссылка строки на колоночную раскладку; пусто = раскладка по умолчанию.
$rowColumnsId = $null
$cidNode = $rowNode.SelectSingleNode("d:columnsID", $ns)
if ($cidNode) { $rowColumnsId = $cidNode.InnerText }
for ($r = $rowIdx; $r -le $indexTo; $r++) {
$rowData[$r] = @{
FormatIdx = $rowFmtIdx
Cells = $cells
Empty = $isEmpty
ColumnsId = $rowColumnsId
}
}
}
@@ -453,9 +478,26 @@ function Get-StyleName {
$maxRowIdx = -1
foreach ($k in $rowData.Keys) { if ([int]$k -gt $maxRowIdx) { $maxRowIdx = [int]$k } }
function Get-RowColumnsId {
param([int]$r)
if ($rowData.ContainsKey($r)) { return $rowData[$r].ColumnsId }
return $null
}
# Область годится в качестве области-диапазона, только если у всех её строк ОДНА раскладка:
# иначе её пришлось бы резать, а имя резать нельзя — такая уходит в координатный список.
function Test-UniformColumnSet {
param([int]$from, [int]$to)
$first = Get-RowColumnsId $from
for ($r = $from + 1; $r -le $to; $r++) {
if ((Get-RowColumnsId $r) -ne $first) { return $false }
}
return $true
}
$blockAreas = @()
$overlayAreas = @()
$claimed = @{} # строка → занята блоком
$claimed = @{} # строка → занята областью-диапазоном
foreach ($a in @($namedAreas | Sort-Object @{ Expression = { $_.BeginRow } }, @{ Expression = { $_.EndRow } })) {
$fitsBlock = ($a.Type -eq 'Rows' -and $a.BeginRow -ge 0 -and $a.EndRow -ge $a.BeginRow)
if ($fitsBlock) {
@@ -463,6 +505,7 @@ foreach ($a in @($namedAreas | Sort-Object @{ Expression = { $_.BeginRow } }, @{
if ($claimed.ContainsKey($r)) { $fitsBlock = $false; break }
}
}
if ($fitsBlock) { $fitsBlock = Test-UniformColumnSet $a.BeginRow $a.EndRow }
if ($fitsBlock) {
for ($r = $a.BeginRow; $r -le $a.EndRow; $r++) { $claimed[$r] = $true }
$blockAreas += $a
@@ -471,18 +514,37 @@ foreach ($a in @($namedAreas | Sort-Object @{ Expression = { $_.BeginRow } }, @{
}
}
# Блоки в порядке строк + безымянные заполнители дыр.
# Безымянный промежуток режем на куски с одной раскладкой: границы наборов не совпадают
# с границами именованных областей, а у области должна быть ровно одна раскладка.
function Split-GapByColumnSet {
param([int]$from, [int]$to)
$out = @()
if ($to -lt $from) { return $out }
$runStart = $from
$runId = Get-RowColumnsId $from
for ($r = $from + 1; $r -le $to; $r++) {
$id = Get-RowColumnsId $r
if ($id -ne $runId) {
$out += @{ Name = $null; BeginRow = $runStart; EndRow = $r - 1; ColumnsId = $runId }
$runStart = $r; $runId = $id
}
}
$out += @{ Name = $null; BeginRow = $runStart; EndRow = $to; ColumnsId = $runId }
return $out
}
# Области в порядке строк + безымянные заполнители дыр.
$blocks = @()
$cursor = 0
foreach ($a in @($blockAreas | Sort-Object @{ Expression = { $_.BeginRow } })) {
if ($a.BeginRow -gt $cursor) {
$blocks += @{ Name = $null; BeginRow = $cursor; EndRow = $a.BeginRow - 1 }
$blocks += Split-GapByColumnSet $cursor ($a.BeginRow - 1)
}
$blocks += @{ Name = $a.Name; BeginRow = $a.BeginRow; EndRow = $a.EndRow }
$blocks += @{ Name = $a.Name; BeginRow = $a.BeginRow; EndRow = $a.EndRow; ColumnsId = (Get-RowColumnsId $a.BeginRow) }
$cursor = $a.EndRow + 1
}
if ($cursor -le $maxRowIdx) {
$blocks += @{ Name = $null; BeginRow = $cursor; EndRow = $maxRowIdx }
$blocks += Split-GapByColumnSet $cursor $maxRowIdx
}
$dslAreas = @()
@@ -609,8 +671,11 @@ foreach ($area in $blocks) {
}
$dslBlock = [ordered]@{}
# Безымянный блок — просто кусок сетки, ключ name у него не пишем.
# Область без имени — просто кусок сетки, ключ name у неё не пишем.
if (-not [string]::IsNullOrEmpty($area.Name)) { $dslBlock["name"] = $area.Name }
# Ссылка на колоночную раскладку по имени из columnSets — как style у ячейки на styles.
# Умолчание (раскладка без id) не пишем.
if (-not [string]::IsNullOrEmpty($area.ColumnsId)) { $dslBlock["columnSet"] = $area.ColumnsId }
$dslBlock["rows"] = [array]$compressedRows
$dslAreas += $dslBlock
}
@@ -682,6 +747,21 @@ foreach ($s in $toRemove) { $styleDefs.Remove($s)
$result["fonts"] = $fontsOut
$result["styles"] = $styleDefs
# Колоночные раскладки помимо умолчания: ключ — идентификатор из макета, на него ссылаются
# области. Содержимое раскладку не опознаёт (в корпусе полно наборов с одинаковым
# содержимым и разными id), поэтому склейки по содержимому нет.
$extraSets = @($columnSets | Where-Object { $_.Id })
if ($extraSets.Count -gt 0) {
$setsOut = [ordered]@{}
foreach ($cs in $extraSets) {
$entry = [ordered]@{ columns = $cs.Size }
if ($cs.Widths.Count -gt 0) { $entry["columnWidths"] = $cs.Widths }
$setsOut[$cs.Id] = $entry
}
$result["columnSets"] = $setsOut
}
$result["areas"] = [array]$dslAreas
# Именованные области, не выразимые блоком, — координатами. Тип не пишем: он выводится
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# mxl-decompile v1.4 — Decompile 1C spreadsheet to JSON
# mxl-decompile v1.5 — Decompile 1C spreadsheet to JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
@@ -296,14 +296,11 @@ def main():
# --- 5. Extract columns and default width ---
col_node = find(root, "d:columns")
total_columns = int_of(find(col_node, "d:size"))
col_format_indices = {}
for ci in findall(col_node, "d:columnsItem"):
col_idx = int_of(find(ci, "d:index"))
fmt_idx = int_of(find(ci, "d:column/d:formatIndex"))
col_format_indices[col_idx] = fmt_idx
# Колоночная раскладка («индивидуальная ширина колонок» для группы строк) — элемент <columns>.
# Их бывает несколько: раскладка БЕЗ <id> — умолчание (ровно одна в каждом макете корпуса),
# остальные адресуются GUID из <id>, на который ссылаются строки (<row><columnsID>) и
# области (<area><columnsID>). Раньше читался только первый <columns> — отсюда терялись
# ширины и вылезал columns: 0.
default_fmt_idx = 0
n = find(root, "d:defaultFormatIndex")
@@ -316,13 +313,31 @@ def main():
if def_fmt and def_fmt["Width"] > 0:
default_width = def_fmt["Width"]
# Build column width map (1-based col -> width), only non-default
col_width_map = OrderedDict()
for col0 in sorted(col_format_indices.keys()):
fmt = get_format(col_format_indices[col0])
if fmt and fmt["Width"] > 0 and fmt["Width"] != default_width:
col1 = str(col0 + 1)
col_width_map[col1] = fmt["Width"]
def read_column_set(node):
by_idx = {}
for ci in findall(node, "d:columnsItem"):
by_idx[int_of(find(ci, "d:index"))] = int_of(find(ci, "d:column/d:formatIndex"))
# Карта ширин (1-based колонка → ширина), только отличные от умолчания.
widths = OrderedDict()
for col0 in sorted(by_idx.keys()):
fmt = get_format(by_idx[col0])
if fmt and fmt["Width"] > 0 and fmt["Width"] != default_width:
widths[str(col0 + 1)] = fmt["Width"]
id_node = find(node, "d:id")
size_node = find(node, "d:size")
return {
"Id": (text_of(id_node) or None) if id_node is not None else None,
"Size": int_of(size_node) if size_node is not None else 0,
"Widths": widths,
}
column_sets = [read_column_set(cn) for cn in findall(root, "d:columns")]
default_set = next((c for c in column_sets if not c["Id"]), None)
if default_set is None and column_sets:
default_set = column_sets[0]
total_columns = default_set["Size"] if default_set else 0
col_width_map = default_set["Widths"] if default_set else OrderedDict()
# --- 6. Extract merges ---
@@ -426,11 +441,16 @@ def main():
"Text": text,
})
# Ссылка строки на колоночную раскладку; пусто = раскладка по умолчанию.
cid_node = find(row_node, "d:columnsID")
row_columns_id = text_of(cid_node) if cid_node is not None else None
for r in range(row_idx, index_to + 1):
row_data[r] = {
"FormatIdx": row_fmt_idx,
"Cells": cells,
"Empty": is_empty,
"ColumnsId": row_columns_id,
}
# --- 9. Build style key (ignoring fillType) ---
@@ -645,6 +665,16 @@ def main():
max_row_idx = max(row_data.keys()) if row_data else -1
def row_columns_id(r):
rd = row_data.get(r)
return rd["ColumnsId"] if rd else None
def uniform_column_set(frm, to):
"""Область годится в качестве области-диапазона, только если у всех её строк ОДНА
раскладка: иначе её пришлось бы резать, а имя резать нельзя."""
first = row_columns_id(frm)
return all(row_columns_id(r) == first for r in range(frm + 1, to + 1))
block_areas = []
overlay_areas = []
claimed = set()
@@ -655,22 +685,41 @@ def main():
if r in claimed:
fits = False
break
if fits:
fits = uniform_column_set(a["BeginRow"], a["EndRow"])
if fits:
claimed.update(range(a["BeginRow"], a["EndRow"] + 1))
block_areas.append(a)
else:
overlay_areas.append(a)
# Блоки в порядке строк + безымянные заполнители дыр.
def split_gap_by_column_set(frm, to):
"""Безымянный промежуток режем на куски с одной раскладкой: границы наборов не
совпадают с границами именованных областей."""
out = []
if to < frm:
return out
run_start = frm
run_id = row_columns_id(frm)
for r in range(frm + 1, to + 1):
cid = row_columns_id(r)
if cid != run_id:
out.append({"Name": None, "BeginRow": run_start, "EndRow": r - 1, "ColumnsId": run_id})
run_start, run_id = r, cid
out.append({"Name": None, "BeginRow": run_start, "EndRow": to, "ColumnsId": run_id})
return out
# Области в порядке строк + безымянные заполнители дыр.
blocks = []
cursor = 0
for a in sorted(block_areas, key=lambda x: x["BeginRow"]):
if a["BeginRow"] > cursor:
blocks.append({"Name": None, "BeginRow": cursor, "EndRow": a["BeginRow"] - 1})
blocks.append({"Name": a["Name"], "BeginRow": a["BeginRow"], "EndRow": a["EndRow"]})
blocks.extend(split_gap_by_column_set(cursor, a["BeginRow"] - 1))
blocks.append({"Name": a["Name"], "BeginRow": a["BeginRow"], "EndRow": a["EndRow"],
"ColumnsId": row_columns_id(a["BeginRow"])})
cursor = a["EndRow"] + 1
if cursor <= max_row_idx:
blocks.append({"Name": None, "BeginRow": cursor, "EndRow": max_row_idx})
blocks.extend(split_gap_by_column_set(cursor, max_row_idx))
dsl_areas = []
@@ -789,9 +838,13 @@ def main():
compressed_rows.append(OrderedDict([("empty", empty_run)]))
dsl_block = OrderedDict()
# Безымянный блок — просто кусок сетки, ключ name у него не пишем.
# Область без имени — просто кусок сетки, ключ name у неё не пишем.
if area["Name"]:
dsl_block["name"] = area["Name"]
# Ссылка на колоночную раскладку по имени из columnSets — как style у ячейки на styles.
# Умолчание (раскладка без id) не пишем.
if area.get("ColumnsId"):
dsl_block["columnSet"] = area["ColumnsId"]
dsl_block["rows"] = compressed_rows
dsl_areas.append(dsl_block)
@@ -875,6 +928,20 @@ def main():
result["fonts"] = fonts_out
result["styles"] = style_defs
# Колоночные раскладки помимо умолчания: ключ — идентификатор из макета, на него ссылаются
# области. Содержимое раскладку не опознаёт (в корпусе полно наборов с одинаковым
# содержимым и разными id), поэтому склейки по содержимому нет.
extra_sets = [c for c in column_sets if c["Id"]]
if extra_sets:
sets_out = OrderedDict()
for cs in extra_sets:
entry = OrderedDict([("columns", cs["Size"])])
if cs["Widths"]:
entry["columnWidths"] = cs["Widths"]
sets_out[cs["Id"]] = entry
result["columnSets"] = sets_out
result["areas"] = dsl_areas
# Именованные области, не выразимые блоком, — координатами. Тип не пишем: он выводится