306 lines
8.4 KiB
Python
306 lines
8.4 KiB
Python
import argparse
|
||
import csv
|
||
import html
|
||
import json
|
||
import re
|
||
from pathlib import Path
|
||
|
||
import pdfplumber
|
||
|
||
|
||
IPA_CHARS = "ˈˌəɪʊɔθðʃʒæɑɒɜŋʌɡ"
|
||
POS_RE = r"(?:n|v|vt|vi|adj|adv|pron|prep|conj|art|num|modal|interj)\.?"
|
||
ENTRY_RE = re.compile(r"^\s*(?P<num>\d{1,3})\.\s*(?P<body>.+?)\s*$")
|
||
DAY_LABELS = [
|
||
"first",
|
||
"second",
|
||
"third",
|
||
"fourth",
|
||
"fifth",
|
||
"sixth",
|
||
"seventh",
|
||
"eighth",
|
||
"ninth",
|
||
"tenth",
|
||
"eleventh",
|
||
"twelfth",
|
||
"thirteenth",
|
||
"fourteenth",
|
||
"fifteenth",
|
||
"sixteenth",
|
||
"seventeenth",
|
||
"eighteenth",
|
||
"nineteenth",
|
||
"twentieth",
|
||
"twenty-first",
|
||
"twenty-second",
|
||
]
|
||
|
||
|
||
def tail_starts_meaning(tail: str) -> bool:
|
||
tail = tail.lstrip()
|
||
return bool(re.match(rf"(?:\(|()?{POS_RE}", tail, re.I) or re.match(r"[\u4e00-\u9fff]", tail))
|
||
|
||
|
||
def find_phonetic_start(body: str) -> int | None:
|
||
for index, char in enumerate(body):
|
||
if char != "/":
|
||
continue
|
||
end = body.find("/", index + 1)
|
||
if end == -1:
|
||
continue
|
||
segment = body[index + 1 : end].strip()
|
||
tail = body[end + 1 :]
|
||
if any(ch in segment for ch in IPA_CHARS) or ":" in segment or tail_starts_meaning(tail):
|
||
return index
|
||
return None
|
||
|
||
|
||
def extract_term(body: str) -> str:
|
||
body = " ".join(body.split())
|
||
phonetic_start = find_phonetic_start(body)
|
||
if phonetic_start is not None:
|
||
term = body[:phonetic_start]
|
||
else:
|
||
term = re.split(
|
||
r"\s+(?:n|v|vt|vi|adj|adv|pron|prep|conj|art|num|modal|interj)\.|[\u4e00-\u9fff]",
|
||
body,
|
||
maxsplit=1,
|
||
)[0]
|
||
|
||
term = term.strip(" /")
|
||
term = re.sub(r"\s+", " ", term)
|
||
return term
|
||
|
||
|
||
def spoken_term(term: str) -> str:
|
||
spoken = term
|
||
spoken = spoken.replace("(", "(").replace(")", ")")
|
||
spoken = re.sub(r"\(\s*(?:pl\.|复|单数)[^)]+\)", "", spoken, flags=re.I)
|
||
spoken = re.sub(r"\(\s*(?:AmE|美)\s*([^)]+)\)", r", \1", spoken, flags=re.I)
|
||
spoken = re.sub(r"\(=([^)]+)\)", r", \1", spoken)
|
||
spoken = re.sub(r"\(([A-Za-z][A-Za-z .'-]*)\)", r", \1", spoken)
|
||
spoken = spoken.replace("=", ", ")
|
||
spoken = spoken.replace("/", ", ")
|
||
spoken = re.sub(r"\bTV\b", "T V", spoken)
|
||
spoken = re.sub(r"\bAI\b", "A I", spoken)
|
||
spoken = re.sub(r"\bUK\b", "U K", spoken)
|
||
spoken = re.sub(r"\bUSA\b", "U S A", spoken)
|
||
spoken = re.sub(r"\s+", " ", spoken)
|
||
return spoken.strip(" ,")
|
||
|
||
|
||
def extract_page_entries(page, day_number: int):
|
||
entries = []
|
||
# The source PDF is two-column. Reading each column separately keeps the
|
||
# spoken order close to the visual order on the page.
|
||
columns = [
|
||
(0, 0, page.width * 0.505, page.height),
|
||
(page.width * 0.505, 0, page.width, page.height),
|
||
]
|
||
for column_index, bbox in enumerate(columns, start=1):
|
||
text = page.crop(bbox).extract_text(x_tolerance=1, y_tolerance=3) or ""
|
||
for line in text.splitlines():
|
||
match = ENTRY_RE.match(line)
|
||
if not match:
|
||
continue
|
||
|
||
term = extract_term(match.group("body"))
|
||
if not term or any(ch.isdigit() for ch in term[:2]):
|
||
continue
|
||
|
||
entries.append(
|
||
{
|
||
"day": day_number,
|
||
"column": column_index,
|
||
"source_number": int(match.group("num")),
|
||
"term": term,
|
||
"spoken": spoken_term(term),
|
||
}
|
||
)
|
||
for index, entry in enumerate(entries, start=1):
|
||
entry["position"] = index
|
||
return entries
|
||
|
||
|
||
def write_player(out_dir: Path, days: list[dict]) -> None:
|
||
audio_dir = "audio"
|
||
rows = []
|
||
for day in days:
|
||
terms = "\n".join(
|
||
f"<li><span>{html.escape(entry['term'])}</span></li>" for entry in day["entries"]
|
||
)
|
||
rows.append(
|
||
f"""
|
||
<section class="day">
|
||
<div class="day-head">
|
||
<div>
|
||
<h2>Day {day['day']:02d}</h2>
|
||
<p>{day['count']} words</p>
|
||
</div>
|
||
<audio controls preload="metadata" src="{audio_dir}/Day{day['day']:02d}.wav"></audio>
|
||
</div>
|
||
<ol>{terms}</ol>
|
||
</section>
|
||
"""
|
||
)
|
||
|
||
document = f"""<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>1600 中考词汇音频播放</title>
|
||
<style>
|
||
:root {{
|
||
color-scheme: light;
|
||
font-family: "Microsoft YaHei", Arial, sans-serif;
|
||
background: #f6f7f9;
|
||
color: #1e2329;
|
||
}}
|
||
body {{
|
||
margin: 0;
|
||
padding: 28px;
|
||
}}
|
||
main {{
|
||
max-width: 1120px;
|
||
margin: 0 auto;
|
||
}}
|
||
h1 {{
|
||
margin: 0 0 8px;
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
}}
|
||
.meta {{
|
||
margin: 0 0 24px;
|
||
color: #5a6472;
|
||
line-height: 1.7;
|
||
}}
|
||
.grid {{
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||
gap: 16px;
|
||
}}
|
||
.day {{
|
||
background: #ffffff;
|
||
border: 1px solid #d9dee7;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
}}
|
||
.day-head {{
|
||
display: grid;
|
||
grid-template-columns: 120px minmax(0, 1fr);
|
||
gap: 12px;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}}
|
||
h2 {{
|
||
margin: 0;
|
||
font-size: 18px;
|
||
}}
|
||
.day-head p {{
|
||
margin: 4px 0 0;
|
||
color: #6b7280;
|
||
font-size: 13px;
|
||
}}
|
||
audio {{
|
||
width: 100%;
|
||
}}
|
||
ol {{
|
||
columns: 2;
|
||
column-gap: 28px;
|
||
margin: 0;
|
||
padding-left: 24px;
|
||
line-height: 1.75;
|
||
font-family: Georgia, "Times New Roman", serif;
|
||
}}
|
||
li {{
|
||
break-inside: avoid;
|
||
padding-right: 6px;
|
||
}}
|
||
@media (max-width: 620px) {{
|
||
body {{
|
||
padding: 16px;
|
||
}}
|
||
.day-head {{
|
||
grid-template-columns: 1fr;
|
||
}}
|
||
ol {{
|
||
columns: 1;
|
||
}}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<h1>1600 中考词汇音频播放</h1>
|
||
<p class="meta">每个单词读两遍,按 PDF 的 22 天分组。音频文件在 <code>audio</code> 文件夹内。</p>
|
||
<div class="grid">
|
||
{''.join(rows)}
|
||
</div>
|
||
</main>
|
||
</body>
|
||
</html>
|
||
"""
|
||
(out_dir / "index.html").write_text(document, encoding="utf-8")
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--pdf", required=True, help="Source PDF path")
|
||
parser.add_argument("--out", default="vocab_audio_output", help="Output directory")
|
||
parser.add_argument("--days", type=int, default=22, help="Number of day pages to extract")
|
||
args = parser.parse_args()
|
||
|
||
pdf_path = Path(args.pdf)
|
||
out_dir = Path(args.out)
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
days = []
|
||
with pdfplumber.open(str(pdf_path)) as pdf:
|
||
for index in range(args.days):
|
||
entries = extract_page_entries(pdf.pages[index], index + 1)
|
||
days.append(
|
||
{
|
||
"day": index + 1,
|
||
"label": DAY_LABELS[index] if index < len(DAY_LABELS) else str(index + 1),
|
||
"count": len(entries),
|
||
"entries": entries,
|
||
}
|
||
)
|
||
|
||
data = {"source_pdf": str(pdf_path), "days": days}
|
||
(out_dir / "vocab_days.json").write_text(
|
||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
|
||
with (out_dir / "vocab_terms.csv").open("w", encoding="utf-8-sig", newline="") as file:
|
||
writer = csv.DictWriter(
|
||
file, fieldnames=["day", "position", "source_number", "term", "spoken"]
|
||
)
|
||
writer.writeheader()
|
||
for day in days:
|
||
for entry in day["entries"]:
|
||
writer.writerow(
|
||
{
|
||
"day": entry["day"],
|
||
"position": entry["position"],
|
||
"source_number": entry["source_number"],
|
||
"term": entry["term"],
|
||
"spoken": entry["spoken"],
|
||
}
|
||
)
|
||
|
||
summary = "\n".join(
|
||
f"Day {day['day']:02d}: {day['count']} words" for day in days
|
||
)
|
||
(out_dir / "summary.txt").write_text(summary + "\n", encoding="utf-8")
|
||
write_player(out_dir, days)
|
||
print(summary)
|
||
print(f"Total: {sum(day['count'] for day in days)} words")
|
||
print(f"Wrote: {out_dir.resolve()}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|