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\d{1,3})\.\s*(?P.+?)\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"
  • {html.escape(entry['term'])}
  • " for entry in day["entries"] ) rows.append( f"""

    Day {day['day']:02d}

    {day['count']} words

      {terms}
    """ ) document = f""" 1600 中考词汇音频播放

    1600 中考词汇音频播放

    每个单词读两遍,按 PDF 的 22 天分组。音频文件在 audio 文件夹内。

    {''.join(rows)}
    """ (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()