first commit

This commit is contained in:
admin committed 2026-11-05 15:16:24 +08:00
commit 9399a88d39
347 files changed
+167716

No files matched your search

@@ -0,0 +1,305 @@
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()
@@ -0,0 +1,53 @@
param(
[string]$DataJson = "vocab_audio_output\vocab_days.json",
[string]$OutDir = "vocab_audio_output\audio",
[string]$Voice = "Microsoft Zira Desktop",
[int]$Rate = -2,
[int]$Volume = 100
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Speech
$data = Get-Content -LiteralPath $DataJson -Encoding UTF8 -Raw | ConvertFrom-Json
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
$synth.SelectVoice($Voice)
$synth.Rate = $Rate
$synth.Volume = $Volume
$format = New-Object System.Speech.AudioFormat.SpeechAudioFormatInfo(
16000,
[System.Speech.AudioFormat.AudioBitsPerSample]::Sixteen,
[System.Speech.AudioFormat.AudioChannel]::Mono
)
$breakShort = [TimeSpan]::FromMilliseconds(320)
$breakLong = [TimeSpan]::FromMilliseconds(640)
foreach ($day in $data.days) {
$dayNumber = [int]$day.day
$path = Join-Path $OutDir ("Day{0:D2}.wav" -f $dayNumber)
$builder = New-Object System.Speech.Synthesis.PromptBuilder(
[System.Globalization.CultureInfo]::GetCultureInfo("en-US")
)
foreach ($entry in $day.entries) {
$term = ([string]$entry.spoken).Trim()
if ([string]::IsNullOrWhiteSpace($term)) {
continue
}
$builder.AppendText($term)
$builder.AppendBreak($breakShort)
$builder.AppendText($term)
$builder.AppendBreak($breakLong)
}
$synth.SetOutputToWaveFile($path, $format)
$synth.Speak($builder)
$synth.SetOutputToNull()
Write-Host ("Day {0:D2}: wrote {1}" -f $dayNumber, $path)
}
$synth.Dispose()