"""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'{text}\n'
def block_code(self, code, info=None):
language = (info or "").strip().split(" ", 1)[0]
if language == "math":
lines = "\n".join(f'
{html.escape(line) or " "}
' for line in code.rstrip().splitlines())
return f'{lines}
\n'
label = f'{html.escape(language)}
' if language else ""
return f'{label}
{html.escape(code)}
\n'
def table(self, text):
return f'\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'{html.escape(label)}'
for level, anchor, label in renderer.headings if level in (2, 3)
)
actions = f'Markdown 文档'
if (ROOT / "week8_analysis.do").exists():
actions += 'Stata 脚本'
actions += ''
document = f'''
{html.escape(title)}
跳转至正文
{soup}
'''
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()