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:
@@ -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
|
||||
Reference in New Issue
Block a user