初始化项目版本

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

256
scripts/convert-word-doc.py Normal file
View File

@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Convert textutil Word HTML export to ct-word-doc seed JS module."""
import argparse
import json
import re
import subprocess
import tempfile
from pathlib import Path
from typing import Dict, List, Optional, Set
from bs4 import BeautifulSoup, NavigableString, Tag
DEFAULT_SEMANTIC = {
"p1": "ct-word-title",
"p2": "ct-word-contract-no",
"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 DEFAULT_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
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)
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, semantic: Dict[str, str]) -> 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"]
for td in table.find_all("td"):
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}} 】"),
(r"协议编号【LNZLHT[^】]*】", "协议编号【LNZLHT {{contractCode}} 】"),
]
literal_replacements = [
("甲方(出租方):羚牛氢能科技(广东)有限公司", "甲方(出租方):{{lessorName}}"),
("甲方(出租方): 羚牛氢能科技(广东)有限公司", "甲方(出租方): {{lessorName}}"),
("甲方:羚牛氢能科技(广东)有限公司", "甲方:{{lessorName}}"),
("致:羚牛氢能科技(广东)有限公司", "致:{{lessorName}}"),
("甲方(车辆提供方):", "甲方(车辆提供方):{{lessorName}}"),
("乙方(车辆使用方):", "乙方(车辆使用方):{{customerName}}"),
("乙方(承租方):</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_html(raw: str) -> str:
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")
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, DEFAULT_SEMANTIC)
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=\"p\d+\"><br\s*/?></p>\s*", "", body_html, count=3)
return (
'<div class="ct-word-doc ct-word-doc--v266">'
f"<style>{scoped}</style>"
f"{body_html}"
"</div>"
)
def export_docx(docx: Path, out: Path, export_name: str) -> None:
with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as tmp:
tmp_path = Path(tmp.name)
subprocess.run(
["textutil", "-convert", "html", "-output", str(tmp_path), str(docx)],
check=True,
)
raw = tmp_path.read_text(encoding="utf-8")
tmp_path.unlink(missing_ok=True)
html = convert_html(raw)
escaped = json.dumps(html, ensure_ascii=False)
out.write_text(
f"// AUTO-GENERATED from {docx.name} — do not edit by hand\n"
f"export var {export_name} = {escaped};\n",
encoding="utf-8",
)
print(f"Wrote {out} ({out.stat().st_size} bytes), redlines={html.count('data-risk-redline')}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("docx", type=Path)
parser.add_argument("out", type=Path)
parser.add_argument("export_name")
args = parser.parse_args()
export_docx(args.docx, args.out, args.export_name)
if __name__ == "__main__":
main()