135 lines
13 KiB
Python
135 lines
13 KiB
Python
"""Build the offline HTML5 edition of the Week 8 Markdown report."""
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import re
|
|
from pathlib import Path
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
import mistune
|
|
from bs4 import BeautifulSoup
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
SOURCE = ROOT / "Week8_研究报告.md"
|
|
OUTPUT = ROOT / "Week8_研究报告.html"
|
|
|
|
|
|
class ReportRenderer(mistune.HTMLRenderer):
|
|
def __init__(self):
|
|
super().__init__(escape=False)
|
|
self.headings: list[tuple[int, str, str]] = []
|
|
self.used_ids: set[str] = set()
|
|
|
|
def heading(self, text, level, **attrs):
|
|
label = BeautifulSoup(text, "html.parser").get_text(" ", strip=True)
|
|
base = re.sub(r"[^\w\u4e00-\u9fff]+", "-", label).strip("-").lower() or "section"
|
|
anchor, index = base, 2
|
|
while anchor in self.used_ids:
|
|
anchor = f"{base}-{index}"
|
|
index += 1
|
|
self.used_ids.add(anchor)
|
|
self.headings.append((level, anchor, label))
|
|
return f'<h{level} id="{html.escape(anchor, quote=True)}">{text}</h{level}>\n'
|
|
|
|
def block_code(self, code, info=None):
|
|
language = (info or "").strip().split(" ", 1)[0]
|
|
if language == "math":
|
|
lines = "\n".join(f'<div>{html.escape(line) or " "}</div>' for line in code.rstrip().splitlines())
|
|
return f'<div class="equation" role="group" aria-label="数学公式">{lines}</div>\n'
|
|
label = f'<div class="code-label">{html.escape(language)}</div>' if language else ""
|
|
return f'<div class="code-block">{label}<pre><code>{html.escape(code)}</code></pre></div>\n'
|
|
|
|
def table(self, text):
|
|
return f'<div class="table-scroll" tabindex="0"><table>{text}</table></div>\n'
|
|
|
|
|
|
CSS = r"""
|
|
:root{--ink:#183344;--text:#293e4b;--muted:#657986;--teal:#147a78;--line:#dbe5e9;--paper:#fff;--bg:#f0f4f6;--serif:"Cambria Math","STIX Two Math","Times New Roman","Noto Serif SC",serif}
|
|
*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:28px}body{margin:0;background:var(--bg);color:var(--text);font-family:"Segoe UI","Microsoft YaHei","PingFang SC",sans-serif;font-size:16px;line-height:1.85}a{color:#126e76;text-decoration-thickness:1px;text-underline-offset:3px}a:hover{color:#0c4e58}button{font:inherit}a:focus-visible,button:focus-visible,[tabindex]:focus-visible{outline:3px solid #e5a543;outline-offset:4px}.skip-link{position:absolute;left:20px;top:-80px;padding:10px;background:white;z-index:10}.skip-link:focus{top:14px}.masthead{background:#153544;border-top:5px solid #2eaaa0;color:#fff}.masthead-inner{max-width:1260px;margin:auto;padding:42px 42px 38px}.eyebrow{font-size:12px;letter-spacing:.19em;color:#a9d8d7;font-weight:600;margin-bottom:14px}.masthead h1{font-size:clamp(26px,3.2vw,39px);font-weight:650;line-height:1.45;letter-spacing:.01em;max-width:1000px;margin:0}.masthead-bottom{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:22px}.masthead-caption{color:#c5d6df;font-size:14px}.header-actions{display:flex;gap:10px;flex-wrap:wrap}.header-actions a,.header-actions button{border:1px solid #6b8c99;background:transparent;color:#edf6f8;border-radius:5px;padding:7px 14px;font-size:13px;text-decoration:none;cursor:pointer;line-height:1.5}.header-actions a:hover,.header-actions button:hover{background:#264d5d}.shell{display:grid;grid-template-columns:230px minmax(0,1fr);gap:34px;max-width:1260px;margin:34px auto 60px;padding:0 34px}.sidebar{min-width:0}.sidebar-inner{position:sticky;top:26px;border-top:2px solid #b1ccd0;padding-top:16px;max-height:calc(100vh - 52px);overflow:auto}.toc-title{font-size:12px;font-weight:700;letter-spacing:.12em;color:#59717d;padding:0 10px 10px}.toc{list-style:none;margin:0;padding:0}.toc li{margin:2px 0}.toc a{display:block;font-size:13px;line-height:1.65;text-decoration:none;padding:7px 10px;color:#4a6674;border-radius:4px;border-left:2px solid transparent}.toc a:hover{background:#e3edef;color:var(--teal);border-left-color:var(--teal)}.toc .level-3 a{padding-left:23px;font-size:12px;color:#6e838e}.sidebar-note{padding:16px 10px 0;margin-top:16px;border-top:1px solid var(--line);color:#738690;font-size:12px}.report{min-width:0;background:var(--paper);padding:36px 42px 42px;border:1px solid #e0e8ec;border-radius:8px;box-shadow:0 4px 18px #17374704}.report>p:first-child{margin-top:0}.report p{margin:15px 0}.report h2{font-size:25px;font-weight:650;line-height:1.5;color:var(--ink);margin:46px 0 18px;padding-top:8px;border-top:1px solid var(--line)}.report h2:first-child{margin-top:0}.report h3{font-size:19px;font-weight:650;line-height:1.65;color:#225466;margin:28px 0 12px}.report h4{font-size:16px;color:var(--ink);margin:22px 0 9px}.report strong{color:#173c4d;font-weight:650}.report ul,.report ol{padding-left:1.55em;margin:14px 0}.report li{padding-left:4px;margin:7px 0}.report li>p{margin:5px 0}.report blockquote{margin:22px 0;padding:13px 19px;border-left:4px solid #2e918e;background:#eef7f6;color:#355c64;border-radius:0 5px 5px 0}.report blockquote p{margin:5px 0}.report hr{border:0;border-top:1px solid var(--line);margin:32px 0}.report code{font-family:"Cascadia Code",Consolas,"Microsoft YaHei",monospace;font-size:.88em;background:#eff4f6;border-radius:3px;padding:2px 5px;overflow-wrap:anywhere}.code-block{margin:20px 0;border:1px solid #dbe5eb;border-radius:6px;overflow:hidden;background:#f5f8fa}.code-label{padding:6px 15px;background:#eaf0f4;border-bottom:1px solid #dbe5eb;font-size:11px;color:#607680;font-weight:650;text-transform:uppercase;letter-spacing:.06em}.report pre{margin:0;padding:16px 18px;line-height:1.65;overflow-x:auto;font-size:13px}.report pre code{padding:0;border:0;border-radius:0;background:transparent;font-size:inherit;overflow-wrap:normal;white-space:pre}.equation{padding:20px 24px;margin:22px 0;background:#fff;border:1px solid #cddfe2;border-left:3px solid #4e9594;border-radius:5px;font-family:var(--serif);font-size:19px;line-height:1.95;text-align:center;overflow-x:auto}.equation>div{min-width:max-content;white-space:pre}.table-scroll{overflow:auto;margin:20px 0 24px;border:1px solid #d9e4e8;border-radius:6px}.table-scroll table{border-collapse:collapse;min-width:100%;font-size:13px;line-height:1.65}.table-scroll th,.table-scroll td{padding:10px 13px;vertical-align:top;border-bottom:1px solid #e4ebee;text-align:left}.table-scroll th{font-weight:650;color:#294c5b;background:#eaf2f4;white-space:nowrap}.table-scroll td{min-width:70px}.table-scroll td code{font-size:12px}.table-scroll tbody tr:nth-child(even){background:#f8fafb}.table-scroll tbody tr:last-child td{border-bottom:0}.table-scroll tbody tr:hover{background:#edf6f5}.report img{display:block;max-width:100%;height:auto;margin:23px auto;border:1px solid #e0e8ec;border-radius:4px}.report a{overflow-wrap:anywhere}.report .footnotes{font-size:13px}.footer{max-width:1260px;margin:0 auto;padding:0 34px 28px;text-align:right;color:#7c8f99;font-size:12px}.print-only{display:none}
|
|
.equation>div{min-width:0;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
@media(min-width:1450px){.shell{gap:40px}.report{padding:40px 50px 46px}}
|
|
@media(max-width:950px){.shell{grid-template-columns:185px minmax(0,1fr);gap:20px;padding:0 22px}.report{padding:28px}.masthead-inner{padding:32px 28px}.toc a{font-size:12px}.toc .level-3{display:none}}
|
|
@media(max-width:720px){body{font-size:15px}.masthead-inner{padding:26px 21px}.masthead-bottom{align-items:flex-start;flex-direction:column;gap:13px}.masthead-caption{font-size:12px}.shell{display:block;margin:20px auto 30px;padding:0 14px}.sidebar{margin:0 2px 20px}.sidebar-inner{position:static;max-height:none;padding-top:12px}.toc-title{padding:0 3px 7px}.toc{display:flex;flex-wrap:wrap;gap:4px}.toc li{margin:0}.toc a{font-size:12px;background:#e5edef;padding:6px 10px}.toc .level-3,.sidebar-note{display:none}.report{padding:24px 19px;border-radius:6px}.report h2{font-size:22px;margin-top:35px}.report h3{font-size:18px}.equation{font-size:17px;padding:14px 16px}.table-scroll th,.table-scroll td{padding:9px 11px}.footer{padding:0 20px 22px;text-align:left}}
|
|
@media print{@page{size:A4;margin:17mm 16mm}html{scroll-behavior:auto}body{background:white;color:#111;font-size:10.5pt;line-height:1.65}.masthead{background:white!important;color:#122b38;border-top:3px solid #468d88}.masthead-inner{padding:14px 0 19px}.eyebrow{font-size:8pt;color:#486b72;margin-bottom:8px}.masthead h1{font-size:23pt}.masthead-bottom{margin-top:12px}.masthead-caption{font-size:9pt;color:#526b76}.header-actions,.sidebar,.skip-link,.footer{display:none!important}.shell{display:block;margin:0;padding:0;max-width:none}.report{padding:0;border:0;box-shadow:none;border-radius:0}.report h2{font-size:16pt;break-after:avoid;margin-top:24px}.report h3{font-size:12pt;break-after:avoid}.report p,.report li{orphans:3;widows:3}.report a{color:inherit;text-decoration:none}.report pre{white-space:pre-wrap;font-size:8pt;overflow:visible}.report pre code{white-space:pre-wrap;overflow-wrap:anywhere}.code-block{break-inside:avoid}.equation{font-size:12pt;break-inside:avoid;padding:10px 14px}.equation>div{white-space:pre-wrap;min-width:0}.table-scroll{overflow:visible;border-radius:0}.table-scroll table{font-size:8pt;width:100%;table-layout:auto}.table-scroll th,.table-scroll td{padding:6px 7px;min-width:0}.table-scroll th{white-space:normal}thead{display:table-header-group}tr{break-inside:avoid}.report img{max-height:220mm;object-fit:contain;break-inside:avoid}.print-only{display:block}}
|
|
"""
|
|
|
|
|
|
def main():
|
|
if not SOURCE.exists():
|
|
raise SystemExit(f"Markdown report does not exist yet: {SOURCE}")
|
|
renderer = ReportRenderer()
|
|
markdown = mistune.create_markdown(renderer=renderer, plugins=["table", "strikethrough", "footnotes"])
|
|
content = markdown(SOURCE.read_text(encoding="utf-8-sig"))
|
|
soup = BeautifulSoup(content, "html.parser")
|
|
first_heading = soup.find("h1")
|
|
title = first_heading.get_text(" ", strip=True) if first_heading else "第8周计量经济学研究报告"
|
|
if first_heading:
|
|
first_heading.decompose()
|
|
toc = "\n".join(
|
|
f'<li class="level-{level}"><a href="#{html.escape(anchor, quote=True)}">{html.escape(label)}</a></li>'
|
|
for level, anchor, label in renderer.headings if level in (2, 3)
|
|
)
|
|
actions = f'<a href="{html.escape(SOURCE.name, quote=True)}">Markdown 文档</a>'
|
|
if (ROOT / "week8_analysis.do").exists():
|
|
actions += '<a href="week8_analysis.do">Stata 脚本</a>'
|
|
actions += '<button type="button" onclick="window.print()">打印报告</button>'
|
|
document = f'''<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="color-scheme" content="light">
|
|
<meta name="description" content="第8周计量经济学研究报告:回归自由度与财务、治理数据合并。">
|
|
<title>{html.escape(title)}</title>
|
|
<style>{CSS}</style>
|
|
</head>
|
|
<body>
|
|
<a class="skip-link" href="#report">跳转至正文</a>
|
|
<header class="masthead">
|
|
<div class="masthead-inner">
|
|
<div class="eyebrow">ECONOMETRICS / WEEK 08</div>
|
|
<h1>{html.escape(title)}</h1>
|
|
<div class="masthead-bottom">
|
|
<div class="masthead-caption">计量经济学课程研究报告</div>
|
|
<div class="header-actions">{actions}</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<div class="shell">
|
|
<aside class="sidebar" aria-label="报告目录">
|
|
<div class="sidebar-inner">
|
|
<div class="toc-title">内容导航</div>
|
|
<nav aria-label="章节"><ol class="toc">{toc}</ol></nav>
|
|
<div class="sidebar-note">正文、公式和样式均可离线阅读。关联数据与脚本保留在同一工作目录。</div>
|
|
</div>
|
|
</aside>
|
|
<main class="report" id="report">{soup}</main>
|
|
</div>
|
|
<footer class="footer">第8周 · 计量经济学</footer>
|
|
</body>
|
|
</html>
|
|
'''
|
|
OUTPUT.write_text(document, encoding="utf-8")
|
|
rendered = BeautifulSoup(document, "html.parser")
|
|
ids = [tag["id"] for tag in rendered.find_all(id=True)]
|
|
assert len(ids) == len(set(ids)), "Duplicate HTML anchors"
|
|
for link in rendered.select("nav a[href^='#']"):
|
|
assert link["href"][1:] in ids, f"Broken navigation: {link['href']}"
|
|
local_targets = []
|
|
for element in rendered.select("[href], [src]"):
|
|
target = element.get("href") or element.get("src")
|
|
if target.startswith("#") or urlsplit(target).scheme:
|
|
continue
|
|
local_targets.append(target)
|
|
assert (ROOT / unquote(urlsplit(target).path)).exists(), f"Missing local reference: {target}"
|
|
assert rendered.html.get("lang") == "zh-CN"
|
|
assert len(rendered.select(".equation")) == len(re.findall(r"^```math\s*$", SOURCE.read_text(encoding="utf-8-sig"), re.M))
|
|
print(f"Created: {OUTPUT.name}")
|
|
print(f"Sections: {len(renderer.headings)}; tables: {len(rendered.find_all('table'))}; equations: {len(rendered.select('.equation'))}")
|
|
print(f"Verified local references: {len(local_targets)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|