#!/usr/bin/env python3 """tools/anki-deck-periodic.py — Generate periodic table Anki decks (.apkg) with Anki's built-in type-the-answer input and Piper (offline, local neural TTS) audio on both sides. See tools/anki-deck-math.py's docstring for one-time setup (venv, genanki + piper-tts, downloading a voice) — same steps apply here, this is a standalone, self-contained script otherwise. Decks: prehs symbol<->name, elements 1-36 (H through Kr) hs symbol<->name plus number->symbol, all 118 elements category element category as multiple choice (A/B/C/D shown as plain text options — not a clickable UI, since that needs a desktop-only Anki add-on and would break on AnkiDroid/AnkiMobile), type the letter — only elements with a confirmed category (excludes 8 very recent superheavy elements whose category is still officially unconfirmed) Element photos on prehs/hs: every card (symbol->name, name->symbol, number->symbol alike) also shows a real photo of the element, fetched once from Wikipedia's own MediaWiki API (the same "pageimage" shown in that element's infobox — the well-documented, standard `action=query&prop=pageimages` endpoint on en.wikipedia.org, not a guessed URL) and cached locally under tools/periodic_images/ so reruns don't re-download. This is the one part of this repo's Anki tooling that needs internet access at generation time — every other deck (math, shapes/clocks/currency, TTS audio) is fully offline. Pass --no-images to skip this and get the old text-only prehs/hs cards back. Elements 100 (Fermium) through 118 (Oganesson) are hard-excluded from this — every atom of these ever made has been produced (or claimed) one at a time in a particle accelerator and never existed in macroscopic, visible quantity, so no real sample photo exists to fetch; anything Wikipedia's pageimage API returned for them would be a diagram or a scientist's portrait, not the element. A couple of element names collide with a more famous Wikipedia topic under the same plain title (Mercury the planet, for one) — TITLE_OVERRIDES below is the fix-up list; if a generated card shows an obviously wrong photo for some element, that's almost certainly another one of these collisions — add it there. Caveat: this fetch logic was written and dry-run tested without live access to Wikipedia (sandboxed here with no route to en.wikipedia.org), so it's exercised structurally (see --dry-run-tts) but not against the real API response shape or real image content. Skips are per-element and non-fatal — one bad/missing photo won't abort the rest of the deck — and every fetch attempt prints what it did, so check that output the first time you actually run this deck for real. Element data: Bowserinator/Periodic-Table-JSON (a widely used, actively maintained public dataset), fetched and spot-checked against known facts before being embedded below — not typed from memory. Usage (run with the venv from anki-deck-math.py's docstring activated; also needs `pip install pillow` for resizing fetched photos): python3 anki-deck-periodic.py --deck prehs python3 anki-deck-periodic.py --deck hs python3 anki-deck-periodic.py --deck hs --no-images python3 anki-deck-periodic.py --deck category (add --voice en_US-amy-medium etc.; --model-path if a voice isn't found automatically; --dry-run-tts to test the deck-building logic without any voice model OR network access at all, using silent placeholder audio and a placeholder image) """ import argparse import hashlib import io import genanki import json import os import random import subprocess import urllib.error import urllib.parse import urllib.request from PIL import Image parser = argparse.ArgumentParser() parser.add_argument("--deck", required=True, choices=["prehs", "hs", "category"]) parser.add_argument("--voice", default="en_US-lessac-medium") parser.add_argument("--model-path", default=None) parser.add_argument("--dry-run-tts", action="store_true") parser.add_argument("--no-images", action="store_true", help="prehs/hs only: skip fetching element photos, keep the old text-only cards") args = parser.parse_args() SCRATCH = os.path.dirname(os.path.abspath(__file__)) IMAGE_CACHE = os.path.join(SCRATCH, "periodic_images") os.makedirs(IMAGE_CACHE, exist_ok=True) # Elements with no macroscopic sample ever produced — see the docstring. NO_PHOTO_NUMBERS = set(range(100, 119)) # Element names whose plain Wikipedia article title is a different, more # famous topic — see the docstring. Add to this if a generated card shows # an obviously wrong photo for some element. TITLE_OVERRIDES = {"Hg": "Mercury (element)"} def fetch_element_image(symbol, name): """Downloads (once, cached under periodic_images/) the real sample photo Wikipedia itself shows for this element — its page's 'pageimage', the same image used in the infobox — via the standard MediaWiki query API. Returns the cached local path, or None if no image exists on the page (normal for some elements, not a bug).""" cache_path = os.path.join(IMAGE_CACHE, f"{symbol}.jpg") if os.path.isfile(cache_path): return cache_path if args.dry_run_tts: # No real network call in dry-run mode — a flat placeholder lets the # rest of the pipeline (HTML wiring, media_files list, .apkg # packaging) still be exercised end-to-end without it. Image.new("RGB", (100, 100), (190, 190, 190)).save(cache_path, "JPEG") return cache_path title = TITLE_OVERRIDES.get(symbol, name) api_url = ("https://en.wikipedia.org/w/api.php?action=query&format=json" "&prop=pageimages&piprop=original&redirects=1&titles=" + urllib.parse.quote(title)) headers = {"User-Agent": "anki-deck-periodic.py (personal Anki deck generator)"} try: req = urllib.request.Request(api_url, headers=headers) with urllib.request.urlopen(req, timeout=15) as resp: data = json.load(resp) pages = data.get("query", {}).get("pages", {}) page = next(iter(pages.values()), {}) image_url = page.get("original", {}).get("source") if not image_url: print(f" (no photo found for {name})") return None img_req = urllib.request.Request(image_url, headers=headers) with urllib.request.urlopen(img_req, timeout=15) as resp: raw = resp.read() img = Image.open(io.BytesIO(raw)).convert("RGB") img.thumbnail((300, 300)) img.save(cache_path, "JPEG", quality=85) print(f" fetched photo for {name} ({title})") return cache_path except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError) as e: print(f" (photo fetch failed for {name}: {e})") return None _CANDIDATES = [ args.model_path, f"{args.voice}.onnx", os.path.join(SCRATCH, f"{args.voice}.onnx"), os.path.expanduser(f"~/{args.voice}.onnx"), os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"), ] VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None) if VOICE_MODEL is None and not args.dry_run_tts: raise SystemExit( f"Voice model for '{args.voice}' not found. Checked:\n" + "\n".join(f" {p}" for p in _CANDIDATES if p) + f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null" + "\nThen pass its exact path with --model-path /the/real/path.onnx" + "\n(or pass --dry-run-tts to test deck-building without any voice at all)" ) def piper_tts(text: str, out_path: str) -> None: if args.dry_run_tts: with open(out_path, "wb") as f: f.write( b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00" b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00" ) return subprocess.run( ["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path], input=text.encode("utf-8"), check=True, capture_output=True, ) ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"] TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"] def num2words(n): if n < 20: return ONES[n] if n < 100: t, o = divmod(n, 10) return TENS[t] + ("-" + ONES[o] if o else "") h, rem = divmod(n, 100) return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "") assert num2words(1) == "one" assert num2words(26) == "twenty-six" assert num2words(118) == "one hundred eighteen" # ─── Element data: (atomic_number, symbol, name, category-or-None) ───────── # category is None for the 8 most recently synthesized superheavy elements # whose chemical category is still officially unconfirmed (excluded from # the category deck below, still included in prehs/hs symbol/name/number). ELEMENTS = [ (1, 'H', 'Hydrogen', 'diatomic nonmetal'), (2, 'He', 'Helium', 'noble gas'), (3, 'Li', 'Lithium', 'alkali metal'), (4, 'Be', 'Beryllium', 'alkaline earth metal'), (5, 'B', 'Boron', 'metalloid'), (6, 'C', 'Carbon', 'polyatomic nonmetal'), (7, 'N', 'Nitrogen', 'diatomic nonmetal'), (8, 'O', 'Oxygen', 'diatomic nonmetal'), (9, 'F', 'Fluorine', 'diatomic nonmetal'), (10, 'Ne', 'Neon', 'noble gas'), (11, 'Na', 'Sodium', 'alkali metal'), (12, 'Mg', 'Magnesium', 'alkaline earth metal'), (13, 'Al', 'Aluminium', 'post-transition metal'), (14, 'Si', 'Silicon', 'metalloid'), (15, 'P', 'Phosphorus', 'polyatomic nonmetal'), (16, 'S', 'Sulfur', 'polyatomic nonmetal'), (17, 'Cl', 'Chlorine', 'diatomic nonmetal'), (18, 'Ar', 'Argon', 'noble gas'), (19, 'K', 'Potassium', 'alkali metal'), (20, 'Ca', 'Calcium', 'alkaline earth metal'), (21, 'Sc', 'Scandium', 'transition metal'), (22, 'Ti', 'Titanium', 'transition metal'), (23, 'V', 'Vanadium', 'transition metal'), (24, 'Cr', 'Chromium', 'transition metal'), (25, 'Mn', 'Manganese', 'transition metal'), (26, 'Fe', 'Iron', 'transition metal'), (27, 'Co', 'Cobalt', 'transition metal'), (28, 'Ni', 'Nickel', 'transition metal'), (29, 'Cu', 'Copper', 'transition metal'), (30, 'Zn', 'Zinc', 'transition metal'), (31, 'Ga', 'Gallium', 'post-transition metal'), (32, 'Ge', 'Germanium', 'metalloid'), (33, 'As', 'Arsenic', 'metalloid'), (34, 'Se', 'Selenium', 'polyatomic nonmetal'), (35, 'Br', 'Bromine', 'diatomic nonmetal'), (36, 'Kr', 'Krypton', 'noble gas'), (37, 'Rb', 'Rubidium', 'alkali metal'), (38, 'Sr', 'Strontium', 'alkaline earth metal'), (39, 'Y', 'Yttrium', 'transition metal'), (40, 'Zr', 'Zirconium', 'transition metal'), (41, 'Nb', 'Niobium', 'transition metal'), (42, 'Mo', 'Molybdenum', 'transition metal'), (43, 'Tc', 'Technetium', 'transition metal'), (44, 'Ru', 'Ruthenium', 'transition metal'), (45, 'Rh', 'Rhodium', 'transition metal'), (46, 'Pd', 'Palladium', 'transition metal'), (47, 'Ag', 'Silver', 'transition metal'), (48, 'Cd', 'Cadmium', 'transition metal'), (49, 'In', 'Indium', 'post-transition metal'), (50, 'Sn', 'Tin', 'post-transition metal'), (51, 'Sb', 'Antimony', 'metalloid'), (52, 'Te', 'Tellurium', 'metalloid'), (53, 'I', 'Iodine', 'diatomic nonmetal'), (54, 'Xe', 'Xenon', 'noble gas'), (55, 'Cs', 'Cesium', 'alkali metal'), (56, 'Ba', 'Barium', 'alkaline earth metal'), (57, 'La', 'Lanthanum', 'lanthanide'), (58, 'Ce', 'Cerium', 'lanthanide'), (59, 'Pr', 'Praseodymium', 'lanthanide'), (60, 'Nd', 'Neodymium', 'lanthanide'), (61, 'Pm', 'Promethium', 'lanthanide'), (62, 'Sm', 'Samarium', 'lanthanide'), (63, 'Eu', 'Europium', 'lanthanide'), (64, 'Gd', 'Gadolinium', 'lanthanide'), (65, 'Tb', 'Terbium', 'lanthanide'), (66, 'Dy', 'Dysprosium', 'lanthanide'), (67, 'Ho', 'Holmium', 'lanthanide'), (68, 'Er', 'Erbium', 'lanthanide'), (69, 'Tm', 'Thulium', 'lanthanide'), (70, 'Yb', 'Ytterbium', 'lanthanide'), (71, 'Lu', 'Lutetium', 'lanthanide'), (72, 'Hf', 'Hafnium', 'transition metal'), (73, 'Ta', 'Tantalum', 'transition metal'), (74, 'W', 'Tungsten', 'transition metal'), (75, 'Re', 'Rhenium', 'transition metal'), (76, 'Os', 'Osmium', 'transition metal'), (77, 'Ir', 'Iridium', 'transition metal'), (78, 'Pt', 'Platinum', 'transition metal'), (79, 'Au', 'Gold', 'transition metal'), (80, 'Hg', 'Mercury', 'transition metal'), (81, 'Tl', 'Thallium', 'post-transition metal'), (82, 'Pb', 'Lead', 'post-transition metal'), (83, 'Bi', 'Bismuth', 'post-transition metal'), (84, 'Po', 'Polonium', 'post-transition metal'), (85, 'At', 'Astatine', 'diatomic nonmetal'), (86, 'Rn', 'Radon', 'noble gas'), (87, 'Fr', 'Francium', 'alkali metal'), (88, 'Ra', 'Radium', 'alkaline earth metal'), (89, 'Ac', 'Actinium', 'actinide'), (90, 'Th', 'Thorium', 'actinide'), (91, 'Pa', 'Protactinium', 'actinide'), (92, 'U', 'Uranium', 'actinide'), (93, 'Np', 'Neptunium', 'actinide'), (94, 'Pu', 'Plutonium', 'actinide'), (95, 'Am', 'Americium', 'actinide'), (96, 'Cm', 'Curium', 'actinide'), (97, 'Bk', 'Berkelium', 'actinide'), (98, 'Cf', 'Californium', 'actinide'), (99, 'Es', 'Einsteinium', 'actinide'), (100, 'Fm', 'Fermium', 'actinide'), (101, 'Md', 'Mendelevium', 'actinide'), (102, 'No', 'Nobelium', 'actinide'), (103, 'Lr', 'Lawrencium', 'actinide'), (104, 'Rf', 'Rutherfordium', 'transition metal'), (105, 'Db', 'Dubnium', 'transition metal'), (106, 'Sg', 'Seaborgium', 'transition metal'), (107, 'Bh', 'Bohrium', 'transition metal'), (108, 'Hs', 'Hassium', 'transition metal'), (109, 'Mt', 'Meitnerium', None), (110, 'Ds', 'Darmstadtium', None), (111, 'Rg', 'Roentgenium', None), (112, 'Cn', 'Copernicium', None), (113, 'Nh', 'Nihonium', 'post-transition metal'), (114, 'Fl', 'Flerovium', 'post-transition metal'), (115, 'Mc', 'Moscovium', None), (116, 'Lv', 'Livermorium', None), (117, 'Ts', 'Tennessine', None), (118, 'Og', 'Oganesson', None), ] assert len(ELEMENTS) == 118 assert [e[0] for e in ELEMENTS] == list(range(1, 119)) assert ELEMENTS[0] == (1, 'H', 'Hydrogen', 'diatomic nonmetal') assert ELEMENTS[25] == (26, 'Fe', 'Iron', 'transition metal') assert ELEMENTS[-1] == (118, 'Og', 'Oganesson', None) ALL_CATEGORIES = sorted({e[3] for e in ELEMENTS if e[3] is not None}) def build_model(deck_key): voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16) model_id = 1_700_000_000 + (voice_hash % 90_000_000) return model_id, genanki.Model( model_id, f"Periodic Table ({deck_key}, {args.voice})", fields=[{"name": "Prompt"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}], templates=[{ "name": "Card", "qfmt": """