305 lines
8.4 KiB
Python
305 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()
|