feat(mxl-compile,mxl-decompile): языки макета и шрифт как у платформы

Контролируемый стенд (upload/epf/МакетТабличныйДокумент) вскрыл четыре
системных расхождения подряд. После правок 7 макетов стенда из 10 проходят
раундтрип БАЙТ В БАЙТ обоими портами — до этого ни один.

1. Объявление языков макета жёстко писалось как «только русский». Это не то же
   самое, что textLanguages: почти во всех макетах ERP объявлен один ru, а текст
   лежит и под ru, и под en, поэтому выводить одно из другого нельзя. Заведены
   недокументированные ключи languages / currentLanguage / defaultLanguage —
   автору они не нужны, нужны раундтрипу. defaultLanguage несём, а не выводим:
   «всегда ru» — наблюдение на четырёх русских типовых, а не правило формата.

2. <font> писался в каждом формате. Платформа пишет его только когда шрифт
   задан: треть форматов корпуса (23 003 из 69 581) обходится без него.

3. Декомпилятор считал шрифт с индексом 0 «обычным» и не писал его в стиль.
   Нулевой шрифт вовсе не обязан быть обычным — в макетах стенда он курсивный
   и жирный, и начертание терялось.

4. Формат, несущий ТОЛЬКО шрифт, выглядел пустым: у ячейки он получал
   зарезервированное имя default и не записывался, у строки терялся целиком.

Остаток на стенде — три макета, и все три про одно: ссылка ячейки на запись
ширины, оставшуюся от прежнего состояния документа. Это не воспроизводится
принципиально, разобрано в docs/1c-spreadsheet-spec.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-11 20:08:28 +03:00
co-authored by Claude Opus 5
parent f72dd544dd
commit fc792bfee6
33 changed files with 356 additions and 379 deletions
@@ -1,4 +1,4 @@
# mxl-decompile v1.13 — Decompile 1C spreadsheet to JSON
# mxl-decompile v1.14 — Decompile 1C spreadsheet to JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -54,6 +54,8 @@ function Get-FormatTagKind {
}
}
$RU_NAME = 'Русский'
# Теги, значение которых — многоязычная строка (<v8:item> на язык), а не скаляр.
function Get-FormatMlTags {
return @{ 'format' = $true; 'editFormat' = $true; 'mask' = $true }
@@ -407,8 +409,9 @@ function Get-StyleProps {
function Get-StyleKey {
param($fmt)
if (-not $fmt) { return "empty" }
$fi = if ($fmt.FontIdx -ge 0) { $fmt.FontIdx } else { 0 }
$parts = @("f=$fi")
# «шрифта нет» и «шрифт с индексом 0» — разные форматы: нулевой шрифт вовсе не обязан
# быть обычным, в макете он бывает курсивным или жирным.
$parts = @("f=" + $(if ($fmt.FontIdx -ge 0) { "$($fmt.FontIdx)" } else { "none" }))
$props = Get-StyleProps $fmt
foreach ($tag in $props.Keys) {
$v = $props[$tag]
@@ -506,7 +509,9 @@ function Get-RowStyleFmt {
if (-not $fmt) { return $null }
$props = Get-StyleProps $fmt
$props.Remove('hidden') | Out-Null
if ($props.Count -eq 0) { return $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] }
@@ -543,8 +548,8 @@ function Name-Style {
$parts = @()
$props = Get-StyleProps $fmt
$fi = if ($fmt.FontIdx -ge 0) { $fmt.FontIdx } else { 0 }
if ($fontNames.ContainsKey($fi) -and $fontNames[$fi] -ne "default") {
$fi = $fmt.FontIdx
if ($fi -ge 0 -and $fontNames.ContainsKey($fi) -and $fontNames[$fi] -ne "default") {
$parts += $fontNames[$fi]
}
@@ -569,7 +574,13 @@ function Name-Style {
# Имя "default" зарезервировано за стилем БЕЗ свойств: под ним компилятор понимает
# отсутствие оформления. Набор из одних неприметных свойств (отступ, защита) обязан
# получить своё имя, иначе он потеряется.
if ($parts.Count -eq 0) { return $(if ($props.Count -eq 0) { "default" } else { "style" }) }
# Имя "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 "-")
}
@@ -586,9 +597,8 @@ foreach ($key in $styleKeys.Keys) {
$styleNames[$key] = $name
$sDef = [ordered]@{}
$fi = if ($fmt.FontIdx -ge 0) { $fmt.FontIdx } else { 0 }
if ($fontNames.ContainsKey($fi) -and $fontNames[$fi] -ne "default") {
$sDef["font"] = $fontNames[$fi]
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] }
@@ -970,6 +980,31 @@ if ($defaultSet) {
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")
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# mxl-decompile v1.13 — Decompile 1C spreadsheet to JSON
# mxl-decompile v1.14 — Decompile 1C spreadsheet to JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
@@ -94,6 +94,9 @@ def format_tag_kind():
}
RU_NAME = 'Русский'
def format_ml_tags():
"""Теги, значение которых — многоязычная строка (<v8:item> на язык), а не скаляр."""
return {'format': True, 'editFormat': True, 'mask': True}
@@ -573,8 +576,9 @@ def main():
def get_style_key(fmt):
if not fmt:
return "empty"
fi = fmt["FontIdx"] if fmt["FontIdx"] >= 0 else 0
parts = [f"f={fi}"]
# «шрифта нет» и «шрифт с индексом 0» — разные форматы: нулевой шрифт вовсе не
# обязан быть обычным, в макете он бывает курсивным или жирным.
parts = ["f=" + (str(fmt["FontIdx"]) if fmt["FontIdx"] >= 0 else "none")]
for tag, val in style_props(fmt).items():
parts.append(f"{tag}={val}")
return "|".join(parts)
@@ -666,7 +670,9 @@ def main():
return None
props = style_props(fmt)
props.pop("hidden", None)
if not props:
# Шрифт в style_props не входит (он адресуется именем), поэтому формат строки,
# несущий ТОЛЬКО шрифт, здесь выглядел пустым и терялся.
if not props and fmt["FontIdx"] < 0:
return None
reduced = OrderedDict()
for tag, raw in fmt["Props"].items():
@@ -703,8 +709,8 @@ def main():
parts = []
props = style_props(fmt)
fi = fmt["FontIdx"] if fmt["FontIdx"] >= 0 else 0
if fi in font_names and font_names[fi] != "default":
fi = fmt["FontIdx"]
if fi >= 0 and font_names.get(fi, "default") != "default":
parts.append(font_names[fi])
if "border" in props:
@@ -734,9 +740,11 @@ def main():
if len(parts) == 0:
# Имя "default" зарезервировано за стилем БЕЗ свойств: под ним компилятор
# понимает отсутствие оформления. Набор из одних неприметных свойств
# (отступ, защита) обязан получить своё имя, иначе он потеряется.
return "default" if not props else "style"
# понимает отсутствие оформления, и ячейка такой стиль не записывает. Набор
# из одних неприметных свойств (отступ, защита) или из одного шрифта обязан
# получить своё имя, иначе он потеряется.
has_content = bool(props) or fmt["FontIdx"] >= 0
return "style" if has_content else "default"
return "-".join(parts)
style_names = OrderedDict()
@@ -755,9 +763,8 @@ def main():
style_names[key] = name
s_def = OrderedDict()
fi = fmt["FontIdx"] if fmt["FontIdx"] >= 0 else 0
if fi in font_names and font_names[fi] != "default":
s_def["font"] = font_names[fi]
if fmt["FontIdx"] >= 0:
s_def["font"] = font_names.get(fmt["FontIdx"], "default")
s_def.update(style_props(fmt))
style_defs[name] = s_def
@@ -1107,6 +1114,29 @@ def main():
if text_languages and text_languages != ['ru']:
result["textLanguages"] = text_languages
# Объявление языков МАКЕТА — не то же, что языки текста: почти во всех макетах ERP
# объявлен один ru, а текст лежит и под ru, и под en. Пишем, только если отличается
# от умолчания компилятора.
ls_node = find(root, "d:languageSettings")
if ls_node is not None:
langs = []
for li in findall(ls_node, "d:languageInfo"):
desc_node = find(li, "d:description")
langs.append(OrderedDict([
("id", text_of(find(li, "d:id")) or ""),
("code", text_of(find(li, "d:code")) or ""),
("description", text_of(desc_node) or "" if desc_node is not None else ""),
]))
if langs != [OrderedDict([("id", "ru"), ("code", RU_NAME), ("description", RU_NAME)])]:
result["languages"] = langs
cur_node = find(ls_node, "d:currentLanguage")
cur = text_of(cur_node) if cur_node is not None else None
if cur != "ru":
result["currentLanguage"] = cur
dflt = text_of(find(ls_node, "d:defaultLanguage"))
if dflt and dflt != "ru":
result["defaultLanguage"] = dflt
# Remove empty "default" style
if "default" in style_defs and len(style_defs["default"]) == 0:
del style_defs["default"]