# mxl-compile v1.40 — Compile 1C spreadsheet from JSON # 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)) { [Console]::Error.WriteLine("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) { [Console]::Error.WriteLine("Required field 'columns' is missing") exit 1 } if (-not $def.PSObject.Properties['areas'] -or $null -eq $def.areas) { [Console]::Error.WriteLine("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) } # Шрифт бывает не собственным, а ссылкой: на элемент стиля конфигурации (style:) или на # системный шрифт (sys:). Вид ссылки определяет kind, и он выводится из префикса. function Get-FontRefKind { param([string]$ref) if ($ref -like 'sys:*') { return 'WindowsFont' } return 'StyleItem' } function Add-Font { param([string]$name, $fontDef) if ($fontDef.ref) { $script:fontMap[$name] = $script:fontEntries.Count $script:fontEntries += @{ Ref = "$($fontDef.ref)"; Kind = (Get-FontRefKind "$($fontDef.ref)") } return } $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. Line palette --- # Рамка хранится не в формате, а в палитре : формат ссылается на запись индексом. # Запись — тройка (стиль, ширина, gap); в корпусе gap всегда false, но тег платформа пишет. $script:lineRegistry = @() function Get-LineKey { param($ln) return "$($ln.Style)|$($ln.Width)|$($ln.Gap)" } function Register-Line { param($ln) $key = Get-LineKey $ln for ($i = 0; $i -lt $script:lineRegistry.Count; $i++) { if ((Get-LineKey $script:lineRegistry[$i]) -ceq $key) { return $i } } $script:lineRegistry += $ln return $script:lineRegistry.Count - 1 } function Get-LineStyles { # Стили линии, встречающиеся в палитрах корпуса. return @('Solid', 'None', 'Dotted', 'ThinDashed', 'LargeDashed', 'ThickDashed', 'Double') } # --- 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 # Стиль колонки — тот же именованный стиль, что у ячейки и строки: колонка третий владелец # формата, и своих свойств у неё нет. Ключи те же, что у columnWidths. function Build-ColStyleMap { param($styles) $map = @{} if ($styles) { foreach ($prop in $styles.PSObject.Properties) { # Пустое значение = колонка перечислена, формата у неё нет. Записи для авторинга # в этом смысла нет, поэтому в описании DSL её не показываем — она нужна # декомпилятору, чтобы раундтрип не терял байты. $v = if ($null -eq $prop.Value) { $null } else { "$($prop.Value)" } foreach ($c in (Parse-ColumnSpec $prop.Name)) { $map[$c] = $v } } } return $map } $colStyleMap = Build-ColStyleMap $def.columnStyles # Колоночные раскладки: документные 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 } # UUID версии 3 (имя + MD5, RFC 4122): биты версии и варианта проставляются, иначе это # не UUID, а просто шестнадцатеричная строка нужной формы. $md5 = [System.Security.Cryptography.MD5]::Create() $b = $md5.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($name)) $b[6] = [byte](($b[6] -band 0x0F) -bor 0x30) # версия 3 $b[8] = [byte](($b[8] -band 0x3F) -bor 0x80) # вариант RFC 4122 $h = ($b | 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; Styles = $colStyleMap } 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 Styles = Build-ColStyleMap $cs.columnStyles } } } # --- 5. Style resolver --- # Значение рамки: строка стиля ("Dotted") либо объект { style, width, gap }. # Прежняя запись borderWidth: thin/thick — это ширина 1 и 2. function ConvertTo-LineValue { param($val, [string]$where) $style = 'Solid'; $width = 1; $gap = 'false' if ($val -is [string] -or $val -is [int]) { $style = "$val" } else { if ($null -ne $val.style) { $style = "$($val.style)" } if ($null -ne $val.width) { $width = "$($val.width)" } if ($null -ne $val.gap) { $gap = if ($val.gap -eq $true) { 'true' } else { 'false' } } } if ($style -ceq 'thin') { $style = 'Solid'; $width = 1 } elseif ($style -ceq 'thick') { $style = 'Solid'; $width = 2 } $canon = Get-LineStyles | Where-Object { $_ -eq $style } | Select-Object -First 1 if (-not $canon) { [Console]::Error.WriteLine("Unknown border style `"$style`" ($where). Allowed: $((Get-LineStyles) -join ', ')") exit 1 } return @{ Style = $canon; Width = $width; Gap = $gap } } # Цвет — значение с префиксом пространства имён (нотация платформы). style: объявлен в корне # документа, web/win — нет, поэтому платформа дописывает объявление прямо на узел. function Get-ColorNamespace { param([string]$val) if ($val -like 'web:*') { return 'http://v8.1c.ru/8.1/data/ui/colors/web' } if ($val -like 'win:*') { return 'http://v8.1c.ru/8.1/data/ui/colors/windows' } return '' } # Стиль строки: имя строкой либо объект { style, apply }. apply — куда лёг стиль: # "both" (умолчание) — и в формат строки, и в форматы ячеек, как пишет платформа, когда # автор оформляет строку целиком; "row" — только строке (так хранится, например, скрытие); # "cells" — только ячейкам. Модификатор нужен раундтрипу, в описании DSL его нет. function Get-RowStyleSpec { param($val, [string]$where) if ($null -eq $val) { return @{ Name = $null; Apply = 'both' } } if ($val -is [string]) { return @{ Name = $val; Apply = 'both' } } $name = "$($val.style)" $apply = if ($val.apply) { "$($val.apply)".ToLower() } else { 'both' } if ($apply -notin @('both', 'row', 'cells')) { [Console]::Error.WriteLine("Unknown 'apply' value `"$($val.apply)`" ($where). Allowed: both, row, cells") exit 1 } if (-not $name) { [Console]::Error.WriteLine("rowStyle object requires 'style' ($where)") exit 1 } return @{ Name = $name; Apply = $apply } } # Формат самой строки: собственные свойства строки (высота, скрытие) плюс стиль строки, # если он ложится на строку. Пустой набор = у строки формата нет. function Get-RowFormatProps { param($row) $props = @{} $spec = Get-RowStyleSpec $row.rowStyle "row" if ($spec.Name -and $spec.Apply -cne 'cells') { $props = Resolve-Style -styleName $spec.Name -fillType "" } if ($row.height) { $props['height'] = [int]$row.height } if ($row.hidden -eq $true) { $props['hidden'] = 'true' } return $props } function Resolve-Style { param([string]$styleName, [string]$fillType) # Набор свойств формата — «тег платформы → значение», только заданные. Порядок вставки # роли не играет: и ключ дедупликации, и эмиссия идут по каноническому порядку тегов. # пишем только когда стиль задал шрифт: треть форматов корпуса (23 003 из 69 581) # обходится без него, а мы раньше подставляли шрифт по умолчанию всегда. $props = @{} if ($styleName -and $def.styles) { $style = $def.styles.$styleName if ($style) { $kinds = Get-FormatTagKind $enums = Get-FormatEnumValues $synonyms = Get-StyleKeySynonyms $where = "style `"$styleName`"" # Прежняя запись рамки: стороны строкой + borderWidth. Разворачиваем в посторонние # ключи ДО общего разбора, чтобы дальше был один путь. $sideKeys = @{ left = 'leftBorder'; top = 'topBorder'; right = 'rightBorder'; bottom = 'bottomBorder' } $legacyBorder = $style.border if ($legacyBorder -is [string] -and $legacyBorder -and (Get-LineStyles | Where-Object { $_ -eq $legacyBorder }).Count -eq 0) { $ln = ConvertTo-LineValue @{ style = 'Solid'; width = $(if ("$($style.borderWidth)" -ceq 'thick') { 2 } else { 1 }) } $where $idx = Register-Line $ln foreach ($side in ($legacyBorder -split ',')) { $s = $side.Trim().ToLower() if ($s -eq 'none') { continue } if ($s -eq 'all') { foreach ($k in $sideKeys.Values) { $props[$k] = $idx } } elseif ($sideKeys.ContainsKey($s)) { $props[$sideKeys[$s]] = $idx } else { [Console]::Error.WriteLine("Unknown border side `"$($side.Trim())`" ($where). Allowed: all, left, top, right, bottom, none") exit 1 } } } foreach ($p in $style.PSObject.Properties) { $raw = $p.Name # Синонимы: канон побеждает, сравнение без регистра и пробелов. $norm = ($raw -replace '\s', '').ToLower() $tag = if ($synonyms.ContainsKey($norm)) { $synonyms[$norm] } else { $raw } if ($tag -ceq 'borderWidth') { continue } if ($tag -ceq 'border' -and $legacyBorder -is [string] -and (Get-LineStyles | Where-Object { $_ -eq $legacyBorder }).Count -eq 0) { continue } $val = $p.Value if ($null -eq $val -or ($val -is [string] -and $val -eq '')) { continue } if ($tag -ceq 'font') { if ($fontMap.Contains("$val")) { $props['font'] = $fontMap["$val"] } continue } # wrap — не имя, а сокращение: булево вместо перечисления textPlacement. if ($tag -ceq 'wrap') { if ($val -eq $true -or "$val" -eq 'true') { $props['textPlacement'] = 'Wrap' } continue } if (-not $kinds.ContainsKey($tag)) { Write-Warning "Unknown style key '$raw' ($where) — ignored." continue } switch ($kinds[$tag]) { 'line' { $props[$tag] = Register-Line (ConvertTo-LineValue $val $where) } 'bool' { $props[$tag] = if ($val -eq $true -or "$val" -eq 'true') { 'true' } else { 'false' } } 'int' { $props[$tag] = [int]$val } 'enum' { $allowed = $enums[$tag] $canon = $allowed | Where-Object { $_ -eq "$val" } | Select-Object -First 1 if (-not $canon) { [Console]::Error.WriteLine("Unknown '$tag' value `"$val`" ($where). Allowed: $($allowed -join ', ')") exit 1 } $props[$tag] = $canon } default { $props[$tag] = "$val" } } } } } if ($fillType) { $props['fillType'] = $fillType } # Одинаковые четыре стороны платформа пишет одним . Правило проверено на корпусе: # 70 265 свёрнутых форматов, 36 783 записанных по сторонам — и среди вторых нет ни одного # с четырьмя совпадающими значениями. $sides = @('leftBorder', 'topBorder', 'rightBorder', 'bottomBorder') $present = @($sides | Where-Object { $props.ContainsKey($_) }) if ($present.Count -eq 4) { $vals = @($sides | ForEach-Object { $props[$_] } | Select-Object -Unique) if ($vals.Count -eq 1) { foreach ($s in $sides) { $props.Remove($s) } $props['border'] = $vals[0] } } return $props } # --- 6. Format palette builder --- $formatRegistry = [ordered]@{} # key -> hashtable with properties $formatOrder = @() # ordered keys for index assignment # Канонический порядок тегов внутри . Снят с корпуса: 766 960 форматов, ни один # его не нарушает — платформа пишет теги строго в этой последовательности, и от неё # зависит побайтовое совпадение с выгрузкой. function Get-FormatTagOrder { return @( 'print', 'drawingBorder', 'drawingHaveLeftBorder', 'drawingHaveTopBorder', 'drawingHaveRightBorder', 'drawingHaveBottomBorder', 'font', 'leftBorder', 'topBorder', 'rightBorder', 'bottomBorder', 'border', 'height', 'borderColor', 'width', 'autoWidthCalculation', 'widthWeightFactor', 'horizontalAlignment', 'verticalAlignment', 'textColor', 'backColor', 'patternColor', 'pattern', 'textPlacement', 'fillType', 'protection', 'hidden', 'textOrientation', 'detailsUse', 'bySelectedColumns', 'markNegatives', 'containsValue', 'valueType', 'format', 'controlType', 'hyperLink', 'autoMarkIncomplete', 'indent', 'autoIndent', 'editFormat', 'columnSizeChange', 'mask', 'picIndex', 'pictureSizeMode', 'picHorizontalAlignment', 'picVerticalAlignment', 'textPosition' ) } # Теги, значение которых — многоязычная строка ( на язык), а не скаляр. function Get-FormatMlTags { return @{ 'format' = $true; 'editFormat' = $true; 'mask' = $true } } # Тип значения каждого тега — выведен из корпуса, а не выписан на глаз. # line — ссылка в палитру ; color — #RRGGBB / style: / web: / win: # ml — многоязычная строка; enum — замкнутый список (см. Get-FormatEnumValues) # containsValue / valueType / controlType сюда НЕ входят: это свойства конкретной ячейки, # а стиль — сущность общая, один на многие ячейки. function Get-FormatTagKind { return @{ 'autoIndent' = 'int'; 'autoMarkIncomplete' = 'bool'; 'autoWidthCalculation' = 'bool' 'backColor' = 'color'; 'border' = 'line'; 'borderColor' = 'color' 'bottomBorder' = 'line'; 'bySelectedColumns' = 'bool'; 'columnSizeChange' = 'enum' 'detailsUse' = 'enum'; 'drawingBorder' = 'int' 'drawingHaveBottomBorder' = 'bool'; 'drawingHaveLeftBorder' = 'bool' 'drawingHaveRightBorder' = 'bool'; 'drawingHaveTopBorder' = 'bool' 'editFormat' = 'ml'; 'fillType' = 'enum'; 'font' = 'int'; 'format' = 'ml' 'height' = 'int'; 'hidden' = 'bool'; 'horizontalAlignment' = 'enum' 'hyperLink' = 'bool'; 'indent' = 'int'; 'leftBorder' = 'line' 'markNegatives' = 'bool'; 'mask' = 'ml'; 'pattern' = 'enum'; 'patternColor' = 'color' 'picHorizontalAlignment' = 'enum'; 'picIndex' = 'int'; 'picVerticalAlignment' = 'enum' 'pictureSizeMode' = 'enum'; 'print' = 'bool'; 'protection' = 'bool' 'rightBorder' = 'line'; 'textColor' = 'color'; 'textOrientation' = 'int' 'textPlacement' = 'enum'; 'textPosition' = 'enum'; 'topBorder' = 'line' 'verticalAlignment' = 'enum'; 'width' = 'int'; 'widthWeightFactor' = 'int' } } # Допустимые значения перечислений — тоже сняты с корпуса. function Get-FormatEnumValues { return @{ 'columnSizeChange' = @('Normal', 'QuickChange') 'detailsUse' = @('Cell', 'Row', 'WithoutProcessing') 'fillType' = @('Parameter', 'Template', 'Text') 'horizontalAlignment' = @('Auto', 'Center', 'Justify', 'Left', 'Right') 'pattern' = @('Pattern7', 'Pattern10', 'Pattern12', 'Pattern13', 'Pattern14', 'Pattern16', 'Solid', 'WithoutPattern') 'picHorizontalAlignment' = @('Auto', 'Center', 'Left', 'Right') 'picVerticalAlignment' = @('Bottom', 'Center', 'Top') 'pictureSizeMode' = @('AutoSize', 'Proportionally', 'RealSize') 'textPlacement' = @('Auto', 'Block', 'Cut', 'Wrap') 'textPosition' = @('Auto', 'Bottom', 'Right', 'Top') 'verticalAlignment' = @('Bottom', 'Center', 'Top') } } # Прощающий ввод: ключ стиля, написанный иначе, чем тег платформы. Канон побеждает — # если заданы оба, синоним отбрасывается. Ключи карты нормализованы (lower, без пробелов). # Инвертированных синонимов (visible для hidden) НЕ заводим — это баг семантики, не удобство. function Get-StyleKeySynonyms { return @{ 'align' = 'horizontalAlignment'; 'textalign' = 'horizontalAlignment' 'halign' = 'horizontalAlignment'; 'горизонтальноеположение' = 'horizontalAlignment' 'valign' = 'verticalAlignment'; 'verticalalign' = 'verticalAlignment' 'вертикальноеположение' = 'verticalAlignment' 'background' = 'backColor'; 'bgcolor' = 'backColor'; 'цветфона' = 'backColor' 'color' = 'textColor'; 'forecolor' = 'textColor'; 'цветтекста' = 'textColor' 'цветрамки' = 'borderColor'; 'цветузора' = 'patternColor' 'borderleft' = 'leftBorder'; 'border-left' = 'leftBorder' 'bordertop' = 'topBorder'; 'border-top' = 'topBorder' 'borderright' = 'rightBorder'; 'border-right' = 'rightBorder' 'borderbottom' = 'bottomBorder'; 'border-bottom' = 'bottomBorder' 'protected' = 'protection'; 'защита' = 'protection' 'отступ' = 'indent'; 'узор' = 'pattern'; 'гиперссылка' = 'hyperLink' 'ориентациятекста' = 'textOrientation'; 'размещениетекста' = 'textPlacement' 'переноспословам' = 'wrap' } } $script:formatTagOrder = Get-FormatTagOrder $script:formatMlTags = Get-FormatMlTags $script:formatTagKind = Get-FormatTagKind function Get-FormatKey { param([hashtable]$props) $parts = @() foreach ($tag in $script:formatTagOrder) { if ($props.ContainsKey($tag)) { $parts += "$tag=$($props[$tag])" } } return ($parts -join '|') } function Register-Format { param([hashtable]$props) $key = Get-FormatKey $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 # Формат по умолчанию платформа кладёт в палитру ПОСЛЕДНИМ: на корпусе он последний # в 8285 макетах из 10 863, первым — в 25. Поэтому регистрируем его после всех остальных, # уже за пре-проходом; здесь только запоминаем ширину. # 6b. Column formats — по одной карте на каждую колоночную раскладку. # У колонки бывает и ширина, и оформление: формат один, свойства складываются. foreach ($layout in $columnLayouts) { $map = @{} # 1-based col -> format index $cols = @($layout.Widths.Keys) + @($layout.Styles.Keys) | ForEach-Object { [int]$_ } | Select-Object -Unique | Sort-Object foreach ($col in $cols) { $styleName = if ($layout.Styles.ContainsKey($col)) { $layout.Styles[$col] } else { $null } # Колонка перечислена без формата вовсе (0): в корпусе так # бывает у 4% макетов. Ноль — не индекс записи, а «формата нет». if (-not $layout.Widths.ContainsKey($col) -and [string]::IsNullOrEmpty($styleName)) { $map[$col] = 0 continue } $props = @{} if ($styleName) { # Шрифт по умолчанию колонке не навязываем: формат колонки без оформления — # это ровно , как пишет платформа. $props = Resolve-Style -styleName $styleName -fillType "" } if ($layout.Widths.ContainsKey($col)) { $props['width'] = $layout.Widths[$col] } $map[$col] = Register-Format $props } $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('>','>') } # Текст ячейки платформа хранит по элементу на язык. Конвенция ML-значений (та же, что у # synonym/tooltip/title в метаданных и формах): объект — по элементу на язык В ПОРЯДКЕ КЛЮЧЕЙ, # строка — один и тот же текст на всех языках макета (textLanguages, по умолчанию только ru). $textLanguages = @('ru') if ($def.textLanguages) { $declared = @($def.textLanguages | ForEach-Object { "$_" } | Where-Object { $_ }) if ($declared.Count -gt 0) { $textLanguages = $declared } } function Emit-CellText { param($value) $pairs = @() if ($value -is [System.Collections.IDictionary]) { foreach ($k in $value.Keys) { $pairs += @{ Lang = "$k"; Text = "$($value[$k])" } } } elseif ($value -is [System.Management.Automation.PSCustomObject]) { foreach ($p in $value.PSObject.Properties) { $pairs += @{ Lang = $p.Name; Text = "$($p.Value)" } } } else { foreach ($l in $textLanguages) { $pairs += @{ Lang = $l; Text = "$value" } } } # Пустой объект — отдельное состояние: тег текста есть, языков в нём нет. Платформа # пишет его самозакрывающимся. Для авторинга бесполезно (визуально это тот же пустой # текст, что и ""), поэтому в описании DSL записи нет — она нужна раундтрипу. if ($pairs.Count -eq 0) { X "`t`t`t`t`t" return } X "`t`t`t`t`t" foreach ($p in $pairs) { X "`t`t`t`t`t`t" X "`t`t`t`t`t`t`t$($p.Lang)" X "`t`t`t`t`t`t`t$(Esc-XmlText $p.Text)" X "`t`t`t`t`t`t" } X "`t`t`t`t`t" } # 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 # У ячейки без собственного оформления формата НЕТ вовсе: 0, где ноль — не индекс # записи, а «формата нет». В корпусе так у 170 710 ячеек против 50 635, ссылающихся на # формат по умолчанию; 0 встречается в 71% макетов. if ($resolved.Count -eq 0) { return 0 } return Register-Format $resolved } # --- 5.5. Шорткат строк: строка-массив ячеек --- # Та же форма, что у макетов СКД (skd-compile): позиция ячейки = индекс в массиве, # ">" продолжает ячейку слева, "|" — ячейку сверху, null — пустая колонка, # "{Имя}" — параметр. Разворачиваем в обычную строку с явными col/span/rowspan, # поэтому весь код ниже про шорткат не знает. function Set-CellProp { param($cell, [string]$name, $value) $cell | Add-Member -NotePropertyName $name -NotePropertyValue $value -Force } # Объект описывает СВОЙСТВА ячейки или является её ЗНАЧЕНИЕМ: в первом случае среди ключей # есть ключ схемы ячейки, во втором ключи — идентификаторы языков. Пересечений нет: в корпусе # это ru, en, ru1, Русский. function Test-CellObject { param($el) $cellKeys = @('col', 'span', 'rowspan', 'style', 'param', 'detail', 'text', 'template') foreach ($p in $el.PSObject.Properties) { if ($cellKeys -contains $p.Name) { return $true } } return $false } 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 } } elseif (-not (Test-CellObject $el)) { # Объект без единого ключа ячейки — это многоязычный ТЕКСТ: в позиционной записи # элемент и есть значение текста, а значение текста по общей конвенции бывает # строкой либо объектом «язык → текст». $cell = [PSCustomObject]@{ col = $idx; span = 1 } 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 # Ячейка занимает СТОЛЬКО позиций, каков её span. У строки со следующими ">" это # получается само (каждый маркер съедает позицию), а объектный элемент несёт span # внутри — без этого сдвига всё правее него уезжало влево. $elSpan = if ($cell.span) { [int]$cell.span } else { 1 } if ($elSpan -gt 1) { for ($s = 1; $s -lt $elSpan; $s++) { $idx++; $placed[$idx] = $cell } } } # Колонки, не занятые в этой строке, теряют «ячейку сверху». $openByCol.Clear() foreach ($k in $placed.Keys) { $openByCol[$k] = $placed[$k] } return [PSCustomObject]@{ cells = $cells } } # Позиционный список ячеек опознаём по наличию хотя бы одного элемента-строки или null: # маркеры, текст и пропуски бывают только в нём. Список из одних объектов разбирается # как обычный — для простой строки обе трактовки дают один результат, неоднозначности нет. function Test-PositionalCells { param($cells) if ($null -eq $cells -or -not ($cells -is [array])) { return $false } foreach ($el in $cells) { if ($null -eq $el -or $el -is [string]) { return $true } } return $false } # Карту занятых колонок ведём и по объектным строкам: "|" продолжает ту ячейку, # которая реально стоит выше, независимо от того, какой формой её записали. 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]) { # Строка целиком массивом — сахар для { cells: [...] }. $expandedRows += Expand-ShorthandRow -row $row -areaName $areaName -rowIdx $rowIdx -openByCol $openByCol -maxCols $areaMaxCols } elseif (Test-PositionalCells $row.cells) { # Короткая запись — свойство СПИСКА ЯЧЕЕК, а не строки: свои height и rowStyle # строка при этом сохраняет. $expanded = Expand-ShorthandRow -row $row.cells -areaName $areaName -rowIdx $rowIdx -openByCol $openByCol -maxCols $areaMaxCols $row.cells = $expanded.cells $expandedRows += $row } 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 } # Формат САМОЙ строки: высота и скрытие — её собственные свойства (у ячейки таких # нет), плюс стиль строки, если он ложится на строку. $rowProps = Get-RowFormatProps $row if ($rowProps.Count -gt 0) { Register-Format $rowProps | Out-Null } $spec = Get-RowStyleSpec $row.rowStyle "row" $cellsStyle = if ($spec.Apply -ceq 'row') { $null } else { $spec.Name } # rowStyle gap-fill format (no content → no fillType) if ($cellsStyle) { Register-CellFormat -styleName $cellsStyle -fillType "" | Out-Null } # Explicit cell formats if ($row.cells) { foreach ($cell in $row.cells) { $cellStyle = if ($cell.style) { $cell.style } elseif ($cellsStyle) { $cellsStyle } else { "default" } $ft = Get-FillType $cell Register-CellFormat -styleName $cellStyle -fillType $ft | Out-Null } } } } # Формат по умолчанию — последняя запись палитры (см. выше). $defaultFormatIndex = Register-Format @{ width = $defaultWidth } # Шрифт, на который не ссылается ни один формат, платформа в палитру не кладёт: у макета # без оформления элемента нет вовсе. Мы же всегда заводили Arial 10 по умолчанию. # Отбрасываем неиспользуемые и перенумеровываем ссылки — индексы шрифтов позиционные. $usedFonts = @{} foreach ($k in $formatRegistry.Keys) { $fp = $formatRegistry[$k] if ($fp.ContainsKey('font')) { $usedFonts[[int]$fp['font']] = $true } } if ($usedFonts.Count -lt $fontEntries.Count) { $fontRemap = @{} $kept = @() for ($i = 0; $i -lt $fontEntries.Count; $i++) { if ($usedFonts.ContainsKey($i)) { $fontRemap[$i] = $kept.Count $kept += $fontEntries[$i] } } foreach ($k in @($formatRegistry.Keys)) { $fp = $formatRegistry[$k] if ($fp.ContainsKey('font')) { $fp['font'] = $fontRemap[[int]$fp['font']] } } $fontEntries = $kept } # --- 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 # Языки МАКЕТА — не то же, что textLanguages: там языки, на которые разворачивается текст # ячейки, здесь объявление в шапке. В 98% макетов ERP объявлен один ru, а текст лежит и под # ru, и под en, поэтому выводить одно из другого нельзя. Ключи недокументированные: автору # они не нужны, нужны раундтрипу. $langCodes = @{ 'ru' = 'Русский'; 'en' = 'Английский' } $langList = @() if ($def.languages) { foreach ($l in @($def.languages)) { if ($l -is [string]) { $code = if ($langCodes.ContainsKey("$l")) { $langCodes["$l"] } else { "$l" } $langList += @{ Id = "$l"; Code = $code; Description = $code } } else { # description отсутствует и пустая строка — одно и то же: тег есть всегда, но # бывает самозакрывающимся (77 записей корпуса против 11 017 со значением). $langList += @{ Id = "$($l.id)" Code = "$($l.code)" Description = if ($null -eq $l.description) { '' } else { "$($l.description)" } } } } } if ($langList.Count -eq 0) { $langList = @(@{ Id = 'ru'; Code = 'Русский'; Description = 'Русский' }) } $currentLang = if ($def.PSObject.Properties['currentLanguage']) { $def.currentLanguage } else { 'ru' } $defaultLang = if ($def.defaultLanguage) { "$($def.defaultLanguage)" } else { 'ru' } X "`t" if ($null -ne $currentLang -and "$currentLang" -ne '') { X "`t`t$(Esc-XmlText "$currentLang")" } X "`t`t$(Esc-XmlText $defaultLang)" foreach ($l in $langList) { X "`t`t" X "`t`t`t$(Esc-XmlText $l.Id)" X "`t`t`t$(Esc-XmlText $l.Code)" if ($l.Description -eq '') { X "`t`t`t" } else { X "`t`t`t$(Esc-XmlText $l.Description)" } 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 # Копилка подряд идущих одинаковых пустых строк — пишется одним rowsItem с indexTo. $pendingGap = $null function Add-GapRow { param([string]$key, [int]$row, [string]$columnSet, [int]$formatIdx) $gap = $script:pendingGap # -ceq: ключ несёт GUID раскладки, а обычное -eq в PowerShell регистр не различает. if ($gap -and $gap.Key -ceq $key -and $gap.End -eq ($row - 1)) { $gap.End = $row return } Flush-Gap $script:pendingGap = @{ Key = $key; Start = $row; End = $row ColumnSet = $columnSet; FormatIdx = $formatIdx } } function Flush-Gap { if (-not $script:pendingGap) { return } $gap = $script:pendingGap $script:pendingGap = $null X "`t" X "`t`t$($gap.Start)" if ($gap.End -gt $gap.Start) { X "`t`t$($gap.End)" } X "`t`t" if ($gap.ColumnSet) { X "`t`t`t$($gap.ColumnSet)" } if ($gap.FormatIdx -gt 0) { X "`t`t`t$($gap.FormatIdx)" } X "`t`t`ttrue" X "`t`t" X "`t" } 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++) { Add-GapRow -key '|0' -row $globalRow -columnSet '' -formatIdx 0 $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 } # Формат самой строки — высота, скрытие и стиль строки, если он ложится на строку $rowFormatIdx = 0 $rowProps = Get-RowFormatProps $row if ($rowProps.Count -gt 0) { $rowFormatIdx = Register-Format $rowProps } $spec = Get-RowStyleSpec $row.rowStyle "row" $cellsStyle = if ($spec.Apply -ceq 'row') { $null } else { $spec.Name } 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) { [Console]::Error.WriteLine("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) { [Console]::Error.WriteLine("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) { [Console]::Error.WriteLine("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 ($cellsStyle) { $cellsStyle } 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 ($cellsStyle) { $gapFmtIdx = Register-CellFormat -styleName $cellsStyle -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 ($cellsStyle) { # Row with only rowStyle, no explicit cells — fill non-rowspan columns $rowHasContent = $true $gapFmtIdx = Register-CellFormat -styleName $cellsStyle -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. # Подряд идущие ОДИНАКОВЫЕ пустые строки платформа хранит одним элементом с indexTo; # непустые не схлопывает, даже когда они совпадают. Поэтому пустую строку не пишем # сразу, а копим в пробеле и сбрасываем, когда он оборвался. if (-not $rowHasContent) { Add-GapRow -key "$areaColumnSet|$rowFormatIdx" -row $globalRow ` -columnSet $areaColumnSet -formatIdx $rowFormatIdx $localRow++ $globalRow++ continue } Flush-Gap 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" } # Номер колонки платформа пишет только при разрыве: подряд идущая ячейка его не # несёт, читатель ведёт счётчик сам. Начальное значение счётчика -1, то есть у # ячейки в колонке 0 номера тоже нет. $prevCol = -1 foreach ($cellInfo in $rowCells) { X "`t`t`t" if ($cellInfo.Col -ne ($prevCol + 1)) { X "`t`t`t`t$($cellInfo.Col)" } $prevCol = $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 ($null -ne $cellInfo.Text) { Emit-CellText $cellInfo.Text } if ($null -ne $cellInfo.Template) { Emit-CellText $cellInfo.Template } 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 } } } Flush-Gap $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: имён с «ё» в выборке не встретилось, этот случай не проверен. # Sort-Object сортирует по текущей культуре даже с -CaseSensitive, поэтому сортируем по # ключу из кодов символов: в нём только 0-9A-F, и культурные правила его переупорядочить # не могут. Прежний вариант давал на кириллице не тот порядок, что py-порт. function Get-OrdinalKey { param([string]$name) return (($name.ToLowerInvariant().ToCharArray() | ForEach-Object { '{0:X4}' -f [int]$_ }) -join '') } $sortedNamedItems = @($namedItems | Sort-Object -Property @{ Expression = { Get-OrdinalKey $_.Name } }) 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 foreach ($ln in $script:lineRegistry) { X "`t" X "`t`t$($ln.Style)" X "`t" } # 7i. Font palette foreach ($fe in $fontEntries) { if ($fe.Ref) { # Префикс sys в корне документа не объявлен, поэтому платформа дописывает объявление # прямо на узел — тот же приём, что с цветами из web-палитры. if ($fe.Ref -like 'sys:*') { X "`t" } else { X "`t" } continue } X "`t" } # 7j. Format palette foreach ($key in $formatRegistry.Keys) { $fmt = $formatRegistry[$key] X "`t" foreach ($tag in $script:formatTagOrder) { if (-not $fmt.ContainsKey($tag)) { continue } $val = $fmt[$tag] if ($script:formatMlTags.ContainsKey($tag)) { X "`t`t<$tag>" X "`t`t`t" X "`t`t`t`tru" X "`t`t`t`t$(Esc-XmlText $val)" X "`t`t`t" X "`t`t" } elseif ($script:formatTagKind[$tag] -ceq 'color' -and (Get-ColorNamespace "$val")) { # web/win-палитры в корне документа не объявлены — платформа дописывает объявление # прямо на узел и пишет значение с этим префиксом. $ns = Get-ColorNamespace "$val" $name = "$val".Substring("$val".IndexOf(':') + 1) X "`t`t<$tag xmlns:d3p1=`"$ns`">d3p1:$name" } else { X "`t`t<$tag>$val" } } 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: $($script:lineRegistry.Count), Formats: $($formatRegistry.Count)" Write-Host " Merges: $($merges.Count)"