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. 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 ## [0.4.0] - 2026-08-11
### Added ### Added
+35 -16
View File
@@ -1,13 +1,13 @@
# export_1c_help # export_1c_help
**Версия:** см. [`VERSION`](VERSION) (текущая: **0.4.0**) · [CHANGELOG](CHANGELOG.md) **Версия:** см. [`VERSION`](VERSION) (текущая: **0.4.4**) · [CHANGELOG](CHANGELOG.md)
**Лицензия:** [MIT](LICENSE) **Лицензия:** [MIT](LICENSE)
Выгрузка встроенной справки **любой** конфигурации 1С (`**/Ext/Help/ru.html`) в Markdown и публикация в wiki git-репозитория (Gitea / GitLab: `*.wiki.git`). Выгрузка встроенной справки **любой** конфигурации 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)). Дополнительно: публикация произвольных 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+**, только стандартная библиотека. Зависимости: **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`) ## 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 ```bash
cd tools/export_1c_help cd tools/export_1c_help
# объект конфигурации # HTML в каталог-структуру
python3 form_xml_to_html.py \ python3 form_xml_to_html.py \
-b crm3-26 \ -b crm3-26 \
-o Справочник.Номенклатура \ -o Справочник.Номенклатура \
-f ФормаЭлемента -f ФормаЭлемента \
-p out/form-prototypes
# общая форма # HTML в один файл + дерево элементов внутри страницы
python3 form_xml_to_html.py \ python3 form_xml_to_html.py \
-b crm3-26.rhana \ -b crm3-26.rhana \
-o Общие \ -o Общие \
-f РК_ФормаНастроекРана -f РК_ФормаНастроекРана \
-O /tmp/РК_ФормаНастроекРана.html \
-T
# параметры из JSON (форма всегда через -f) # только консоль: дерево и ASCII-макет
python3 form_xml_to_html.py \ python3 form_xml_to_html.py \
-c configs/form_prototype.crm3-26.json \ -c configs/form_prototype.crm3-26.rhana.json \
-f ФормаЭлемента -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/`) | | `-b` / `--base` | имя/путь каталога конфигурации (или её `src/`) |
| `-o` / `--object` | `Справочник.Номенклатура`, `Документ.X`, … или `Общие` | | `-o` / `--object` | `Справочник.Номенклатура`, `Документ.X`, … или `Общие` |
| `-f` / `--form` | имя формы | | `-f` / `--form` | имя формы |
| `-p` / `--out-path` | корень вывода (по умолчанию `out/form-prototypes`) | | `-p` / `--out-path` | корень вывода HTML: `<out>/<конфа>/<тип>/<объект>/<форма>.html` |
| `-c` / `--config` | JSON с `base`, `object`, `out_path`, `search_roots` (без формы) | | `-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
+590 -18
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import html import html
import json import json
import re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -15,7 +16,7 @@ from typing import Any
from .resolve import ResolveError, resolve_config_root, resolve_src_root 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 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>" __author__ = "Michael BAG <mk@p7net.ru>"
__email__ = "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") 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: def get_title(el: ET.Element) -> str:
for t in el: for t in el:
if local(t.tag) != "Title": if local(t.tag) != "Title":
continue continue
for item in t.iter(): for item in t.iter():
if local(item.tag) == "content" and item.text: if local(item.tag) == "content" and item.text:
return item.text.strip() return strip_v8_formatted(item.text)
return "" return ""
@@ -318,6 +343,297 @@ def load_settings_file(path: Path) -> dict[str, Any]:
return data 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: def parse_node(el: ET.Element) -> dict[str, Any] | None:
tag = local(el.tag) tag = local(el.tag)
if tag in SKIP_TAGS: 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), "enabled": get_bool(el, "Enabled", True),
"read_only": get_bool(el, "ReadOnly", False), "read_only": get_bool(el, "ReadOnly", False),
"data_path": get_text(el, "DataPath"), "data_path": get_text(el, "DataPath"),
"data_type": "",
"group": get_text(el, "Group", "Vertical"), "group": get_text(el, "Group", "Vertical"),
"representation": get_text(el, "Representation", "None"), "representation": get_text(el, "Representation", "None"),
"behavior": get_text(el, "Behavior", "Usual"), "behavior": get_text(el, "Behavior", "Usual"),
@@ -390,7 +707,7 @@ def parse_form(path: Path) -> dict[str, Any]:
if local(c.tag) == "Title": if local(c.tag) == "Title":
for item in c.iter(): for item in c.iter():
if local(item.tag) == "content" and item.text: if local(item.tag) == "content" and item.text:
form_title = item.text.strip() form_title = strip_v8_formatted(item.text)
break break
children: list[dict[str, Any]] = [] children: list[dict[str, Any]] = []
@@ -401,10 +718,14 @@ def parse_form(path: Path) -> dict[str, Any]:
if node is not None: if node is not None:
children.append(node) children.append(node)
type_index = parse_form_type_index(root, path)
annotate_data_types(children, type_index)
return { return {
"title": form_title or infer_title_from_path(path), "title": form_title or infer_title_from_path(path),
"source": str(path), "source": str(path),
"children": children, "children": children,
"type_index": type_index,
} }
@@ -417,16 +738,24 @@ def render_field(node: dict[str, Any]) -> str:
label = esc(label_of(node)) label = esc(label_of(node))
name = esc(node["name"]) name = esc(node["name"])
dp = esc(node["data_path"]) dp = esc(node["data_path"])
dtype = esc(node.get("data_type") or "")
ph = esc(field_placeholder(node))
hidden = "" if node["visible"] else " is-hidden" 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 "" ro = " readonly" if node["read_only"] else ""
dis = " disabled" if not node["enabled"] 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": if tag == "CheckBoxField":
hint = render_copyable_hint(field_placeholder(node)) if ph else ""
return ( return (
f'<label class="field checkbox{hidden}" {meta}>' f'<label class="field checkbox{hidden}" {meta}>'
f'<input type="checkbox"{ro}{dis}> <span class="caption">{label}</span>' 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": 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}" checked{dis}> Вариант 1</label>'
f'<label><input type="radio" name="{name}"{dis}> Вариант 2</label>' f'<label><input type="radio" name="{name}"{dis}> Вариант 2</label>'
f"</div>" f"</div>"
f'<span class="meta">{name} · {dp}</span></div>' f'<span class="meta">{esc(meta_line)}</span></div>'
) )
if tag == "LabelField": if tag == "LabelField":
return ( return (
f'<div class="field label-field{hidden}" {meta}>' f'<div class="field label-field{hidden}" {meta}>'
f'<div class="caption">{label}</div>' f'<div class="caption">{label}</div>'
f'<div class="value-box">—</div>' f'{render_copyable_hint(field_placeholder(node) or "—")}'
f'<span class="meta">{name} · {dp}</span></div>' f'<span class="meta">{esc(meta_line)}</span></div>'
) )
if tag == "LabelDecoration": 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="field document{hidden}" {meta}>'
f'<div class="caption">{label}</div>' f'<div class="caption">{label}</div>'
f'<div class="doc-stub">{kind}</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": if tag == "PeriodField":
return ( return (
f'<div class="field{hidden}" {meta}>' f'<div class="field{hidden}" {meta}>'
f'<label class="caption">{label}</label>' f'<label class="caption">{label}</label>'
f'<input type="text" value="01.01.2026 — 31.12.2026"{ro}{dis}>' f'{render_copyable_hint(ph, demo="01.01.2026 — 31.12.2026")}'
f'<span class="meta">{name} · {dp}</span></div>' f'<span class="meta">{esc(meta_line)}</span></div>'
) )
return ( return (
f'<div class="field{hidden}" {meta}>' f'<div class="field{hidden}" {meta}>'
f'<label class="caption">{label}</label>' f'<label class="caption">{label}</label>'
f'<input type="text" placeholder="{dp or name}"{ro}{dis}>' f'{render_copyable_hint(ph)}'
f'<span class="meta">{name} · {dp}</span></div>' 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"]) body = "".join(render_group(ch, 0) for ch in form["children"])
title = esc(form["title"]) title = esc(form["title"])
source = esc(form["source"]) source = esc(form["source"])
ver = esc(generator_version or __version__) ver = esc(generator_version or __version__)
author = esc(__author__) 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> return f"""<!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
@@ -733,7 +1244,26 @@ details.group .group-body {{ padding: 10px; }}
.field input[type="text"] {{ .field input[type="text"] {{
width: 100%; padding: 4px 6px; border: 1px solid var(--border); border-radius: 2px; font: inherit; font-size: 12px; 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; }} .radio-row {{ display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; margin-top: 4px; }}
.value-box {{ .value-box {{
border: 1px dashed var(--border); padding: 4px 6px; background: #fafafa; font-size: 12px; color: var(--muted); 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; }} body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
.highlight {{ outline: 2px solid var(--accent) !important; background: var(--accent-soft) !important; }} .highlight {{ outline: 2px solid var(--accent) !important; background: var(--accent-soft) !important; }}
.stats {{ font-size: 11px; color: var(--muted); margin-left: auto; }} .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) {{ @media (max-width: 800px) {{
.field {{ grid-template-columns: 1fr; }} .field {{ grid-template-columns: 1fr; }}
.group.horizontal > .group-body > * {{ min-width: 100%; }} .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> <button type="button" id="collapseAll">Свернуть группы</button>
<span class="stats" id="stats"></span> <span class="stats" id="stats"></span>
</div> </div>
<div class="window"> {extras}<div class="window">
<div class="commandbar"> <div class="commandbar">
<button type="button" class="btn">Записать и закрыть</button> <button type="button" class="btn">Записать и закрыть</button>
<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.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 filter = document.getElementById('filter');
const stats = document.getElementById('stats'); const stats = document.getElementById('stats');
function applyFilter() {{ function applyFilter() {{
@@ -868,14 +1432,22 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
def export_form_prototype( def export_form_prototype(
*, *,
form_xml: Path, form_xml: Path | None = None,
form: dict[str, Any] | None = None,
output_html: Path, output_html: Path,
display_title: str | None = None, display_title: str | None = None,
include_elements_tree: bool = False,
include_text_maket: bool = False,
) -> Path: ) -> Path:
if form is None:
if form_xml is None:
raise ValueError("нужен form_xml или form")
form = parse_form(form_xml) form = parse_form(form_xml)
if display_title: if display_title:
form["title"] = 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 = Path(output_html)
output_html.parent.mkdir(parents=True, exist_ok=True) output_html.parent.mkdir(parents=True, exist_ok=True)
output_html.write_text(html_text, encoding="utf-8") output_html.write_text(html_text, encoding="utf-8")
+108 -22
View File
@@ -1,13 +1,14 @@
#!/usr/bin/env python3 #!/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> Автор: Michael BAG <mk@p7net.ru>
Примеры: Примеры:
python3 form_xml_to_html.py -b crm3-26 -o Справочник.Номенклатура -f ФормаЭлемента python3 form_xml_to_html.py -b crm3-26 -o Справочник.Номенклатура -f ФормаЭлемента -p out/form-prototypes
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 РК_ФормаНастроекРана -O /tmp/form.html -T
python3 form_xml_to_html.py -b crm3-26.rhana -o Общие -f РК_ФормаНастроекРана 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 from __future__ import annotations
@@ -22,9 +23,11 @@ from export1c_help.form_prototype import (
__email__, __email__,
__version__, __version__,
build_output_path, build_output_path,
compose_text_report,
default_search_roots, default_search_roots,
export_form_prototype, export_form_prototype,
load_settings_file, load_settings_file,
parse_form,
resolve_form_xml, resolve_form_xml,
) )
from export1c_help.resolve import ResolveError from export1c_help.resolve import ResolveError
@@ -34,7 +37,7 @@ def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="form_xml_to_html", prog="form_xml_to_html",
description=( description=(
"Собрать HTML-прототип формы 1С по -b/-o/-f " "HTML/текстовый прототип формы 1С по -b/-o/-f "
f"(v{__version__}, {__author__})" f"(v{__version__}, {__author__})"
), ),
) )
@@ -58,14 +61,36 @@ def _build_parser() -> argparse.ArgumentParser:
"-p", "-p",
"--out-path", "--out-path",
help=( help=(
"Каталог вывода: <out>/<конфа>/<тип>/<объект>/<форма>.html " "Каталог вывода HTML: <out>/<конфа>/<тип>/<объект>/<форма>.html"
"(по умолчанию out/form-prototypes)"
), ),
) )
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( parser.add_argument(
"-c", "-c",
"--config", "--config",
help="JSON с параметрами (base, object, out_path, search_roots), без формы", help="JSON с параметрами (base, object, out_path, out_file, search_roots), без формы",
) )
parser.add_argument( parser.add_argument(
"--version", "--version",
@@ -75,19 +100,16 @@ def _build_parser() -> argparse.ArgumentParser:
return parser return parser
def _resolve_out_path(raw: str | None, *, config_path: Path | None) -> Path: def _resolve_path(raw: str, *, config_path: Path | None) -> Path:
tool_root = Path(__file__).resolve().parent
if not raw:
return (tool_root / "out" / "form-prototypes").resolve()
path = Path(raw).expanduser() path = Path(raw).expanduser()
if path.is_absolute(): if path.is_absolute():
return path.resolve() return path.resolve()
# Относительный путь: от cwd; если запускали с -c — также пробуем от каталога config
cwd_path = (Path.cwd() / path).resolve() cwd_path = (Path.cwd() / path).resolve()
if path.parts[0] in (".", "..") or cwd_path.parent.exists(): if path.parts[0] in (".", "..") or cwd_path.parent.exists():
return cwd_path return cwd_path
if config_path is not None: if config_path is not None:
return (config_path.parent / path).resolve() return (config_path.parent / path).resolve()
tool_root = Path(__file__).resolve().parent
return (tool_root / path).resolve() return (tool_root / path).resolve()
@@ -107,12 +129,30 @@ def main(argv: list[str] | None = None) -> int:
base = args.base or settings.get("base") base = args.base or settings.get("base")
object_ref = args.object or settings.get("object") 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: if not base:
parser.error("нужен -b/--base или base в -c/--config") parser.error("нужен -b/--base или base в -c/--config")
if not object_ref: if not object_ref:
parser.error("нужен -o/--object или object в -c/--config") 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) search_roots = default_search_roots(config_path)
extra_roots = settings.get("search_roots") or [] extra_roots = settings.get("search_roots") or []
@@ -131,9 +171,30 @@ def main(argv: list[str] | None = None) -> int:
form_name=args.form, form_name=args.form,
search_roots=search_roots, search_roots=search_roots,
) )
out_root = _resolve_out_path( form = parse_form(form_xml)
str(out_raw) if out_raw else None, config_path=config_path 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( output_html = build_output_path(
out_root, out_root,
base_name=base_name, base_name=base_name,
@@ -141,18 +202,43 @@ def main(argv: list[str] | None = None) -> int:
object_name=object_name, object_name=object_name,
form_name=args.form.strip(), 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( export_form_prototype(
form_xml=form_xml, form=form,
output_html=output_html, output_html=output_html,
display_title=f"{object_name} · {args.form.strip()}", display_title=display_title,
include_elements_tree=embed_tree,
include_text_maket=embed_maket,
) )
written = output_html
except ResolveError as exc: except ResolveError as exc:
print(f"error: {exc}", file=sys.stderr) print(f"error: {exc}", file=sys.stderr)
return 1 return 1
print(f"OK: {output_html}") if written is not None:
print(f"src: {form_xml}") print(f"OK: {written}", file=sys.stderr)
print(f"form_xml_to_html {__version__} — {__author__}") print(f"src: {form_xml}", file=sys.stderr)
print(f"form_xml_to_html {__version__} — {__author__}", file=sys.stderr)
return 0 return 0