Files
moxiaonian 415cb472b1 v3.6.1 安全修复同步发版
- 盘面图 XSS 防护:装配层全面转义用户可控字段 + 模板严格 CSP(default-src 'none',无 script-src)+ 6 项注入向量回归测试
- 自更新签名信任锚:--apply 双源 manifest 交叉验证 → ed25519 验签(纯 Python 实现,缺 sig 即拒)→ 逐文件 SHA256,全 fail-closed
- sync 工具同步后自动重建 manifest + 签名 + 独立验签,私钥缺失 fail-closed
- 测试 170 项全绿
2026-10-10 16:21:30 +08:00

256 lines
11 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# scripts/render/html.py
"""ChartData → 单文件 HTML 盘面(模板 render/templates/paipan.html)。
与 Markdown 输出同为纯计算渲染:十神/藏干/空亡/纳音/十二长生/月令旺衰
均为固定查表,不含任何命理判断(P1)。岁运干支(大运/流年格)的十神/
星运/空亡/纳音复用 chart/ganzhi.py,神煞复用 chart/shensha.py 的岁运
求值器 evaluate_target(与 query_shensha.py 同一口径),不在渲染层
另造规则。
logo 与品牌字体(render/assets/)以 base64 内嵌,保证 HTML 单文件便携;
神煞条目的查法注释(全角/半角括号)仅在本装配层剔除,Markdown 输出保持原样。
"""
import base64
import re
from datetime import datetime
from html import escape as _escape
from pathlib import Path
from chart.ganzhi import NAYIN, WX_GAN, WX_ZHI, gz_void, ten_god, terrain
from chart.shensha import evaluate_target
_WX_CLASS = {"木": "wx-mu", "火": "wx-huo", "土": "wx-tu",
"金": "wx-jin", "水": "wx-shui"}
_SEASON = {"寅": ("木", "火", "水", "金", "土"), "卯": ("木", "火", "水", "金", "土"),
"巳": ("火", "土", "木", "水", "金"), "午": ("火", "土", "木", "水", "金"),
"申": ("金", "水", "土", "火", "木"), "酉": ("金", "水", "土", "火", "木"),
"亥": ("水", "木", "金", "土", "火"), "子": ("水", "木", "金", "土", "火"),
"辰": ("土", "金", "火", "木", "水"), "戌": ("土", "金", "火", "木", "水"),
"丑": ("土", "金", "火", "木", "水"), "未": ("土", "金", "火", "木", "水")}
_SEASON_LABEL = ("旺", "相", "休", "囚", "死")
_ASSETS_DIR = Path(__file__).resolve().parent / "assets"
_SHA_NOTE_RE = re.compile(r"[((][^))]*[))]")
# 结构性占位符:值本身是装配层渲染出的 HTML 片段或 base64 资源,
# 填充时原样注入;其余占位符均为文本字段,一律 HTML 转义防注入。
_STRUCTURAL_KEYS = frozenset({
"LOGO_B64", "FONT_B64", "QRCODE_B64",
"WARNINGS", "MAIN_TABLE", "LUCK_STRIP", "CHILDHOOD_BLOCK",
"FLOW_YEARS", "FLOW_MONTHS",
})
def _strip_sha_note(entry: str) -> str:
"""神煞条目剔除查法括号注释:`将星(以年支子查,见于月支子)` → `将星`。"""
return _SHA_NOTE_RE.sub("", entry).strip()
def _asset_b64(name: str) -> str:
return base64.b64encode((_ASSETS_DIR / name).read_bytes()).decode("ascii")
def _wx(ch: str) -> str:
wx = WX_GAN.get(ch) or WX_ZHI.get(ch)
return _WX_CLASS.get(wx, "")
def _z(zhi: str, voids: str) -> str:
cls = "voidz" if zhi in voids else ""
return f'<span class="{_wx(zhi)} {cls}">{zhi}</span>'
def _pillar_col(chart, key: str, label: str, cur: bool = False) -> dict:
"""四柱之一 → 各行的 HTML 单元格内容。"""
p = getattr(chart, key)
voids = chart.void.get("day", "") + chart.void.get("hour", "")
return {
"label": label, "cur": cur,
"god": p.gan_ten_god or ("日主" if key == "day" else ""),
"gan": p.gan, "zhi": p.zhi,
"hidden": list(zip(p.hidden_stems, p.hidden_ten_gods)),
"terrain": chart.terrain.get(key, ""),
"void": gz_void(p.gan + p.zhi),
"nayin": p.na_yin or "",
"shensha": [_strip_sha_note(s) for s in chart.shen_sha.get(key, [])],
"voids": voids,
}
def _render_table(cols: list) -> str:
"""干支列列表 → 排盘表格(主星/天干/地支/藏干/星运/空亡/纳音/神煞)。"""
def th(c):
tag = '<span class="curtag">当前</span>' if c["cur"] else ""
return f'<th class="{"cur" if c["cur"] else ""}">{c["label"]}{tag}</th>'
def row(label, fn):
tds = "".join(
f'<td class="{"cur" if c["cur"] else ""}">{fn(c)}</td>' for c in cols)
return f'<tr><td class="rowlabel">{label}</td>{tds}</tr>'
rows = [
"<tr>" + '<td class="rowlabel"></td>'
+ "".join(th(c) for c in cols) + "</tr>",
row("主星", lambda c: f'<span class="god{" day" if c["god"] == "日主" else ""}">{c["god"]}</span>'),
row("天干", lambda c: f'<span class="gz {_wx(c["gan"])}">{c["gan"]}</span>'),
row("地支", lambda c: f'<span class="gz">{_z(c["zhi"], c["voids"])}</span>'),
row("藏干", lambda c: '<div class="hidden">'
+ "<br>".join(f'<span class="hg {_wx(h)}">{h}</span> <span class="god">{g}</span>'
for h, g in c["hidden"]) + "</div>"),
row("星运", lambda c: f'<span class="small">{c["terrain"]}</span>'),
row("空亡", lambda c: f'<span class="small">{c["void"]}</span>'),
row("纳音", lambda c: f'<span class="small">{c["nayin"]}</span>'),
row("神煞", lambda c: '<div class="small">'
+ ("<br>".join(c["shensha"]) if c["shensha"] else "—") + "</div>"),
]
return '<table class="pan">' + "".join(rows) + "</table>"
def _main_table(chart) -> str:
"""排盘卡:仅年月日时四柱。"""
cols = [_pillar_col(chart, key, label, cur=(key == "day"))
for key, label in (("year", "年柱"), ("month", "月柱"),
("day", "日柱"), ("hour", "时柱"))]
return _render_table(cols)
def _target_shensha(chart, gz: str) -> list[str]:
"""岁运干支相对本命锚点所逢的神煞(与 query_shensha.py 同一求值器)。"""
pillars = {k: getattr(chart, k).gan + getattr(chart, k).zhi
for k in ("year", "month", "day", "hour")}
return [star for star, _ in evaluate_target(pillars, chart.gender, gz)["anchored"]]
def _gz_sub(chart, gz: str) -> str:
"""岁运小卡的干支附属行:十神·星运 / 空亡 / 纳音 / 神煞(无标签,值本身说话)。"""
lines = [
f"{ten_god(chart.day_master, gz[0])} · {terrain(chart.day_master, gz[1])}",
gz_void(gz),
NAYIN[gz],
]
sha = _target_shensha(chart, gz)
if sha:
lines.append(" · ".join(sha))
return "".join(f'<div class="cs">{line}</div>' for line in lines)
def _luck_strip(chart) -> str:
this_year = datetime.now().year
cells = []
for lp in chart.luck_pillars:
gz = lp.gan + lp.zhi
cur = lp.start_year <= this_year < lp.start_year + 10
cells.append(
f'<div class="cell{" cur" if cur else ""}">'
f'<div class="cy">{lp.start_year} · {lp.start_age:.0f} 岁起</div>'
f'<div class="cg"><span class="{_wx(lp.gan)}">{lp.gan}</span>'
f'<span class="{_wx(lp.zhi)}">{lp.zhi}</span></div>'
f'{_gz_sub(chart, gz)}</div>')
return '<div class="strip">' + "".join(cells) + "</div>"
def _childhood_block(chart) -> str:
if not chart.childhood_luck:
return ""
cells = "".join(
f'<div class="cell"><div class="cy">{age} 岁 · {year}</div>'
f'<div class="cg"><span class="{_wx(gz[0])}">{gz[0]}</span>'
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div></div>'
for age, year, gz in chart.childhood_luck)
return ('<div class="sect" style="margin-top:14px">小运'
'<span class="note">童限(未交大运)各虚岁</span></div>'
f'<div class="strip">{cells}</div>')
def _flow_years(chart) -> str:
this_year = datetime.now().year
cells = []
for year, gz, label in chart.flow_years:
cls = "cell"
if year == this_year:
cls += " cur"
if label.startswith("交运年"):
cls += " jiao"
cells.append(f'<div class="{cls}"><div class="cy">{year}</div>'
f'<div class="cg"><span class="{_wx(gz[0])}">{gz[0]}</span>'
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div>'
f'{_gz_sub(chart, gz)}</div>')
return '<div class="strip">' + "".join(cells) + "</div>"
def _flow_months(chart) -> str:
cells = []
for label, gz in chart.flow_months:
cells.append(f'<div class="cell"><div class="cy">{label}</div>'
f'<div class="cg"><span class="{_wx(gz[0])}">{gz[0]}</span>'
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div></div>')
return '<div class="strip">' + "".join(cells) + "</div>"
def _season_bar(chart) -> str:
seq = _SEASON.get(chart.month_ling)
if not seq:
return ""
parts = " ".join(f"{wx}{lab}" for wx, lab in zip(seq, _SEASON_LABEL))
return f"月令 {chart.month_ling}:{parts}"
def build_html(chart) -> str:
"""ChartData → 完整 HTML 文档。"""
tpl = (Path(__file__).resolve().parent
/ "templates" / "paipan.html").read_text(encoding="utf-8")
warns = []
if chart.is_late_zi:
warns.append("⚠️ 晚子时:本造按子初换日归入次日;子正换日一派日柱、时柱不同。")
if chart.jie_gap_warning:
warns.append(chart.jie_gap_warning)
warnings = "".join(f'<div class="warn">{w}</div>' for w in warns)
qiyun = ""
if chart.luck_pillars:
qiyun = (f"起运:{chart.start_age_text}"
f"({'顺行' if chart.luck_is_forward else '逆行'})")
audit = (f"时区 {chart.tz_used}(夏令时:{'是' if chart.is_dst else '否'})· "
f"经度修正 {chart.lon_correction_min:+.2f} 分 · 均时差 {chart.eot_minutes:+.2f} 分 · "
f"日界 子初换日(23:00)")
audit_full = (f"{audit}· tyme4py {chart.library_version} · "
f"schema {chart.schema_version}")
mapping = {
"TITLE": f"命盘:{chart.name}",
"LOGO_B64": _asset_b64("lingyao-logo.png"),
"FONT_B64": _asset_b64("logo-font.ttf"),
"QRCODE_B64": _asset_b64("qun-qrcode.jpg"),
"NAME": chart.name,
"GENDER_LABEL": "乾造" if chart.gender == "男" else "坤造",
"DAY_MASTER": f"{chart.day_master}{WX_GAN[chart.day_master]}",
"BIRTHPLACE": chart.birthplace,
"BIRTH_CLOCK": chart.birth_clock,
"TRUE_SOLAR": chart.true_solar_time,
"MONTH_LING": chart.month_ling,
"TAI_YUAN": chart.tai_yuan,
"MING_GONG": chart.ming_gong,
"QIYUN": qiyun,
"WARNINGS": warnings,
"MAIN_TABLE": _main_table(chart),
"LUCK_NOTE": f"{chart.start_age_text}起运,当前一步高亮",
"LUCK_STRIP": _luck_strip(chart),
"CHILDHOOD_BLOCK": _childhood_block(chart),
"FLOW_YEARS": _flow_years(chart),
"FLOW_MONTHS": _flow_months(chart),
"SEASON_BAR": _season_bar(chart),
"AUDIT": audit,
"AUDIT_FULL": audit_full,
}
out = tpl
for k, v in mapping.items():
v = str(v)
if k not in _STRUCTURAL_KEYS:
v = _escape(v, quote=True)
out = out.replace("{{" + k + "}}", v)
return out