# mxl-compile v1.21 — Compile 1C spreadsheet from JSON (+write_xml_file/write_utf8_bom: общий эталон записи) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] [string]$JsonPath, [Parameter(Mandatory)] [string]$OutputPath ) $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 # --- Support guard (Ext/ParentConfigurations.bin) --- # See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / # read-only configs unless allowed. Trigger = bin present; reaction from # .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never # throws — guard errors degrade to allow. function Get-RootUuid([string]$xmlPath) { if (-not (Test-Path $xmlPath)) { return $null } try { [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 $el = $mx.DocumentElement.FirstChild while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } } catch {} return $null } function Test-ExternalObjectRoot([string]$xmlPath) { if (-not (Test-Path $xmlPath)) { return $false } try { [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 $el = $mx.DocumentElement.FirstChild while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName } } catch {} return $false } function Find-V8Project([string]$startDir) { $d = $startDir for ($i = 0; $i -lt 20 -and $d; $i++) { $pj = Join-Path $d ".v8-project.json" if (Test-Path $pj) { return $pj } $parent = [System.IO.Path]::GetDirectoryName($d) if ($parent -eq $d) { break } $d = $parent } return $null } function Get-EditMode([string]$cfgDir) { try { $pj = Find-V8Project (Get-Location).Path if (-not $pj) { $pj = Find-V8Project $cfgDir } if (-not $pj) { return 'deny' } $proj = Get-Content -Raw $pj | ConvertFrom-Json $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') if ($proj.databases) { foreach ($db in $proj.databases) { if ($db.configSrc) { $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } } } } } if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } return 'deny' } catch { return 'deny' } } function Assert-EditAllowed([string]$targetPath, [string]$require) { try { $rp = $targetPath try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} # Autonomous external object (EPF/ERF): never part of a config on support (issue #39). if (Test-ExternalObjectRoot $rp) { return } $elemUuid = Get-RootUuid $rp $cfgDir = $null; $binPath = $null $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } for ($i = 0; $i -lt 12 -and $d; $i++) { if (Test-ExternalObjectRoot "$d.xml") { return } if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } if (-not $cfgDir) { $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } } if ($elemUuid -and $cfgDir) { break } $parent = [System.IO.Path]::GetDirectoryName($d) if ($parent -eq $d) { break } $d = $parent } # New object (no element file): fall back to config root uuid. if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } if (-not $binPath -or -not (Test-Path $binPath)) { return } $bytes = [System.IO.File]::ReadAllBytes($binPath) if ($bytes.Length -le 32) { return } $start = 0 if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') if (-not $hm.Success) { return } $G = [int]$hm.Groups[1].Value $K = [int]$hm.Groups[2].Value if ($K -eq 0) { return } $best = $null if ($elemUuid) { $u = [regex]::Escape($elemUuid.ToLower()) foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { $f1 = [int]$m.Groups[1].Value if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } } } $blocked = $false; $code = ""; $reason = "" if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } elseif ($require -eq 'removed') { if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" } } else { if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" } } if (-not $blocked) { return } $mode = Get-EditMode $cfgDir if ($mode -eq 'off') { return } # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the # latter throws and would be swallowed by this function's own catch. if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } $head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления." $cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются." $offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json." if ($code -eq "capability-off") { $state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя." $fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора." } elseif ($code -eq "not-removed") { $state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора." $fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно." } else { $state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)." $fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят." } [Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote") exit 1 } catch { return } } # --- Detect XML format version --- # У корня нет атрибута version, поэтому версию берём из конфигурации, в дерево # которой пишем макет. Вне конфигурации (автономный .xml, исходники EPF) остаётся 2.17. function Detect-FormatVersion([string]$dir) { $d = $dir while ($d) { # Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт # корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17. $extPath = "$d.xml" if (Test-Path $extPath) { $extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8) $extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length)) if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match ']+version="(\d+\.\d+)"') { return $Matches[1] } } $cfgPath = Join-Path $d "Configuration.xml" if (Test-Path $cfgPath) { $cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8) # Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает # СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением. $head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length)) if ($head -match ']+version="(\d+\.\d+)"') { return $Matches[1] } } $parent = Split-Path $d -Parent if ($parent -eq $d) { break } $d = $parent } return "2.17" } # Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209. # Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка. function Get-FormatRank([string]$ver) { if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] } return 0 } $script:outPathResolved = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path (Get-Location) $OutputPath } $script:formatVersion = Detect-FormatVersion ([System.IO.Path]::GetDirectoryName($script:outPathResolved)) # --- 1. Load and validate JSON --- if (-not (Test-Path $JsonPath)) { Write-Error "File not found: $JsonPath" exit 1 } $json = Get-Content -Raw -Encoding UTF8 $JsonPath $def = $json | ConvertFrom-Json # Проверяем НАЛИЧИЕ ключа, а не истинность значения: `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.PSObject.Properties['areas'] -or $null -eq $def.areas) { Write-Error "Required field 'areas' is missing" exit 1 } $totalColumns = [int]$def.columns $defaultWidth = if ($def.defaultWidth) { [int]$def.defaultWidth } else { 10 } # --- 2. Build font palette --- $fontMap = [ordered]@{} # name -> 0-based index $fontEntries = @() # array of hashtables # Размер шрифта бывает дробным (8.3, 11.3). [int] его ТИХО округлял. Читаем инвариантной # культурой и держим целым, когда дробной части нет, — иначе "10" стало бы "10.0". function ConvertTo-FontSize { param($raw) $s = [string]$raw if ([string]::IsNullOrWhiteSpace($s)) { return 0 } $d = 0.0 if (-not [double]::TryParse($s, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$d)) { return 0 } if ($d -eq [math]::Floor($d)) { return [int]$d } return $d } # Число в XML-атрибут: интерполяция строкой отдала бы "8,3" под русской культурой. function Format-Num { param($v) return [System.Convert]::ToString($v, [System.Globalization.CultureInfo]::InvariantCulture) } function Add-Font { param([string]$name, $fontDef) $face = if ($fontDef.face) { $fontDef.face } else { "Arial" } $size = if ($fontDef.size) { ConvertTo-FontSize $fontDef.size } else { 10 } $bold = if ($fontDef.bold -eq $true) { "true" } else { "false" } $italic = if ($fontDef.italic -eq $true) { "true" } else { "false" } $underline = if ($fontDef.underline -eq $true) { "true" } else { "false" } $strikeout = if ($fontDef.strikeout -eq $true) { "true" } else { "false" } $idx = $script:fontEntries.Count $script:fontMap[$name] = $idx $script:fontEntries += @{ Face = $face Size = $size Bold = $bold Italic = $italic Underline = $underline Strikeout = $strikeout } } # Add user-defined fonts $hasDefault = $false if ($def.fonts) { foreach ($prop in $def.fonts.PSObject.Properties) { if ($prop.Name -eq "default") { $hasDefault = $true } Add-Font -name $prop.Name -fontDef $prop.Value } } # Ensure default font exists if (-not $hasDefault) { $defaultDef = New-Object PSObject -Property @{ face = "Arial"; size = 10 } Add-Font -name "default" -fontDef $defaultDef } # --- 3. Determine line palette --- $hasThinBorders = $false $hasThickBorders = $false # Scan styles for border usage if ($def.styles) { foreach ($prop in $def.styles.PSObject.Properties) { $s = $prop.Value if ($s.border -and $s.border -ne "none") { if ($s.borderWidth -eq "thick") { $hasThickBorders = $true } else { $hasThinBorders = $true } } } } $thinLineIndex = -1 $thickLineIndex = -1 $lineCount = 0 if ($hasThinBorders) { $thinLineIndex = $lineCount; $lineCount++ } if ($hasThickBorders) { $thickLineIndex = $lineCount; $lineCount++ } # --- 4. Parse column width specs --- function Parse-ColumnSpec { param([string]$spec) $cols = @() foreach ($part in $spec -split ',') { $part = $part.Trim() if ($part -match '^(\d+)-(\d+)$') { $from = [int]$Matches[1] $to = [int]$Matches[2] for ($i = $from; $i -le $to; $i++) { $cols += $i } } else { $cols += [int]$part } } return $cols } # --- 4a. Auto-calculate defaultWidth from page format --- $pageTargets = @{ "A4-landscape" = 780 "A4-portrait" = 540 } if ($def.page) { $pageName = "$($def.page)" $targetWidth = $null if ($pageName -match '^\d+$') { $targetWidth = [int]$pageName } elseif ($pageTargets.ContainsKey($pageName)) { $targetWidth = $pageTargets[$pageName] } else { Write-Warning "Unknown page format '$pageName'. Known: $($pageTargets.Keys -join ', '), or a number." } if ($targetWidth) { $totalUnits = 0.0 $absoluteSum = 0 $specifiedCols = @{} if ($def.columnWidths) { foreach ($prop in $def.columnWidths.PSObject.Properties) { $val = "$($prop.Value)" $cols = Parse-ColumnSpec $prop.Name foreach ($c in $cols) { $specifiedCols[[int]$c] = $true if ($val -match '^([0-9.]+)x$') { $totalUnits += [double]$Matches[1] } else { $absoluteSum += [int]$val } } } } for ($c = 1; $c -le $totalColumns; $c++) { if (-not $specifiedCols.ContainsKey($c)) { $totalUnits += 1.0 } } if ($totalUnits -gt 0) { $defaultWidth = [int][math]::Round(($targetWidth - $absoluteSum) / $totalUnits) } } } # Build column width map: 1-based col -> width 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 } } } return $map } $colWidthMap = Build-ColWidthMap $def.columnWidths # Колоночные раскладки: документные columns/columnWidths — раскладка по умолчанию (в XML # элемент БЕЗ , он всегда идёт первым). Дополнительные объявляются в # columnSets, ключ — идентификатор, на него ссылается область ключом columnSet. # Склейки по содержимому нет: в корпусе полно раскладок с одинаковым содержимым и разными # идентификаторами, поэтому опознаёт раскладку только идентификатор. # Платформа хранит идентификатор раскладки как UUID и другой не принимает. Имя из # columnSets, полученное декомпиляцией, уже UUID — оставляем как есть, иначе раундтрип # перестал бы совпадать. Читаемое имя, написанное автором, превращаем в UUID ДЕТЕРМИНИРОВАННО # (из хэша имени), чтобы повторная компиляция давала тот же файл. function ConvertTo-LayoutId { param([string]$name) if ($name -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { return $name } $md5 = [System.Security.Cryptography.MD5]::Create() $h = ($md5.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($name)) | ForEach-Object { $_.ToString('x2') }) -join '' return "$($h.Substring(0,8))-$($h.Substring(8,4))-$($h.Substring(12,4))-$($h.Substring(16,4))-$($h.Substring(20,12))" } $columnLayouts = @() $columnLayouts += @{ Id = $null; Name = $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 = ConvertTo-LayoutId $prop.Name Name = $prop.Name Size = $size Widths = Build-ColWidthMap $cs.columnWidths } } } # --- 5. Style resolver --- function Resolve-Style { param([string]$styleName, [string]$fillType) $fontIdx = $fontMap["default"] $lb = -1; $tb = -1; $rb = -1; $bb = -1 $ha = ""; $va = ""; $nf = "" $wrap = $false if ($styleName -and $def.styles) { $style = $def.styles.$styleName if ($style) { # Font if ($style.font -and $fontMap.Contains($style.font)) { $fontIdx = $fontMap[$style.font] } # Borders if ($style.border -and $style.border -ne "none") { $lineIdx = if ($style.borderWidth -eq "thick") { $thickLineIndex } else { $thinLineIndex } foreach ($side in ($style.border -split ',')) { switch ($side.Trim()) { "all" { $lb = $lineIdx; $tb = $lineIdx; $rb = $lineIdx; $bb = $lineIdx } "left" { $lb = $lineIdx } "top" { $tb = $lineIdx } "right" { $rb = $lineIdx } "bottom" { $bb = $lineIdx } } } } # Alignment if ($style.align) { switch ($style.align) { "left" { $ha = "Left" } "center" { $ha = "Center" } "right" { $ha = "Right" } } } if ($style.valign) { switch ($style.valign) { "top" { $va = "Top" } "center" { $va = "Center" } } } # Wrap if ($style.wrap -eq $true) { $wrap = $true } # Number format if ($style.format) { $nf = $style.format } } } return @{ FontIdx = $fontIdx LB = $lb; TB = $tb; RB = $rb; BB = $bb HA = $ha; VA = $va Wrap = $wrap FillType = $fillType NumberFormat = $nf } } # --- 6. Format palette builder --- $formatRegistry = [ordered]@{} # key -> hashtable with properties $formatOrder = @() # ordered keys for index assignment function Get-FormatKey { param( [int]$fontIdx = -1, [int]$lb = -1, [int]$tb = -1, [int]$rb = -1, [int]$bb = -1, [string]$ha = "", [string]$va = "", [bool]$wrap = $false, [string]$fillType = "", [string]$numberFormat = "", [int]$width = -1, [int]$height = -1 ) return "f=$fontIdx|lb=$lb|tb=$tb|rb=$rb|bb=$bb|ha=$ha|va=$va|wr=$wrap|ft=$fillType|nf=$numberFormat|w=$width|h=$height" } function Register-Format { param([string]$key, [hashtable]$props) if (-not $script:formatRegistry.Contains($key)) { $script:formatRegistry[$key] = $props $script:formatOrder += $key } # Return 1-based index $idx = 0 foreach ($k in $script:formatRegistry.Keys) { $idx++ if ($k -eq $key) { return $idx } } return $idx } # 6a. Default width format $defaultFormatKey = Get-FormatKey -width $defaultWidth $defaultFormatIndex = Register-Format -key $defaultFormatKey -props @{ Width = $defaultWidth } # 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 # Helper: escape XML special characters function Esc-Xml { param([string]$s) # Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна. return $s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"') } function Esc-XmlText { # Экранирование ТЕКСТА элемента: только & < > . Кавычки в тексте платформа НЕ экранирует — # пишет литерально (проверено: 92142 сырых кавычки на корпус, ни одной "). " платформа # принимает, но при выгрузке нормализует обратно в кавычку → лишний шум в роундтрипе. param([string]$s) return $s.Replace('&','&').Replace('<','<').Replace('>','>') } # Helper: determine fillType from cell content # Text НЕ эмитим: платформа его практически не пишет — на выборке корпуса 344 981 текстовая # ячейка из 348 023 (99,1%) ссылается на формат БЕЗ fillType. Наличие и так означает # текст, поэтому тег избыточен. Parameter и Template платформа пишет — их оставляем. function Get-FillType { param($cell) if ($cell.param) { return "Parameter" } if ($cell.template) { return "Template" } return "" } # Helper: register a cell format and return its index function Register-CellFormat { param($styleName, [string]$fillType) $resolved = Resolve-Style -styleName $styleName -fillType $fillType $key = Get-FormatKey -fontIdx $resolved.FontIdx ` -lb $resolved.LB -tb $resolved.TB -rb $resolved.RB -bb $resolved.BB ` -ha $resolved.HA -va $resolved.VA ` -wrap $resolved.Wrap -fillType $resolved.FillType ` -numberFormat $resolved.NumberFormat $props = @{ FontIdx = $resolved.FontIdx LB = $resolved.LB; TB = $resolved.TB RB = $resolved.RB; BB = $resolved.BB HA = $resolved.HA; VA = $resolved.VA Wrap = $resolved.Wrap FillType = $resolved.FillType NumberFormat = $resolved.NumberFormat } return Register-Format -key $key -props $props } # --- 5.5. Шорткат строк: строка-массив ячеек --- # Та же форма, что у макетов СКД (skd-compile): позиция ячейки = индекс в массиве, # ">" продолжает ячейку слева, "|" — ячейку сверху, null — пустая колонка, # "{Имя}" — параметр. Разворачиваем в обычную строку с явными col/span/rowspan, # поэтому весь код ниже про шорткат не знает. function Set-CellProp { param($cell, [string]$name, $value) $cell | Add-Member -NotePropertyName $name -NotePropertyValue $value -Force } function Expand-ShorthandRow { param($row, [string]$areaName, [int]$rowIdx, $openByCol, [int]$maxCols) $cells = @() $placed = @{} # 1-based col -> ячейка, занимающая колонку в ЭТОЙ строке $extended = @() # ячейки, чей rowspan уже нарастили в этой строке (span>1 даёт несколько "|") $last = $null # последняя реальная ячейка слева — цель для ">" $idx = 0 foreach ($el in $row) { $idx++ # Внутри функции пишем в stderr напрямую: Write-Error приписал бы к сообщению имя # функции, и текст перестал бы совпадать с py-портом. if ($idx -gt $maxCols) { [Console]::Error.WriteLine("Row exceeds 'columns' ($maxCols): area `"$areaName`", row $rowIdx") exit 1 } if ($null -eq $el) { $last = $null; continue } if ($el -is [string] -and $el -eq '>') { if ($null -eq $last) { [Console]::Error.WriteLine("Row shorthand: '>' has no cell to the left: area `"$areaName`", row $rowIdx, cell $idx") exit 1 } $span = if ($last.span) { [int]$last.span } else { 1 } Set-CellProp $last 'span' ($span + 1) $placed[$idx] = $last continue } if ($el -is [string] -and $el -eq '|') { $above = $openByCol[$idx] if ($null -eq $above) { [Console]::Error.WriteLine("Row shorthand: '|' has no cell above: area `"$areaName`", row $rowIdx, cell $idx") exit 1 } if (-not ($extended -contains $above)) { $rowspan = if ($above.rowspan) { [int]$above.rowspan } else { 1 } Set-CellProp $above 'rowspan' ($rowspan + 1) $extended += $above } $placed[$idx] = $above $last = $null continue } if ($el -is [string]) { $cell = [PSCustomObject]@{ col = $idx; span = 1 } $m = [regex]::Match($el, '^\{(.+)\}$') if ($m.Success) { Set-CellProp $cell 'param' $m.Groups[1].Value } else { Set-CellProp $cell 'text' $el } } else { # Объектный элемент — обычная ячейка mxl, позиция берётся из индекса. if ($el.PSObject.Properties['col']) { [Console]::Error.WriteLine("Row shorthand: cell object must not carry 'col': area `"$areaName`", row $rowIdx, cell $idx") exit 1 } $cell = $el Set-CellProp $cell 'col' $idx if (-not $cell.span) { Set-CellProp $cell 'span' 1 } } $cells += $cell $placed[$idx] = $cell $last = $cell } # Колонки, не занятые в этой строке, теряют «ячейку сверху». $openByCol.Clear() foreach ($k in $placed.Keys) { $openByCol[$k] = $placed[$k] } return [PSCustomObject]@{ cells = $cells } } # Карту занятых колонок ведём и по объектным строкам: "|" продолжает ту ячейку, # которая реально стоит выше, независимо от того, какой формой её записали. function Update-OpenByCol { param($row, $openByCol) $placed = @{} if ($row.cells) { foreach ($cell in $row.cells) { # Ячейки без col (строка с автопотоком) на этом шаге ещё не разложены — позиция # станет известна позже, поэтому «ячейку сверху» они не дают, и "|" под такой # строкой честно скажет, что сверху ничего нет. if (-not $cell.PSObject.Properties['col'] -or $null -eq $cell.col) { continue } $col = [int]$cell.col $span = if ($cell.span) { [int]$cell.span } else { 1 } for ($c = $col; $c -lt ($col + $span); $c++) { $placed[$c] = $cell } } } $openByCol.Clear() foreach ($k in $placed.Keys) { $openByCol[$k] = $placed[$k] } } foreach ($area in $def.areas) { $areaName = $area.name # Ширина сетки берётся из раскладки области: у каждой она своя. $areaMaxCols = $totalColumns if ($area.PSObject.Properties['columnSet'] -and "$($area.columnSet)" -ne '') { $lay = @($columnLayouts | Where-Object { $_.Name -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 -maxCols $areaMaxCols } else { $expandedRows += $row if ($row.empty) { $openByCol.Clear() } else { Update-OpenByCol -row $row -openByCol $openByCol } } } $area.rows = $expandedRows } # Pre-register all formats from areas foreach ($area in $def.areas) { foreach ($row in $area.rows) { # Skip empty row placeholder if ($row.empty) { continue } # Row height format if ($row.height) { $hKey = Get-FormatKey -height ([int]$row.height) Register-Format -key $hKey -props @{ Height = [int]$row.height } | Out-Null } # rowStyle gap-fill format (no content → no fillType) if ($row.rowStyle) { Register-CellFormat -styleName $row.rowStyle -fillType "" | Out-Null } # Explicit cell formats if ($row.cells) { foreach ($cell in $row.cells) { $cellStyle = if ($cell.style) { $cell.style } elseif ($row.rowStyle) { $row.rowStyle } else { "default" } $ft = Get-FillType $cell Register-CellFormat -styleName $cellStyle -fillType $ft | Out-Null } } } } # --- 7. Generate XML --- $xml = New-Object System.Text.StringBuilder 4096 function X { param([string]$text) $script:xml.AppendLine($text) | Out-Null } # 7a. Header $docNsDecl = 'xmlns="http://v8.1c.ru/8.2/data/spreadsheet" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' # 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (перед style): # платформа держит объявления по алфавиту, дописать в конец нельзя. if ((Get-FormatRank $script:formatVersion) -ge 221) { $docNsDecl = $docNsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=' } X '' X "" # 7b. Language settings X "`t" X "`t`tru" X "`t`tru" X "`t`t" X "`t`t`tru" X "`t`t`tРусский" X "`t`t`tРусский" X "`t`t" X "`t" # 7c. Columns # Раскладка по умолчанию идёт первой и без — так их хранит платформа. foreach ($layout in $columnLayouts) { X "`t" if ($layout.Id) { X "`t`t$($layout.Id)" } X "`t`t$($layout.Size)" # 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" X "`t`t`t$colIdx" X "`t`t`t" X "`t`t`t`t$fmtIdx" X "`t`t`t" X "`t`t" } X "`t" } # 7d. Rows — main generation loop $globalRow = 0 $merges = @() $namedItems = @() $totalRowCount = 0 foreach ($area in $def.areas) { $areaStartRow = $globalRow $areaName = $area.name $activeRowspans = @() # @{ColStart=1-based; ColEnd=1-based; EndLocalRow=int} $localRow = 0 # Ссылка области на колоночную раскладку — её получают все строки области. # Раскладка адресуется ИМЕНЕМ из columnSets — инлайновой формы в этом DSL нет ни у чего # (стиль и шрифт тоже только по имени). Иначе сообщение включало бы сериализованный # объект, а он у портов выглядит по-разному. if ($area.PSObject.Properties['columnSet'] -and ($area.columnSet -is [System.Management.Automation.PSCustomObject] -or $area.columnSet -is [System.Collections.IDictionary])) { [Console]::Error.WriteLine("'columnSet' must be a name declared in columnSets, got an object: area `"$($area.name)`"") exit 1 } $areaColumnSetName = if ($area.PSObject.Properties['columnSet']) { "$($area.columnSet)" } else { '' } $areaColumnSet = '' $areaLayout = $columnLayouts[0] if ($areaColumnSetName) { $areaLayout = @($columnLayouts | Where-Object { $_.Name -eq $areaColumnSetName })[0] if (-not $areaLayout) { [Console]::Error.WriteLine("Unknown 'columnSet': `"$areaColumnSetName`" is not declared in columnSets") exit 1 } $areaColumnSet = $areaLayout.Id } # Ширина сетки — у КАЖДОЙ раскладки своя, поэтому позиции колонок сверяем с ней, # а не с документным columns (у макетов с раскладками умолчание бывает и пустым). $areaColumns = [int]$areaLayout.Size foreach ($row in $area.rows) { # Empty row placeholder: emit N empty rows if ($row.empty) { $count = [int]$row.empty for ($ei = 0; $ei -lt $count; $ei++) { X "`t" X "`t`t$globalRow" X "`t`t" X "`t`t`ttrue" X "`t`t" X "`t" $globalRow++; $localRow++ } continue } # Build set of columns occupied by rowspans from previous rows $rowspanOccupied = @{} # 1-based col -> $true foreach ($rs in $activeRowspans) { if ($localRow -gt $rs.StartLocalRow -and $localRow -le $rs.EndLocalRow) { for ($c = $rs.ColStart; $c -le $rs.ColEnd; $c++) { $rowspanOccupied[$c] = $true } } } $rowHasContent = $false $rowCells = @() # array of { Col(0-based), FormatIdx, Content } # Determine row height format $rowFormatIdx = 0 if ($row.height) { $hKey = Get-FormatKey -height ([int]$row.height) # Find format index for this key $rIdx = 0 foreach ($k in $formatRegistry.Keys) { $rIdx++ if ($k -eq $hKey) { $rowFormatIdx = $rIdx; break } } } 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 $areaColumns) { Write-Error "Row exceeds 'columns' ($areaColumns): 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 $areaColumns) { 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 } foreach ($cell in $row.cells) { $colStart = [int]$cell.col $colSpan = if ($cell.span) { [int]$cell.span } else { 1 } for ($c = $colStart; $c -lt ($colStart + $colSpan); $c++) { $occupiedCols[$c] = $true } } # Generate explicit cells foreach ($cell in $row.cells) { $colStart = [int]$cell.col $colSpan = if ($cell.span) { [int]$cell.span } else { 1 } $rowspan = if ($cell.rowspan) { [int]$cell.rowspan } else { 1 } $cellStyle = if ($cell.style) { $cell.style } elseif ($row.rowStyle) { $row.rowStyle } else { "default" } $ft = Get-FillType $cell $fmtIdx = Register-CellFormat -styleName $cellStyle -fillType $ft $cellInfo = @{ Col = $colStart - 1 # 0-based FormatIdx = $fmtIdx Param = $cell.param Detail = $cell.detail Text = $cell.text Template = $cell.template } $rowCells += $cellInfo # Track rowspan for subsequent rows if ($rowspan -gt 1) { $activeRowspans += @{ ColStart = $colStart ColEnd = $colStart + $colSpan - 1 StartLocalRow = $localRow EndLocalRow = $localRow + $rowspan - 1 } } # Collect merge (horizontal, vertical, or both) if ($colSpan -gt 1 -or $rowspan -gt 1) { $merge = @{ R = $globalRow; C = $colStart - 1; W = $colSpan - 1 } if ($rowspan -gt 1) { $merge.H = $rowspan - 1 } $merges += $merge } } # Generate gap-fill cells for rowStyle if ($row.rowStyle) { $gapFmtIdx = Register-CellFormat -styleName $row.rowStyle -fillType "" for ($c = 1; $c -le $totalColumns; $c++) { if (-not $occupiedCols.ContainsKey($c)) { $rowCells += @{ Col = $c - 1 # 0-based FormatIdx = $gapFmtIdx Param = $null Detail = $null Text = $null Template = $null } } } } # Sort cells by column $rowCells = $rowCells | Sort-Object { $_.Col } } elseif ($row.rowStyle) { # Row with only rowStyle, no explicit cells — fill non-rowspan columns $rowHasContent = $true $gapFmtIdx = Register-CellFormat -styleName $row.rowStyle -fillType "" for ($c = 1; $c -le $totalColumns; $c++) { if ($rowspanOccupied.ContainsKey($c)) { continue } $rowCells += @{ Col = $c - 1 FormatIdx = $gapFmtIdx Param = $null Detail = $null Text = $null Template = $null } } } # Emit rowsItem X "`t" X "`t`t$globalRow" X "`t`t" if ($areaColumnSet) { X "`t`t`t$areaColumnSet" } if ($rowFormatIdx -gt 0) { X "`t`t`t$rowFormatIdx" } if (-not $rowHasContent) { X "`t`t`ttrue" } else { foreach ($cellInfo in $rowCells) { X "`t`t`t" X "`t`t`t`t$($cellInfo.Col)" X "`t`t`t`t" X "`t`t`t`t`t$($cellInfo.FormatIdx)" if ($cellInfo.Param) { X "`t`t`t`t`t$($cellInfo.Param)" if ($cellInfo.Detail) { X "`t`t`t`t`t$($cellInfo.Detail)" } } if ($cellInfo.Text) { X "`t`t`t`t`t" X "`t`t`t`t`t`t" X "`t`t`t`t`t`t`tru" X "`t`t`t`t`t`t`t$(Esc-XmlText $cellInfo.Text)" X "`t`t`t`t`t`t" X "`t`t`t`t`t" } if ($cellInfo.Template) { X "`t`t`t`t`t" X "`t`t`t`t`t`t" X "`t`t`t`t`t`t`tru" X "`t`t`t`t`t`t`t$(Esc-XmlText $cellInfo.Template)" X "`t`t`t`t`t`t" X "`t`t`t`t`t" } X "`t`t`t`t" X "`t`t`t" } } X "`t`t" X "`t" $localRow++ $globalRow++ } $areaEndRow = $globalRow - 1 # Блок без имени — просто кусок сетки: строки, не принадлежащие ни одной именованной # области (в корпусе таких дыр 34%, а макетов вовсе без Rows-областей — 21%). # Имя на блоке — сахар: разворачиваем его в обычную именованную область типа Rows. if (-not [string]::IsNullOrEmpty($areaName)) { $namedItems += @{ Name = $areaName BeginRow = $areaStartRow EndRow = $areaEndRow BeginCol = -1 EndCol = -1 } } } $totalRowCount = $globalRow # 7d-bis. Именованные области, заданные координатами (namedAreas). # Нужны для всего, что блоком не выражается: области не-Rows и пересекающиеся Rows. # Ось, которую не указали, означает «вся» — так же устроен ТабличныйДокумент.Область(). # Диапазон — та же грамматика, что у columnWidths: число или "N-M". Список через запятую # для области бессмыслен: область непрерывна, платформа разрывную не хранит. function ConvertTo-AreaRange { param($spec, [string]$axis, [string]$where) if ($null -eq $spec) { return $null } $s = ([string]$spec).Trim() if ($s -eq '') { return $null } if ($s.Contains(',')) { [Console]::Error.WriteLine("namedAreas: '$axis' must be a single number or range, got list `"$s`": $where") exit 1 } if ($s -match '^(\d+)\s*-\s*(\d+)$') { $from = [int]$Matches[1]; $to = [int]$Matches[2] } elseif ($s -match '^(\d+)$') { $from = [int]$Matches[1]; $to = $from } else { [Console]::Error.WriteLine("namedAreas: invalid '$axis' value `"$s`": $where") exit 1 } # Платформа трактует 0 как 1 (см. описание Область()) — принимаем так же. if ($from -lt 1) { $from = 1 } if ($to -lt $from) { [Console]::Error.WriteLine("namedAreas: '$axis' range is reversed `"$s`": $where") exit 1 } return @{ From = $from; To = $to } } # Прощающий ввод: платформенный адрес "R1C1:R2C2" — модель, пишущая код на встроенном # языке, естественно потянется за ним. В документацию не выносим. function ConvertFrom-R1C1 { param([string]$s) $m = [regex]::Match($s.Trim(), '^R(\d+)(?:C(\d+))?(?::R(\d+)(?:C(\d+))?)?$', 'IgnoreCase') if (-not $m.Success) { $m2 = [regex]::Match($s.Trim(), '^C(\d+)(?::C(\d+))?$', 'IgnoreCase') if (-not $m2.Success) { return $null } $c1 = [int]$m2.Groups[1].Value $c2 = if ($m2.Groups[2].Success) { [int]$m2.Groups[2].Value } else { $c1 } return @{ Rows = $null; Cols = @{ From = $c1; To = $c2 } } } $r1 = [int]$m.Groups[1].Value $r2 = if ($m.Groups[3].Success) { [int]$m.Groups[3].Value } else { $r1 } $cols = $null if ($m.Groups[2].Success) { $c1 = [int]$m.Groups[2].Value $c2 = if ($m.Groups[4].Success) { [int]$m.Groups[4].Value } else { $c1 } $cols = @{ From = $c1; To = $c2 } } return @{ Rows = @{ From = $r1; To = $r2 }; Cols = $cols } } if ($def.namedAreas) { $naIdx = 0 foreach ($na in $def.namedAreas) { $naIdx++ $naName = "$($na.name)" $where = "namedAreas[$naIdx]" + $(if ($naName) { " `"$naName`"" } else { '' }) if ([string]::IsNullOrEmpty($naName)) { [Console]::Error.WriteLine("namedAreas: 'name' is required: $where") exit 1 } $rows = ConvertTo-AreaRange $na.rows 'rows' $where $cols = ConvertTo-AreaRange $na.cols 'cols' $where if (-not $rows -and -not $cols) { $addr = $null foreach ($k in 'area', 'at', 'address') { if ($na.PSObject.Properties[$k] -and "$($na.$k)" -ne '') { $addr = "$($na.$k)"; break } } if ($addr) { $parsed = ConvertFrom-R1C1 $addr if ($null -eq $parsed) { [Console]::Error.WriteLine("namedAreas: invalid address `"$addr`": $where") exit 1 } $rows = $parsed.Rows; $cols = $parsed.Cols } } if (-not $rows -and -not $cols) { [Console]::Error.WriteLine("namedAreas: at least one of 'rows'/'cols' is required: $where") exit 1 } # DSL 1-based, XML 0-based; отсутствующая ось помечается -1. $namedItems += @{ Name = $naName BeginRow = if ($rows) { $rows.From - 1 } else { -1 } EndRow = if ($rows) { $rows.To - 1 } else { -1 } BeginCol = if ($cols) { $cols.From - 1 } else { -1 } EndCol = if ($cols) { $cols.To - 1 } else { -1 } } } } # 7e. Scalar metadata X "`ttrue" X "`t$defaultFormatIndex" X "`t$totalRowCount" X "`t$totalRowCount" # 7f. Merges foreach ($m in $merges) { X "`t" X "`t`t$($m.R)" X "`t`t$($m.C)" if ($m.H) { X "`t`t$($m.H)" } X "`t`t$($m.W)" X "`t" } # 7g. Named items # Платформа хранит именованные элементы ОТСОРТИРОВАННЫМИ по имени: на выборке 541 макета # с несколькими элементами иного порядка нет ни разу. Сортировка регистронезависимая и # ординальная — Sort-Object брать нельзя, он сортирует по текущей культуре и на кириллице # даст другой порядок. NB: имён с «ё» в выборке не встретилось, этот случай не проверен. $sortedNamedItems = @($namedItems | Sort-Object -Property @{ Expression = { $_.Name.ToLowerInvariant() } } ` -CaseSensitive) foreach ($ni in $sortedNamedItems) { # Тип области выводится из указанных осей, как в ТабличныйДокумент.Область(): # нет колонок → полоса строк, нет строк → полоса колонок, обе → прямоугольник. $hasRows = $ni.BeginRow -ge 0 $hasCols = $ni.BeginCol -ge 0 $type = if ($hasRows -and $hasCols) { 'Rectangle' } elseif ($hasCols) { 'Columns' } else { 'Rows' } X "`t" X "`t`t$($ni.Name)" X "`t`t" X "`t`t`t$type" X "`t`t`t$($ni.BeginRow)" X "`t`t`t$($ni.EndRow)" X "`t`t`t$($ni.BeginCol)" X "`t`t`t$($ni.EndCol)" X "`t`t" X "`t" } # 7h. Line palette if ($hasThinBorders) { X "`t" X "`t`tSolid" X "`t" } if ($hasThickBorders) { X "`t" X "`t`tSolid" X "`t" } # 7i. Font palette foreach ($fe in $fontEntries) { X "`t" } # 7j. Format palette foreach ($key in $formatRegistry.Keys) { $fmt = $formatRegistry[$key] X "`t" if ($fmt.FontIdx -ne $null -and $fmt.FontIdx -ge 0) { X "`t`t$($fmt.FontIdx)" } if ($fmt.LB -ne $null -and $fmt.LB -ge 0) { X "`t`t$($fmt.LB)" } if ($fmt.TB -ne $null -and $fmt.TB -ge 0) { X "`t`t$($fmt.TB)" } if ($fmt.RB -ne $null -and $fmt.RB -ge 0) { X "`t`t$($fmt.RB)" } if ($fmt.BB -ne $null -and $fmt.BB -ge 0) { X "`t`t$($fmt.BB)" } if ($fmt.Width) { X "`t`t$($fmt.Width)" } if ($fmt.Height) { X "`t`t$($fmt.Height)" } if ($fmt.HA) { X "`t`t$($fmt.HA)" } if ($fmt.VA) { X "`t`t$($fmt.VA)" } if ($fmt.Wrap -eq $true) { X "`t`tWrap" } if ($fmt.FillType) { X "`t`t$($fmt.FillType)" } if ($fmt.NumberFormat) { X "`t`t" X "`t`t`t" X "`t`t`t`tru" X "`t`t`t`t$(Esc-XmlText $fmt.NumberFormat)" X "`t`t`t" X "`t`t" } X "`t" } # 7k. Close document X '' # --- 8. Write output --- $enc = New-Object System.Text.UTF8Encoding($true) $resolvedPath = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path (Get-Location) $OutputPath } # Каталог назначения создаём сами: типовой путь — Templates/<Имя>/Ext/Template.xml, # и его может ещё не быть. Так делают и form-compile, и skd-compile, и py-порт этого # навыка; без этого PS-порт падал на «Could not find a part of the path». $outDir = [System.IO.Path]::GetDirectoryName($resolvedPath) if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null } Assert-EditAllowed $resolvedPath 'editable' [System.IO.File]::WriteAllText($resolvedPath, $xml.ToString().TrimEnd("`r", "`n"), $enc) # --- 9. Summary --- Write-Host "[OK] Compiled: $OutputPath" if ($def.page) { Write-Host " Page: $pageName -> target $targetWidth, defaultWidth=$defaultWidth" } Write-Host " Areas: $($namedItems.Count), Rows: $totalRowCount, Columns: $totalColumns" Write-Host " Fonts: $($fontEntries.Count), Lines: $lineCount, Formats: $($formatRegistry.Count)" Write-Host " Merges: $($merges.Count)"