Release 0.4.0: form_xml_to_html — HTML-прототипы управляемых форм 1С.

CLI -b/-o/-f/-p/-c; автор Michael BAG <mk@p7net.ru> (скрипт v0.1.0).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mihailkudravcev
2026-08-11 17:13:59 +03:00
parent a2c68fa5d5
commit 82e2d34c9e
7 changed files with 1114 additions and 2 deletions
+13
View File
@@ -2,6 +2,19 @@
All notable changes to **export_1c_help** are documented in this file.
## [0.4.0] - 2026-08-11
### Added
- `form_xml_to_html.py` (v0.1.0) — HTML-прототипы управляемых форм из `Form.xml`.
Автор: Michael BAG <mk@p7net.ru>.
- `-b/--base`, `-o/--object`, `-f/--form`, `-p/--out-path`, `-c/--config`
- вывод: `<out>/<конфа>/<тип>/<объект>/<форма>.html`
- примеры конфигов: `configs/form_prototype.crm3-26.json`,
`configs/form_prototype.crm3-26.rhana.json`
---
## [0.3.12] - 2026-07-29
### Fixed
+46 -1
View File
@@ -1,12 +1,14 @@
# export_1c_help
**Версия:** см. [`VERSION`](VERSION) (текущая: **0.3.12**) · [CHANGELOG](CHANGELOG.md)
**Версия:** см. [`VERSION`](VERSION) (текущая: **0.4.0**) · [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>).
Зависимости: **Python 3.10+**, только стандартная библиотека.
---
@@ -223,6 +225,49 @@ 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>.
Собирает интерактивный HTML по `Ext/Form.xml` (вкладки, группы, поля, фильтр).
```bash
cd tools/export_1c_help
# объект конфигурации
python3 form_xml_to_html.py \
-b crm3-26 \
-o Справочник.Номенклатура \
-f ФормаЭлемента
# общая форма
python3 form_xml_to_html.py \
-b crm3-26.rhana \
-o Общие \
-f РК_ФормаНастроекРана
# параметры из JSON (форма всегда через -f)
python3 form_xml_to_html.py \
-c configs/form_prototype.crm3-26.json \
-f ФормаЭлемента
```
| Параметр | Описание |
|----------|----------|
| `-b` / `--base` | имя/путь каталога конфигурации (или её `src/`) |
| `-o` / `--object` | `Справочник.Номенклатура`, `Документ.X`, … или `Общие` |
| `-f` / `--form` | имя формы |
| `-p` / `--out-path` | корень вывода (по умолчанию `out/form-prototypes`) |
| `-c` / `--config` | JSON с `base`, `object`, `out_path`, `search_roots` (без формы) |
Путь к XML собирается автоматически. HTML:
`out/<конфа>/<тип>/<объект>/<форма>.html`
например `out/form-prototypes/crm3-26/Справочники/Номенклатура/ФормаЭлемента.html`.
---
## Репозиторий инструмента
- путь в монорепо: `tools/export_1c_help/`
+1 -1
View File
@@ -1 +1 @@
0.3.12
0.4.0
+6
View File
@@ -0,0 +1,6 @@
{
"base": "crm3-26",
"object": "Справочник.Номенклатура",
"out_path": "out/form-prototypes",
"search_roots": ["../.."]
}
@@ -0,0 +1,6 @@
{
"base": "crm3-26.rhana",
"object": "Общие",
"out_path": "out/form-prototypes",
"search_roots": ["../.."]
}
+882
View File
@@ -0,0 +1,882 @@
"""HTML-прототип управляемой формы 1С из Form.xml.
Версия скрипта/модуля: см. __version__.
Автор: Michael BAG <mk@p7net.ru>
"""
from __future__ import annotations
import html
import json
import xml.etree.ElementTree as ET
from pathlib import Path
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"
__author__ = "Michael BAG <mk@p7net.ru>"
__email__ = "mk@p7net.ru"
# Русские/английские имена типов → каталог выгрузки
_TYPE_ALIASES: dict[str, str] = {
"справочник": "Catalogs",
"справочники": "Catalogs",
"catalog": "Catalogs",
"catalogs": "Catalogs",
"документ": "Documents",
"документы": "Documents",
"document": "Documents",
"documents": "Documents",
"журналдокументов": "DocumentJournals",
"documentjournal": "DocumentJournals",
"перечисление": "Enums",
"перечисления": "Enums",
"enum": "Enums",
"отчет": "Reports",
"отчёт": "Reports",
"отчеты": "Reports",
"отчёты": "Reports",
"report": "Reports",
"обработка": "DataProcessors",
"обработки": "DataProcessors",
"dataprocessor": "DataProcessors",
"регистрсведений": "InformationRegisters",
"informationregister": "InformationRegisters",
"регистрнакопления": "AccumulationRegisters",
"accumulationregister": "AccumulationRegisters",
"регистрбухгалтерии": "AccountingRegisters",
"accountingregister": "AccountingRegisters",
"регистррасчета": "CalculationRegisters",
"регистррасчёта": "CalculationRegisters",
"calculationregister": "CalculationRegisters",
"планвидовхарактеристик": "ChartsOfCharacteristicTypes",
"chartofcharacteristictypes": "ChartsOfCharacteristicTypes",
"плансчетов": "ChartsOfAccounts",
"chartofaccounts": "ChartsOfAccounts",
"планвидоврасчета": "ChartsOfCalculationTypes",
"планвидоврасчёта": "ChartsOfCalculationTypes",
"chartofcalculationtypes": "ChartsOfCalculationTypes",
"бизнеспроцесс": "BusinessProcesses",
"businessprocess": "BusinessProcesses",
"задача": "Tasks",
"task": "Tasks",
"планобмена": "ExchangePlans",
"exchangeplan": "ExchangePlans",
"хранилищенастроек": "SettingsStorages",
"settingsstorage": "SettingsStorages",
"общие": "CommonForms",
"общаяформа": "CommonForms",
"общиеформы": "CommonForms",
"commonform": "CommonForms",
"commonforms": "CommonForms",
"константа": "Constants",
"constant": "Constants",
}
for _dir_name in DIR_TO_LINK_TYPE:
_TYPE_ALIASES[_dir_name.lower()] = _dir_name
for _link, _dir_name in LINK_TYPE_TO_DIR.items():
_TYPE_ALIASES[_link.lower()] = _dir_name
UI_TAGS = frozenset(
{
"Pages",
"Page",
"UsualGroup",
"InputField",
"CheckBoxField",
"LabelField",
"LabelDecoration",
"PictureDecoration",
"Button",
"Table",
"RadioButtonField",
"PictureField",
"HTMLDocumentField",
"SpreadSheetDocumentField",
"FormattedDocumentField",
"TextDocumentField",
"ColumnGroup",
"ButtonGroup",
"Popup",
"PeriodField",
"CalendarField",
"ProgressBarField",
"TrackBarField",
"ChartField",
"CommandBar",
}
)
SKIP_TAGS = frozenset(
{
"ExtendedTooltip",
"ContextMenu",
"SearchStringAddition",
"ViewStatusAddition",
"SearchControlAddition",
"Events",
"Attributes",
"Commands",
"Parameters",
"CommandSet",
"AutoCommandBar",
}
)
def local(tag: str) -> str:
return tag.split("}")[-1] if "}" in tag else tag
def get_text(el: ET.Element, name: str, default: str = "") -> str:
for c in el:
if local(c.tag) == name:
return (c.text or default).strip()
return default
def get_bool(el: ET.Element, name: str, default: bool = True) -> bool:
raw = get_text(el, name, "")
if not raw:
return default
return raw.lower() not in ("false", "0")
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 ""
def esc(text: str) -> str:
return html.escape(text or "", quote=True)
def normalize_type_key(raw: str) -> str:
key = raw.strip().replace(" ", "").replace("_", "").replace("-", "")
return key.casefold()
def resolve_type_dir(type_name: str) -> str:
mapped = _TYPE_ALIASES.get(normalize_type_key(type_name))
if not mapped:
raise ResolveError(
f"неизвестный тип объекта: {type_name!r} "
"(например Справочник, Документ, Общие, Catalog, CommonForm)"
)
return mapped
def parse_object_ref(object_ref: str) -> tuple[str, str | None]:
"""
Справочник.Номенклатура → (Catalogs, Номенклатура)
Общие → (CommonForms, None)
Catalog.Номенклатура → (Catalogs, Номенклатура)
"""
text = (object_ref or "").strip()
if not text:
raise ResolveError("не указан объект (-o / --object)")
if "." in text:
type_part, name_part = text.split(".", 1)
type_dir = resolve_type_dir(type_part)
name = name_part.strip()
if not name:
raise ResolveError(f"пустое имя объекта в {object_ref!r}")
return type_dir, name
type_dir = resolve_type_dir(text)
if type_dir != "CommonForms":
raise ResolveError(
f"для типа {text!r} укажите имя: {text}.ИмяОбъекта "
"(для общих форм достаточно: Общие)"
)
return type_dir, None
def default_search_roots(config_path: Path | None = None) -> list[Path]:
roots: list[Path] = [Path.cwd()]
tool_root = Path(__file__).resolve().parent.parent
roots.append(tool_root)
roots.append(tool_root.parent) # tools/
roots.append(tool_root.parent.parent) # monorepo
if config_path is not None:
roots.insert(0, config_path.parent.resolve())
seen: set[Path] = set()
out: list[Path] = []
for r in roots:
r = r.resolve()
if r not in seen:
seen.add(r)
out.append(r)
return out
def resolve_base_dir(base: str, search_roots: list[Path] | None = None) -> Path:
"""-b имя_каталога или путь к конфигурации / src."""
raw = Path(base).expanduser()
candidates: list[Path] = []
if raw.is_absolute():
candidates.append(raw)
else:
roots = search_roots or default_search_roots()
candidates.append(Path.cwd() / raw)
for root in roots:
candidates.append(root / raw)
tried: list[str] = []
for cand in candidates:
tried.append(str(cand))
if not cand.exists():
continue
try:
return resolve_config_root(cand)
except ResolveError:
continue
raise ResolveError(
f"конфигурация {base!r} не найдена. Проверено:\n - " + "\n - ".join(tried)
)
def resolve_form_xml(
*,
base: str,
object_ref: str,
form_name: str,
search_roots: list[Path] | None = None,
) -> tuple[Path, Path, str, str, str]:
"""
Returns:
form_xml, config_root, base_name, type_dir, object_name
"""
form_name = (form_name or "").strip()
if not form_name:
raise ResolveError("не указано имя формы (-f / --form)")
config_root = resolve_base_dir(base, search_roots)
src = resolve_src_root(config_root)
type_dir, object_name = parse_object_ref(object_ref)
if type_dir == "CommonForms":
obj = object_name or form_name
if object_name and object_name != form_name:
raise ResolveError(
f"для общей формы имя объекта ({object_name}) "
f"и формы ({form_name}) должны совпадать"
)
form_xml = src / "CommonForms" / form_name / "Ext" / "Form.xml"
object_name = obj
else:
assert object_name is not None
form_xml = src / type_dir / object_name / "Forms" / form_name / "Ext" / "Form.xml"
if not form_xml.is_file():
raise ResolveError(f"Form.xml не найден: {form_xml}")
return form_xml, config_root, config_root.name, type_dir, object_name
def type_dir_label(type_dir: str) -> str:
return DIR_TO_RU.get(type_dir, type_dir)
def build_output_path(
out_path: Path,
*,
base_name: str,
type_dir: str,
object_name: str,
form_name: str,
) -> Path:
"""out_path / confa / тип / объект / форма.html"""
return (
Path(out_path)
/ base_name
/ type_dir_label(type_dir)
/ object_name
/ f"{form_name}.html"
)
def load_settings_file(path: Path) -> dict[str, Any]:
path = Path(path).expanduser().resolve()
if not path.is_file():
raise ResolveError(f"файл настроек не найден: {path}")
data = json.loads(path.read_text(encoding="utf-8-sig"))
if not isinstance(data, dict):
raise ResolveError(f"ожидался JSON-объект в {path}")
data["_config_path"] = str(path)
return data
def parse_node(el: ET.Element) -> dict[str, Any] | None:
tag = local(el.tag)
if tag in SKIP_TAGS:
return None
if tag == "ChildItems":
children = []
for c in el:
node = parse_node(c)
if node is not None:
children.append(node)
return {"tag": "ChildItems", "children": children}
if tag not in UI_TAGS:
return None
node: dict[str, Any] = {
"tag": tag,
"name": el.get("name") or "",
"id": el.get("id") or "",
"title": get_title(el),
"visible": get_bool(el, "Visible", True),
"enabled": get_bool(el, "Enabled", True),
"read_only": get_bool(el, "ReadOnly", False),
"data_path": get_text(el, "DataPath"),
"group": get_text(el, "Group", "Vertical"),
"representation": get_text(el, "Representation", "None"),
"behavior": get_text(el, "Behavior", "Usual"),
"show_title": get_bool(el, "ShowTitle", True),
"united": get_bool(el, "United", True),
"check_box_type": get_text(el, "CheckBoxType", "Auto"),
"children": [],
}
for c in el:
if local(c.tag) == "ChildItems":
for grandchild in c:
child = parse_node(grandchild)
if child is not None:
node["children"].append(child)
elif local(c.tag) in UI_TAGS:
child = parse_node(c)
if child is not None:
node["children"].append(child)
return node
def infer_title_from_path(path: Path) -> str:
parts = list(path.parts)
try:
forms_idx = parts.index("Forms")
form_name = parts[forms_idx + 1] if forms_idx + 1 < len(parts) else ""
object_name = parts[forms_idx - 1] if forms_idx > 0 else ""
if object_name and form_name:
return f"{object_name} · {form_name}"
return form_name or object_name or path.stem
except ValueError:
try:
idx = parts.index("CommonForms")
name = parts[idx + 1] if idx + 1 < len(parts) else path.stem
return f"Общая форма · {name}"
except ValueError:
return path.stem
def parse_form(path: Path) -> dict[str, Any]:
root = ET.parse(path).getroot()
form_title = ""
for c in root:
if local(c.tag) == "Title":
for item in c.iter():
if local(item.tag) == "content" and item.text:
form_title = item.text.strip()
break
children: list[dict[str, Any]] = []
for c in root:
if local(c.tag) == "ChildItems":
for item in c:
node = parse_node(item)
if node is not None:
children.append(node)
return {
"title": form_title or infer_title_from_path(path),
"source": str(path),
"children": children,
}
def label_of(node: dict[str, Any]) -> str:
return node["title"] or node["name"] or node["tag"]
def render_field(node: dict[str, Any]) -> str:
tag = node["tag"]
label = esc(label_of(node))
name = esc(node["name"])
dp = esc(node["data_path"])
hidden = "" if node["visible"] else " is-hidden"
meta = f'data-name="{name}" data-type="{esc(tag)}" data-path="{dp}"'
ro = " readonly" if node["read_only"] else ""
dis = " disabled" if not node["enabled"] else ""
if tag == "CheckBoxField":
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>'
)
if tag == "RadioButtonField":
return (
f'<div class="field radio{hidden}" {meta}>'
f'<div class="caption">{label}</div>'
f'<div class="radio-row">'
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>'
)
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>'
)
if tag == "LabelDecoration":
return (
f'<div class="decoration label{hidden}" {meta}>'
f'{label}<span class="meta">{name}</span></div>'
)
if tag in ("PictureDecoration", "PictureField"):
return (
f'<div class="decoration picture{hidden}" {meta}>'
f'<div class="pic-stub">🖼</div>'
f'<span class="caption">{label}</span>'
f'<span class="meta">{name}</span></div>'
)
if tag == "Button":
return (
f'<button type="button" class="btn{hidden}" {meta}{dis}>{label}'
f'<span class="meta">{name}</span></button>'
)
if tag in (
"HTMLDocumentField",
"SpreadSheetDocumentField",
"FormattedDocumentField",
"TextDocumentField",
):
kind = {
"HTMLDocumentField": "HTML",
"SpreadSheetDocumentField": "Табличный документ",
"FormattedDocumentField": "Форматированный документ",
"TextDocumentField": "Текстовый документ",
}[tag]
return (
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>'
)
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>'
)
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>'
)
def render_table(node: dict[str, Any]) -> str:
label = esc(label_of(node))
name = esc(node["name"])
dp = esc(node["data_path"])
hidden = "" if node["visible"] else " is-hidden"
cols: list[str] = []
def collect_columns(children: list[dict[str, Any]]) -> None:
for ch in children:
if ch["tag"] == "ColumnGroup":
collect_columns(ch["children"])
elif ch["tag"] in (
"InputField",
"CheckBoxField",
"LabelField",
"PictureField",
):
cols.append(label_of(ch) or ch["name"])
collect_columns(node["children"])
if not cols:
cols = ["Колонка 1", "Колонка 2", "Колонка 3"]
head = "".join(f"<th>{esc(c)}</th>" for c in cols)
empty = "".join("<td></td>" for _ in cols)
rows = "".join(f"<tr>{empty}</tr>" for _ in range(3))
return (
f'<div class="table-wrap{hidden}" data-name="{name}" data-type="Table" data-path="{dp}">'
f'<div class="caption">{label}</div>'
f"<table><thead><tr>{head}</tr></thead><tbody>{rows}</tbody></table>"
f'<span class="meta">{name} · {dp}</span></div>'
)
def render_group(node: dict[str, Any], depth: int = 0) -> str:
tag = node["tag"]
if tag in (
"InputField",
"CheckBoxField",
"LabelField",
"LabelDecoration",
"PictureDecoration",
"PictureField",
"Button",
"RadioButtonField",
"HTMLDocumentField",
"SpreadSheetDocumentField",
"FormattedDocumentField",
"TextDocumentField",
"PeriodField",
):
return render_field(node)
if tag == "Table":
return render_table(node)
if tag == "Pages":
return render_pages(node)
if tag == "Page":
return "".join(render_group(ch, depth + 1) for ch in node["children"])
if tag in ("ButtonGroup", "Popup", "CommandBar", "ColumnGroup"):
body = "".join(render_group(ch, depth + 1) for ch in node["children"])
cls = "button-group" if tag == "ButtonGroup" else "nested"
hidden = "" if node["visible"] else " is-hidden"
return f'<div class="{cls}{hidden}">{body}</div>'
orient = (node["group"] or "Vertical").lower()
is_horizontal = "horizontal" in orient
behavior_raw = node["behavior"] or "Usual"
collapsible = behavior_raw in ("Collapsible", "Pop-up", "PopUp")
if not collapsible and (node["name"] or "").startswith("Сворачиваемая"):
collapsible = True
hidden = "" if node["visible"] else " is-hidden"
title = label_of(node)
show_title = node["show_title"] and bool(title)
name = esc(node["name"])
cls = "group horizontal" if is_horizontal else "group vertical"
if collapsible:
cls += " collapsible open"
inner = "".join(render_group(ch, depth + 1) for ch in node["children"])
if collapsible and show_title:
return (
f'<details class="{cls}{hidden}" open '
f'data-name="{name}" data-type="UsualGroup">'
f"<summary><span>{esc(title)}</span>"
f'<span class="meta">{name}</span></summary>'
f'<div class="group-body">{inner}</div></details>'
)
title_html = (
f'<div class="group-title">{esc(title)}'
f'<span class="meta">{name}</span></div>'
if show_title
else f'<span class="meta only">{name}</span>'
)
return (
f'<div class="{cls}{hidden}" data-name="{name}" data-type="UsualGroup">'
f'{title_html}<div class="group-body">{inner}</div></div>'
)
def render_pages(node: dict[str, Any]) -> str:
pages = [ch for ch in node["children"] if ch["tag"] == "Page"]
if not pages:
return ""
name = esc(node["name"])
tabs = []
panels = []
for i, page in enumerate(pages):
pid = f"{name}-{i}"
active = " active" if i == 0 else ""
hidden = "" if page["visible"] else " is-hidden"
tabs.append(
f'<button type="button" class="tab{active}{hidden}" data-tab="{esc(pid)}" '
f'data-name="{esc(page["name"])}">{esc(label_of(page))}</button>'
)
body = "".join(render_group(ch, 1) for ch in page["children"])
panels.append(
f'<div class="tab-panel{active}{hidden}" id="{esc(pid)}" '
f'data-name="{esc(page["name"])}" data-type="Page">{body}</div>'
)
return (
f'<div class="pages" data-name="{name}" data-type="Pages">'
f'<div class="tabs">{"".join(tabs)}</div>'
f'<div class="tab-panels">{"".join(panels)}</div></div>'
)
def build_html(form: dict[str, Any], *, generator_version: 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__)
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="form_xml_to_html {ver}">
<meta name="author" content="{author}">
<title>Прототип: {title}</title>
<style>
:root {{
--bg: #f0f0f0;
--panel: #ffffff;
--border: #c8c8c8;
--muted: #6b6b6b;
--accent: #3a7fc1;
--accent-soft: #e8f1fa;
--title: #2a2a2a;
--warn-bg: #fff8e1;
--hidden-bg: repeating-linear-gradient(45deg,#f7f7f7,#f7f7f7 6px,#efefef 6px,#efefef 12px);
--font: "Segoe UI", "Tahoma", sans-serif;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0; font-family: var(--font); background: var(--bg); color: var(--title);
}}
.toolbar {{
position: sticky; top: 0; z-index: 20; display: flex; flex-wrap: wrap; gap: 8px;
align-items: center; padding: 10px 14px; background: #e8e8e8; border-bottom: 1px solid var(--border);
}}
.toolbar h1 {{ font-size: 15px; margin: 0 12px 0 0; font-weight: 600; }}
.toolbar .src {{ font-size: 11px; color: var(--muted); max-width: 42vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.toolbar label {{ font-size: 12px; display: flex; gap: 6px; align-items: center; }}
.toolbar input[type="search"] {{
width: 220px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 3px;
}}
.toolbar button, .btn {{
border: 1px solid var(--border); background: #fafafa; padding: 4px 10px; border-radius: 3px;
cursor: pointer; font: inherit; font-size: 12px;
}}
.toolbar button:hover, .btn:hover {{ background: var(--accent-soft); border-color: var(--accent); }}
.window {{
margin: 12px; background: var(--panel); border: 1px solid var(--border); border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,.08); min-height: 70vh;
}}
.commandbar {{
display: flex; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border); background: #f7f7f7;
}}
.content {{ padding: 12px; }}
.pages {{ display: flex; flex-direction: column; gap: 0; }}
.tabs {{ display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 10px; }}
.tab {{
border: 1px solid transparent; border-bottom: none; background: transparent;
padding: 7px 14px; cursor: pointer; font: inherit; font-size: 13px; color: var(--muted);
border-radius: 4px 4px 0 0;
}}
.tab.active {{ background: var(--panel); border-color: var(--border); color: var(--title); font-weight: 600; }}
.tab-panel {{ display: none; }}
.tab-panel.active {{ display: block; }}
.group {{
border: 1px solid transparent; margin: 0 0 8px; padding: 4px;
}}
.group.vertical > .group-body {{ display: flex; flex-direction: column; gap: 6px; }}
.group.horizontal > .group-body {{ display: flex; flex-direction: row; flex-wrap: wrap; gap: 12px; align-items: flex-start; }}
.group.horizontal > .group-body > * {{ flex: 1 1 320px; min-width: 280px; }}
.group-title {{
font-size: 12px; font-weight: 600; color: var(--muted); margin: 2px 0 6px;
}}
details.group {{
border: 1px solid var(--border); border-radius: 4px; background: #fcfcfc; padding: 0;
}}
details.group > summary {{
list-style: none; cursor: pointer; padding: 8px 10px; background: #f3f6f9;
border-bottom: 1px solid var(--border); font-weight: 600; font-size: 13px;
display: flex; justify-content: space-between; align-items: center; gap: 8px;
}}
details.group > summary::-webkit-details-marker {{ display: none; }}
details.group > summary::before {{ content: "▾ "; color: var(--accent); }}
details.group:not([open]) > summary::before {{ content: "▸ "; }}
details.group .group-body {{ padding: 10px; }}
.field {{
display: grid; grid-template-columns: minmax(140px, 220px) 1fr; gap: 4px 10px;
align-items: center; padding: 2px 0;
}}
.field.checkbox, .field.radio, .decoration, .document, .table-wrap {{
display: block; grid-template-columns: none;
}}
.field .caption, .decoration .caption {{ font-size: 12px; color: #333; }}
.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; }}
.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);
}}
.doc-stub, .pic-stub {{
border: 1px dashed var(--border); background: var(--warn-bg); min-height: 80px;
display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 12px;
border-radius: 3px; margin-top: 4px;
}}
.pic-stub {{ width: 96px; height: 96px; font-size: 28px; }}
.decoration.label {{ font-size: 12px; color: var(--muted); padding: 2px 0; }}
.table-wrap table {{ width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 4px; }}
.table-wrap th, .table-wrap td {{ border: 1px solid var(--border); padding: 4px 6px; height: 22px; }}
.table-wrap th {{ background: #f0f4f8; text-align: left; }}
.btn {{ display: inline-flex; align-items: center; gap: 8px; margin: 4px 0; }}
.meta {{
display: none; font-size: 10px; color: #8a8a8a; font-weight: 400; margin-left: 6px;
}}
body.show-meta .meta {{ display: inline; }}
body.show-meta .meta.only {{ display: block; margin: 0 0 4px; }}
.is-hidden {{ display: none !important; }}
body.show-hidden .is-hidden {{
display: block !important; outline: 1px dashed #e0a800; background: var(--hidden-bg);
}}
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; }}
@media (max-width: 800px) {{
.field {{ grid-template-columns: 1fr; }}
.group.horizontal > .group-body > * {{ min-width: 100%; }}
}}
</style>
</head>
<body>
<div class="toolbar">
<h1>{title}</h1>
<span class="src" title="{source}">{source}</span>
<input type="search" id="filter" placeholder="Фильтр: имя / заголовок / DataPath">
<label><input type="checkbox" id="showHidden"> Скрытые</label>
<label><input type="checkbox" id="showMeta"> Имена элементов</label>
<button type="button" id="expandAll">Развернуть группы</button>
<button type="button" id="collapseAll">Свернуть группы</button>
<span class="stats" id="stats"></span>
</div>
<div class="window">
<div class="commandbar">
<button type="button" class="btn">Записать и закрыть</button>
<button type="button" class="btn">Записать</button>
<button type="button" class="btn">Закрыть</button>
</div>
<div class="content" id="form-root">
{body}
</div>
</div>
<script>
(function () {{
const root = document.getElementById('form-root');
document.querySelectorAll('.pages').forEach(pages => {{
const tabs = pages.querySelectorAll(':scope > .tabs > .tab');
const panels = pages.querySelectorAll(':scope > .tab-panels > .tab-panel');
tabs.forEach(tab => {{
tab.addEventListener('click', () => {{
const id = tab.getAttribute('data-tab');
tabs.forEach(t => t.classList.toggle('active', t === tab));
panels.forEach(p => p.classList.toggle('active', p.id === id));
}});
}});
}});
const showHidden = document.getElementById('showHidden');
const showMeta = document.getElementById('showMeta');
showHidden.addEventListener('change', () => {{
document.body.classList.toggle('show-hidden', showHidden.checked);
}});
showMeta.addEventListener('change', () => {{
document.body.classList.toggle('show-meta', showMeta.checked);
}});
document.getElementById('expandAll').addEventListener('click', () => {{
root.querySelectorAll('details.group').forEach(d => d.open = true);
}});
document.getElementById('collapseAll').addEventListener('click', () => {{
root.querySelectorAll('details.group').forEach(d => d.open = false);
}});
const filter = document.getElementById('filter');
const stats = document.getElementById('stats');
function applyFilter() {{
const q = filter.value.trim().toLowerCase();
const nodes = root.querySelectorAll('[data-name]');
let matched = 0;
nodes.forEach(el => {{
el.classList.remove('highlight');
if (!q) return;
const hay = [
el.getAttribute('data-name') || '',
el.getAttribute('data-path') || '',
el.getAttribute('data-type') || '',
el.textContent || ''
].join(' ').toLowerCase();
if (hay.includes(q)) {{
el.classList.add('highlight');
matched += 1;
let p = el.parentElement;
while (p) {{
if (p.tagName === 'DETAILS') p.open = true;
if (p.classList && p.classList.contains('tab-panel')) {{
const id = p.id;
const pages = p.closest('.pages');
if (pages) {{
pages.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.getAttribute('data-tab') === id));
pages.querySelectorAll('.tab-panel').forEach(tp => tp.classList.toggle('active', tp.id === id));
}}
}}
p = p.parentElement;
}}
}}
}});
const total = root.querySelectorAll('[data-name]').length;
stats.textContent = q ? `найдено ${{matched}} / ${{total}}` : `элементов: ${{total}}`;
}}
filter.addEventListener('input', applyFilter);
applyFilter();
}})();
</script>
</body>
</html>
"""
def export_form_prototype(
*,
form_xml: Path,
output_html: Path,
display_title: str | None = None,
) -> Path:
form = parse_form(form_xml)
if display_title:
form["title"] = display_title
html_text = build_html(form)
output_html = Path(output_html)
output_html.parent.mkdir(parents=True, exist_ok=True)
output_html.write_text(html_text, encoding="utf-8")
return output_html
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""CLI: HTML-прототип управляемой формы 1С из Form.xml.
Версия: 0.1.0
Автор: 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 РК_ФормаНастроекРана
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from export1c_help.form_prototype import (
__author__,
__email__,
__version__,
build_output_path,
default_search_roots,
export_form_prototype,
load_settings_file,
resolve_form_xml,
)
from export1c_help.resolve import ResolveError
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="form_xml_to_html",
description=(
"Собрать HTML-прототип формы 1С по -b/-o/-f "
f"(v{__version__}, {__author__})"
),
)
parser.add_argument(
"-b",
"--base",
help="Имя/путь каталога конфигурации (или …/src)",
)
parser.add_argument(
"-o",
"--object",
help="Объект: Справочник.Номенклатура | Документ.X | Общие",
)
parser.add_argument(
"-f",
"--form",
required=True,
help="Имя формы (для общих форм — имя общей формы)",
)
parser.add_argument(
"-p",
"--out-path",
help=(
"Каталог вывода: <out>/<конфа>/<тип>/<объект>/<форма>.html "
"(по умолчанию out/form-prototypes)"
),
)
parser.add_argument(
"-c",
"--config",
help="JSON с параметрами (base, object, out_path, search_roots), без формы",
)
parser.add_argument(
"--version",
action="version",
version=f"form_xml_to_html {__version__} — {__author__} ({__email__})",
)
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()
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()
return (tool_root / path).resolve()
def main(argv: list[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
settings: dict = {}
config_path: Path | None = None
if args.config:
try:
settings = load_settings_file(Path(args.config))
config_path = Path(settings["_config_path"])
except (ResolveError, OSError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
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")
if not base:
parser.error("нужен -b/--base или base в -c/--config")
if not object_ref:
parser.error("нужен -o/--object или object в -c/--config")
search_roots = default_search_roots(config_path)
extra_roots = settings.get("search_roots") or []
if isinstance(extra_roots, list):
base_for_rel = config_path.parent if config_path else Path.cwd()
for item in extra_roots:
p = Path(str(item)).expanduser()
if not p.is_absolute():
p = (base_for_rel / p).resolve()
search_roots.insert(0, p)
try:
form_xml, _config_root, base_name, type_dir, object_name = resolve_form_xml(
base=str(base),
object_ref=str(object_ref),
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()}",
)
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__}")
return 0
if __name__ == "__main__":
raise SystemExit(main())