Files
cc-1c-skills/.claude/skills/mxl-compile/scripts/mxl-compile.ps1
T
Nick ShirokovandClaude Opus 5 6619a4e7ad fix(mxl-compile,mxl-decompile): висячие ссылки на стиль и культурная сортировка
Два дефекта, найденных при сведении портов.

Стиль, на который ссылалась только дополнительная колоночная раскладка,
отсекался как неиспользуемый: проверка смотрела в result, а columnSets
попадает туда ПОЗЖЕ неё. Ссылка оставалась висячей — 5 макетов пилота
из 40 указывали на стиль, которого в styles нет. Берём стили из самих
раскладок.

Именованные элементы платформа хранит отсортированными по имени ординально.
ps1 сортировал их Sort-Object -CaseSensitive, а он всё равно сравнивает по
текущей культуре — комментарий рядом сам об этом предупреждал. На кириллице
порядок расходился с py-портом (4800 строк разницы на одном макете).
Сортируем по ключу из кодов символов: в нём только 0-9A-F, культура его
переупорядочить не может.

Порты сошлись: JSON декомпиляторов совпадает на всех 40 макетах пилота
(было 26 расхождений), собранный XML — на 39 из 40 (было 20 расхождений).
Остаток — один макет, где расходятся компиляторы: ps1 подставляет
faceName="Arial" height="10" там, где py пишет пустой шрифт.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:03:53 +03:00

1670 lines
75 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# mxl-compile v1.38 — 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 ---
# У корня <document> нет атрибута 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 '<MetaDataObject[^>]+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 '<MetaDataObject[^>]+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)
}
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. Line palette ---
# Рамка хранится не в формате, а в палитре <line>: формат ссылается на запись индексом.
# Запись — тройка (стиль, ширина, 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
# элемент <columns> БЕЗ <id>, он всегда идёт первым). Дополнительные объявляются в
# 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)
# Набор свойств формата — «тег платформы → значение», только заданные. Порядок вставки
# роли не играет: и ключ дедупликации, и эмиссия идут по каноническому порядку тегов.
# <font> пишем только когда стиль задал шрифт: треть форматов корпуса (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 }
# Одинаковые четыре стороны платформа пишет одним <border>. Правило проверено на корпусе:
# 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
# Канонический порядок тегов внутри <format>. Снят с корпуса: 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'
)
}
# Теги, значение которых — многоязычная строка (<v8:item> на язык), а не скаляр.
function Get-FormatMlTags {
return @{ 'format' = $true; 'editFormat' = $true; 'mask' = $true }
}
# Тип значения каждого тега — выведен из корпуса, а не выписан на глаз.
# line — ссылка в палитру <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 }
# Колонка перечислена без формата вовсе (<formatIndex>0</formatIndex>): в корпусе так
# бывает у 4% макетов. Ноль — не индекс записи, а «формата нет».
if (-not $layout.Widths.ContainsKey($col) -and [string]::IsNullOrEmpty($styleName)) {
$map[$col] = 0
continue
}
$props = @{}
if ($styleName) {
# Шрифт по умолчанию колонке не навязываем: формат колонки без оформления —
# это ровно <width>, как пишет платформа.
$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('&','&amp;').Replace('<','&lt;').Replace('>','&gt;').Replace('"','&quot;')
}
function Esc-XmlText {
# Экранирование ТЕКСТА элемента: только & < > . Кавычки в тексте платформа НЕ экранирует —
# пишет литерально (проверено: 92142 сырых кавычки на корпус, ни одной &quot;). &quot; платформа
# принимает, но при выгрузке нормализует обратно в кавычку → лишний шум в роундтрипе.
param([string]$s)
return $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;')
}
# Текст ячейки платформа хранит по элементу на язык. Конвенция 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" } }
}
X "`t`t`t`t`t<tl>"
foreach ($p in $pairs) {
X "`t`t`t`t`t`t<v8:item>"
X "`t`t`t`t`t`t`t<v8:lang>$($p.Lang)</v8:lang>"
X "`t`t`t`t`t`t`t<v8:content>$(Esc-XmlText $p.Text)</v8:content>"
X "`t`t`t`t`t`t</v8:item>"
}
X "`t`t`t`t`t</tl>"
}
# Helper: determine fillType from cell content
# Text НЕ эмитим: платформа его практически не пишет — на выборке корпуса 344 981 текстовая
# ячейка из 348 023 (99,1%) ссылается на формат БЕЗ fillType. Наличие <tl> и так означает
# текст, поэтому тег избыточен. 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
# У ячейки без собственного оформления формата НЕТ вовсе: <f>0</f>, где ноль — не индекс
# записи, а «формата нет». В корпусе так у 170 710 ячеек против 50 635, ссылающихся на
# формат по умолчанию; <f>0</f> встречается в 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 }
# Шрифт, на который не ссылается ни один формат, платформа в палитру не кладёт: у макета
# без оформления элемента <font> нет вовсе. Мы же всегда заводили 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 '<?xml version="1.0" encoding="UTF-8"?>'
X "<document $docNsDecl>"
# 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<languageSettings>"
if ($null -ne $currentLang -and "$currentLang" -ne '') {
X "`t`t<currentLanguage>$(Esc-XmlText "$currentLang")</currentLanguage>"
}
X "`t`t<defaultLanguage>$(Esc-XmlText $defaultLang)</defaultLanguage>"
foreach ($l in $langList) {
X "`t`t<languageInfo>"
X "`t`t`t<id>$(Esc-XmlText $l.Id)</id>"
X "`t`t`t<code>$(Esc-XmlText $l.Code)</code>"
if ($l.Description -eq '') {
X "`t`t`t<description/>"
} else {
X "`t`t`t<description>$(Esc-XmlText $l.Description)</description>"
}
X "`t`t</languageInfo>"
}
X "`t</languageSettings>"
# 7c. Columns
# Раскладка по умолчанию идёт первой и без <id> — так их хранит платформа.
foreach ($layout in $columnLayouts) {
X "`t<columns>"
if ($layout.Id) { X "`t`t<id>$($layout.Id)</id>" }
X "`t`t<size>$($layout.Size)</size>"
# Emit columnsItem for columns with non-default widths
foreach ($col in ($layout.FormatMap.Keys | Sort-Object)) {
$fmtIdx = $layout.FormatMap[$col]
$colIdx = $col - 1 # Convert to 0-based
X "`t`t<columnsItem>"
X "`t`t`t<index>$colIdx</index>"
X "`t`t`t<column>"
X "`t`t`t`t<formatIndex>$fmtIdx</formatIndex>"
X "`t`t`t</column>"
X "`t`t</columnsItem>"
}
X "`t</columns>"
}
# 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<rowsItem>"
X "`t`t<index>$($gap.Start)</index>"
if ($gap.End -gt $gap.Start) { X "`t`t<indexTo>$($gap.End)</indexTo>" }
X "`t`t<row>"
if ($gap.ColumnSet) { X "`t`t`t<columnsID>$($gap.ColumnSet)</columnsID>" }
if ($gap.FormatIdx -gt 0) { X "`t`t`t<formatIndex>$($gap.FormatIdx)</formatIndex>" }
X "`t`t`t<empty>true</empty>"
X "`t`t</row>"
X "`t</rowsItem>"
}
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<rowsItem>"
X "`t`t<index>$globalRow</index>"
X "`t`t<row>"
if ($areaColumnSet) {
X "`t`t`t<columnsID>$areaColumnSet</columnsID>"
}
if ($rowFormatIdx -gt 0) {
X "`t`t`t<formatIndex>$rowFormatIdx</formatIndex>"
}
# Номер колонки платформа пишет только при разрыве: подряд идущая ячейка его не
# несёт, читатель ведёт счётчик сам. Начальное значение счётчика -1, то есть у
# ячейки в колонке 0 номера тоже нет.
$prevCol = -1
foreach ($cellInfo in $rowCells) {
X "`t`t`t<c>"
if ($cellInfo.Col -ne ($prevCol + 1)) {
X "`t`t`t`t<i>$($cellInfo.Col)</i>"
}
$prevCol = $cellInfo.Col
X "`t`t`t`t<c>"
X "`t`t`t`t`t<f>$($cellInfo.FormatIdx)</f>"
if ($cellInfo.Param) {
X "`t`t`t`t`t<parameter>$($cellInfo.Param)</parameter>"
if ($cellInfo.Detail) {
X "`t`t`t`t`t<detailParameter>$($cellInfo.Detail)</detailParameter>"
}
}
# Проверяем НАЛИЧИЕ ключа, а не истинность: пустая строка — это текст, платформа
# такие ячейки пишет с пустым <tl>, и по истинности он терялся.
if ($null -ne $cellInfo.Text) { Emit-CellText $cellInfo.Text }
if ($null -ne $cellInfo.Template) { Emit-CellText $cellInfo.Template }
X "`t`t`t`t</c>"
X "`t`t`t</c>"
}
X "`t`t</row>"
X "`t</rowsItem>"
$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 "`t<templateMode>true</templateMode>"
X "`t<defaultFormatIndex>$defaultFormatIndex</defaultFormatIndex>"
X "`t<height>$totalRowCount</height>"
X "`t<vgRows>$totalRowCount</vgRows>"
# 7f. Merges
foreach ($m in $merges) {
X "`t<merge>"
X "`t`t<r>$($m.R)</r>"
X "`t`t<c>$($m.C)</c>"
if ($m.H) { X "`t`t<h>$($m.H)</h>" }
X "`t`t<w>$($m.W)</w>"
X "`t</merge>"
}
# 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<namedItem xsi:type=`"NamedItemCells`">"
X "`t`t<name>$($ni.Name)</name>"
X "`t`t<area>"
X "`t`t`t<type>$type</type>"
X "`t`t`t<beginRow>$($ni.BeginRow)</beginRow>"
X "`t`t`t<endRow>$($ni.EndRow)</endRow>"
X "`t`t`t<beginColumn>$($ni.BeginCol)</beginColumn>"
X "`t`t`t<endColumn>$($ni.EndCol)</endColumn>"
X "`t`t</area>"
X "`t</namedItem>"
}
# 7h. Line palette
foreach ($ln in $script:lineRegistry) {
X "`t<line width=`"$($ln.Width)`" gap=`"$($ln.Gap)`">"
X "`t`t<v8ui:style xsi:type=`"v8ui:SpreadsheetDocumentCellLineType`">$($ln.Style)</v8ui:style>"
X "`t</line>"
}
# 7i. Font palette
foreach ($fe in $fontEntries) {
X "`t<font faceName=`"$($fe.Face)`" height=`"$(Format-Num $fe.Size)`" bold=`"$($fe.Bold)`" italic=`"$($fe.Italic)`" underline=`"$($fe.Underline)`" strikeout=`"$($fe.Strikeout)`" kind=`"Absolute`" scale=`"100`"/>"
}
# 7j. Format palette
foreach ($key in $formatRegistry.Keys) {
$fmt = $formatRegistry[$key]
X "`t<format>"
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<v8:item>"
X "`t`t`t`t<v8:lang>ru</v8:lang>"
X "`t`t`t`t<v8:content>$(Esc-XmlText $val)</v8:content>"
X "`t`t`t</v8:item>"
X "`t`t</$tag>"
} 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</$tag>"
} else {
X "`t`t<$tag>$val</$tag>"
}
}
X "`t</format>"
}
# 7k. Close document
X '</document>'
# --- 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)"