diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4cd8c..5c8363d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 . + - `-b/--base`, `-o/--object`, `-f/--form`, `-p/--out-path`, `-c/--config` + - вывод: `/<конфа>/<тип>/<объект>/<форма>.html` + - примеры конфигов: `configs/form_prototype.crm3-26.json`, + `configs/form_prototype.crm3-26.rhana.json` + +--- + ## [0.3.12] - 2026-07-29 ### Fixed diff --git a/README.md b/README.md index f32f411..4213966 100644 --- a/README.md +++ b/README.md @@ -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 ). + Зависимости: **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** . + +Собирает интерактивный 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/` diff --git a/VERSION b/VERSION index 0b9c019..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.12 +0.4.0 diff --git a/configs/form_prototype.crm3-26.json b/configs/form_prototype.crm3-26.json new file mode 100644 index 0000000..fd02097 --- /dev/null +++ b/configs/form_prototype.crm3-26.json @@ -0,0 +1,6 @@ +{ + "base": "crm3-26", + "object": "Справочник.Номенклатура", + "out_path": "out/form-prototypes", + "search_roots": ["../.."] +} diff --git a/configs/form_prototype.crm3-26.rhana.json b/configs/form_prototype.crm3-26.rhana.json new file mode 100644 index 0000000..cfdaf77 --- /dev/null +++ b/configs/form_prototype.crm3-26.rhana.json @@ -0,0 +1,6 @@ +{ + "base": "crm3-26.rhana", + "object": "Общие", + "out_path": "out/form-prototypes", + "search_roots": ["../.."] +} diff --git a/export1c_help/form_prototype.py b/export1c_help/form_prototype.py new file mode 100644 index 0000000..4e9179e --- /dev/null +++ b/export1c_help/form_prototype.py @@ -0,0 +1,882 @@ +"""HTML-прототип управляемой формы 1С из Form.xml. + +Версия скрипта/модуля: см. __version__. +Автор: Michael BAG +""" + +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 " +__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'' + ) + + if tag == "RadioButtonField": + return ( + f'
' + f'
{label}
' + f'
' + f'' + f'' + f"
" + f'{name} · {dp}
' + ) + + if tag == "LabelField": + return ( + f'
' + f'
{label}
' + f'
—
' + f'{name} · {dp}
' + ) + + if tag == "LabelDecoration": + return ( + f'
' + f'{label}{name}
' + ) + + if tag in ("PictureDecoration", "PictureField"): + return ( + f'
' + f'
🖼
' + f'{label}' + f'{name}
' + ) + + if tag == "Button": + return ( + f'' + ) + + if tag in ( + "HTMLDocumentField", + "SpreadSheetDocumentField", + "FormattedDocumentField", + "TextDocumentField", + ): + kind = { + "HTMLDocumentField": "HTML", + "SpreadSheetDocumentField": "Табличный документ", + "FormattedDocumentField": "Форматированный документ", + "TextDocumentField": "Текстовый документ", + }[tag] + return ( + f'
' + f'
{label}
' + f'
{kind}
' + f'{name} · {dp}
' + ) + + if tag == "PeriodField": + return ( + f'
' + f'' + f'' + f'{name} · {dp}
' + ) + + return ( + f'
' + f'' + f'' + f'{name} · {dp}
' + ) + + +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"{esc(c)}" for c in cols) + empty = "".join("" for _ in cols) + rows = "".join(f"{empty}" for _ in range(3)) + return ( + f'
' + f'
{label}
' + f"{head}{rows}
" + f'{name} · {dp}
' + ) + + +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'
{body}
' + + 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'
' + f"{esc(title)}" + f'{name}' + f'
{inner}
' + ) + + title_html = ( + f'
{esc(title)}' + f'{name}
' + if show_title + else f'{name}' + ) + return ( + f'
' + f'{title_html}
{inner}
' + ) + + +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'' + ) + body = "".join(render_group(ch, 1) for ch in page["children"]) + panels.append( + f'
{body}
' + ) + + return ( + f'
' + f'
{"".join(tabs)}
' + f'
{"".join(panels)}
' + ) + + +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""" + + + + + + +Прототип: {title} + + + +
+

{title}

+ {source} + + + + + + +
+
+
+ + + +
+
+{body} +
+
+ + + +""" + + +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 diff --git a/form_xml_to_html.py b/form_xml_to_html.py new file mode 100644 index 0000000..cd8f1b6 --- /dev/null +++ b/form_xml_to_html.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""CLI: HTML-прототип управляемой формы 1С из Form.xml. + +Версия: 0.1.0 +Автор: Michael BAG + +Примеры: + 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=( + "Каталог вывода: /<конфа>/<тип>/<объект>/<форма>.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())