Release 0.4.4: form_xml_to_html v0.2.3 — прототипы форм 1С.

Дерево элементов, ASCII-макет, DataPath · тип в полях, копирование в буфер.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mihailkudravcev
2026-08-12 15:08:22 +03:00
parent 82e2d34c9e
commit f6003e9ebf
5 changed files with 789 additions and 68 deletions
+44
View File
@@ -2,6 +2,50 @@
All notable changes to **export_1c_help** are documented in this file.
## [0.4.4] - 2026-08-12
### Changed
- `form_xml_to_html` **v0.2.3**: DataPath · тип — readonly поле (выделение) + кнопка ⎘
для копирования в буфер обмена.
---
## [0.4.3] - 2026-08-12
### Fixed
- `form_xml_to_html` **v0.2.2**: очистка форматированных заголовков 1С
(`<link>`, `<bgcolorstyle>`, `<img>`) — «Сегменты (Метизы)» вместо сырой разметки.
---
## [0.4.2] - 2026-08-12
### Changed
- `form_xml_to_html.py` **v0.2.1**: в `placeholder` полей — DataPath и тип данных
(атрибуты формы + метаданные объекта/констант).
---
## [0.4.1] - 2026-08-12
### Added
- `form_xml_to_html.py` **v0.2.0**:
- `-O/--out-file` — файл вывода, если не задан `-p`
- `-T/--elements-tree` — текстовое дерево элементов
- `-M/--text-maket` — ASCII-макет «как в окне»
- `-t/--text-only` — выгрузка текста в `-O` без HTML
- без `-O`: `-T`/`-M` идут в консоль; с `-O`: HTML (+ дерево/макет в страницу)
### Changed
- Убран неявный default `out/form-prototypes`: нужен `-p` или `-O` (либо только `-T`/`-M` в консоль).
---
## [0.4.0] - 2026-08-11
### Added
+35 -16
View File
@@ -1,13 +1,13 @@
# export_1c_help
**Версия:** см. [`VERSION`](VERSION) (текущая: **0.4.0**) · [CHANGELOG](CHANGELOG.md)
**Версия:** см. [`VERSION`](VERSION) (текущая: **0.4.4**) · [CHANGELOG](CHANGELOG.md)
**Лицензия:** [MIT](LICENSE)
Выгрузка встроенной справки **любой** конфигурации 1С (`**/Ext/Help/ru.html`) в Markdown и публикация в wiki git-репозитория (Gitea / GitLab: `*.wiki.git`).
Дополнительно: публикация произвольных Markdown-статей в wiki через `export_docs.py` (спецификация: [`SPEC_EXPORT_DOCS.md`](SPEC_EXPORT_DOCS.md)).
Также: HTML-прототипы управляемых форм — `form_xml_to_html.py` (v0.1.0, Michael BAG <mk@p7net.ru>).
Также: HTML/текстовые прототипы управляемых форм — `form_xml_to_html.py` (v0.2.3, Michael BAG <mk@p7net.ru>).
Зависимости: **Python 3.10+**, только стандартная библиотека.
@@ -227,29 +227,39 @@ python3 export_1c_help.py convert -c ../../crm3-26 -o ./out/wiki-md-crm3-26 --cl
## HTML-прототипы форм (`form_xml_to_html.py`)
Скрипт **v0.1.0**, автор **Michael BAG** <mk@p7net.ru>.
Скрипт **v0.2.3**, автор **Michael BAG** <mk@p7net.ru>.
Собирает интерактивный HTML по `Ext/Form.xml` (вкладки, группы, поля, фильтр).
Собирает интерактивный HTML и/или текстовое дерево/макет по `Ext/Form.xml`.
В `placeholder` полей: DataPath и тип данных.
```bash
cd tools/export_1c_help
# объект конфигурации
# HTML в каталог-структуру
python3 form_xml_to_html.py \
-b crm3-26 \
-o Справочник.Номенклатура \
-f ФормаЭлемента
-f ФормаЭлемента \
-p out/form-prototypes
# общая форма
# HTML в один файл + дерево элементов внутри страницы
python3 form_xml_to_html.py \
-b crm3-26.rhana \
-o Общие \
-f РК_ФормаНастроекРана
-f РК_ФормаНастроекРана \
-O /tmp/РК_ФормаНастроекРана.html \
-T
# параметры из JSON (форма всегда через -f)
# только консоль: дерево и ASCII-макет
python3 form_xml_to_html.py \
-c configs/form_prototype.crm3-26.json \
-f ФормаЭлемента
-c configs/form_prototype.crm3-26.rhana.json \
-f РК_ФормаНастроекРана \
-T -M
# текстовый файл без HTML
python3 form_xml_to_html.py \
-b crm3-26.rhana -o Общие -f РК_ФормаНастроекРана \
-T -M -t -O /tmp/form.txt
```
| Параметр | Описание |
@@ -257,14 +267,23 @@ python3 form_xml_to_html.py \
| `-b` / `--base` | имя/путь каталога конфигурации (или её `src/`) |
| `-o` / `--object` | `Справочник.Номенклатура`, `Документ.X`, … или `Общие` |
| `-f` / `--form` | имя формы |
| `-p` / `--out-path` | корень вывода (по умолчанию `out/form-prototypes`) |
| `-c` / `--config` | JSON с `base`, `object`, `out_path`, `search_roots` (без формы) |
| `-p` / `--out-path` | корень вывода HTML: `<out>/<конфа>/<тип>/<объект>/<форма>.html` |
| `-O` / `--out-file` | файл вывода, если нет `-p` (HTML или текст при `-t`) |
| `-T` / `--elements-tree` | дерево элементов |
| `-M` / `--text-maket` | текстовый макет «как в окне» |
| `-t` / `--text-only` | в `-O` только текст, без HTML |
| `-c` / `--config` | JSON с `base`, `object`, `out_path`/`out_file`, `search_roots` (без формы) |
Путь к XML собирается автоматически. HTML:
Правила вывода:
`out/<конфа>/<тип>/<объект>/<форма>.html`
- нет `-O` → `-T`/`-M` в **консоль**;
- есть `-O` (без `-t`) → **HTML** в файл, при `-T`/`-M` блоки добавляются на страницу;
- `-t` + `-O` → текстовый файл как в консоли;
- без `-p`/`-O` и без `-T`/`-M` — ошибка (нужен явный вывод).
например `out/form-prototypes/crm3-26/Справочники/Номенклатура/ФормаЭлемента.html`.
Пример HTML-пути при `-p`:
`out/form-prototypes/crm3-26/Справочники/Номенклатура/ФормаЭлемента.html`.
---
+1 -1
View File
@@ -1 +1 @@
0.4.0
0.4.4
+591 -19
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import html
import json
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
@@ -15,7 +16,7 @@ from typing import Any
from .resolve import ResolveError, resolve_config_root, resolve_src_root
from .types_map import DIR_TO_LINK_TYPE, DIR_TO_RU, LINK_TYPE_TO_DIR
__version__ = "0.1.0"
__version__ = "0.2.3"
__author__ = "Michael BAG <mk@p7net.ru>"
__email__ = "mk@p7net.ru"
@@ -146,13 +147,37 @@ def get_bool(el: ET.Element, name: str, default: bool = True) -> bool:
return raw.lower() not in ("false", "0")
def strip_v8_formatted(text: str) -> str:
"""Убрать разметку форматированной строки 1С (<link>, <bgcolorstyle>, <img>…)."""
if not text:
return ""
s = html.unescape(text)
# самозакрывающиеся (картинки и т.п.)
s = re.sub(r"<(?:img|br|hr)\b[^>]*?/?>", "", s, flags=re.I)
prev = None
while prev != s:
prev = s
# <tag …>content</> (типичный закрывающий тег платформы)
s = re.sub(r"<[A-Za-z][\w:-]*(?:\s[^>]*)?>(.*?)</>", r"\1", s, flags=re.S)
# <tag …>content</tag>
s = re.sub(
r"<([A-Za-z][\w:-]*)(?:\s[^>]*)?>(.*?)</\1\s*>",
r"\2",
s,
flags=re.S | re.I,
)
s = re.sub(r"<[^>]+>", "", s)
s = re.sub(r"\s+", " ", s).strip()
return s
def get_title(el: ET.Element) -> str:
for t in el:
if local(t.tag) != "Title":
continue
for item in t.iter():
if local(item.tag) == "content" and item.text:
return item.text.strip()
return strip_v8_formatted(item.text)
return ""
@@ -318,6 +343,297 @@ def load_settings_file(path: Path) -> dict[str, Any]:
return data
def format_v8_type(type_el: ET.Element | None) -> str:
"""Краткое представление типа из узла <Type> формы/метаданных."""
if type_el is None:
return ""
raw_types: list[str] = []
date_fractions = ""
for item in type_el.iter():
tag = local(item.tag)
if tag == "Type" and item.text and item.text.strip():
raw_types.append(item.text.strip())
elif tag == "DateFractions" and item.text:
date_fractions = item.text.strip()
if not raw_types:
return ""
def one(t: str) -> str:
if t == "xs:string":
return "Строка"
if t == "xs:boolean":
return "Булево"
if t == "xs:decimal":
return "Число"
if t == "xs:dateTime":
if date_fractions == "Date":
return "Дата"
if date_fractions == "Time":
return "Время"
return "ДатаВремя"
if t == "v8:UUID":
return "УникальныйИдентификатор"
if t == "v8:ValueTable":
return "ТаблицаЗначений"
if t == "v8:ValueListType":
return "СписокЗначений"
if t.startswith("cfg:"):
body = t[4:]
repl = (
("CatalogRef.", "СправочникСсылка."),
("CatalogObject.", "СправочникОбъект."),
("DocumentRef.", "ДокументСсылка."),
("DocumentObject.", "ДокументОбъект."),
("EnumRef.", "ПеречислениеСсылка."),
("DataProcessorObject.", "ОбработкаОбъект."),
("ReportObject.", "ОтчетОбъект."),
("InformationRegisterRecord.", "РегистрСведенийЗапись."),
("ConstantsSet", "КонстантыНабор"),
)
for a, b in repl:
if body.startswith(a):
return b + body[len(a) :]
return body
return t
# убрать пустые/дубликаты, сохранить порядок
seen: set[str] = set()
out: list[str] = []
for t in raw_types:
pretty = one(t)
if pretty and pretty not in seen:
seen.add(pretty)
out.append(pretty)
return " | ".join(out)
def _find_type_child(el: ET.Element) -> ET.Element | None:
for c in el:
if local(c.tag) == "Type":
return c
# MetaDataObject: Type внутри Properties
if local(c.tag) == "Properties":
for p in c:
if local(p.tag) == "Type":
return p
return None
def _props_name(el: ET.Element) -> str:
for c in el:
if local(c.tag) == "Properties":
for p in c:
if local(p.tag) == "Name" and p.text:
return p.text.strip()
if local(c.tag) == "Name" and c.text:
return c.text.strip()
return el.get("name") or ""
OBJECT_TYPE_TO_DIR: dict[str, str] = {
"CatalogObject": "Catalogs",
"CatalogRef": "Catalogs",
"DocumentObject": "Documents",
"DocumentRef": "Documents",
"DataProcessorObject": "DataProcessors",
"ReportObject": "Reports",
"ExchangePlanObject": "ExchangePlans",
"BusinessProcessObject": "BusinessProcesses",
"TaskObject": "Tasks",
"ChartOfCharacteristicTypesObject": "ChartsOfCharacteristicTypes",
"ChartOfAccountsObject": "ChartsOfAccounts",
"ChartOfCalculationTypesObject": "ChartsOfCalculationTypes",
"InformationRegisterRecord": "InformationRegisters",
"AccumulationRegisterRecord": "AccumulationRegisters",
}
def _src_root_from_form(form_xml: Path) -> Path | None:
"""…/src/DataProcessors/X/Forms/…/Ext/Form.xml → …/src"""
parts = list(form_xml.resolve().parts)
if "src" in parts:
return Path(*parts[: parts.index("src") + 1])
# иногда корень выгрузки без src
for marker in ("Catalogs", "Documents", "DataProcessors", "CommonForms"):
if marker in parts:
return Path(*parts[: parts.index(marker)])
return None
def load_metadata_object_types(src_root: Path, cfg_object_type: str) -> dict[str, str]:
"""
Типы реквизитов/колонок ТЧ объекта метаданных.
Ключи: 'Реквизит', 'ТЧ.Реквизит'.
"""
result: dict[str, str] = {}
body = cfg_object_type[4:] if cfg_object_type.startswith("cfg:") else cfg_object_type
if "." not in body:
return result
kind, name = body.split(".", 1)
folder = OBJECT_TYPE_TO_DIR.get(kind)
if not folder:
return result
meta_path = src_root / folder / f"{name}.xml"
if not meta_path.is_file():
return result
root = ET.parse(meta_path).getroot()
def collect_from_child_objects(container: ET.Element) -> None:
for child in container:
tag = local(child.tag)
if tag == "Attribute":
aname = _props_name(child)
t = format_v8_type(_find_type_child(child))
if aname and t:
result[aname] = t
elif tag == "TabularSection":
ts_name = _props_name(child)
for sub in child:
if local(sub.tag) == "ChildObjects":
for attr in sub:
if local(attr.tag) != "Attribute":
continue
col = _props_name(attr)
t = format_v8_type(_find_type_child(attr))
if ts_name and col and t:
result[f"{ts_name}.{col}"] = t
for top in root:
for mid in top:
if local(mid.tag) == "ChildObjects":
collect_from_child_objects(mid)
return result
def _is_direct_ts_attribute(ts_el: ET.Element, attr_el: ET.Element) -> bool:
"""Attribute принадлежит этой ТЧ (не используется после упрощения collect)."""
parent_map = {c: p for p in ts_el.iter() for c in p}
cur: ET.Element | None = attr_el
while cur is not None and cur is not ts_el:
if local(cur.tag) == "TabularSection" and cur is not ts_el:
return False
cur = parent_map.get(cur)
return cur is ts_el
def parse_form_type_index(root: ET.Element, form_xml: Path) -> dict[str, str]:
"""DataPath → тип (из атрибутов формы + метаданных объекта)."""
types: dict[str, str] = {}
src_root = _src_root_from_form(form_xml)
object_attr_names: dict[str, str] = {} # form attr name → cfg object type
for c in root:
if local(c.tag) != "Attributes":
continue
for attr in c:
if local(attr.tag) != "Attribute":
continue
aname = attr.get("name") or ""
type_el = None
columns_el = None
for ch in attr:
tname = local(ch.tag)
if tname == "Type":
type_el = ch
elif tname == "Columns":
columns_el = ch
type_str = format_v8_type(type_el)
if aname and type_str:
types[aname] = type_str
if type_el is not None:
for tnode in type_el.iter():
if local(tnode.tag) != "Type" or not tnode.text:
continue
text = tnode.text.strip()
if "Object." in text or text.endswith("Object"):
object_attr_names[aname] = text
if text == "cfg:ConstantsSet":
object_attr_names[aname] = "cfg:ConstantsSet"
if columns_el is not None:
for col in columns_el:
if local(col.tag) != "Column":
continue
cname = col.get("name") or ""
ct = format_v8_type(_find_type_child(col))
if aname and cname and ct:
types[f"{aname}.{cname}"] = ct
if src_root is not None:
for form_attr, cfg_type in object_attr_names.items():
if cfg_type == "cfg:ConstantsSet":
const_dir = src_root / "Constants"
if const_dir.is_dir():
for const_file in const_dir.glob("*.xml"):
try:
croot = ET.parse(const_file).getroot()
except ET.ParseError:
continue
t_el = None
for mid in croot:
for ch in mid:
if local(ch.tag) != "Properties":
continue
for p in ch:
if local(p.tag) == "Type":
t_el = p
break
t = format_v8_type(t_el)
if t:
types[f"{form_attr}.{const_file.stem}"] = t
continue
meta_types = load_metadata_object_types(src_root, cfg_type)
for key, val in meta_types.items():
types[f"{form_attr}.{key}"] = val
return types
def annotate_data_types(nodes: list[dict[str, Any]], type_index: dict[str, str]) -> None:
for node in nodes:
dp = node.get("data_path") or ""
if dp:
node["data_type"] = type_index.get(dp, "")
children = node.get("children") or []
if children:
annotate_data_types(children, type_index)
def field_placeholder(node: dict[str, Any]) -> str:
"""DataPath + тип для placeholder."""
dp = node.get("data_path") or ""
dtype = node.get("data_type") or ""
name = node.get("name") or ""
if dp and dtype:
return f"{dp} · {dtype}"
if dp:
return dp
if dtype:
return dtype
return name
def render_copyable_hint(text: str, *, demo: str = "") -> str:
"""Readonly строка DataPath · тип + кнопка копирования."""
hint = esc(text)
if not text:
if demo:
return (
f'<input type="text" class="field-demo" readonly disabled '
f'value="{esc(demo)}">'
)
return '<input type="text" readonly disabled value="">'
return (
f'<div class="field-value">'
f'<input type="text" class="field-hint" readonly value="{hint}" '
f'title="DataPath · тип — выделите или нажмите ⎘">'
f'<button type="button" class="btn-copy" data-copy="{hint}" '
f'title="Копировать DataPath · тип">⎘</button>'
f"</div>"
)
def parse_node(el: ET.Element) -> dict[str, Any] | None:
tag = local(el.tag)
if tag in SKIP_TAGS:
@@ -342,6 +658,7 @@ def parse_node(el: ET.Element) -> dict[str, Any] | None:
"enabled": get_bool(el, "Enabled", True),
"read_only": get_bool(el, "ReadOnly", False),
"data_path": get_text(el, "DataPath"),
"data_type": "",
"group": get_text(el, "Group", "Vertical"),
"representation": get_text(el, "Representation", "None"),
"behavior": get_text(el, "Behavior", "Usual"),
@@ -390,7 +707,7 @@ def parse_form(path: Path) -> dict[str, Any]:
if local(c.tag) == "Title":
for item in c.iter():
if local(item.tag) == "content" and item.text:
form_title = item.text.strip()
form_title = strip_v8_formatted(item.text)
break
children: list[dict[str, Any]] = []
@@ -401,10 +718,14 @@ def parse_form(path: Path) -> dict[str, Any]:
if node is not None:
children.append(node)
type_index = parse_form_type_index(root, path)
annotate_data_types(children, type_index)
return {
"title": form_title or infer_title_from_path(path),
"source": str(path),
"children": children,
"type_index": type_index,
}
@@ -417,16 +738,24 @@ def render_field(node: dict[str, Any]) -> str:
label = esc(label_of(node))
name = esc(node["name"])
dp = esc(node["data_path"])
dtype = esc(node.get("data_type") or "")
ph = esc(field_placeholder(node))
hidden = "" if node["visible"] else " is-hidden"
meta = f'data-name="{name}" data-type="{esc(tag)}" data-path="{dp}"'
meta = (
f'data-name="{name}" data-type="{esc(tag)}" data-path="{dp}" '
f'data-value-type="{dtype}"'
)
ro = " readonly" if node["read_only"] else ""
dis = " disabled" if not node["enabled"] else ""
meta_line = f"{name}" + (f" · {dp}" if dp else "") + (f" · {dtype}" if dtype else "")
if tag == "CheckBoxField":
hint = render_copyable_hint(field_placeholder(node)) if ph else ""
return (
f'<label class="field checkbox{hidden}" {meta}>'
f'<input type="checkbox"{ro}{dis}> <span class="caption">{label}</span>'
f'<span class="meta">{name}</span></label>'
f'{hint}'
f'<span class="meta">{esc(meta_line)}</span></label>'
)
if tag == "RadioButtonField":
@@ -437,15 +766,15 @@ def render_field(node: dict[str, Any]) -> str:
f'<label><input type="radio" name="{name}" checked{dis}> Вариант 1</label>'
f'<label><input type="radio" name="{name}"{dis}> Вариант 2</label>'
f"</div>"
f'<span class="meta">{name} · {dp}</span></div>'
f'<span class="meta">{esc(meta_line)}</span></div>'
)
if tag == "LabelField":
return (
f'<div class="field label-field{hidden}" {meta}>'
f'<div class="caption">{label}</div>'
f'<div class="value-box">—</div>'
f'<span class="meta">{name} · {dp}</span></div>'
f'{render_copyable_hint(field_placeholder(node) or "—")}'
f'<span class="meta">{esc(meta_line)}</span></div>'
)
if tag == "LabelDecoration":
@@ -484,22 +813,22 @@ def render_field(node: dict[str, Any]) -> str:
f'<div class="field document{hidden}" {meta}>'
f'<div class="caption">{label}</div>'
f'<div class="doc-stub">{kind}</div>'
f'<span class="meta">{name} · {dp}</span></div>'
f'<span class="meta">{esc(meta_line)}</span></div>'
)
if tag == "PeriodField":
return (
f'<div class="field{hidden}" {meta}>'
f'<label class="caption">{label}</label>'
f'<input type="text" value="01.01.2026 — 31.12.2026"{ro}{dis}>'
f'<span class="meta">{name} · {dp}</span></div>'
f'{render_copyable_hint(ph, demo="01.01.2026 — 31.12.2026")}'
f'<span class="meta">{esc(meta_line)}</span></div>'
)
return (
f'<div class="field{hidden}" {meta}>'
f'<label class="caption">{label}</label>'
f'<input type="text" placeholder="{dp or name}"{ro}{dis}>'
f'<span class="meta">{name} · {dp}</span></div>'
f'{render_copyable_hint(ph)}'
f'<span class="meta">{esc(meta_line)}</span></div>'
)
@@ -637,12 +966,194 @@ def render_pages(node: dict[str, Any]) -> str:
)
def build_html(form: dict[str, Any], *, generator_version: str | None = None) -> str:
def _tree_prefix(is_last: bool, ancestors_last: list[bool]) -> str:
parts = []
for last in ancestors_last:
parts.append(" " if last else "│ ")
parts.append("└── " if is_last else "├── ")
return "".join(parts)
def _node_tree_label(node: dict[str, Any]) -> str:
tag = node["tag"]
title = label_of(node)
name = node.get("name") or ""
dp = node.get("data_path") or ""
dtype = node.get("data_type") or ""
hidden = "" if node.get("visible", True) else " [скрыт]"
if tag in ("UsualGroup", "Pages", "Page", "Table", "ButtonGroup", "Popup", "CommandBar"):
return f"{tag} {title}" + (f" ({name})" if name and name != title else "") + hidden
extra = ""
if dp:
extra = f" → {dp}"
if dtype:
extra += f" · {dtype}"
elif dtype:
extra = f" · {dtype}"
return f"{tag} {title}{extra}{hidden}"
def format_elements_tree(form: dict[str, Any]) -> str:
"""Текстовое дерево элементов формы."""
lines = [f"{form.get('title') or 'Форма'}", "│"]
def walk(nodes: list[dict[str, Any]], ancestors_last: list[bool]) -> None:
for i, node in enumerate(nodes):
is_last = i == len(nodes) - 1
lines.append(_tree_prefix(is_last, ancestors_last) + _node_tree_label(node))
children = node.get("children") or []
if children:
walk(children, ancestors_last + [is_last])
walk(form.get("children") or [], [])
return "\n".join(lines)
def _maket_field_line(node: dict[str, Any], indent: str) -> str:
tag = node["tag"]
title = label_of(node)
hidden = " (скрыто)" if not node.get("visible", True) else ""
if tag == "CheckBoxField":
return f"{indent}☐ {title}{hidden}"
if tag == "RadioButtonField":
return f"{indent}○ {title} (○ вариант1 ○ вариант2){hidden}"
if tag == "LabelField":
return f"{indent}{title}: —{hidden}"
if tag == "LabelDecoration":
return f"{indent}«{title}»{hidden}"
if tag in ("PictureDecoration", "PictureField"):
return f"{indent}[картинка] {title}{hidden}"
if tag == "Button":
return f"{indent}[{title}]{hidden}"
if tag == "Table":
return f"{indent}┌ таблица: {title} ┐{hidden}"
if tag in (
"HTMLDocumentField",
"SpreadSheetDocumentField",
"FormattedDocumentField",
"TextDocumentField",
):
return f"{indent}┌ документ: {title} ┐{hidden}"
if tag == "PeriodField":
return f"{indent}{title}: [__.__.____ — __.__.____]{hidden}"
# InputField / fallback
return f"{indent}{title}: [____________________]{hidden}"
def format_text_maket(form: dict[str, Any], *, width: int = 64) -> str:
"""ASCII-макет формы «как в окне»."""
title = form.get("title") or "Форма"
inner_w = max(40, width - 2)
top = "┌─ " + title[: inner_w - 3].ljust(inner_w - 3, "─") + "┐"
bot = "└" + ("─" * inner_w) + "┘"
lines = [
top,
"│" + " [Записать и закрыть] [Записать] [Закрыть]".ljust(inner_w)[:inner_w] + "│",
"│" + ("─" * inner_w) + "│",
]
def add_text(text: str, indent: int = 0) -> None:
pad = " " * indent
room = max(8, inner_w - len(pad))
raw = pad + text
while raw:
if len(raw) <= inner_w:
lines.append("│" + raw.ljust(inner_w) + "│")
break
cut = raw.rfind(" ", len(pad), inner_w + 1)
if cut <= len(pad):
cut = inner_w
piece = raw[:cut].ljust(inner_w)
lines.append("│" + piece + "│")
raw = pad + raw[cut:].lstrip()
def walk(nodes: list[dict[str, Any]], indent: int = 1) -> None:
for node in nodes:
tag = node["tag"]
if tag == "Pages":
pages = [ch for ch in node.get("children") or [] if ch["tag"] == "Page"]
tabs = " | ".join(label_of(p) for p in pages) or "(страницы)"
add_text(f"[{tabs}]", indent)
for page in pages:
add_text(f"— {label_of(page)} —", indent)
walk(page.get("children") or [], indent + 1)
continue
if tag == "Page":
add_text(f"— {label_of(node)} —", indent)
walk(node.get("children") or [], indent + 1)
continue
if tag == "UsualGroup":
title_g = label_of(node)
show = node.get("show_title", True) and bool(title_g)
horiz = "horizontal" in (node.get("group") or "").lower()
if show:
add_text(("↔ " if horiz else "▾ ") + title_g, indent)
walk(node.get("children") or [], indent + (1 if show else 0))
continue
if tag in ("ButtonGroup", "Popup", "CommandBar", "ColumnGroup"):
walk(node.get("children") or [], indent)
continue
add_text(_maket_field_line(node, "").lstrip(), indent)
if tag == "Table" and node.get("children"):
# one-line columns hint
cols = []
for ch in node["children"]:
if ch["tag"] == "ColumnGroup":
for gch in ch.get("children") or []:
cols.append(label_of(gch))
else:
cols.append(label_of(ch))
if cols:
add_text(" " + " | ".join(cols[:6]), indent)
walk(form.get("children") or [])
lines.append(bot)
return "\n".join(lines)
def compose_text_report(
form: dict[str, Any],
*,
include_tree: bool = False,
include_maket: bool = False,
) -> str:
parts: list[str] = []
if include_tree:
parts.append("## Дерево элементов")
parts.append(format_elements_tree(form))
if include_maket:
if parts:
parts.append("")
parts.append("## Макет (как в окне)")
parts.append(format_text_maket(form))
return "\n".join(parts).rstrip() + ("\n" if parts else "")
def build_html(
form: dict[str, Any],
*,
generator_version: str | None = None,
elements_tree: str | None = None,
text_maket: str | None = None,
) -> str:
body = "".join(render_group(ch, 0) for ch in form["children"])
title = esc(form["title"])
source = esc(form["source"])
ver = esc(generator_version or __version__)
author = esc(__author__)
extras = ""
if elements_tree:
extras += (
'<details class="text-extra" open>'
"<summary>Дерево элементов</summary>"
f"<pre>{esc(elements_tree)}</pre></details>\n"
)
if text_maket:
extras += (
'<details class="text-extra">'
"<summary>Текстовый макет</summary>"
f"<pre>{esc(text_maket)}</pre></details>\n"
)
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
@@ -733,7 +1244,26 @@ details.group .group-body {{ padding: 10px; }}
.field input[type="text"] {{
width: 100%; padding: 4px 6px; border: 1px solid var(--border); border-radius: 2px; font: inherit; font-size: 12px;
}}
.field.checkbox {{ display: flex; align-items: center; gap: 8px; }}
.field-value {{
display: flex; gap: 4px; align-items: stretch; width: 100%; min-width: 0;
}}
.field-hint, .field-demo {{
flex: 1; min-width: 0; padding: 4px 6px; border: 1px solid var(--border); border-radius: 2px;
font: inherit; font-size: 11px; background: #fafafa;
}}
.field-hint {{
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
color: #444; user-select: all; cursor: text;
}}
.field-hint:focus {{ outline: 2px solid var(--accent-soft); border-color: var(--accent); }}
.btn-copy {{
flex: 0 0 auto; padding: 2px 8px; border: 1px solid var(--border); border-radius: 2px;
background: #f5f5f5; cursor: pointer; font: inherit; font-size: 12px; line-height: 1.4;
}}
.btn-copy:hover {{ background: var(--accent-soft); border-color: var(--accent); }}
.btn-copy.copied {{ background: #e8f5e9; border-color: #81c784; }}
.field.checkbox {{ display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }}
.field.checkbox .field-value {{ flex: 1 1 100%; margin-left: 1.4em; }}
.radio-row {{ display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; margin-top: 4px; }}
.value-box {{
border: 1px dashed var(--border); padding: 4px 6px; background: #fafafa; font-size: 12px; color: var(--muted);
@@ -762,6 +1292,16 @@ body.show-hidden .field.is-hidden {{ display: grid !important; }}
body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
.highlight {{ outline: 2px solid var(--accent) !important; background: var(--accent-soft) !important; }}
.stats {{ font-size: 11px; color: var(--muted); margin-left: auto; }}
.text-extra {{
margin: 12px; border: 1px solid var(--border); border-radius: 4px; background: #fafafa;
}}
.text-extra > summary {{
cursor: pointer; padding: 8px 10px; font-weight: 600; font-size: 13px; background: #f3f6f9;
}}
.text-extra pre {{
margin: 0; padding: 10px 12px; overflow: auto; font-size: 12px; line-height: 1.35;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; white-space: pre;
}}
@media (max-width: 800px) {{
.field {{ grid-template-columns: 1fr; }}
.group.horizontal > .group-body > * {{ min-width: 100%; }}
@@ -779,7 +1319,7 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
<button type="button" id="collapseAll">Свернуть группы</button>
<span class="stats" id="stats"></span>
</div>
<div class="window">
{extras}<div class="window">
<div class="commandbar">
<button type="button" class="btn">Записать и закрыть</button>
<button type="button" class="btn">Записать</button>
@@ -821,6 +1361,30 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
root.querySelectorAll('details.group').forEach(d => d.open = false);
}});
root.addEventListener('click', (e) => {{
const btn = e.target.closest('.btn-copy');
if (!btn) return;
e.preventDefault();
const text = btn.getAttribute('data-copy') || '';
const done = () => {{
btn.classList.add('copied');
btn.setAttribute('title', 'Скопировано');
setTimeout(() => {{
btn.classList.remove('copied');
btn.setAttribute('title', 'Копировать DataPath · тип');
}}, 1200);
}};
if (navigator.clipboard && navigator.clipboard.writeText) {{
navigator.clipboard.writeText(text).then(done).catch(() => {{
const inp = btn.previousElementSibling;
if (inp && inp.select) {{ inp.select(); document.execCommand('copy'); done(); }}
}});
}} else {{
const inp = btn.previousElementSibling;
if (inp && inp.select) {{ inp.select(); document.execCommand('copy'); done(); }}
}}
}});
const filter = document.getElementById('filter');
const stats = document.getElementById('stats');
function applyFilter() {{
@@ -868,14 +1432,22 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
def export_form_prototype(
*,
form_xml: Path,
form_xml: Path | None = None,
form: dict[str, Any] | None = None,
output_html: Path,
display_title: str | None = None,
include_elements_tree: bool = False,
include_text_maket: bool = False,
) -> Path:
form = parse_form(form_xml)
if form is None:
if form_xml is None:
raise ValueError("нужен form_xml или form")
form = parse_form(form_xml)
if display_title:
form["title"] = display_title
html_text = build_html(form)
tree = format_elements_tree(form) if include_elements_tree else None
maket = format_text_maket(form) if include_text_maket else None
html_text = build_html(form, elements_tree=tree, text_maket=maket)
output_html = Path(output_html)
output_html.parent.mkdir(parents=True, exist_ok=True)
output_html.write_text(html_text, encoding="utf-8")
+118 -32
View File
@@ -1,13 +1,14 @@
#!/usr/bin/env python3
"""CLI: HTML-прототип управляемой формы 1С из Form.xml.
"""CLI: HTML/текстовый прототип управляемой формы 1С из Form.xml.
Версия: 0.1.0
Версия: 0.2.3
Автор: Michael BAG <mk@p7net.ru>
Примеры:
python3 form_xml_to_html.py -b crm3-26 -o Справочник.Номенклатура -f ФормаЭлемента
python3 form_xml_to_html.py -c configs/form_prototype.crm3-26.json -f ФормаЭлемента
python3 form_xml_to_html.py -b crm3-26.rhana -o Общие -f РК_ФормаНастроекРана
python3 form_xml_to_html.py -b crm3-26 -o Справочник.Номенклатура -f ФормаЭлемента -p out/form-prototypes
python3 form_xml_to_html.py -b crm3-26.rhana -o Общие -f РК_ФормаНастроекРана -O /tmp/form.html -T
python3 form_xml_to_html.py -c configs/form_prototype.crm3-26.rhana.json -f РК_ФормаНастроекРана -T -M
python3 form_xml_to_html.py -b crm3-26.rhana -o Общие -f РК_ФормаНастроекРана -T -M -t -O /tmp/form.txt
"""
from __future__ import annotations
@@ -22,9 +23,11 @@ from export1c_help.form_prototype import (
__email__,
__version__,
build_output_path,
compose_text_report,
default_search_roots,
export_form_prototype,
load_settings_file,
parse_form,
resolve_form_xml,
)
from export1c_help.resolve import ResolveError
@@ -34,7 +37,7 @@ def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="form_xml_to_html",
description=(
"Собрать HTML-прототип формы 1С по -b/-o/-f "
"HTML/текстовый прототип формы 1С по -b/-o/-f "
f"(v{__version__}, {__author__})"
),
)
@@ -58,14 +61,36 @@ def _build_parser() -> argparse.ArgumentParser:
"-p",
"--out-path",
help=(
"Каталог вывода: <out>/<конфа>/<тип>/<объект>/<форма>.html "
"(по умолчанию out/form-prototypes)"
"Каталог вывода HTML: <out>/<конфа>/<тип>/<объект>/<форма>.html"
),
)
parser.add_argument(
"-O",
"--out-file",
help="Файл вывода (если нет -p): HTML или текст при -t",
)
parser.add_argument(
"-T",
"--elements-tree",
action="store_true",
help="Дерево элементов (консоль / в HTML / в текстовый файл)",
)
parser.add_argument(
"-M",
"--text-maket",
action="store_true",
help="Текстовый макет «как в окне» (консоль / в HTML / в текстовый файл)",
)
parser.add_argument(
"-t",
"--text-only",
action="store_true",
help="Только текст в -O (без HTML); нужны -T и/или -M",
)
parser.add_argument(
"-c",
"--config",
help="JSON с параметрами (base, object, out_path, search_roots), без формы",
help="JSON с параметрами (base, object, out_path, out_file, search_roots), без формы",
)
parser.add_argument(
"--version",
@@ -75,19 +100,16 @@ def _build_parser() -> argparse.ArgumentParser:
return parser
def _resolve_out_path(raw: str | None, *, config_path: Path | None) -> Path:
tool_root = Path(__file__).resolve().parent
if not raw:
return (tool_root / "out" / "form-prototypes").resolve()
def _resolve_path(raw: str, *, config_path: Path | None) -> Path:
path = Path(raw).expanduser()
if path.is_absolute():
return path.resolve()
# Относительный путь: от cwd; если запускали с -c — также пробуем от каталога config
cwd_path = (Path.cwd() / path).resolve()
if path.parts[0] in (".", "..") or cwd_path.parent.exists():
return cwd_path
if config_path is not None:
return (config_path.parent / path).resolve()
tool_root = Path(__file__).resolve().parent
return (tool_root / path).resolve()
@@ -107,12 +129,30 @@ def main(argv: list[str] | None = None) -> int:
base = args.base or settings.get("base")
object_ref = args.object or settings.get("object")
out_raw = args.out_path or settings.get("out_path") or settings.get("out")
out_path_raw = args.out_path or settings.get("out_path")
out_file_raw = args.out_file or settings.get("out_file") or settings.get("out")
want_tree = bool(args.elements_tree)
want_maket = bool(args.text_maket)
text_only = bool(args.text_only)
if not base:
parser.error("нужен -b/--base или base в -c/--config")
if not object_ref:
parser.error("нужен -o/--object или object в -c/--config")
if text_only and not out_file_raw:
parser.error("при -t/--text-only нужен -O/--out-file")
if text_only and not (want_tree or want_maket):
parser.error("при -t/--text-only укажите -T и/или -M")
if text_only and out_path_raw:
parser.error("при -t/--text-only используйте -O, не -p")
want_html = not text_only and bool(out_path_raw or out_file_raw)
if not want_html and not (want_tree or want_maket):
parser.error(
"укажите вывод: -p/--out-path или -O/--out-file для HTML, "
"либо -T/-M для текста в консоль (или -t -O для текстового файла)"
)
search_roots = default_search_roots(config_path)
extra_roots = settings.get("search_roots") or []
@@ -131,28 +171,74 @@ def main(argv: list[str] | None = None) -> int:
form_name=args.form,
search_roots=search_roots,
)
out_root = _resolve_out_path(
str(out_raw) if out_raw else None, config_path=config_path
)
output_html = build_output_path(
out_root,
base_name=base_name,
type_dir=type_dir,
object_name=object_name,
form_name=args.form.strip(),
)
export_form_prototype(
form_xml=form_xml,
output_html=output_html,
display_title=f"{object_name} · {args.form.strip()}",
form = parse_form(form_xml)
display_title = f"{object_name} · {args.form.strip()}"
form["title"] = display_title
text_report = compose_text_report(
form, include_tree=want_tree, include_maket=want_maket
)
# Текст в консоль, если -O не задан (и есть что показывать)
if not out_file_raw and (want_tree or want_maket) and text_report:
sys.stdout.write(text_report)
if not text_report.endswith("\n"):
sys.stdout.write("\n")
written: Path | None = None
if text_only:
out_file = _resolve_path(str(out_file_raw), config_path=config_path)
out_file.parent.mkdir(parents=True, exist_ok=True)
out_file.write_text(text_report, encoding="utf-8")
written = out_file
elif want_html:
if out_path_raw:
out_root = _resolve_path(str(out_path_raw), config_path=config_path)
output_html = build_output_path(
out_root,
base_name=base_name,
type_dir=type_dir,
object_name=object_name,
form_name=args.form.strip(),
)
else:
output_html = _resolve_path(str(out_file_raw), config_path=config_path)
# В HTML дерево/макет добавляются при -O (и при -p тоже, если флаги заданы)
# По ТЗ: при заданном -O — в HTML; без -O текст в консоль уже выше.
embed_tree = want_tree and bool(out_file_raw or out_path_raw)
embed_maket = want_maket and bool(out_file_raw or out_path_raw)
# Уточнение ТЗ: «Если -O не задан → консоль; иначе → html с деревом».
# При только -p без -O: HTML без обязательного встраивания; дерево уже в консоли.
if out_file_raw and not out_path_raw:
embed_tree = want_tree
embed_maket = want_maket
elif out_path_raw and not out_file_raw:
# -p без -O: HTML базовый; -T/-M уже ушли в консоль
embed_tree = False
embed_maket = False
else:
# и -p, и -O: HTML в структуру -p, в файл встраиваем при наличии флагов
embed_tree = want_tree
embed_maket = want_maket
export_form_prototype(
form=form,
output_html=output_html,
display_title=display_title,
include_elements_tree=embed_tree,
include_text_maket=embed_maket,
)
written = output_html
except ResolveError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"OK: {output_html}")
print(f"src: {form_xml}")
print(f"form_xml_to_html {__version__} — {__author__}")
if written is not None:
print(f"OK: {written}", file=sys.stderr)
print(f"src: {form_xml}", file=sys.stderr)
print(f"form_xml_to_html {__version__} — {__author__}", file=sys.stderr)
return 0