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:
+591
-19
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -15,7 +16,7 @@ from typing import Any
|
||||
from .resolve import ResolveError, resolve_config_root, resolve_src_root
|
||||
from .types_map import DIR_TO_LINK_TYPE, DIR_TO_RU, LINK_TYPE_TO_DIR
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.3"
|
||||
__author__ = "Michael BAG <mk@p7net.ru>"
|
||||
__email__ = "mk@p7net.ru"
|
||||
|
||||
@@ -146,13 +147,37 @@ def get_bool(el: ET.Element, name: str, default: bool = True) -> bool:
|
||||
return raw.lower() not in ("false", "0")
|
||||
|
||||
|
||||
def strip_v8_formatted(text: str) -> str:
|
||||
"""Убрать разметку форматированной строки 1С (<link>, <bgcolorstyle>, <img>…)."""
|
||||
if not text:
|
||||
return ""
|
||||
s = html.unescape(text)
|
||||
# самозакрывающиеся (картинки и т.п.)
|
||||
s = re.sub(r"<(?:img|br|hr)\b[^>]*?/?>", "", s, flags=re.I)
|
||||
prev = None
|
||||
while prev != s:
|
||||
prev = s
|
||||
# <tag …>content</> (типичный закрывающий тег платформы)
|
||||
s = re.sub(r"<[A-Za-z][\w:-]*(?:\s[^>]*)?>(.*?)</>", r"\1", s, flags=re.S)
|
||||
# <tag …>content</tag>
|
||||
s = re.sub(
|
||||
r"<([A-Za-z][\w:-]*)(?:\s[^>]*)?>(.*?)</\1\s*>",
|
||||
r"\2",
|
||||
s,
|
||||
flags=re.S | re.I,
|
||||
)
|
||||
s = re.sub(r"<[^>]+>", "", s)
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def get_title(el: ET.Element) -> str:
|
||||
for t in el:
|
||||
if local(t.tag) != "Title":
|
||||
continue
|
||||
for item in t.iter():
|
||||
if local(item.tag) == "content" and item.text:
|
||||
return item.text.strip()
|
||||
return strip_v8_formatted(item.text)
|
||||
return ""
|
||||
|
||||
|
||||
@@ -318,6 +343,297 @@ def load_settings_file(path: Path) -> dict[str, Any]:
|
||||
return data
|
||||
|
||||
|
||||
def format_v8_type(type_el: ET.Element | None) -> str:
|
||||
"""Краткое представление типа из узла <Type> формы/метаданных."""
|
||||
if type_el is None:
|
||||
return ""
|
||||
raw_types: list[str] = []
|
||||
date_fractions = ""
|
||||
for item in type_el.iter():
|
||||
tag = local(item.tag)
|
||||
if tag == "Type" and item.text and item.text.strip():
|
||||
raw_types.append(item.text.strip())
|
||||
elif tag == "DateFractions" and item.text:
|
||||
date_fractions = item.text.strip()
|
||||
if not raw_types:
|
||||
return ""
|
||||
|
||||
def one(t: str) -> str:
|
||||
if t == "xs:string":
|
||||
return "Строка"
|
||||
if t == "xs:boolean":
|
||||
return "Булево"
|
||||
if t == "xs:decimal":
|
||||
return "Число"
|
||||
if t == "xs:dateTime":
|
||||
if date_fractions == "Date":
|
||||
return "Дата"
|
||||
if date_fractions == "Time":
|
||||
return "Время"
|
||||
return "ДатаВремя"
|
||||
if t == "v8:UUID":
|
||||
return "УникальныйИдентификатор"
|
||||
if t == "v8:ValueTable":
|
||||
return "ТаблицаЗначений"
|
||||
if t == "v8:ValueListType":
|
||||
return "СписокЗначений"
|
||||
if t.startswith("cfg:"):
|
||||
body = t[4:]
|
||||
repl = (
|
||||
("CatalogRef.", "СправочникСсылка."),
|
||||
("CatalogObject.", "СправочникОбъект."),
|
||||
("DocumentRef.", "ДокументСсылка."),
|
||||
("DocumentObject.", "ДокументОбъект."),
|
||||
("EnumRef.", "ПеречислениеСсылка."),
|
||||
("DataProcessorObject.", "ОбработкаОбъект."),
|
||||
("ReportObject.", "ОтчетОбъект."),
|
||||
("InformationRegisterRecord.", "РегистрСведенийЗапись."),
|
||||
("ConstantsSet", "КонстантыНабор"),
|
||||
)
|
||||
for a, b in repl:
|
||||
if body.startswith(a):
|
||||
return b + body[len(a) :]
|
||||
return body
|
||||
return t
|
||||
|
||||
# убрать пустые/дубликаты, сохранить порядок
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for t in raw_types:
|
||||
pretty = one(t)
|
||||
if pretty and pretty not in seen:
|
||||
seen.add(pretty)
|
||||
out.append(pretty)
|
||||
return " | ".join(out)
|
||||
|
||||
|
||||
def _find_type_child(el: ET.Element) -> ET.Element | None:
|
||||
for c in el:
|
||||
if local(c.tag) == "Type":
|
||||
return c
|
||||
# MetaDataObject: Type внутри Properties
|
||||
if local(c.tag) == "Properties":
|
||||
for p in c:
|
||||
if local(p.tag) == "Type":
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _props_name(el: ET.Element) -> str:
|
||||
for c in el:
|
||||
if local(c.tag) == "Properties":
|
||||
for p in c:
|
||||
if local(p.tag) == "Name" and p.text:
|
||||
return p.text.strip()
|
||||
if local(c.tag) == "Name" and c.text:
|
||||
return c.text.strip()
|
||||
return el.get("name") or ""
|
||||
|
||||
|
||||
OBJECT_TYPE_TO_DIR: dict[str, str] = {
|
||||
"CatalogObject": "Catalogs",
|
||||
"CatalogRef": "Catalogs",
|
||||
"DocumentObject": "Documents",
|
||||
"DocumentRef": "Documents",
|
||||
"DataProcessorObject": "DataProcessors",
|
||||
"ReportObject": "Reports",
|
||||
"ExchangePlanObject": "ExchangePlans",
|
||||
"BusinessProcessObject": "BusinessProcesses",
|
||||
"TaskObject": "Tasks",
|
||||
"ChartOfCharacteristicTypesObject": "ChartsOfCharacteristicTypes",
|
||||
"ChartOfAccountsObject": "ChartsOfAccounts",
|
||||
"ChartOfCalculationTypesObject": "ChartsOfCalculationTypes",
|
||||
"InformationRegisterRecord": "InformationRegisters",
|
||||
"AccumulationRegisterRecord": "AccumulationRegisters",
|
||||
}
|
||||
|
||||
|
||||
def _src_root_from_form(form_xml: Path) -> Path | None:
|
||||
"""…/src/DataProcessors/X/Forms/…/Ext/Form.xml → …/src"""
|
||||
parts = list(form_xml.resolve().parts)
|
||||
if "src" in parts:
|
||||
return Path(*parts[: parts.index("src") + 1])
|
||||
# иногда корень выгрузки без src
|
||||
for marker in ("Catalogs", "Documents", "DataProcessors", "CommonForms"):
|
||||
if marker in parts:
|
||||
return Path(*parts[: parts.index(marker)])
|
||||
return None
|
||||
|
||||
|
||||
def load_metadata_object_types(src_root: Path, cfg_object_type: str) -> dict[str, str]:
|
||||
"""
|
||||
Типы реквизитов/колонок ТЧ объекта метаданных.
|
||||
Ключи: 'Реквизит', 'ТЧ.Реквизит'.
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
body = cfg_object_type[4:] if cfg_object_type.startswith("cfg:") else cfg_object_type
|
||||
if "." not in body:
|
||||
return result
|
||||
kind, name = body.split(".", 1)
|
||||
folder = OBJECT_TYPE_TO_DIR.get(kind)
|
||||
if not folder:
|
||||
return result
|
||||
meta_path = src_root / folder / f"{name}.xml"
|
||||
if not meta_path.is_file():
|
||||
return result
|
||||
|
||||
root = ET.parse(meta_path).getroot()
|
||||
|
||||
def collect_from_child_objects(container: ET.Element) -> None:
|
||||
for child in container:
|
||||
tag = local(child.tag)
|
||||
if tag == "Attribute":
|
||||
aname = _props_name(child)
|
||||
t = format_v8_type(_find_type_child(child))
|
||||
if aname and t:
|
||||
result[aname] = t
|
||||
elif tag == "TabularSection":
|
||||
ts_name = _props_name(child)
|
||||
for sub in child:
|
||||
if local(sub.tag) == "ChildObjects":
|
||||
for attr in sub:
|
||||
if local(attr.tag) != "Attribute":
|
||||
continue
|
||||
col = _props_name(attr)
|
||||
t = format_v8_type(_find_type_child(attr))
|
||||
if ts_name and col and t:
|
||||
result[f"{ts_name}.{col}"] = t
|
||||
|
||||
for top in root:
|
||||
for mid in top:
|
||||
if local(mid.tag) == "ChildObjects":
|
||||
collect_from_child_objects(mid)
|
||||
return result
|
||||
|
||||
|
||||
def _is_direct_ts_attribute(ts_el: ET.Element, attr_el: ET.Element) -> bool:
|
||||
"""Attribute принадлежит этой ТЧ (не используется после упрощения collect)."""
|
||||
parent_map = {c: p for p in ts_el.iter() for c in p}
|
||||
cur: ET.Element | None = attr_el
|
||||
while cur is not None and cur is not ts_el:
|
||||
if local(cur.tag) == "TabularSection" and cur is not ts_el:
|
||||
return False
|
||||
cur = parent_map.get(cur)
|
||||
return cur is ts_el
|
||||
|
||||
|
||||
def parse_form_type_index(root: ET.Element, form_xml: Path) -> dict[str, str]:
|
||||
"""DataPath → тип (из атрибутов формы + метаданных объекта)."""
|
||||
types: dict[str, str] = {}
|
||||
src_root = _src_root_from_form(form_xml)
|
||||
object_attr_names: dict[str, str] = {} # form attr name → cfg object type
|
||||
|
||||
for c in root:
|
||||
if local(c.tag) != "Attributes":
|
||||
continue
|
||||
for attr in c:
|
||||
if local(attr.tag) != "Attribute":
|
||||
continue
|
||||
aname = attr.get("name") or ""
|
||||
type_el = None
|
||||
columns_el = None
|
||||
for ch in attr:
|
||||
tname = local(ch.tag)
|
||||
if tname == "Type":
|
||||
type_el = ch
|
||||
elif tname == "Columns":
|
||||
columns_el = ch
|
||||
type_str = format_v8_type(type_el)
|
||||
if aname and type_str:
|
||||
types[aname] = type_str
|
||||
if type_el is not None:
|
||||
for tnode in type_el.iter():
|
||||
if local(tnode.tag) != "Type" or not tnode.text:
|
||||
continue
|
||||
text = tnode.text.strip()
|
||||
if "Object." in text or text.endswith("Object"):
|
||||
object_attr_names[aname] = text
|
||||
if text == "cfg:ConstantsSet":
|
||||
object_attr_names[aname] = "cfg:ConstantsSet"
|
||||
if columns_el is not None:
|
||||
for col in columns_el:
|
||||
if local(col.tag) != "Column":
|
||||
continue
|
||||
cname = col.get("name") or ""
|
||||
ct = format_v8_type(_find_type_child(col))
|
||||
if aname and cname and ct:
|
||||
types[f"{aname}.{cname}"] = ct
|
||||
|
||||
if src_root is not None:
|
||||
for form_attr, cfg_type in object_attr_names.items():
|
||||
if cfg_type == "cfg:ConstantsSet":
|
||||
const_dir = src_root / "Constants"
|
||||
if const_dir.is_dir():
|
||||
for const_file in const_dir.glob("*.xml"):
|
||||
try:
|
||||
croot = ET.parse(const_file).getroot()
|
||||
except ET.ParseError:
|
||||
continue
|
||||
t_el = None
|
||||
for mid in croot:
|
||||
for ch in mid:
|
||||
if local(ch.tag) != "Properties":
|
||||
continue
|
||||
for p in ch:
|
||||
if local(p.tag) == "Type":
|
||||
t_el = p
|
||||
break
|
||||
t = format_v8_type(t_el)
|
||||
if t:
|
||||
types[f"{form_attr}.{const_file.stem}"] = t
|
||||
continue
|
||||
|
||||
meta_types = load_metadata_object_types(src_root, cfg_type)
|
||||
for key, val in meta_types.items():
|
||||
types[f"{form_attr}.{key}"] = val
|
||||
|
||||
return types
|
||||
|
||||
|
||||
def annotate_data_types(nodes: list[dict[str, Any]], type_index: dict[str, str]) -> None:
|
||||
for node in nodes:
|
||||
dp = node.get("data_path") or ""
|
||||
if dp:
|
||||
node["data_type"] = type_index.get(dp, "")
|
||||
children = node.get("children") or []
|
||||
if children:
|
||||
annotate_data_types(children, type_index)
|
||||
|
||||
|
||||
def field_placeholder(node: dict[str, Any]) -> str:
|
||||
"""DataPath + тип для placeholder."""
|
||||
dp = node.get("data_path") or ""
|
||||
dtype = node.get("data_type") or ""
|
||||
name = node.get("name") or ""
|
||||
if dp and dtype:
|
||||
return f"{dp} · {dtype}"
|
||||
if dp:
|
||||
return dp
|
||||
if dtype:
|
||||
return dtype
|
||||
return name
|
||||
|
||||
|
||||
def render_copyable_hint(text: str, *, demo: str = "") -> str:
|
||||
"""Readonly строка DataPath · тип + кнопка копирования."""
|
||||
hint = esc(text)
|
||||
if not text:
|
||||
if demo:
|
||||
return (
|
||||
f'<input type="text" class="field-demo" readonly disabled '
|
||||
f'value="{esc(demo)}">'
|
||||
)
|
||||
return '<input type="text" readonly disabled value="">'
|
||||
|
||||
return (
|
||||
f'<div class="field-value">'
|
||||
f'<input type="text" class="field-hint" readonly value="{hint}" '
|
||||
f'title="DataPath · тип — выделите или нажмите ⎘">'
|
||||
f'<button type="button" class="btn-copy" data-copy="{hint}" '
|
||||
f'title="Копировать DataPath · тип">⎘</button>'
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
def parse_node(el: ET.Element) -> dict[str, Any] | None:
|
||||
tag = local(el.tag)
|
||||
if tag in SKIP_TAGS:
|
||||
@@ -342,6 +658,7 @@ def parse_node(el: ET.Element) -> dict[str, Any] | None:
|
||||
"enabled": get_bool(el, "Enabled", True),
|
||||
"read_only": get_bool(el, "ReadOnly", False),
|
||||
"data_path": get_text(el, "DataPath"),
|
||||
"data_type": "",
|
||||
"group": get_text(el, "Group", "Vertical"),
|
||||
"representation": get_text(el, "Representation", "None"),
|
||||
"behavior": get_text(el, "Behavior", "Usual"),
|
||||
@@ -390,7 +707,7 @@ def parse_form(path: Path) -> dict[str, Any]:
|
||||
if local(c.tag) == "Title":
|
||||
for item in c.iter():
|
||||
if local(item.tag) == "content" and item.text:
|
||||
form_title = item.text.strip()
|
||||
form_title = strip_v8_formatted(item.text)
|
||||
break
|
||||
|
||||
children: list[dict[str, Any]] = []
|
||||
@@ -401,10 +718,14 @@ def parse_form(path: Path) -> dict[str, Any]:
|
||||
if node is not None:
|
||||
children.append(node)
|
||||
|
||||
type_index = parse_form_type_index(root, path)
|
||||
annotate_data_types(children, type_index)
|
||||
|
||||
return {
|
||||
"title": form_title or infer_title_from_path(path),
|
||||
"source": str(path),
|
||||
"children": children,
|
||||
"type_index": type_index,
|
||||
}
|
||||
|
||||
|
||||
@@ -417,16 +738,24 @@ def render_field(node: dict[str, Any]) -> str:
|
||||
label = esc(label_of(node))
|
||||
name = esc(node["name"])
|
||||
dp = esc(node["data_path"])
|
||||
dtype = esc(node.get("data_type") or "")
|
||||
ph = esc(field_placeholder(node))
|
||||
hidden = "" if node["visible"] else " is-hidden"
|
||||
meta = f'data-name="{name}" data-type="{esc(tag)}" data-path="{dp}"'
|
||||
meta = (
|
||||
f'data-name="{name}" data-type="{esc(tag)}" data-path="{dp}" '
|
||||
f'data-value-type="{dtype}"'
|
||||
)
|
||||
ro = " readonly" if node["read_only"] else ""
|
||||
dis = " disabled" if not node["enabled"] else ""
|
||||
meta_line = f"{name}" + (f" · {dp}" if dp else "") + (f" · {dtype}" if dtype else "")
|
||||
|
||||
if tag == "CheckBoxField":
|
||||
hint = render_copyable_hint(field_placeholder(node)) if ph else ""
|
||||
return (
|
||||
f'<label class="field checkbox{hidden}" {meta}>'
|
||||
f'<input type="checkbox"{ro}{dis}> <span class="caption">{label}</span>'
|
||||
f'<span class="meta">{name}</span></label>'
|
||||
f'{hint}'
|
||||
f'<span class="meta">{esc(meta_line)}</span></label>'
|
||||
)
|
||||
|
||||
if tag == "RadioButtonField":
|
||||
@@ -437,15 +766,15 @@ def render_field(node: dict[str, Any]) -> str:
|
||||
f'<label><input type="radio" name="{name}" checked{dis}> Вариант 1</label>'
|
||||
f'<label><input type="radio" name="{name}"{dis}> Вариант 2</label>'
|
||||
f"</div>"
|
||||
f'<span class="meta">{name} · {dp}</span></div>'
|
||||
f'<span class="meta">{esc(meta_line)}</span></div>'
|
||||
)
|
||||
|
||||
if tag == "LabelField":
|
||||
return (
|
||||
f'<div class="field label-field{hidden}" {meta}>'
|
||||
f'<div class="caption">{label}</div>'
|
||||
f'<div class="value-box">—</div>'
|
||||
f'<span class="meta">{name} · {dp}</span></div>'
|
||||
f'{render_copyable_hint(field_placeholder(node) or "—")}'
|
||||
f'<span class="meta">{esc(meta_line)}</span></div>'
|
||||
)
|
||||
|
||||
if tag == "LabelDecoration":
|
||||
@@ -484,22 +813,22 @@ def render_field(node: dict[str, Any]) -> str:
|
||||
f'<div class="field document{hidden}" {meta}>'
|
||||
f'<div class="caption">{label}</div>'
|
||||
f'<div class="doc-stub">{kind}</div>'
|
||||
f'<span class="meta">{name} · {dp}</span></div>'
|
||||
f'<span class="meta">{esc(meta_line)}</span></div>'
|
||||
)
|
||||
|
||||
if tag == "PeriodField":
|
||||
return (
|
||||
f'<div class="field{hidden}" {meta}>'
|
||||
f'<label class="caption">{label}</label>'
|
||||
f'<input type="text" value="01.01.2026 — 31.12.2026"{ro}{dis}>'
|
||||
f'<span class="meta">{name} · {dp}</span></div>'
|
||||
f'{render_copyable_hint(ph, demo="01.01.2026 — 31.12.2026")}'
|
||||
f'<span class="meta">{esc(meta_line)}</span></div>'
|
||||
)
|
||||
|
||||
return (
|
||||
f'<div class="field{hidden}" {meta}>'
|
||||
f'<label class="caption">{label}</label>'
|
||||
f'<input type="text" placeholder="{dp or name}"{ro}{dis}>'
|
||||
f'<span class="meta">{name} · {dp}</span></div>'
|
||||
f'{render_copyable_hint(ph)}'
|
||||
f'<span class="meta">{esc(meta_line)}</span></div>'
|
||||
)
|
||||
|
||||
|
||||
@@ -637,12 +966,194 @@ def render_pages(node: dict[str, Any]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def build_html(form: dict[str, Any], *, generator_version: str | None = None) -> str:
|
||||
def _tree_prefix(is_last: bool, ancestors_last: list[bool]) -> str:
|
||||
parts = []
|
||||
for last in ancestors_last:
|
||||
parts.append(" " if last else "│ ")
|
||||
parts.append("└── " if is_last else "├── ")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _node_tree_label(node: dict[str, Any]) -> str:
|
||||
tag = node["tag"]
|
||||
title = label_of(node)
|
||||
name = node.get("name") or ""
|
||||
dp = node.get("data_path") or ""
|
||||
dtype = node.get("data_type") or ""
|
||||
hidden = "" if node.get("visible", True) else " [скрыт]"
|
||||
if tag in ("UsualGroup", "Pages", "Page", "Table", "ButtonGroup", "Popup", "CommandBar"):
|
||||
return f"{tag} {title}" + (f" ({name})" if name and name != title else "") + hidden
|
||||
extra = ""
|
||||
if dp:
|
||||
extra = f" → {dp}"
|
||||
if dtype:
|
||||
extra += f" · {dtype}"
|
||||
elif dtype:
|
||||
extra = f" · {dtype}"
|
||||
return f"{tag} {title}{extra}{hidden}"
|
||||
|
||||
|
||||
def format_elements_tree(form: dict[str, Any]) -> str:
|
||||
"""Текстовое дерево элементов формы."""
|
||||
lines = [f"{form.get('title') or 'Форма'}", "│"]
|
||||
|
||||
def walk(nodes: list[dict[str, Any]], ancestors_last: list[bool]) -> None:
|
||||
for i, node in enumerate(nodes):
|
||||
is_last = i == len(nodes) - 1
|
||||
lines.append(_tree_prefix(is_last, ancestors_last) + _node_tree_label(node))
|
||||
children = node.get("children") or []
|
||||
if children:
|
||||
walk(children, ancestors_last + [is_last])
|
||||
|
||||
walk(form.get("children") or [], [])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _maket_field_line(node: dict[str, Any], indent: str) -> str:
|
||||
tag = node["tag"]
|
||||
title = label_of(node)
|
||||
hidden = " (скрыто)" if not node.get("visible", True) else ""
|
||||
if tag == "CheckBoxField":
|
||||
return f"{indent}☐ {title}{hidden}"
|
||||
if tag == "RadioButtonField":
|
||||
return f"{indent}○ {title} (○ вариант1 ○ вариант2){hidden}"
|
||||
if tag == "LabelField":
|
||||
return f"{indent}{title}: —{hidden}"
|
||||
if tag == "LabelDecoration":
|
||||
return f"{indent}«{title}»{hidden}"
|
||||
if tag in ("PictureDecoration", "PictureField"):
|
||||
return f"{indent}[картинка] {title}{hidden}"
|
||||
if tag == "Button":
|
||||
return f"{indent}[{title}]{hidden}"
|
||||
if tag == "Table":
|
||||
return f"{indent}┌ таблица: {title} ┐{hidden}"
|
||||
if tag in (
|
||||
"HTMLDocumentField",
|
||||
"SpreadSheetDocumentField",
|
||||
"FormattedDocumentField",
|
||||
"TextDocumentField",
|
||||
):
|
||||
return f"{indent}┌ документ: {title} ┐{hidden}"
|
||||
if tag == "PeriodField":
|
||||
return f"{indent}{title}: [__.__.____ — __.__.____]{hidden}"
|
||||
# InputField / fallback
|
||||
return f"{indent}{title}: [____________________]{hidden}"
|
||||
|
||||
|
||||
def format_text_maket(form: dict[str, Any], *, width: int = 64) -> str:
|
||||
"""ASCII-макет формы «как в окне»."""
|
||||
title = form.get("title") or "Форма"
|
||||
inner_w = max(40, width - 2)
|
||||
top = "┌─ " + title[: inner_w - 3].ljust(inner_w - 3, "─") + "┐"
|
||||
bot = "└" + ("─" * inner_w) + "┘"
|
||||
lines = [
|
||||
top,
|
||||
"│" + " [Записать и закрыть] [Записать] [Закрыть]".ljust(inner_w)[:inner_w] + "│",
|
||||
"│" + ("─" * inner_w) + "│",
|
||||
]
|
||||
|
||||
def add_text(text: str, indent: int = 0) -> None:
|
||||
pad = " " * indent
|
||||
room = max(8, inner_w - len(pad))
|
||||
raw = pad + text
|
||||
while raw:
|
||||
if len(raw) <= inner_w:
|
||||
lines.append("│" + raw.ljust(inner_w) + "│")
|
||||
break
|
||||
cut = raw.rfind(" ", len(pad), inner_w + 1)
|
||||
if cut <= len(pad):
|
||||
cut = inner_w
|
||||
piece = raw[:cut].ljust(inner_w)
|
||||
lines.append("│" + piece + "│")
|
||||
raw = pad + raw[cut:].lstrip()
|
||||
|
||||
def walk(nodes: list[dict[str, Any]], indent: int = 1) -> None:
|
||||
for node in nodes:
|
||||
tag = node["tag"]
|
||||
if tag == "Pages":
|
||||
pages = [ch for ch in node.get("children") or [] if ch["tag"] == "Page"]
|
||||
tabs = " | ".join(label_of(p) for p in pages) or "(страницы)"
|
||||
add_text(f"[{tabs}]", indent)
|
||||
for page in pages:
|
||||
add_text(f"— {label_of(page)} —", indent)
|
||||
walk(page.get("children") or [], indent + 1)
|
||||
continue
|
||||
if tag == "Page":
|
||||
add_text(f"— {label_of(node)} —", indent)
|
||||
walk(node.get("children") or [], indent + 1)
|
||||
continue
|
||||
if tag == "UsualGroup":
|
||||
title_g = label_of(node)
|
||||
show = node.get("show_title", True) and bool(title_g)
|
||||
horiz = "horizontal" in (node.get("group") or "").lower()
|
||||
if show:
|
||||
add_text(("↔ " if horiz else "▾ ") + title_g, indent)
|
||||
walk(node.get("children") or [], indent + (1 if show else 0))
|
||||
continue
|
||||
if tag in ("ButtonGroup", "Popup", "CommandBar", "ColumnGroup"):
|
||||
walk(node.get("children") or [], indent)
|
||||
continue
|
||||
add_text(_maket_field_line(node, "").lstrip(), indent)
|
||||
if tag == "Table" and node.get("children"):
|
||||
# one-line columns hint
|
||||
cols = []
|
||||
for ch in node["children"]:
|
||||
if ch["tag"] == "ColumnGroup":
|
||||
for gch in ch.get("children") or []:
|
||||
cols.append(label_of(gch))
|
||||
else:
|
||||
cols.append(label_of(ch))
|
||||
if cols:
|
||||
add_text(" " + " | ".join(cols[:6]), indent)
|
||||
|
||||
walk(form.get("children") or [])
|
||||
lines.append(bot)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def compose_text_report(
|
||||
form: dict[str, Any],
|
||||
*,
|
||||
include_tree: bool = False,
|
||||
include_maket: bool = False,
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
if include_tree:
|
||||
parts.append("## Дерево элементов")
|
||||
parts.append(format_elements_tree(form))
|
||||
if include_maket:
|
||||
if parts:
|
||||
parts.append("")
|
||||
parts.append("## Макет (как в окне)")
|
||||
parts.append(format_text_maket(form))
|
||||
return "\n".join(parts).rstrip() + ("\n" if parts else "")
|
||||
|
||||
|
||||
def build_html(
|
||||
form: dict[str, Any],
|
||||
*,
|
||||
generator_version: str | None = None,
|
||||
elements_tree: str | None = None,
|
||||
text_maket: str | None = None,
|
||||
) -> str:
|
||||
body = "".join(render_group(ch, 0) for ch in form["children"])
|
||||
title = esc(form["title"])
|
||||
source = esc(form["source"])
|
||||
ver = esc(generator_version or __version__)
|
||||
author = esc(__author__)
|
||||
extras = ""
|
||||
if elements_tree:
|
||||
extras += (
|
||||
'<details class="text-extra" open>'
|
||||
"<summary>Дерево элементов</summary>"
|
||||
f"<pre>{esc(elements_tree)}</pre></details>\n"
|
||||
)
|
||||
if text_maket:
|
||||
extras += (
|
||||
'<details class="text-extra">'
|
||||
"<summary>Текстовый макет</summary>"
|
||||
f"<pre>{esc(text_maket)}</pre></details>\n"
|
||||
)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
@@ -733,7 +1244,26 @@ details.group .group-body {{ padding: 10px; }}
|
||||
.field input[type="text"] {{
|
||||
width: 100%; padding: 4px 6px; border: 1px solid var(--border); border-radius: 2px; font: inherit; font-size: 12px;
|
||||
}}
|
||||
.field.checkbox {{ display: flex; align-items: center; gap: 8px; }}
|
||||
.field-value {{
|
||||
display: flex; gap: 4px; align-items: stretch; width: 100%; min-width: 0;
|
||||
}}
|
||||
.field-hint, .field-demo {{
|
||||
flex: 1; min-width: 0; padding: 4px 6px; border: 1px solid var(--border); border-radius: 2px;
|
||||
font: inherit; font-size: 11px; background: #fafafa;
|
||||
}}
|
||||
.field-hint {{
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
color: #444; user-select: all; cursor: text;
|
||||
}}
|
||||
.field-hint:focus {{ outline: 2px solid var(--accent-soft); border-color: var(--accent); }}
|
||||
.btn-copy {{
|
||||
flex: 0 0 auto; padding: 2px 8px; border: 1px solid var(--border); border-radius: 2px;
|
||||
background: #f5f5f5; cursor: pointer; font: inherit; font-size: 12px; line-height: 1.4;
|
||||
}}
|
||||
.btn-copy:hover {{ background: var(--accent-soft); border-color: var(--accent); }}
|
||||
.btn-copy.copied {{ background: #e8f5e9; border-color: #81c784; }}
|
||||
.field.checkbox {{ display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }}
|
||||
.field.checkbox .field-value {{ flex: 1 1 100%; margin-left: 1.4em; }}
|
||||
.radio-row {{ display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; margin-top: 4px; }}
|
||||
.value-box {{
|
||||
border: 1px dashed var(--border); padding: 4px 6px; background: #fafafa; font-size: 12px; color: var(--muted);
|
||||
@@ -762,6 +1292,16 @@ body.show-hidden .field.is-hidden {{ display: grid !important; }}
|
||||
body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
|
||||
.highlight {{ outline: 2px solid var(--accent) !important; background: var(--accent-soft) !important; }}
|
||||
.stats {{ font-size: 11px; color: var(--muted); margin-left: auto; }}
|
||||
.text-extra {{
|
||||
margin: 12px; border: 1px solid var(--border); border-radius: 4px; background: #fafafa;
|
||||
}}
|
||||
.text-extra > summary {{
|
||||
cursor: pointer; padding: 8px 10px; font-weight: 600; font-size: 13px; background: #f3f6f9;
|
||||
}}
|
||||
.text-extra pre {{
|
||||
margin: 0; padding: 10px 12px; overflow: auto; font-size: 12px; line-height: 1.35;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; white-space: pre;
|
||||
}}
|
||||
@media (max-width: 800px) {{
|
||||
.field {{ grid-template-columns: 1fr; }}
|
||||
.group.horizontal > .group-body > * {{ min-width: 100%; }}
|
||||
@@ -779,7 +1319,7 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
|
||||
<button type="button" id="collapseAll">Свернуть группы</button>
|
||||
<span class="stats" id="stats"></span>
|
||||
</div>
|
||||
<div class="window">
|
||||
{extras}<div class="window">
|
||||
<div class="commandbar">
|
||||
<button type="button" class="btn">Записать и закрыть</button>
|
||||
<button type="button" class="btn">Записать</button>
|
||||
@@ -821,6 +1361,30 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
|
||||
root.querySelectorAll('details.group').forEach(d => d.open = false);
|
||||
}});
|
||||
|
||||
root.addEventListener('click', (e) => {{
|
||||
const btn = e.target.closest('.btn-copy');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
const text = btn.getAttribute('data-copy') || '';
|
||||
const done = () => {{
|
||||
btn.classList.add('copied');
|
||||
btn.setAttribute('title', 'Скопировано');
|
||||
setTimeout(() => {{
|
||||
btn.classList.remove('copied');
|
||||
btn.setAttribute('title', 'Копировать DataPath · тип');
|
||||
}}, 1200);
|
||||
}};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {{
|
||||
navigator.clipboard.writeText(text).then(done).catch(() => {{
|
||||
const inp = btn.previousElementSibling;
|
||||
if (inp && inp.select) {{ inp.select(); document.execCommand('copy'); done(); }}
|
||||
}});
|
||||
}} else {{
|
||||
const inp = btn.previousElementSibling;
|
||||
if (inp && inp.select) {{ inp.select(); document.execCommand('copy'); done(); }}
|
||||
}}
|
||||
}});
|
||||
|
||||
const filter = document.getElementById('filter');
|
||||
const stats = document.getElementById('stats');
|
||||
function applyFilter() {{
|
||||
@@ -868,14 +1432,22 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
|
||||
|
||||
def export_form_prototype(
|
||||
*,
|
||||
form_xml: Path,
|
||||
form_xml: Path | None = None,
|
||||
form: dict[str, Any] | None = None,
|
||||
output_html: Path,
|
||||
display_title: str | None = None,
|
||||
include_elements_tree: bool = False,
|
||||
include_text_maket: bool = False,
|
||||
) -> Path:
|
||||
form = parse_form(form_xml)
|
||||
if form is None:
|
||||
if form_xml is None:
|
||||
raise ValueError("нужен form_xml или form")
|
||||
form = parse_form(form_xml)
|
||||
if display_title:
|
||||
form["title"] = display_title
|
||||
html_text = build_html(form)
|
||||
tree = format_elements_tree(form) if include_elements_tree else None
|
||||
maket = format_text_maket(form) if include_text_maket else None
|
||||
html_text = build_html(form, elements_tree=tree, text_maket=maket)
|
||||
output_html = Path(output_html)
|
||||
output_html.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_html.write_text(html_text, encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user