初始化项目版本

This commit is contained in:
Axhub Make
2026-07-29 16:04:39 +08:00
commit 4305a1082b
2629 changed files with 760590 additions and 0 deletions

View File

@@ -0,0 +1,259 @@
#!/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()