first commit
This commit is contained in:
commit
9399a88d39
347 files changed
+167716
No files matched your search
Binary file not shown.
@@ -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()
|
||||
Reference in new issue
Block a user