From f33436adffcb39234f9e2b93a94fef4962851f6f Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 10 Aug 2026 13:04:03 +0300 Subject: [PATCH] =?UTF-8?q?feat(mxl-compile):=20=D0=BF=D1=80=D0=BE=D1=89?= =?UTF-8?q?=D0=B0=D1=8E=D1=89=D0=B8=D0=B9=20=D0=B2=D0=B2=D0=BE=D0=B4=20?= =?UTF-8?q?=E2=80=94=20=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D0=B0=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20col?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ячейка без col роняла py голым KeyError, а ps1 молча брал null как 0 и писал Col = -1 — битую ячейку без единого сообщения. При этом опустить col модели естественно: DSL устроен как HTML-таблица (rows → cells), и одиночный заголовок или обычная строка пишутся без позиций. Строка, в которой col нет НИ У ОДНОЙ ячейки, теперь раскладывается слева направо с учётом span и занятых сверху rowspan-колонок. Смешанную строку не угадываем — это опечатка; переполнение columns и явный col вне 1..columns тоже дают внятную ошибку в stderr вместо тихой порчи. Документация не менялась: col остаётся единственной каноничной формой, прощающий ввод живёт только в коде — как регистронезависимость ключей DSL. Второй способ адресации в SKILL.md превратил бы одно правило в развилку. Правка сделана на ps1 и зазеркалена в py; вывод портов на общем примере совпадает байт в байт. Co-Authored-By: Claude Opus 5 (1M context) --- .../mxl-compile/scripts/mxl-compile.ps1 | 45 ++++- .../skills/mxl-compile/scripts/mxl-compile.py | 49 ++++- .../mxl-compile/error-col-out-of-range.json | 18 ++ .../mxl-compile/error-implicit-overflow.json | 18 ++ .../cases/mxl-compile/error-mixed-row.json | 18 ++ .../mxl-compile/lenient-implicit-cols.json | 26 +++ .../lenient-implicit-cols/Template.xml | 188 ++++++++++++++++++ 7 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 tests/skills/cases/mxl-compile/error-col-out-of-range.json create mode 100644 tests/skills/cases/mxl-compile/error-implicit-overflow.json create mode 100644 tests/skills/cases/mxl-compile/error-mixed-row.json create mode 100644 tests/skills/cases/mxl-compile/lenient-implicit-cols.json create mode 100644 tests/skills/cases/mxl-compile/snapshots/lenient-implicit-cols/Template.xml diff --git a/.claude/skills/mxl-compile/scripts/mxl-compile.ps1 b/.claude/skills/mxl-compile/scripts/mxl-compile.ps1 index ffb6ffb9..0b9630a5 100644 --- a/.claude/skills/mxl-compile/scripts/mxl-compile.ps1 +++ b/.claude/skills/mxl-compile/scripts/mxl-compile.ps1 @@ -1,4 +1,4 @@ -# mxl-compile v1.14 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи) +# mxl-compile v1.15 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -652,6 +652,49 @@ foreach ($area in $def.areas) { if ($row.cells -and $row.cells.Count -gt 0) { $rowHasContent = $true + # Прощающий ввод: строка, в которой НИ У ОДНОЙ ячейки нет col, раскладывается + # слева направо с учётом span и rowspan сверху. Канон один и он в документации — + # col обязателен; здесь мы лишь спасаем естественный DSL вместо тихой порчи + # ($null -> [int]0 -> Col = -1). Смешанную строку не угадываем: это опечатка. + $positioned = @($row.cells | Where-Object { + $_.PSObject.Properties['col'] -and $null -ne $_.col -and "$($_.col)" -ne "" + }) + if ($positioned.Count -eq 0) { + $cursor = 1 + foreach ($cell in $row.cells) { + $colSpan = if ($cell.span) { [int]$cell.span } else { 1 } + while ($true) { + $isFree = $true + for ($c = $cursor; $c -lt ($cursor + $colSpan); $c++) { + if ($rowspanOccupied[$c]) { $isFree = $false; break } + } + if ($isFree) { break } + $cursor++ + } + if (($cursor + $colSpan - 1) -gt $totalColumns) { + Write-Error "Row exceeds 'columns' ($totalColumns): area `"$areaName`", row $($localRow + 1)" + exit 1 + } + $cell | Add-Member -NotePropertyName col -NotePropertyValue $cursor -Force + $cursor += $colSpan + } + } elseif ($positioned.Count -ne $row.cells.Count) { + Write-Error "Cell without 'col' mixed with positioned cells: area `"$areaName`", row $($localRow + 1)" + exit 1 + } + + # Позиция обязана быть в 1..columns: до этой проверки нечисловой или нулевой col + # молча превращался в Col = -1 и давал битую ячейку без единого сообщения. + $cellIdx = 0 + foreach ($cell in $row.cells) { + $cellIdx++ + $colParsed = 0 + if (-not [int]::TryParse("$($cell.col)", [ref]$colParsed) -or $colParsed -lt 1 -or $colParsed -gt $totalColumns) { + Write-Error "Invalid 'col' value `"$($cell.col)`": area `"$areaName`", row $($localRow + 1), cell $cellIdx" + exit 1 + } + } + # Build set of occupied columns (1-based): explicit cells + rowspan from above $occupiedCols = @{} foreach ($rsk in $rowspanOccupied.Keys) { $occupiedCols[$rsk] = $true } diff --git a/.claude/skills/mxl-compile/scripts/mxl-compile.py b/.claude/skills/mxl-compile/scripts/mxl-compile.py index 68685cf2..be3bae0d 100644 --- a/.claude/skills/mxl-compile/scripts/mxl-compile.py +++ b/.claude/skills/mxl-compile/scripts/mxl-compile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# mxl-compile v1.14 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи) +# mxl-compile v1.15 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json @@ -297,6 +297,21 @@ def format_rank(ver): return int(m.group(1)) * 100 + int(m.group(2)) if m else 0 +def parse_col_value(val): + """Позиция колонки как целое, иначе None. Аналог [int]::TryParse в ps1: + целое из JSON приходит int, "3" — строкой, 3.0 — float (ps1 печатает такое как "3").""" + if isinstance(val, bool) or val is None: + return None + if isinstance(val, int): + return val + if isinstance(val, float): + return int(val) if val.is_integer() else None + try: + return int(str(val).strip()) + except (TypeError, ValueError): + return None + + def main(): sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") @@ -690,6 +705,38 @@ def main(): if row.get('cells') and len(row['cells']) > 0: row_has_content = True + # Прощающий ввод: строка, в которой НИ У ОДНОЙ ячейки нет col, раскладывается + # слева направо с учётом span и rowspan сверху. Канон один и он в документации — + # col обязателен; здесь мы лишь спасаем естественный DSL вместо тихой порчи + # (в ps1 $null -> [int]0 -> Col = -1). Смешанную строку не угадываем: это опечатка. + positioned = [c for c in row['cells'] + if 'col' in c and c.get('col') is not None and str(c.get('col')) != ''] + if len(positioned) == 0: + cursor = 1 + for cell in row['cells']: + 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}",' + f' row {local_row + 1}', file=sys.stderr) + sys.exit(1) + cell['col'] = cursor + cursor += col_span + elif len(positioned) != len(row['cells']): + print(f'Cell without \'col\' mixed with positioned cells: area "{area_name}",' + f' row {local_row + 1}', file=sys.stderr) + sys.exit(1) + + # Позиция обязана быть в 1..columns: до этой проверки нечисловой или нулевой col + # ронял 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: + 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) + # Build set of occupied columns (1-based) occupied_cols = dict(rowspan_occupied) for cell in row['cells']: diff --git a/tests/skills/cases/mxl-compile/error-col-out-of-range.json b/tests/skills/cases/mxl-compile/error-col-out-of-range.json new file mode 100644 index 00000000..fd6538d5 --- /dev/null +++ b/tests/skills/cases/mxl-compile/error-col-out-of-range.json @@ -0,0 +1,18 @@ +{ + "name": "Ошибка: явный col вне диапазона 1..columns", + "input": { + "columns": 3, + "areas": [ + { + "name": "Шапка", + "rows": [ + { "cells": [{ "col": 0, "text": "А" }] } + ] + } + ] + }, + "params": { + "outputPath": "Template.xml" + }, + "expectError": "Invalid 'col' value \"0\": area \"Шапка\", row 1, cell 1" +} diff --git a/tests/skills/cases/mxl-compile/error-implicit-overflow.json b/tests/skills/cases/mxl-compile/error-implicit-overflow.json new file mode 100644 index 00000000..e6f7d677 --- /dev/null +++ b/tests/skills/cases/mxl-compile/error-implicit-overflow.json @@ -0,0 +1,18 @@ +{ + "name": "Ошибка: раскладка без col не влезает в columns", + "input": { + "columns": 3, + "areas": [ + { + "name": "Шапка", + "rows": [ + { "cells": [{ "text": "А" }, { "text": "Б" }, { "text": "В" }, { "text": "Г" }] } + ] + } + ] + }, + "params": { + "outputPath": "Template.xml" + }, + "expectError": "Row exceeds 'columns' (3): area \"Шапка\", row 1" +} diff --git a/tests/skills/cases/mxl-compile/error-mixed-row.json b/tests/skills/cases/mxl-compile/error-mixed-row.json new file mode 100644 index 00000000..9101058b --- /dev/null +++ b/tests/skills/cases/mxl-compile/error-mixed-row.json @@ -0,0 +1,18 @@ +{ + "name": "Ошибка: в строке часть ячеек с col, часть без", + "input": { + "columns": 3, + "areas": [ + { + "name": "Шапка", + "rows": [ + { "cells": [{ "col": 1, "text": "А" }, { "text": "Б" }] } + ] + } + ] + }, + "params": { + "outputPath": "Template.xml" + }, + "expectError": "Cell without 'col' mixed with positioned cells: area \"Шапка\", row 1" +} diff --git a/tests/skills/cases/mxl-compile/lenient-implicit-cols.json b/tests/skills/cases/mxl-compile/lenient-implicit-cols.json new file mode 100644 index 00000000..d538a51c --- /dev/null +++ b/tests/skills/cases/mxl-compile/lenient-implicit-cols.json @@ -0,0 +1,26 @@ +{ + "name": "Прощающий ввод: строка без col раскладывается слева направо", + "input": { + "columns": 3, + "areas": [ + { + "name": "Таблица", + "rows": [ + { "cells": [{ "text": "А" }, { "text": "Б" }, { "text": "В" }] }, + { "cells": [{ "col": 1, "rowspan": 2, "text": "Сквозная" }, { "col": 2, "text": "X" }, { "col": 3, "text": "Y" }] }, + { "cells": [{ "text": "П" }, { "text": "Р" }] }, + { "cells": [{ "span": 2, "text": "Широкая" }, { "text": "Хвост" }] } + ] + } + ] + }, + "params": { + "outputPath": "Template.xml" + }, + "validatePath": "Template.xml", + "expect": { + "files": [ + "Template.xml" + ] + } +} diff --git a/tests/skills/cases/mxl-compile/snapshots/lenient-implicit-cols/Template.xml b/tests/skills/cases/mxl-compile/snapshots/lenient-implicit-cols/Template.xml new file mode 100644 index 00000000..e0a92019 --- /dev/null +++ b/tests/skills/cases/mxl-compile/snapshots/lenient-implicit-cols/Template.xml @@ -0,0 +1,188 @@ + + + + ru + ru + + ru + Русский + Русский + + + + 3 + + + 0 + + + 0 + + 2 + + + ru + А + + + + + + 1 + + 2 + + + ru + Б + + + + + + 2 + + 2 + + + ru + В + + + + + + + + 1 + + + 0 + + 2 + + + ru + Сквозная + + + + + + 1 + + 2 + + + ru + X + + + + + + 2 + + 2 + + + ru + Y + + + + + + + + 2 + + + 1 + + 2 + + + ru + П + + + + + + 2 + + 2 + + + ru + Р + + + + + + + + 3 + + + 0 + + 2 + + + ru + Широкая + + + + + + 2 + + 2 + + + ru + Хвост + + + + + + + true + 1 + 4 + 4 + + 1 + 0 + 1 + 0 + + + 3 + 0 + 1 + + + Таблица + + Rows + 0 + 3 + -1 + -1 + + + + + 10 + + + 0 + Text + + \ No newline at end of file