Initial commit: OneOS prototype workspace baseline.
Include weekly report and other prototype sources with project tooling config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
259
scripts/convert-v266-contract.py
Normal file
259
scripts/convert-v266-contract.py
Normal 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()
|
||||
Reference in New Issue
Block a user