Files
cc-1c-skills/.claude/skills/mxl-decompile/scripts/mxl-decompile.ps1
T
Nick ShirokovandClaude Opus 5 b5fef09844 feat(mxl-compile,mxl-decompile): многоязычный текст элементом строки-массива
В позиционной записи строки элемент — это значение содержимого ячейки, а значение
текста по общей конвенции бывает строкой либо объектом «язык → текст». Значит
объект {ru, en} там законен так же, как строка, и новой формы это не заводит.

Раньше ячейка с многоязычным текстом не считалась «простой», поэтому строка
двуязычного макета оставалась объектной:

  { "cells": [{ "text": { "ru": "Пусто", "en": "Empty" } }, { … }] }
  [{ "ru": "Пусто", "en": "Empty" }, { … }]

Объект-элемент читается как ячейка, если несёт хоть один её ключ, и как текст
в противном случае: идентификаторы языков с ключами ячейки не пересекаются
(в корпусе это ru, en, ru1, Русский).

Попутно исправлен признак «список позиционный»: он искал элемент-строку, поэтому
строка из одних многоязычных текстов позиционной не признавалась и ключ cells
у неё оставался.

Правка не должна менять скомпилированный XML — и не меняет: пилот из 40 макетов
собрался побайтово так же, JSON при этом изменился у 13. Стенд 7 из 10.

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

1235 lines
50 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-decompile v1.16 — Decompile 1C spreadsheet to JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
[Alias('Path')]
[string]$TemplatePath,
[string]$OutputPath
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- 1. Load and parse XML ---
if (-not (Test-Path $TemplatePath)) {
Write-Error "File not found: $TemplatePath"
exit 1
}
$xmlDoc = New-Object System.Xml.XmlDocument
$xmlDoc.PreserveWhitespace = $false
$xmlDoc.Load((Resolve-Path $TemplatePath).Path)
$root = $xmlDoc.DocumentElement
$ns = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$ns.AddNamespace("d", "http://v8.1c.ru/8.2/data/spreadsheet")
$ns.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
$ns.AddNamespace("v8ui", "http://v8.1c.ru/8.1/data/ui")
$ns.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
# Тип значения каждого тега формата — выведен из корпуса, а не выписан на глаз.
# line — ссылка в палитру <line>; color — #RRGGBB / style: / web: / win:
# ml — многоязычная строка; enum — замкнутый список.
# 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'
}
}
$RU_NAME = 'Русский'
# Теги, значение которых — многоязычная строка (<v8:item> на язык), а не скаляр.
function Get-FormatMlTags {
return @{ 'format' = $true; 'editFormat' = $true; 'mask' = $true }
}
# Цвет платформа хранит значением с префиксом пространства имён. style: объявлен в корне
# документа и читается как есть; web/win объявлены прямо на узле сгенерированным префиксом
# (d3p1), поэтому его надо разрешить в URI и вернуть привычное web:/win:.
function ConvertTo-DslColor {
param($node, [string]$val)
if ($val -notlike '*:*' -or $val -like 'style:*') { return $val }
$prefix = $val.Substring(0, $val.IndexOf(':'))
$name = $val.Substring($val.IndexOf(':') + 1)
# get_*(): XML-адаптер PowerShell перекрывает .NET-члены одноимёнными атрибутами узла.
$uri = $node.GetNamespaceOfPrefix($prefix)
if ($uri -ceq 'http://v8.1c.ru/8.1/data/ui/colors/web') { return "web:$name" }
if ($uri -ceq 'http://v8.1c.ru/8.1/data/ui/colors/windows') { return "win:$name" }
return $val
}
# --- 2. Extract font palette ---
# Размер шрифта бывает дробным (8.3, 11.3 — в корпусе ERP это треть макетов). [int] его
# ТИХО округлял, а py-порт падал на 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
}
$rawFonts = @()
foreach ($fNode in $root.SelectNodes("d:font", $ns)) {
$rawFonts += @{
Face = $fNode.GetAttribute("faceName")
Size = ConvertTo-FontSize $fNode.GetAttribute("height")
Bold = $fNode.GetAttribute("bold") -eq "true"
Italic = $fNode.GetAttribute("italic") -eq "true"
Underline = $fNode.GetAttribute("underline") -eq "true"
Strikeout = $fNode.GetAttribute("strikeout") -eq "true"
}
}
# --- 3. Extract line palette ---
$rawLines = @()
foreach ($lNode in $root.SelectNodes("d:line", $ns)) {
$st = $lNode.SelectSingleNode("v8ui:style", $ns)
$rawLines += @{
Width = [int]$lNode.GetAttribute("width")
Gap = $lNode.GetAttribute("gap")
Style = if ($st) { $st.InnerText } else { "Solid" }
}
}
# --- 4. Extract format palette ---
# Формат читаем целиком — «тег → значение», а не выборочные девять полей. Тип значения
# берём из общей таблицы, она же у компилятора.
$formatKinds = Get-FormatTagKind
$formatMl = Get-FormatMlTags
$rawFormats = @()
foreach ($fmtNode in $root.SelectNodes("d:format", $ns)) {
# Совместимость с прежними полями: ими пользуются ширина колонки, высота строки и
# признак заполнения — они не часть стиля.
$fmt = @{
FontIdx = -1; Width = 0; Height = 0; FillType = ""
Props = [ordered]@{}
}
foreach ($child in $fmtNode.ChildNodes) {
if ($child.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
$tag = $child.get_LocalName()
if ($formatMl.ContainsKey($tag)) {
$item = $child.SelectSingleNode("v8:item/v8:content", $ns)
if ($item -and $item.InnerText) { $fmt.Props[$tag] = $item.InnerText }
continue
}
$val = $child.InnerText
if ([string]::IsNullOrWhiteSpace($val)) { continue }
if ($formatKinds[$tag] -ceq 'color') { $val = ConvertTo-DslColor $child $val }
$fmt.Props[$tag] = $val
}
if ($fmt.Props.Contains('font')) { $fmt.FontIdx = [int]$fmt.Props['font'] }
if ($fmt.Props.Contains('width')) { $fmt.Width = [int]$fmt.Props['width'] }
if ($fmt.Props.Contains('height')) { $fmt.Height = [int]$fmt.Props['height'] }
if ($fmt.Props.Contains('fillType')) { $fmt.FillType = $fmt.Props['fillType'] }
$rawFormats += $fmt
}
function Get-Format {
param([int]$idx)
if ($idx -le 0 -or $idx -gt $rawFormats.Count) { return $null }
return $rawFormats[$idx - 1]
}
# --- 5. Extract columns and default width ---
# Колоночная раскладка («индивидуальная ширина колонок» для группы строк) — элемент <columns>.
# Их бывает несколько: раскладка БЕЗ <id> — умолчание (ровно одна в каждом макете корпуса),
# остальные адресуются GUID из <id>, на который ссылаются строки (<row><columnsID>) и
# области (<area><columnsID>). Раньше читался только первый <columns> — отсюда терялись
# ширины и вылезал columns: 0.
$defaultFmtIdx = 0
$n = $root.SelectSingleNode("d:defaultFormatIndex", $ns)
if ($n) { $defaultFmtIdx = [int]$n.InnerText }
$defaultWidth = 10
if ($defaultFmtIdx -gt 0) {
$defFmt = Get-Format $defaultFmtIdx
if ($defFmt -and $defFmt.Width -gt 0) { $defaultWidth = $defFmt.Width }
}
function Read-ColumnSet {
param($node)
$byIdx = @{}
foreach ($ci in $node.SelectNodes("d:columnsItem", $ns)) {
$colIdx = [int]$ci.SelectSingleNode("d:index", $ns).InnerText
$fmtIdx = [int]$ci.SelectSingleNode("d:column/d:formatIndex", $ns).InnerText
$byIdx[$colIdx] = $fmtIdx
}
# Карта ширин (1-based колонка → ширина), только отличные от умолчания.
$widths = [ordered]@{}
foreach ($col0 in ($byIdx.Keys | Sort-Object)) {
$fmt = Get-Format $byIdx[$col0]
if ($fmt -and $fmt.Width -gt 0 -and $fmt.Width -ne $defaultWidth) {
$widths.Add([string]($col0 + 1), $fmt.Width)
}
}
# Формат колонки несёт не только ширину — имя стиля подставим ниже, когда стили
# будут поименованы.
$fmtIdx = [ordered]@{}
foreach ($col0 in ($byIdx.Keys | Sort-Object)) { $fmtIdx.Add([string]($col0 + 1), $byIdx[$col0]) }
$sizeNode = $node.SelectSingleNode("d:size", $ns)
$idNode = $node.SelectSingleNode("d:id", $ns)
return @{
Id = if ($idNode) { $idNode.InnerText } else { $null }
Size = if ($sizeNode) { [int]$sizeNode.InnerText } else { 0 }
Widths = $widths
FmtIdx = $fmtIdx
}
}
$columnSets = @()
foreach ($cn in $root.SelectNodes("d:columns", $ns)) { $columnSets += Read-ColumnSet $cn }
$defaultSet = @($columnSets | Where-Object { -not $_.Id })[0]
if (-not $defaultSet -and $columnSets.Count -gt 0) { $defaultSet = $columnSets[0] }
$totalColumns = if ($defaultSet) { $defaultSet.Size } else { 0 }
$colWidthMap = if ($defaultSet) { $defaultSet.Widths } else { [ordered]@{} }
# --- 6. Extract merges ---
$mergeMap = @{}
foreach ($mNode in $root.SelectNodes("d:merge", $ns)) {
$r = [int]$mNode.SelectSingleNode("d:r", $ns).InnerText
$c = [int]$mNode.SelectSingleNode("d:c", $ns).InnerText
$w = [int]$mNode.SelectSingleNode("d:w", $ns).InnerText
$hNode = $mNode.SelectSingleNode("d:h", $ns)
$h = if ($hNode) { [int]$hNode.InnerText } else { 0 }
$mergeMap["$r,$c"] = @{ W = $w; H = $h }
}
# --- 7. Extract named items ---
# Захватываем области ВСЕХ типов. Раньше здесь стоял `if ($areaType -ne "Rows") { continue }`,
# из-за чего терялись Rectangle и Columns — а они есть у 61% макетов корпуса.
$namedAreas = @()
foreach ($niNode in $root.SelectNodes("d:namedItem", $ns)) {
$xsiType = $niNode.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
if ($xsiType -ne "NamedItemCells") { continue }
$areaNode = $niNode.SelectSingleNode("d:area", $ns)
if (-not $areaNode) { continue }
$getCoord = {
param([string]$tag)
$n = $areaNode.SelectSingleNode("d:$tag", $ns)
if ($n) { return [int]$n.InnerText }
return -1
}
$namedAreas += @{
Name = $niNode.SelectSingleNode("d:name", $ns).InnerText
Type = $areaNode.SelectSingleNode("d:type", $ns).InnerText
BeginRow = & $getCoord 'beginRow'
EndRow = & $getCoord 'endRow'
BeginCol = & $getCoord 'beginColumn'
EndCol = & $getCoord 'endColumn'
}
}
# --- 8. Extract rows ---
$rowData = @{}
# Языки, на которых в макете вообще есть текст. Порядок — первого появления.
$docLangs = [ordered]@{}
foreach ($riNode in $root.SelectNodes("d:rowsItem", $ns)) {
$rowIdx = [int]$riNode.SelectSingleNode("d:index", $ns).InnerText
$rowNode = $riNode.SelectSingleNode("d:row", $ns)
$indexTo = $rowIdx
$itNode = $riNode.SelectSingleNode("d:indexTo", $ns)
if ($itNode) { $indexTo = [int]$itNode.InnerText }
$rowFmtIdx = 0
$fmtNode = $rowNode.SelectSingleNode("d:formatIndex", $ns)
if ($fmtNode) { $rowFmtIdx = [int]$fmtNode.InnerText }
$isEmpty = $false
$emptyNode = $rowNode.SelectSingleNode("d:empty", $ns)
if ($emptyNode -and $emptyNode.InnerText -eq "true") { $isEmpty = $true }
$cells = @()
if (-not $isEmpty) {
$col = -1
foreach ($cGroup in $rowNode.SelectNodes("d:c", $ns)) {
$iNode = $cGroup.SelectSingleNode("d:i", $ns)
if ($iNode) { $col = [int]$iNode.InnerText }
else { $col++ }
$cContent = $cGroup.SelectSingleNode("d:c", $ns)
if (-not $cContent) { continue }
$cellFmtIdx = 0
$fNode = $cContent.SelectSingleNode("d:f", $ns)
if ($fNode) { $cellFmtIdx = [int]$fNode.InnerText }
$param = $null
$pNode = $cContent.SelectSingleNode("d:parameter", $ns)
if ($pNode) { $param = $pNode.InnerText }
$detail = $null
$dNode = $cContent.SelectSingleNode("d:detailParameter", $ns)
if ($dNode) { $detail = $dNode.InnerText }
# Текст ячейки платформа хранит по элементу на язык. Раньше брался ПЕРВЫЙ, и всё
# остальное терялось — в корпусе ERP 98% макетов держат текст и под ru, и под en.
# Здесь собираем «язык → текст» как есть; свернётся в строку позже, когда станет
# известен набор языков всего макета (Get-DslText).
$text = $null
$hasText = $false
$items = $cContent.SelectNodes("d:tl/v8:item", $ns)
if ($items -and $items.Count -gt 0) {
$byLang = [ordered]@{}
foreach ($it in $items) {
$langNode = $it.SelectSingleNode("v8:lang", $ns)
$contentNode = $it.SelectSingleNode("v8:content", $ns)
$lang = if ($langNode) { $langNode.InnerText } else { '' }
$byLang[$lang] = if ($contentNode) { $contentNode.InnerText } else { '' }
$docLangs[$lang] = $true
}
$text = $byLang
# Единственный пустой русский текст содержимым не считается — такая ячейка
# уходит в заполнители строки.
$hasText = -not ($byLang.Count -eq 1 -and $byLang.Contains('ru') -and "$($byLang['ru'])" -eq '')
}
$cells += @{
Col = $col
FormatIdx = $cellFmtIdx
Param = $param
Detail = $detail
Text = $text
HasText = $hasText
}
}
}
# Ссылка строки на колоночную раскладку; пусто = раскладка по умолчанию.
$rowColumnsId = $null
$cidNode = $rowNode.SelectSingleNode("d:columnsID", $ns)
if ($cidNode) { $rowColumnsId = $cidNode.InnerText }
for ($r = $rowIdx; $r -le $indexTo; $r++) {
$rowData[$r] = @{
FormatIdx = $rowFmtIdx
Cells = $cells
Empty = $isEmpty
ColumnsId = $rowColumnsId
}
}
}
# Языки текстов макета и языки, объявленные в конфигурации, — разные вещи: у типовых они не
# совпадают. Строкой пишем текст, одинаковый на ВСЁМ наборе языков макета; остальное — объектом.
$textLanguages = @($docLangs.Keys)
function Get-DslText {
param($byLang)
if ($byLang -isnot [System.Collections.IDictionary]) { return $byLang }
if ($byLang.Count -ne $textLanguages.Count) { return $byLang }
$common = $null
foreach ($l in $textLanguages) {
if (-not $byLang.Contains($l)) { return $byLang }
if ($null -eq $common) { $common = "$($byLang[$l])" }
elseif ("$($byLang[$l])" -cne $common) { return $byLang }
}
return $common
}
# --- 9. Build style key (ignoring fillType) ---
# Свойства формата, которые стилем НЕ являются: ширина принадлежит колонке, высота —
# строке, вид заполнения выводится из того, чем задано содержимое ячейки.
$nonStyleTags = @('width', 'height', 'fillType', 'font')
$borderTags = @('border', 'leftBorder', 'topBorder', 'rightBorder', 'bottomBorder')
function ConvertTo-DslLine {
# Ссылку в палитру разворачиваем в описание линии. Ширина 1 подразумевается, поэтому
# обычная линия пишется просто именем стиля.
param($idx)
$i = [int]$idx
if ($i -lt 0 -or $i -ge $rawLines.Count) { return "Solid" }
$ln = $rawLines[$i]
$out = [ordered]@{ style = $ln.Style }
if ($ln.Width -ne 1) { $out['width'] = $ln.Width }
if ($ln.Gap -ceq 'true') { $out['gap'] = $true }
if ($out.Count -eq 1) { return $ln.Style }
return $out
}
function Get-StyleProps {
# Свойства формата, попадающие в стиль, — в каноническом порядке тегов.
param($fmt)
$out = [ordered]@{}
if (-not $fmt) { return $out }
foreach ($tag in $fmt.Props.Keys) {
if ($nonStyleTags -ccontains $tag) { continue }
$val = $fmt.Props[$tag]
if ($borderTags -ccontains $tag) {
$out[$tag] = ConvertTo-DslLine $val
} elseif ($formatKinds[$tag] -ceq 'bool') {
$out[$tag] = ($val -ceq 'true')
} elseif ($formatKinds[$tag] -ceq 'int') {
$out[$tag] = [int]$val
} else {
$out[$tag] = $val
}
}
return $out
}
function Get-StyleKey {
param($fmt)
if (-not $fmt) { return "empty" }
# «шрифта нет» и «шрифт с индексом 0» — разные форматы: нулевой шрифт вовсе не обязан
# быть обычным, в макете он бывает курсивным или жирным.
$parts = @("f=" + $(if ($fmt.FontIdx -ge 0) { "$($fmt.FontIdx)" } else { "none" }))
$props = Get-StyleProps $fmt
foreach ($tag in $props.Keys) {
$v = $props[$tag]
if ($v -is [System.Collections.IDictionary]) {
$v = (($v.Keys | ForEach-Object { "$_=$($v[$_])" }) -join ',')
}
$parts += "$tag=$v"
}
return ($parts -join '|')
}
# --- 10. Name fonts ---
$fontNames = @{}
$fontDefs = [ordered]@{}
if ($rawFonts.Count -gt 0) {
$fontNames[0] = "default"
$fontDefs["default"] = $rawFonts[0]
}
function Get-FontKey {
param($f)
return "$($f.Face)|$($f.Size)|$($f.Bold)|$($f.Italic)|$($f.Underline)|$($f.Strikeout)"
}
$fontKeyMap = @{}
$fontKeyMap[(Get-FontKey $rawFonts[0])] = "default"
for ($i = 1; $i -lt $rawFonts.Count; $i++) {
$f = $rawFonts[$i]
$df = $rawFonts[0]
# Dedup: if identical font already named, reuse
$fKey = Get-FontKey $f
if ($fontKeyMap.ContainsKey($fKey)) {
$fontNames[$i] = $fontKeyMap[$fKey]
continue
}
$name = $null
if ($f.Face -eq $df.Face -and $f.Size -eq $df.Size) {
if ($f.Bold -and -not $df.Bold -and -not $f.Italic -and -not $f.Underline -and -not $f.Strikeout) {
$name = "bold"
} elseif ($f.Italic -and -not $df.Italic -and -not $f.Bold) {
$name = "italic"
} elseif ($f.Underline -and -not $df.Underline -and -not $f.Bold -and -not $f.Italic) {
$name = "underline"
}
} elseif ($f.Face -eq $df.Face -and $f.Size -gt $df.Size -and $f.Bold) {
$name = "header"
} elseif ($f.Face -eq $df.Face -and $f.Size -lt $df.Size) {
$name = "small"
}
if (-not $name) {
$parts = @()
if ($f.Face -and $f.Face -ne $df.Face) { $parts += $f.Face.ToLower() }
$parts += "$($f.Size)"
if ($f.Bold) { $parts += "bold" }
if ($f.Italic) { $parts += "italic" }
if ($f.Underline) { $parts += "underline" }
if ($f.Strikeout) { $parts += "strikeout" }
$name = $parts -join "-"
}
$baseName = $name; $suffix = 2
while ($fontDefs.Contains($name)) { $name = "$baseName$suffix"; $suffix++ }
$fontNames[$i] = $name
$fontDefs[$name] = $f
$fontKeyMap[$fKey] = $name
}
# --- 11. Collect and name styles ---
$styleKeys = [ordered]@{}
$formatToStyleKey = @{}
foreach ($r in $rowData.Values) {
foreach ($cell in $r.Cells) {
$fmt = Get-Format $cell.FormatIdx
if (-not $fmt) { continue }
$key = Get-StyleKey $fmt
if (-not $styleKeys.Contains($key)) { $styleKeys[$key] = $fmt }
$formatToStyleKey[$cell.FormatIdx] = $key
}
}
# Оформление строки без её собственных свойств: скрытие уезжает инлайном к height,
# поэтому в именованный стиль попадать не должно.
function Get-RowStyleFmt {
param($fmt)
if (-not $fmt) { return $null }
$props = Get-StyleProps $fmt
$props.Remove('hidden') | Out-Null
# Шрифт в Get-StyleProps не входит (он адресуется именем), поэтому формат строки,
# несущий ТОЛЬКО шрифт, здесь выглядел пустым и терялся.
if ($props.Count -eq 0 -and $fmt.FontIdx -lt 0) { return $null }
$reduced = [ordered]@{}
foreach ($tag in $fmt.Props.Keys) {
if ($tag -cne 'hidden') { $reduced[$tag] = $fmt.Props[$tag] }
}
return @{ FontIdx = $fmt.FontIdx; Width = 0; Height = 0; FillType = ""; Props = $reduced }
}
# Строка — тоже владелец формата: её оформление становится именованным стилем.
foreach ($rd in $rowData.Values) {
if ($rd.FormatIdx -le 0) { continue }
$rf = Get-RowStyleFmt (Get-Format $rd.FormatIdx)
if (-not $rf) { continue }
$key = Get-StyleKey $rf
if (-not $styleKeys.Contains($key)) { $styleKeys[$key] = $rf }
}
# Колонка — третий владелец формата, её оформление тоже становится именованным стилем.
foreach ($cs in $columnSets) {
foreach ($fi in $cs.FmtIdx.Values) {
$fmt = Get-Format $fi
if (-not $fmt) { continue }
if ((Get-StyleProps $fmt).Count -eq 0) { continue }
$key = Get-StyleKey $fmt
if (-not $styleKeys.Contains($key)) { $styleKeys[$key] = $fmt }
$formatToStyleKey[$fi] = $key
}
}
# Имя стиля — читаемая метка по самым заметным свойствам. Полнота тут не нужна:
# различает стили ключ, имя лишь помогает автору ориентироваться.
function Name-Style {
param($fmt)
if (-not $fmt) { return "default" }
$parts = @()
$props = Get-StyleProps $fmt
$fi = $fmt.FontIdx
if ($fi -ge 0 -and $fontNames.ContainsKey($fi) -and $fontNames[$fi] -ne "default") {
$parts += $fontNames[$fi]
}
if ($props.Contains('border')) {
$parts += "bordered"
} else {
$sides = @()
foreach ($s in @('leftBorder', 'topBorder', 'rightBorder', 'bottomBorder')) {
if ($props.Contains($s)) { $sides += $s.Substring(0, $s.Length - 6) }
}
if ($sides.Count -gt 0) { $parts += "border-$($sides -join ',')" }
}
if ($props['horizontalAlignment'] -ceq "Center") { $parts += "center" }
elseif ($props['horizontalAlignment'] -ceq "Right") { $parts += "right" }
if ($props['verticalAlignment'] -ceq "Center") { $parts += "vcenter" }
elseif ($props['verticalAlignment'] -ceq "Top") { $parts += "vtop" }
if ($props['textPlacement'] -ceq "Wrap") { $parts += "wrap" }
if ($props.Contains('backColor')) { $parts += "bg" }
if ($props.Contains('format')) { $parts += "fmt" }
# Имя "default" зарезервировано за стилем БЕЗ свойств: под ним компилятор понимает
# отсутствие оформления. Набор из одних неприметных свойств (отступ, защита) обязан
# получить своё имя, иначе он потеряется.
# Имя "default" зарезервировано за стилем БЕЗ свойств: под ним компилятор понимает
# отсутствие оформления, и ячейка такой стиль не записывает. Набор из одних неприметных
# свойств или из одного шрифта обязан получить своё имя, иначе он потеряется.
if ($parts.Count -eq 0) {
$hasContent = ($props.Count -gt 0) -or ($fmt.FontIdx -ge 0)
return $(if ($hasContent) { "style" } else { "default" })
}
return ($parts -join "-")
}
$styleNames = [ordered]@{}
$styleDefs = [ordered]@{}
foreach ($key in $styleKeys.Keys) {
$fmt = $styleKeys[$key]
$name = Name-Style $fmt
$baseName = $name; $suffix = 2
while ($styleDefs.Contains($name)) { $name = "$baseName$suffix"; $suffix++ }
$styleNames[$key] = $name
$sDef = [ordered]@{}
if ($fmt.FontIdx -ge 0) {
$sDef["font"] = $(if ($fontNames.ContainsKey($fmt.FontIdx)) { $fontNames[$fmt.FontIdx] } else { "default" })
}
$props = Get-StyleProps $fmt
foreach ($tag in $props.Keys) { $sDef[$tag] = $props[$tag] }
$styleDefs[$name] = $sDef
}
function Get-StyleName {
param([int]$fmtIdx)
$key = $formatToStyleKey[$fmtIdx]
if ($key -and $styleNames.Contains($key)) { return $styleNames[$key] }
return "default"
}
# Колонки раскладки, у которых формат несёт не только ширину, — «колонка → стиль».
function Get-ColumnStyles {
param($cs)
$out = [ordered]@{}
foreach ($col in $cs.FmtIdx.Keys) {
$fi = $cs.FmtIdx[$col]
if ($fi -eq 0) {
# Колонка перечислена, формата у неё нет. Для авторинга бесполезно, но без
# этого раундтрип теряет элемент целиком.
$out[$col] = $null
continue
}
$fmt = Get-Format $fi
if ($fmt -and (Get-StyleProps $fmt).Count -gt 0) { $out[$col] = Get-StyleName $fi }
}
return $out
}
# Список позиционный, если позиция в нём НЕ записана: ни у одного элемента нет col.
# Прежняя проверка искала элемент-строку, поэтому строка из одних многоязычных текстов
# позиционной не признавалась и оставалась объектной.
function Test-PositionalList {
param($cells)
foreach ($el in $cells) {
if ($el -is [System.Collections.IDictionary] -and $el.Contains('col')) { return $false }
}
return $true
}
# Custom JSON serializer — компактный, 2-пробельный indent, массивы примитивов inline.
# В отличие от ConvertTo-Json (PS5.1):
# - не выравнивает ключи объекта по самому длинному
# - не разворачивает массивы примитивов на отдельные строки
# - кириллица в UTF-8 (без \uXXXX-escapes)
function Convert-StringToJsonLiteral {
param([string]$s)
if ($null -eq $s) { return 'null' }
$sb = New-Object System.Text.StringBuilder
[void]$sb.Append('"')
foreach ($ch in $s.ToCharArray()) {
$code = [int]$ch
if ($code -eq 0x22) { [void]$sb.Append('\"') }
elseif ($code -eq 0x5C) { [void]$sb.Append('\\') }
elseif ($code -eq 0x08) { [void]$sb.Append('\b') }
elseif ($code -eq 0x09) { [void]$sb.Append('\t') }
elseif ($code -eq 0x0A) { [void]$sb.Append('\n') }
elseif ($code -eq 0x0C) { [void]$sb.Append('\f') }
elseif ($code -eq 0x0D) { [void]$sb.Append('\r') }
elseif ($code -lt 0x20) { [void]$sb.AppendFormat('\u{0:x4}', $code) }
else { [void]$sb.Append($ch) }
}
[void]$sb.Append('"')
return $sb.ToString()
}
# Попробовать сериализовать значение полностью inline (одна строка).
# Возвращает строку либо $null, если содержимое не помещается.
function Try-InlineJson {
param($obj)
if ($null -eq $obj) { return 'null' }
if ($obj -is [bool]) { if ($obj) { return 'true' } else { return 'false' } }
if ($obj -is [string]) { return (Convert-StringToJsonLiteral $obj) }
if ($obj -is [int] -or $obj -is [long]) { return "$obj" }
if ($obj -is [double] -or $obj -is [single] -or $obj -is [decimal]) {
return ([System.Convert]::ToString($obj, [System.Globalization.CultureInfo]::InvariantCulture))
}
if ($obj -is [System.Collections.IDictionary]) {
if ($obj.Count -eq 0) { return '{}' }
$parts = @()
foreach ($k in $obj.Keys) {
$v = Try-InlineJson $obj[$k]
if ($null -eq $v) { return $null }
$parts += "$(Convert-StringToJsonLiteral "$k"): $v"
}
return '{ ' + ($parts -join ', ') + ' }'
}
if ($obj -is [System.Management.Automation.PSCustomObject]) {
$props = @($obj.PSObject.Properties)
if ($props.Count -eq 0) { return '{}' }
$parts = @()
foreach ($p in $props) {
$v = Try-InlineJson $p.Value
if ($null -eq $v) { return $null }
$parts += "$(Convert-StringToJsonLiteral "$($p.Name)"): $v"
}
return '{ ' + ($parts -join ', ') + ' }'
}
if ($obj -is [array] -or $obj -is [System.Collections.IList]) {
$items = @($obj)
if ($items.Count -eq 0) { return '[]' }
$parts = @()
foreach ($it in $items) {
$v = Try-InlineJson $it
if ($null -eq $v) { return $null }
$parts += $v
}
return '[' + ($parts -join ', ') + ']'
}
return $null
}
function ConvertTo-CompactJson {
param($obj, [int]$depth = 0, [string]$indentUnit = ' ', [int]$lineLimit = 400)
$indent = $indentUnit * $depth
$childIndent = $indentUnit * ($depth + 1)
if ($null -eq $obj) { return 'null' }
if ($obj -is [bool]) { if ($obj) { return 'true' } else { return 'false' } }
if ($obj -is [string]) { return (Convert-StringToJsonLiteral $obj) }
if ($obj -is [int] -or $obj -is [long]) { return "$obj" }
if ($obj -is [double] -or $obj -is [single] -or $obj -is [decimal]) {
return ([System.Convert]::ToString($obj, [System.Globalization.CultureInfo]::InvariantCulture))
}
# Try inline для объектов и массивов с объектами — если помещается в lineLimit с учётом текущего indent.
$isContainer = ($obj -is [System.Collections.IDictionary]) -or ($obj -is [System.Management.Automation.PSCustomObject]) -or ($obj -is [array]) -or ($obj -is [System.Collections.IList])
if ($isContainer) {
$inlineAttempt = Try-InlineJson $obj
if ($null -ne $inlineAttempt -and ($indent.Length + $inlineAttempt.Length) -le $lineLimit) {
return $inlineAttempt
}
}
# Hashtable / OrderedDictionary — объект multi-line
if ($obj -is [System.Collections.IDictionary]) {
$keys = @($obj.Keys)
if ($keys.Count -eq 0) { return '{}' }
$parts = @()
foreach ($k in $keys) {
$val = ConvertTo-CompactJson -obj $obj[$k] -depth ($depth + 1) -indentUnit $indentUnit -lineLimit $lineLimit
$parts += "$childIndent$(Convert-StringToJsonLiteral "$k"): $val"
}
return "{`n" + ($parts -join ",`n") + "`n$indent}"
}
if ($obj -is [System.Management.Automation.PSCustomObject]) {
$props = @($obj.PSObject.Properties)
if ($props.Count -eq 0) { return '{}' }
$parts = @()
foreach ($p in $props) {
$val = ConvertTo-CompactJson -obj $p.Value -depth ($depth + 1) -indentUnit $indentUnit -lineLimit $lineLimit
$parts += "$childIndent$(Convert-StringToJsonLiteral "$($p.Name)"): $val"
}
return "{`n" + ($parts -join ",`n") + "`n$indent}"
}
# Array / IList multi-line
if ($obj -is [array] -or $obj -is [System.Collections.IList]) {
$items = @($obj)
if ($items.Count -eq 0) { return '[]' }
$parts = @($items | ForEach-Object { "$childIndent$(ConvertTo-CompactJson -obj $_ -depth ($depth + 1) -indentUnit $indentUnit -lineLimit $lineLimit)" })
return "[`n" + ($parts -join ",`n") + "`n$indent]"
}
# Fallback
return (Convert-StringToJsonLiteral "$obj")
}
# Позиционная запись списка ячеек: позиция берётся из порядка, `col` не пишется.
# Ячейка, у которой кроме текста или параметра ничего нет, пишется значением; span
# раскрывается маркерами ">"; пропуск колонки — null; всё прочее — объектным элементом
# без col. Выбираем ту запись, которая КОРОЧЕ.
function ConvertTo-PositionalCells {
param($cells)
# Пропуск колонки — null. Выбираем ту запись, которая КОРОЧЕ: раньше позиционная форма
# отбрасывалась, как только первая ячейка стояла не в первой колонке, хотя один-два null
# впереди обычно короче объектной записи с `col`.
if ($cells.Count -eq 0) { return @() }
$out = @()
$expected = 1
foreach ($c in $cells) {
$col = [int]$c["col"]
if ($col -lt $expected) { return $cells } # перекрытие — позиционно не выразить
while ($expected -lt $col) { $out += $null; $expected++ }
$span = if ($c.Contains("span")) { [int]$c["span"] } else { 1 }
$keys = @($c.Keys | Where-Object { $_ -notin @("col", "span") })
# Текст — единственное содержимое: пишем значением. Оно бывает строкой либо
# объектом «язык → текст», и в позиционной записи элемент И ЕСТЬ это значение.
$plainText = ($keys.Count -eq 1 -and $keys[0] -eq "text")
$plainParam = ($keys.Count -eq 1 -and $keys[0] -eq "param")
if ($plainText) { $out += , $c["text"] }
elseif ($plainParam) { $out += "{$($c["param"])}" }
else {
$obj = [ordered]@{}
foreach ($k in $c.Keys) { if ($k -ne "col") { $obj[$k] = $c[$k] } }
$out += $obj
}
# span раскрываем маркерами — кроме объектного элемента, он несёт span сам.
if (($plainText -or $plainParam) -and $span -gt 1) {
for ($i = 1; $i -lt $span; $i++) { $out += ">" }
}
$expected = $col + $span
}
# Позиционная форма ценна компактностью: если она длиннее объектной, смысла в ней нет.
$a = Try-InlineJson $out
$b = Try-InlineJson $cells
if ($null -ne $a -and $null -ne $b -and $a.Length -gt $b.Length) { return $cells }
return $out
}
# --- 12. Build areas ---
# Сетка нарезается на блоки: непересекающиеся области типа Rows задают границы, строки вне
# них становятся БЕЗЫМЯННЫМИ блоками. Раньше строки вне областей просто терялись — в корпусе
# такие дыры у 34% макетов, а макетов вовсе без Rows-областей 21%.
# Всё, что блоком не выражается (области не-Rows и пересекающиеся Rows), уходит в namedAreas
# координатами. Правило детерминированное — иначе раундтрип поехал бы.
$maxRowIdx = -1
foreach ($k in $rowData.Keys) { if ([int]$k -gt $maxRowIdx) { $maxRowIdx = [int]$k } }
function Get-RowColumnsId {
param([int]$r)
if ($rowData.ContainsKey($r)) { return $rowData[$r].ColumnsId }
return $null
}
# Область годится в качестве области-диапазона, только если у всех её строк ОДНА раскладка:
# иначе её пришлось бы резать, а имя резать нельзя — такая уходит в координатный список.
function Test-UniformColumnSet {
param([int]$from, [int]$to)
$first = Get-RowColumnsId $from
for ($r = $from + 1; $r -le $to; $r++) {
if ((Get-RowColumnsId $r) -ne $first) { return $false }
}
return $true
}
$blockAreas = @()
$overlayAreas = @()
$claimed = @{} # строка → занята областью-диапазоном
foreach ($a in @($namedAreas | Sort-Object @{ Expression = { $_.BeginRow } }, @{ Expression = { $_.EndRow } })) {
$fitsBlock = ($a.Type -eq 'Rows' -and $a.BeginRow -ge 0 -and $a.EndRow -ge $a.BeginRow)
if ($fitsBlock) {
for ($r = $a.BeginRow; $r -le $a.EndRow; $r++) {
if ($claimed.ContainsKey($r)) { $fitsBlock = $false; break }
}
}
if ($fitsBlock) { $fitsBlock = Test-UniformColumnSet $a.BeginRow $a.EndRow }
if ($fitsBlock) {
for ($r = $a.BeginRow; $r -le $a.EndRow; $r++) { $claimed[$r] = $true }
$blockAreas += $a
} else {
$overlayAreas += $a
}
}
# Безымянный промежуток режем на куски с одной раскладкой: границы наборов не совпадают
# с границами именованных областей, а у области должна быть ровно одна раскладка.
function Split-GapByColumnSet {
param([int]$from, [int]$to)
$out = @()
if ($to -lt $from) { return $out }
$runStart = $from
$runId = Get-RowColumnsId $from
for ($r = $from + 1; $r -le $to; $r++) {
$id = Get-RowColumnsId $r
if ($id -ne $runId) {
$out += @{ Name = $null; BeginRow = $runStart; EndRow = $r - 1; ColumnsId = $runId }
$runStart = $r; $runId = $id
}
}
$out += @{ Name = $null; BeginRow = $runStart; EndRow = $to; ColumnsId = $runId }
return $out
}
# Области в порядке строк + безымянные заполнители дыр.
$blocks = @()
$cursor = 0
foreach ($a in @($blockAreas | Sort-Object @{ Expression = { $_.BeginRow } })) {
if ($a.BeginRow -gt $cursor) {
$blocks += Split-GapByColumnSet $cursor ($a.BeginRow - 1)
}
$blocks += @{ Name = $a.Name; BeginRow = $a.BeginRow; EndRow = $a.EndRow; ColumnsId = (Get-RowColumnsId $a.BeginRow) }
$cursor = $a.EndRow + 1
}
if ($cursor -le $maxRowIdx) {
$blocks += Split-GapByColumnSet $cursor $maxRowIdx
}
$dslAreas = @()
foreach ($area in $blocks) {
$areaRows = @()
for ($globalRow = $area.BeginRow; $globalRow -le $area.EndRow; $globalRow++) {
$rd = $rowData[$globalRow]
if (-not $rd -or $rd.Empty) {
$areaRows += [ordered]@{}
continue
}
$dslRow = [ordered]@{}
# Формат самой строки: высота и скрытие — её собственные свойства (у ячейки таких
# нет), остальное — оформление, оно же может лежать и на ячейках.
$rowFmt = if ($rd.FormatIdx -gt 0) { Get-Format $rd.FormatIdx } else { $null }
$rowOwn = if ($rowFmt) { Get-StyleProps $rowFmt } else { [ordered]@{} }
$rowHidden = ($rowOwn['hidden'] -eq $true)
if ($rowFmt -and $rowFmt.Height -gt 0) { $dslRow["height"] = $rowFmt.Height }
if ($rowHidden) { $dslRow["hidden"] = $true }
# Separate content cells from gap-fill cells
$contentCells = @()
$gapCells = @()
foreach ($cell in $rd.Cells) {
$hasContent = $cell.Param -or $cell.HasText
$hasMerge = $mergeMap.ContainsKey("$globalRow,$($cell.Col)")
if ($hasContent -or $hasMerge) {
$contentCells += $cell
} else {
$gapCells += $cell
}
}
# Detect rowStyle
$rowStyleName = $null
$rowStyleKey = $null
if ($gapCells.Count -gt 0) {
$gapKeys = @{}
foreach ($gc in $gapCells) {
$fmt = Get-Format $gc.FormatIdx
$gapKeys[(Get-StyleKey $fmt)] = $true
}
if ($gapKeys.Count -eq 1) {
$rowStyleKey = @($gapKeys.Keys)[0]
if ($styleNames.Contains($rowStyleKey)) {
$rowStyleName = $styleNames[$rowStyleKey]
}
}
}
# Стиль строки и стиль ячеек — независимые вещи: платформа их часто, но не всегда
# пишет одинаковыми. Один ключ с модификатором apply покрывает все три случая.
$ownFmt = Get-RowStyleFmt $rowFmt
$ownKey = if ($ownFmt) { Get-StyleKey $ownFmt } else { $null }
$ownName = if ($ownKey -and $styleNames.Contains($ownKey)) { $styleNames[$ownKey] } else { $null }
$cellsName = if ($rowStyleName -and $rowStyleName -ne "default") { $rowStyleName } else { $null }
if ($ownName -and $cellsName -and $ownKey -ceq $rowStyleKey) {
$dslRow["rowStyle"] = $ownName
} elseif ($ownName -and -not $cellsName) {
$dslRow["rowStyle"] = [ordered]@{ style = $ownName; apply = "row" }
} elseif ($cellsName -and -not $ownName) {
$dslRow["rowStyle"] = [ordered]@{ style = $cellsName; apply = "cells" }
} elseif ($ownName -and $cellsName) {
# Стили разные — строке своё, ячейкам своё; ячейкам стиль раздаётся поячеечно.
$dslRow["rowStyle"] = [ordered]@{ style = $ownName; apply = "row" }
$rowStyleName = $null
$rowStyleKey = $null
$cellsName = $null
}
# Build cell list
$dslCells = @()
foreach ($cell in ($contentCells | Sort-Object { $_.Col })) {
$dslCell = [ordered]@{ col = $cell.Col + 1 }
# Span/rowspan from merge
$mk = "$globalRow,$($cell.Col)"
if ($mergeMap.ContainsKey($mk)) {
$m = $mergeMap[$mk]
if ($m.W -gt 0) { $dslCell["span"] = $m.W + 1 }
if ($m.H -gt 0) { $dslCell["rowspan"] = $m.H + 1 }
}
# Style
$cellFmt = Get-Format $cell.FormatIdx
$cellStyleKey = Get-StyleKey $cellFmt
if ($rowStyleKey -and $cellStyleKey -eq $rowStyleKey) {
# Inherits rowStyle
} else {
# Стиль пишем, только когда он отличается от того, что подставит компилятор:
# без rowStyle умолчание и есть "default", поэтому такой ключ — шум (56% ячеек).
# А при заданном rowStyle стиль "default" писать ОБЯЗАТЕЛЬНО: раньше здесь стояло
# `-not $rowStyleName`, и такая ячейка теряла стиль вовсе — при обратной сборке
# она наследовала rowStyle.
$sn = Get-StyleName $cell.FormatIdx
if ($sn -ne "default" -or $cellsName) {
$dslCell["style"] = $sn
}
}
# Content
$fillType = if ($cellFmt) { $cellFmt.FillType } else { "" }
if ($cell.Param) {
$dslCell["param"] = $cell.Param
if ($cell.Detail) { $dslCell["detail"] = $cell.Detail }
} elseif ($fillType -eq "Template" -and $cell.HasText) {
$dslCell["template"] = Get-DslText $cell.Text
} elseif ($cell.HasText) {
$dslCell["text"] = Get-DslText $cell.Text
}
$dslCells += $dslCell
}
if ($dslCells.Count -gt 0) { $dslRow["cells"] = [array](ConvertTo-PositionalCells $dslCells) }
# Самая короткая из применимых форм: если у строки нет своих свойств, а список ячеек
# позиционный — строка пишется просто массивом, без ключа cells.
if ($dslRow.Count -eq 1 -and $dslRow.Contains("cells") -and (Test-PositionalList $dslRow["cells"])) {
$areaRows += ,([array]$dslRow["cells"])
} else {
$areaRows += $dslRow
}
}
# Compress consecutive empty rows ({}) into { empty = N }
$compressedRows = @()
$emptyRun = 0
foreach ($r in $areaRows) {
if ($r.Count -eq 0) {
$emptyRun++
} else {
if ($emptyRun -gt 0) {
if ($emptyRun -eq 1) { $compressedRows += [ordered]@{} }
else { $compressedRows += [ordered]@{ empty = $emptyRun } }
$emptyRun = 0
}
# Запятая обязательна: строка-массив иначе развернётся в список строк.
$compressedRows += ,$r
}
}
if ($emptyRun -gt 0) {
if ($emptyRun -eq 1) { $compressedRows += [ordered]@{} }
else { $compressedRows += [ordered]@{ empty = $emptyRun } }
}
$dslBlock = [ordered]@{}
# Область без имени — просто кусок сетки, ключ name у неё не пишем.
if (-not [string]::IsNullOrEmpty($area.Name)) { $dslBlock["name"] = $area.Name }
# Ссылка на колоночную раскладку по имени из columnSets — как style у ячейки на styles.
# Умолчание (раскладка без id) не пишем.
if (-not [string]::IsNullOrEmpty($area.ColumnsId)) { $dslBlock["columnSet"] = $area.ColumnsId }
$dslBlock["rows"] = [array]$compressedRows
$dslAreas += $dslBlock
}
# --- 13. Compress columnWidths ---
$compressedWidths = [ordered]@{}
if ($colWidthMap.Count -gt 0) {
$grouped = $colWidthMap.Keys | Group-Object { $colWidthMap[$_] }
foreach ($g in $grouped) {
$width = [int]$g.Name
$cols = @($g.Group | Sort-Object { [int]$_ })
$ranges = @()
$rangeStart = $cols[0]; $rangePrev = $cols[0]
for ($i = 1; $i -lt $cols.Count; $i++) {
if ([int]$cols[$i] -eq [int]$rangePrev + 1) {
$rangePrev = $cols[$i]
} else {
if ($rangeStart -eq $rangePrev) { $ranges += "$rangeStart" }
else { $ranges += "$rangeStart-$rangePrev" }
$rangeStart = $cols[$i]; $rangePrev = $cols[$i]
}
}
if ($rangeStart -eq $rangePrev) { $ranges += "$rangeStart" }
else { $ranges += "$rangeStart-$rangePrev" }
foreach ($range in $ranges) { $compressedWidths[$range] = $width }
}
}
# --- 14. Build fonts output ---
$fontsOut = [ordered]@{}
foreach ($name in $fontDefs.Keys) {
$f = $fontDefs[$name]
$fOut = [ordered]@{ face = $f.Face; size = $f.Size }
if ($f.Bold) { $fOut["bold"] = $true }
if ($f.Italic) { $fOut["italic"] = $true }
if ($f.Underline) { $fOut["underline"] = $true }
if ($f.Strikeout) { $fOut["strikeout"] = $true }
$fontsOut[$name] = $fOut
}
# --- 15. Assemble result ---
$result = [ordered]@{
columns = $totalColumns
defaultWidth = $defaultWidth
}
if ($compressedWidths.Count -gt 0) { $result["columnWidths"] = $compressedWidths }
if ($defaultSet) {
$defaultColStyles = Get-ColumnStyles $defaultSet
if ($defaultColStyles.Count -gt 0) { $result["columnStyles"] = $defaultColStyles }
}
# Набор языков объявляем, только если он отличается от умолчания компилятора (один ru).
if ($textLanguages.Count -gt 0 -and -not ($textLanguages.Count -eq 1 -and $textLanguages[0] -ceq 'ru')) {
$result["textLanguages"] = [array]$textLanguages
}
# Объявление языков МАКЕТА — не то же, что языки текста: почти во всех макетах ERP объявлен
# один ru, а текст лежит и под ru, и под en. Пишем, только если отличается от умолчания.
$lsNode = $root.SelectSingleNode("d:languageSettings", $ns)
if ($lsNode) {
$langs = @()
foreach ($li in $lsNode.SelectNodes("d:languageInfo", $ns)) {
$idN = $li.SelectSingleNode("d:id", $ns)
$codeN = $li.SelectSingleNode("d:code", $ns)
$descN = $li.SelectSingleNode("d:description", $ns)
$langs += [ordered]@{
id = $(if ($idN) { $idN.InnerText } else { "" })
code = $(if ($codeN) { $codeN.InnerText } else { "" })
description = $(if ($descN) { $descN.InnerText } else { "" })
}
}
$isDefault = ($langs.Count -eq 1 -and $langs[0].id -ceq 'ru' -and
$langs[0].code -ceq $RU_NAME -and $langs[0].description -ceq $RU_NAME)
if (-not $isDefault) { $result["languages"] = [array]$langs }
$curN = $lsNode.SelectSingleNode("d:currentLanguage", $ns)
$cur = if ($curN) { $curN.InnerText } else { $null }
if ($cur -cne 'ru') { $result["currentLanguage"] = $cur }
$dfltN = $lsNode.SelectSingleNode("d:defaultLanguage", $ns)
if ($dfltN -and $dfltN.InnerText -cne 'ru') { $result["defaultLanguage"] = $dfltN.InnerText }
}
# Remove empty "default" style
if ($styleDefs.Contains("default") -and $styleDefs["default"].Count -eq 0) {
$styleDefs.Remove("default")
}
# Remove unused styles
$usedStyles = @{}
foreach ($a in $dslAreas) {
foreach ($r in $a.rows) {
# Строка может быть массивом (короткая форма) — у неё нет свойства cells, и без этой
# ветки стиль, использованный только внутри такой строки, вырезался как «неиспользуемый».
$cellList = if ($r -is [array]) { $r } else { $r.cells }
if ($r -isnot [array] -and $r.rowStyle) {
$rs = $r.rowStyle
$usedStyles[$(if ($rs -is [System.Collections.IDictionary]) { $rs['style'] } else { $rs })] = $true
}
if ($cellList) { foreach ($c in $cellList) { if ($c -isnot [string] -and $c.style) { $usedStyles[$c.style] = $true } } }
}
}
# Стиль бывает не только у ячейки и строки: колонка — третий владелец формата.
foreach ($cs in $columnSets) {
foreach ($name in (Get-ColumnStyles $cs).Values) {
if ($name) { $usedStyles[$name] = $true }
}
}
$toRemove = @($styleDefs.Keys | Where-Object { -not $usedStyles.ContainsKey($_) })
foreach ($s in $toRemove) { $styleDefs.Remove($s)
}
$result["fonts"] = $fontsOut
$result["styles"] = $styleDefs
# Колоночные раскладки помимо умолчания: ключ — идентификатор из макета, на него ссылаются
# области. Содержимое раскладку не опознаёт (в корпусе полно наборов с одинаковым
# содержимым и разными id), поэтому склейки по содержимому нет.
$extraSets = @($columnSets | Where-Object { $_.Id })
if ($extraSets.Count -gt 0) {
$setsOut = [ordered]@{}
foreach ($cs in $extraSets) {
$entry = [ordered]@{ columns = $cs.Size }
if ($cs.Widths.Count -gt 0) { $entry["columnWidths"] = $cs.Widths }
$csStyles = Get-ColumnStyles $cs
if ($csStyles.Count -gt 0) { $entry["columnStyles"] = $csStyles }
$setsOut[$cs.Id] = $entry
}
$result["columnSets"] = $setsOut
}
$result["areas"] = [array]$dslAreas
# Именованные области, не выразимые блоком, — координатами. Тип не пишем: он выводится
# из указанных осей (как в ТабличныйДокумент.Область()). DSL 1-based, XML 0-based.
if ($overlayAreas.Count -gt 0) {
$naOut = @()
foreach ($a in $overlayAreas) {
$entry = [ordered]@{ name = $a.Name }
if ($a.BeginRow -ge 0) {
$entry["rows"] = if ($a.EndRow -gt $a.BeginRow) { "$($a.BeginRow + 1)-$($a.EndRow + 1)" } else { $a.BeginRow + 1 }
}
if ($a.BeginCol -ge 0) {
$entry["cols"] = if ($a.EndCol -gt $a.BeginCol) { "$($a.BeginCol + 1)-$($a.EndCol + 1)" } else { $a.BeginCol + 1 }
}
$naOut += $entry
}
$result["namedAreas"] = [array]$naOut
}
# --- 16. Convert to JSON ---
$json = ConvertTo-CompactJson $result
# --- 17. Output ---
if ($OutputPath) {
# Путь принимаем и относительным, и абсолютным: безусловная склейка с текущим каталогом
# давала "C:\cwd\C:\out.json" и роняла WriteAllText сообщением про формат пути.
$outAbs = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath }
else { Join-Path (Get-Location).Path $OutputPath }
$enc = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText(
$outAbs,
$json,
$enc
)
Write-Host "[OK] Decompiled: $OutputPath"
} else {
Write-Output $json
}
Write-Host " Areas: $($namedAreas.Count), Rows: $($rowData.Count), Columns: $totalColumns" -ForegroundColor DarkGray
Write-Host " Fonts: $($fontDefs.Count), Styles: $($styleDefs.Count), Merges: $($mergeMap.Count)" -ForegroundColor DarkGray