Files
OneOS-V2/scripts/convert-v266-contract.py
2026-07-29 16:04:39 +08:00

260 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Convert textutil Word HTML export to ct-word-doc seed for ContractTemplate."""
import json
import re
from pathlib import Path
from typing import Dict, List, Optional, Set
from bs4 import BeautifulSoup, NavigableString, Tag
SRC = Path("/tmp/v266-contract.html")
OUT = Path(__file__).resolve().parent.parent / "src/prototypes/contract-template-management/v266-lease-document.js"
# Semantic ct-word classes for key blocks (layout still driven by embedded CSS)
SEMANTIC = {
"p2": "ct-word-title",
"p3": "ct-word-contract-no",
"p5": "ct-word-party-line",
"p6": "ct-word-party-line",
"p7": "ct-word-party-line",
"p8": "ct-word-party-line",
}
def parse_css_rules(css_text: str) -> Dict[str, str]:
rules: Dict[str, str] = {}
for block in re.findall(r"([^{]+)\{([^}]+)\}", css_text):
selector = block[0].strip()
props = block[1].strip()
if "," in selector:
continue
selector = selector.lstrip(".")
if re.match(r"^(p|span|td|table)\.", selector):
rules[selector] = props
return rules
def is_red_style(style: str) -> bool:
return bool(re.search(r"color\s*:\s*#ff0000", style, re.I))
def build_red_classes(rules: Dict[str, str]) -> Set[str]:
red = set()
for cls, props in rules.items():
if is_red_style(props):
red.add(cls.split(".")[-1])
return red
def class_list(tag: Tag) -> List[str]:
raw = tag.get("class") or []
if isinstance(raw, str):
return raw.split()
return list(raw)
def primary_class(tag: Tag) -> Optional[str]:
classes = class_list(tag)
for c in classes:
if c in SEMANTIC or re.match(r"^[pst]\d+$", c) or re.match(r"^td\d+$", c):
return c
return classes[0] if classes else None
def merge_style(tag: Tag, rules: Dict[str, str]) -> None:
parts: List[str] = []
for c in class_list(tag):
key_p = f"p.{c}" if c.startswith("p") else None
key_s = f"span.{c}" if c.startswith("s") else None
key_td = f"td.{c}" if c.startswith("td") else None
key_t = f"table.{c}" if c.startswith("t") else None
for key in (key_p, key_s, key_td, key_t):
if key and key in rules:
parts.append(rules[key])
if parts:
existing = tag.get("style", "")
merged = ";".join([existing] + parts) if existing else ";".join(parts)
tag["style"] = merged
def strip_apple_noise(soup: BeautifulSoup) -> None:
for el in soup.find_all(class_="Apple-converted-space"):
el.replace_with("\u00a0" * max(1, len(el.get_text())))
for el in soup.find_all(class_="Apple-tab-span"):
el.replace_with("\t")
def wrap_risk_redlines(soup: BeautifulSoup, red_classes: Set[str]) -> None:
risk_seq = 0
def next_id() -> str:
nonlocal risk_seq
risk_seq += 1
return f"risk-{risk_seq}"
def is_red_tag(tag: Tag) -> bool:
pc = primary_class(tag)
return pc in red_classes if pc else False
# Block-level red paragraphs: wrap inner content once
for p in list(soup.find_all("p")):
if not is_red_tag(p):
continue
if p.find_parent(class_=lambda x: x and "ct-risk-redline" in x):
continue
inner = list(p.contents)
if not inner:
continue
wrapper = soup.new_tag(
"span",
attrs={
"class": "ct-risk-redline",
"data-risk-redline": "1",
"data-risk-id": next_id(),
},
)
for child in inner:
wrapper.append(child.extract() if isinstance(child, Tag) else child)
p.clear()
p.append(wrapper)
# Inline red spans inside non-red paragraphs
for span in list(soup.find_all("span")):
if not is_red_tag(span):
continue
if span.find_parent(class_=lambda x: x and "ct-risk-redline" in x):
continue
parent_p = span.find_parent("p")
if parent_p and is_red_tag(parent_p):
continue
wrapper = soup.new_tag(
"span",
attrs={
"class": "ct-risk-redline",
"data-risk-redline": "1",
"data-risk-id": next_id(),
},
)
span.wrap(wrapper)
def apply_semantic_classes(root: Tag) -> None:
for tag in root.find_all(True):
pc = primary_class(tag)
if pc and pc in SEMANTIC:
classes = class_list(tag)
extra = SEMANTIC[pc]
if extra not in classes:
tag["class"] = classes + [extra]
def map_tables(root: Tag) -> None:
for i, table in enumerate(root.find_all("table")):
classes = class_list(table)
if "ct-word-table" not in classes:
table["class"] = classes + ["ct-doc-table", "ct-word-table"]
if i == 0:
for td in table.find_all("td"):
tdc = primary_class(td)
tr = td.find_parent("tr")
row_idx = len(list(tr.find_previous_siblings("tr"))) if tr else 0
if row_idx == 0:
if tdc == "td1":
td["class"] = class_list(td) + ["ct-word-party-left"]
elif tdc == "td2":
td["class"] = class_list(td) + ["ct-word-party-right"]
else:
td["class"] = class_list(td) + ["ct-word-td"]
def apply_template_vars(html: str) -> str:
regex_replacements = [
(r"合同编号<span[^>]*></span>【LNZLHT\s*<span[^>]*>[\s\S]*?</span>\s*】",
"合同编号【LNZLHT {{contractCode}} 】"),
(r"合同编号【LNZLHT[^】]*】", "合同编号【LNZLHT {{contractCode}} 】"),
]
literal_replacements = [
("甲方(出租方):羚牛氢能科技(广东)有限公司", "甲方(出租方):{{lessorName}}"),
("甲方(出租方): 羚牛氢能科技(广东)有限公司", "甲方(出租方): {{lessorName}}"),
("甲方(出租方): 羚牛氢能科技(广东)有限公司", "甲方(出租方): {{lessorName}}"),
("甲方:羚牛氢能科技(广东)有限公司", "甲方:{{lessorName}}"),
("致:羚牛氢能科技(广东)有限公司", "致:{{lessorName}}"),
("户 名:【羚牛氢能科技(广东)有限公司", "户 名:【{{lessorAccountName}}"),
("开户行:【招商银行广州萝岗支行 】", "开户行:【{{lessorBankName}} 】"),
("账 号【120924165110201 】", "账 号:【{{lessorBankAccount}} 】"),
("乙方</b><span class=\"s3\"><b>(承租方):</b></span>",
"乙方</b><span class=\"s3\"><b>(承租方):{{customerName}}</b></span>"),
("乙方(承租方):</b>", "乙方(承租方): {{customerName}}</b>"),
("乙方(承租方):</b>", "乙方(承租方):{{customerName}}</b>"),
]
out = html
for pattern, repl in regex_replacements:
out = re.sub(pattern, repl, out)
for old, new in literal_replacements:
out = out.replace(old, new)
return out
def scope_css(css_text: str) -> str:
scoped = []
for block in re.findall(r"([^{]+)\{([^}]+)\}", css_text):
selector = block[0].strip()
props = block[1].strip()
if selector.startswith("@") or "," in selector:
continue
scoped.append(f".ct-word-doc--v266 {selector}{{{props}}}")
return "\n".join(scoped)
def convert() -> str:
raw = SRC.read_text(encoding="utf-8")
css_match = re.search(r"<style[^>]*>([\s\S]*?)</style>", raw)
body_match = re.search(r"<body>([\s\S]*?)</body>", raw)
if not css_match or not body_match:
raise SystemExit("Invalid source HTML")
rules = parse_css_rules(css_match.group(1))
red_classes = build_red_classes(rules)
scoped = scope_css(css_match.group(1))
soup = BeautifulSoup(body_match.group(1), "lxml")
# lxml adds html/body wrapper
root = soup.body or soup
strip_apple_noise(soup)
for tag in soup.find_all(True):
merge_style(tag, rules)
wrap_risk_redlines(soup, red_classes)
apply_semantic_classes(soup)
map_tables(soup)
body_html = "".join(str(c) for c in (soup.body or soup).contents)
body_html = apply_template_vars(body_html)
body_html = re.sub(r"<p class=\"p1\"><br\s*/?></p>\s*", "", body_html, count=1)
doc = (
'<div class="ct-word-doc ct-word-doc--v266">'
f"<style>{scoped}</style>"
f"{body_html}"
"</div>"
)
return doc
def main() -> None:
html = convert()
escaped = json.dumps(html, ensure_ascii=False)
OUT.write_text(
"// AUTO-GENERATED V26.6 Word HTML — do not edit by hand\n"
f"export var V266_LEASE_DOCUMENT_HTML = {escaped};\n",
encoding="utf-8",
)
print(f"Wrote {OUT} ({OUT.stat().st_size} bytes)")
print(f"Redline markers: {html.count('data-risk-redline')}")
if __name__ == "__main__":
main()