diff --git a/.gitignore b/.gitignore
index 23188d7..c7a82ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,10 @@
# Vanilla Tweaks ZIP files placed for automatic install — never commit these
extras/datapacks/*.zip
+
+# tools/anki-deck-*.py output and caches — generated locally, regenerable,
+# never meant to be committed (periodic_images/ especially: fetched
+# element photos, not source)
+tools/*.apkg
+tools/media_*/
+tools/periodic_images/
+tools/__pycache__/
diff --git a/README.md b/README.md
index 8ee56b1..893ad6d 100644
--- a/README.md
+++ b/README.md
@@ -300,8 +300,12 @@ account has no content — `tools/anki-deck-math.py`,
`tools/anki-deck-periodic.py`, and `tools/anki-deck-visual.py` generate
ready-to-import `.apkg` decks (multiplication/division/addition/
subtraction/fractions/decimals, the periodic table, and shapes/clocks/
-coin-counting) with Anki's built-in type-the-answer input and offline
-neural TTS audio (Piper) on every card. All three are standalone Python
+coin-counting) with offline neural TTS audio (Piper) on every card. Most
+cards use Anki's built-in type-the-answer input; a few (periodic table
+categories, `--deck shapes_mc`) are multiple choice instead, shown as
+plain-text A/B/C/D options — type the letter rather than tapping, since a
+clickable UI needs a desktop-only Anki add-on and would break on
+AnkiDroid/AnkiMobile. All three are standalone Python
scripts, unrelated to the `services/*.sh` installer framework — run them
on any machine with Python, not necessarily the server itself. Full setup
(a venv, `genanki` + `piper-tts`, downloading a voice) and every deck's
@@ -317,6 +321,15 @@ because AnkiDroid has long-standing, still-open bugs rendering SVG
`` tags on mobile; see `tools/anki-deck-visual.py`'s own docstring
for more on both tradeoffs.
+The periodic table `prehs`/`hs` decks also show each element's real
+sample photo (fetched once from Wikipedia's own MediaWiki API and cached
+under `tools/periodic_images/`) alongside every card — the one part of
+this tooling that needs internet access at generation time; pass
+`--no-images` for the old text-only cards. Elements 100 (Fermium) through
+118 (Oganesson) are skipped, since none has ever existed in a
+photographable quantity. See `tools/anki-deck-periodic.py`'s own
+docstring for the details and its caveats.
+
## Layout
```
diff --git a/tools/anki-deck-periodic.py b/tools/anki-deck-periodic.py
index 784c6cb..25ccdae 100644
--- a/tools/anki-deck-periodic.py
+++ b/tools/anki-deck-periodic.py
@@ -16,33 +16,130 @@ Decks:
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):
+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 at all, using silent placeholder audio)
+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,
@@ -257,6 +354,7 @@ def build_model(deck_key):
css="""
.card { font-family: Arial, sans-serif; font-size: 26px; text-align: center; }
.prompt { font-size: 40px; margin: 20px auto; white-space: pre-line; }
+.elem-img { max-width: 220px; max-height: 220px; display: block; margin: 0 auto 10px; }
""",
)
@@ -267,6 +365,20 @@ def build_deck(deck_key, deck_title):
return genanki.Deck(deck_id, deck_title)
+def element_image_html(number, symbol, name, media_files):
+ """Returns an
tag for this element's cached/fetched photo, or ""
+ if --no-images was passed, the element is in NO_PHOTO_NUMBERS, or no
+ photo could be found/downloaded for it."""
+ if args.no_images or number in NO_PHOTO_NUMBERS:
+ return ""
+ local_path = fetch_element_image(symbol, name)
+ if local_path is None:
+ return ""
+ if local_path not in media_files:
+ media_files.append(local_path)
+ return f'
'
+
+
def add_note(deck, model, prompt, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
@@ -294,12 +406,13 @@ def gen_prehs():
random.seed(50)
random.shuffle(cards)
for kind, number, symbol, name in cards:
+ img = element_image_html(number, symbol, name, media_files)
if kind == "symbol_to_name":
- add_note(deck, model, symbol, name,
+ add_note(deck, model, img + symbol, name,
f"What element has the symbol {symbol}?", name,
media_files, f"prehs_s2n_{number}")
else:
- add_note(deck, model, name, symbol,
+ add_note(deck, model, img + name, symbol,
f"What is the symbol for {name}?", symbol,
media_files, f"prehs_n2s_{number}")
return deck, media_files, len(cards)
@@ -318,16 +431,17 @@ def gen_hs():
random.seed(51)
random.shuffle(cards)
for kind, number, symbol, name in cards:
+ img = element_image_html(number, symbol, name, media_files)
if kind == "symbol_to_name":
- add_note(deck, model, symbol, name,
+ add_note(deck, model, img + symbol, name,
f"What element has the symbol {symbol}?", name,
media_files, f"hs_s2n_{number}")
elif kind == "name_to_symbol":
- add_note(deck, model, name, symbol,
+ add_note(deck, model, img + name, symbol,
f"What is the symbol for {name}?", symbol,
media_files, f"hs_n2s_{number}")
else:
- add_note(deck, model, f"Element #{number}", symbol,
+ add_note(deck, model, img + f"Element #{number}", symbol,
f"What is the symbol for element number {num2words(number)}?", symbol,
media_files, f"hs_num2s_{number}")
return deck, media_files, len(cards)
@@ -382,4 +496,4 @@ out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
-print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} audio clips)")
+print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} media files)")
diff --git a/tools/anki-deck-visual.py b/tools/anki-deck-visual.py
index 1226386..948e5ea 100644
--- a/tools/anki-deck-visual.py
+++ b/tools/anki-deck-visual.py
@@ -28,6 +28,12 @@ Decks:
plus 5 quadrilateral types (image->name: square, rectangle,
rhombus, trapezoid, parallelogram — each one's geometry is
genuinely distinct, not just differently labeled)
+ shapes_mc same shape images as "shapes", but as multiple choice
+ (A/B/C/D shown as plain text below the image — not a
+ clickable UI, since that needs a desktop-only Anki add-on
+ and would break on AnkiDroid/AnkiMobile — same approach as
+ tools/anki-deck-periodic.py's "category" deck), type the
+ letter
clocks analog clock faces, all 144 hour/5-min combinations,
type the time as H:MM (the hour hand moves fractionally
with the minutes, e.g. 6:30 sits halfway between 6 and 7 —
@@ -39,6 +45,7 @@ Decks:
Usage (run with the venv from anki-deck-math.py's docstring activated):
python3 anki-deck-visual.py --deck shapes
+ python3 anki-deck-visual.py --deck shapes_mc
python3 anki-deck-visual.py --deck clocks
python3 anki-deck-visual.py --deck currency
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
@@ -56,7 +63,8 @@ from PIL import Image, ImageDraw, ImageFont
import subprocess
parser = argparse.ArgumentParser()
-parser.add_argument("--deck", required=True, choices=["shapes", "clocks", "currency"])
+parser.add_argument("--deck", required=True,
+ choices=["shapes", "shapes_mc", "clocks", "currency"])
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")
@@ -160,6 +168,8 @@ def build_model(deck_key):
.card { font-family: Arial, sans-serif; font-size: 24px; text-align: center; }
.imgwrap { margin: 10px auto; }
.imgwrap img { max-width: 260px; max-height: 260px; }
+.mc-choices { display: inline-block; text-align: left; margin-top: 14px; font-size: 22px; }
+.mc-choices div { margin: 4px 0; }
""",
)
@@ -363,6 +373,48 @@ def gen_shapes():
return deck, media_files, len(jobs)
+def gen_shapes_mc():
+ """Same shape images as gen_shapes(), but multiple choice instead of
+ type-the-name — A/B/C/D distractors drawn from every other shape name
+ in the pool (polygons and quadrilaterals share one distractor pool, so
+ a polygon question can pull "square" as a wrong answer and vice versa)."""
+ deck_key = "shapes_mc"
+ model_id, model = build_model(deck_key)
+ deck = build_deck(deck_key, "Shapes: Polygons & Quadrilaterals (multiple choice)")
+ media_files = []
+
+ jobs = [("polygon", n, POLYGON_NAMES[n]) for n in range(3, 11)]
+ jobs += [("quad", qname, qname) for qname in QUADRILATERALS]
+ all_names = [name for _, _, name in jobs]
+ random.seed(63)
+ random.shuffle(jobs)
+
+ letters = ["A", "B", "C", "D"]
+ for kind, val, name in jobs:
+ if kind == "polygon":
+ png_path = polygon_png(regular_polygon_points(val), f"mc_poly_{val}.png")
+ tag = f"shape_mc_poly_{val}"
+ else:
+ png_path = polygon_png(QUADRILATERALS[val], f"mc_quad_{val}.png")
+ tag = f"shape_mc_quad_{val}"
+ media_files.append(png_path)
+
+ distractor_pool = [n for n in all_names if n != name]
+ distractors = random.sample(distractor_pool, 3)
+ choices = distractors + [name]
+ random.shuffle(choices)
+ correct_letter = letters[choices.index(name)]
+
+ choice_html = "".join(f"