Merge pull request #452 from outis1one/claude/pensive-hopper-4c9e7i

anki decks: add --skip-ones multiplication/division variant; switch s…
This commit is contained in:
Outis
2026-09-10 10:01:26 -04:00
committed by GitHub
3 changed files with 134 additions and 71 deletions
+7 -5
View File
@@ -309,11 +309,13 @@ exact usage is documented in `tools/anki-deck-math.py`'s own header
docstring; the other two scripts point back to it rather than repeating docstring; the other two scripts point back to it rather than repeating
the same instructions three times. the same instructions three times.
Shapes, clocks, and coin images are drawn programmatically (SVG) rather Shapes, clocks, and coin images are drawn programmatically (PNG, via
than AI-generated — image generation is a poor fit for content that has Pillow) rather than AI-generated — image generation is a poor fit for
to be exactly correct (an exact clock time, an exact side count), not content that has to be exactly correct (an exact clock time, an exact
just plausible-looking; see `tools/anki-deck-visual.py`'s own docstring side count), not just plausible-looking. PNG rather than SVG specifically
for more on that tradeoff. because AnkiDroid has long-standing, still-open bugs rendering SVG
`<img>` tags on mobile; see `tools/anki-deck-visual.py`'s own docstring
for more on both tradeoffs.
## Layout ## Layout
+23 -7
View File
@@ -13,10 +13,13 @@ and services/anki-progress.sh for the actual self-hosted sync backend and
progress dashboard this content is meant to be studied through. progress dashboard this content is meant to be studied through.
Decks: Decks:
multiplication 1-12, all 144 ordered pairs (a x b), shuffled multiplication 1-12, all 144 ordered pairs (a x b), shuffled
(not sequential — see the note near (not sequential — see the note near
random.shuffle(pairs) below for why) random.shuffle(pairs) below for why). Add
division inverse of the multiplication deck (144 facts) --skip-ones to drop every fact involving 1
(trivial, 121 facts instead of 144).
division inverse of the multiplication deck (144 facts,
or 121 with --skip-ones)
addsub --lo L --hi H addition + subtraction fact family for [L, H] addsub --lo L --hi H addition + subtraction fact family for [L, H]
(subtraction facts derived from the addition (subtraction facts derived from the addition
facts, e.g. 7+3=10 also gives 10-7=3 and facts, e.g. 7+3=10 also gives 10-7=3 and
@@ -45,6 +48,7 @@ Setup (one time):
Usage (run with the venv activated): Usage (run with the venv activated):
python3 anki-deck-math.py --deck multiplication python3 anki-deck-math.py --deck multiplication
python3 anki-deck-math.py --deck multiplication --skip-ones
python3 anki-deck-math.py --deck division python3 anki-deck-math.py --deck division
python3 anki-deck-math.py --deck addsub --lo 3 --hi 7 python3 anki-deck-math.py --deck addsub --lo 3 --hi 7
python3 anki-deck-math.py --deck addsub --lo 3 --hi 13 python3 anki-deck-math.py --deck addsub --lo 3 --hi 13
@@ -74,6 +78,10 @@ parser.add_argument("--deck", required=True,
choices=["multiplication", "division", "addsub", "fractions", "decimals"]) choices=["multiplication", "division", "addsub", "fractions", "decimals"])
parser.add_argument("--lo", type=int, default=None, help="addsub only: low end of range") parser.add_argument("--lo", type=int, default=None, help="addsub only: low end of range")
parser.add_argument("--hi", type=int, default=None, help="addsub only: high end of range") parser.add_argument("--hi", type=int, default=None, help="addsub only: high end of range")
parser.add_argument("--skip-ones", action="store_true",
help="multiplication/division only: drop every fact where either"
" number is 1 (1x1..1x12, 2x1..12x1, and their division"
" inverses) — those are trivial and not worth drilling.")
parser.add_argument("--voice", default="en_US-lessac-medium") parser.add_argument("--voice", default="en_US-lessac-medium")
parser.add_argument("--model-path", default=None) parser.add_argument("--model-path", default=None)
parser.add_argument("--dry-run-tts", action="store_true", parser.add_argument("--dry-run-tts", action="store_true",
@@ -269,11 +277,14 @@ def add_note(deck, model, top, bottom, answer, qtext, atext, media_files, tag):
# ─── Per-deck generators ───────────────────────────────────────────────────── # ─── Per-deck generators ─────────────────────────────────────────────────────
def gen_multiplication(): def gen_multiplication():
deck_key = "multiplication" deck_key = "multiplication" + ("_no_ones" if args.skip_ones else "")
model_id, model = build_model(deck_key) model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Multiplication Facts (1-12)") title = "Multiplication Facts (1-12)" + (", no 1s" if args.skip_ones else "")
deck = build_deck(deck_key, title)
media_files = [] media_files = []
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)] pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
if args.skip_ones:
pairs = [(a, b) for a, b in pairs if a != 1 and b != 1]
random.seed(42) random.seed(42)
random.shuffle(pairs) random.shuffle(pairs)
for a, b in pairs: for a, b in pairs:
@@ -285,13 +296,16 @@ def gen_multiplication():
def gen_division(): def gen_division():
deck_key = "division" deck_key = "division" + ("_no_ones" if args.skip_ones else "")
model_id, model = build_model(deck_key) model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Division Facts (inverse of 1-12 times tables)") title = "Division Facts (inverse of 1-12 times tables)" + (", no 1s" if args.skip_ones else "")
deck = build_deck(deck_key, title)
media_files = [] media_files = []
# Same (a, b) pairs as multiplication: product / a = b. This is the # Same (a, b) pairs as multiplication: product / a = b. This is the
# direct inverse of every multiplication card in that deck. # direct inverse of every multiplication card in that deck.
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)] pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
if args.skip_ones:
pairs = [(a, b) for a, b in pairs if a != 1 and b != 1]
random.seed(43) random.seed(43)
random.shuffle(pairs) random.shuffle(pairs)
for a, b in pairs: for a, b in pairs:
@@ -393,6 +407,8 @@ def gen_decimals():
# ─── Dispatch ───────────────────────────────────────────────────────────────── # ─── Dispatch ─────────────────────────────────────────────────────────────────
if args.deck == "addsub": if args.deck == "addsub":
deck_key = f"addsub_{args.lo}_{args.hi}" deck_key = f"addsub_{args.lo}_{args.hi}"
elif args.deck in ("multiplication", "division") and args.skip_ones:
deck_key = f"{args.deck}_no_ones"
else: else:
deck_key = args.deck deck_key = args.deck
+104 -59
View File
@@ -3,9 +3,11 @@
shapes, clocks, and coin-counting, with Anki's built-in type-the-answer shapes, clocks, and coin-counting, with Anki's built-in type-the-answer
input and Piper (offline, local neural TTS) audio. See input and Piper (offline, local neural TTS) audio. See
tools/anki-deck-math.py's docstring for one-time setup (venv, genanki + tools/anki-deck-math.py's docstring for one-time setup (venv, genanki +
piper-tts, downloading a voice) — same steps apply here. piper-tts, downloading a voice) — same steps apply here, plus one more
package this script alone needs: `pip install pillow` (for drawing the
images — see the note below on PNG vs SVG for why).
All images are drawn programmatically as SVG (regular-polygon geometry, All images are drawn programmatically (regular-polygon geometry,
clock-hand trigonometry, coin layouts) rather than AI-generated — image clock-hand trigonometry, coin layouts) rather than AI-generated — image
generation (local or cloud) is a poor fit for content that has to be generation (local or cloud) is a poor fit for content that has to be
exactly correct (an exact clock time, an exact side count, an exact coin exactly correct (an exact clock time, an exact side count, an exact coin
@@ -13,6 +15,14 @@ total), not just plausible-looking. See this script's own point/angle
generation functions for how each shape's geometry is computed directly generation functions for how each shape's geometry is computed directly
rather than approximated. rather than approximated.
Rendered as PNG (via Pillow), not SVG — AnkiDroid has a long-documented
history of unreliable SVG rendering (multiple open ankidroid/Anki-Android
GitHub issues going back years: some SVGs render, some silently don't,
with no clear pattern tied to how the file itself is written). PNG has no
such history on any Anki client. Confirmed live: an earlier SVG-based
version of this script produced images that displayed fine on desktop
Anki but never appeared at all on a mobile client.
Decks: Decks:
shapes regular polygons (3-10 sides, image->name and name->sides) shapes regular polygons (3-10 sides, image->name and name->sides)
plus 5 quadrilateral types (image->name: square, rectangle, plus 5 quadrilateral types (image->name: square, rectangle,
@@ -41,6 +51,8 @@ import genanki
import math import math
import os import os
import random import random
from PIL import Image, ImageDraw, ImageFont
import subprocess import subprocess
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@@ -178,14 +190,45 @@ def add_text_note(deck, model, text, answer, qtext, atext, media_files, tag):
qtext, atext, media_files, tag) qtext, atext, media_files, tag)
# ─── SVG generation ─────────────────────────────────────────────────────────── # ─── PNG generation (Pillow) ─────────────────────────────────────────────────
def save_svg(svg_body, filename, viewbox="0 0 200 200"): _FONT_CACHE = {}
_FONT_PATH_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
]
def load_font(size):
"""Loads a real bold TTF font if one of the common Debian/Ubuntu paths
exists (DejaVu Sans Bold ships with fonts-dejavu-core, a very common
baseline package); falls back to Pillow's own scalable default font
otherwise, so this never crashes even if no system font is found."""
if size in _FONT_CACHE:
return _FONT_CACHE[size]
for path in _FONT_PATH_CANDIDATES:
if os.path.isfile(path):
font = ImageFont.truetype(path, size)
_FONT_CACHE[size] = font
return font
font = ImageFont.load_default(size=size)
_FONT_CACHE[size] = font
return font
def draw_text_centered(draw, xy, text, font, fill="black"):
bbox = draw.textbbox((0, 0), text, font=font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
x, y = xy
draw.text((x - w / 2 - bbox[0], y - h / 2 - bbox[1]), text, font=font, fill=fill)
def save_png(filename, draw_fn, size=(200, 200)):
img = Image.new("RGB", size, "white")
draw = ImageDraw.Draw(img)
draw_fn(draw)
path = os.path.join(MEDIA_DIR, filename) path = os.path.join(MEDIA_DIR, filename)
with open(path, "w") as f: img.save(path)
f.write(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{viewbox}" '
f'width="200" height="200">{svg_body}</svg>'
)
return path return path
@@ -202,9 +245,10 @@ def regular_polygon_points(n_sides, cx=100, cy=100, r=80):
return points return points
def polygon_svg(points): def polygon_png(points, filename):
pts_str = " ".join(f"{x},{y}" for x, y in points) def draw_fn(draw):
return f'<polygon points="{pts_str}" fill="#6fa8dc" stroke="#1c4587" stroke-width="4"/>' draw.polygon(points, fill=(111, 168, 220), outline=(28, 69, 135), width=4)
return save_png(filename, draw_fn)
POLYGON_NAMES = { POLYGON_NAMES = {
@@ -221,54 +265,54 @@ QUADRILATERALS = {
} }
def clock_svg(hour, minute): def clock_png(hour, minute, filename):
cx, cy, r = 100, 100, 90 cx, cy, r = 100, 100, 90
minute_angle = minute * 6 - 90 minute_angle = minute * 6 - 90
hour_angle = (hour % 12) * 30 + minute * 0.5 - 90 hour_angle = (hour % 12) * 30 + minute * 0.5 - 90
def hand(angle_deg, length, width, color): def draw_fn(draw):
rad = math.radians(angle_deg) draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill="white", outline="black", width=3)
x2 = cx + length * math.cos(rad)
y2 = cy + length * math.sin(rad)
return f'<line x1="{cx}" y1="{cy}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="{color}" stroke-width="{width}" stroke-linecap="round"/>'
ticks = [] font = load_font(15)
numerals = [] for h in range(1, 13):
for h in range(1, 13): angle = math.radians(h * 30 - 90)
angle = math.radians(h * 30 - 90) tx1, ty1 = cx + (r - 10) * math.cos(angle), cy + (r - 10) * math.sin(angle)
tx1, ty1 = cx + (r - 10) * math.cos(angle), cy + (r - 10) * math.sin(angle) tx2, ty2 = cx + r * math.cos(angle), cy + r * math.sin(angle)
tx2, ty2 = cx + r * math.cos(angle), cy + r * math.sin(angle) draw.line([(tx1, ty1), (tx2, ty2)], fill="black", width=2)
ticks.append(f'<line x1="{tx1:.1f}" y1="{ty1:.1f}" x2="{tx2:.1f}" y2="{ty2:.1f}" stroke="black" stroke-width="2"/>') nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle)
nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle) draw_text_centered(draw, (nx, ny), str(h), font)
numerals.append(f'<text x="{nx:.1f}" y="{ny:.1f}" font-size="14" text-anchor="middle" dominant-baseline="middle">{h}</text>')
body = ( def hand(angle_deg, length, width):
f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="white" stroke="black" stroke-width="3"/>' rad = math.radians(angle_deg)
+ "".join(ticks) + "".join(numerals) x2, y2 = cx + length * math.cos(rad), cy + length * math.sin(rad)
+ hand(hour_angle, 45, 6, "black") draw.line([(cx, cy), (x2, y2)], fill="black", width=width)
+ hand(minute_angle, 70, 4, "black")
+ f'<circle cx="{cx}" cy="{cy}" r="4" fill="black"/>' hand(hour_angle, 45, 6)
) hand(minute_angle, 70, 4)
return body draw.ellipse([cx - 4, cy - 4, cx + 4, cy + 4], fill="black")
return save_png(filename, draw_fn)
COIN_INFO = {5: ("#c0c0c0", ""), 10: ("#d9d9d9", "10¢"), 25: ("#b8b8b8", "25¢")} COIN_INFO = {5: ("#c0c0c0", ""), 10: ("#d9d9d9", "10¢"), 25: ("#b8b8b8", "25¢")}
COIN_NAMES = {5: "nickel", 10: "dime", 25: "quarter"} COIN_NAMES = {5: "nickel", 10: "dime", 25: "quarter"}
def coins_svg(coin_values): def coins_png(coin_values, filename):
n = len(coin_values) n = len(coin_values)
spacing = 200 // (n + 1) spacing = 200 // (n + 1)
parts = []
for i, v in enumerate(coin_values): def draw_fn(draw):
cx = spacing * (i + 1) font = load_font(14)
color, label = COIN_INFO[v] for i, v in enumerate(coin_values):
radius = 30 if v == 25 else (26 if v == 10 else 28) cx = spacing * (i + 1)
parts.append( color, label = COIN_INFO[v]
f'<circle cx="{cx}" cy="100" r="{radius}" fill="{color}" stroke="#444" stroke-width="2"/>' radius = 30 if v == 25 else (26 if v == 10 else 28)
f'<text x="{cx}" y="105" font-size="14" text-anchor="middle">{label}</text>' draw.ellipse([cx - radius, 100 - radius, cx + radius, 100 + radius],
) fill=color, outline=(68, 68, 68), width=2)
return "".join(parts) draw_text_centered(draw, (cx, 100), label, font)
return save_png(filename, draw_fn)
def coin_list_words(coin_values): def coin_list_words(coin_values):
@@ -300,9 +344,9 @@ def gen_shapes():
if kind == "polygon_image": if kind == "polygon_image":
n = val n = val
name = POLYGON_NAMES[n] name = POLYGON_NAMES[n]
svg_path = save_svg(polygon_svg(regular_polygon_points(n)), f"poly_{n}.svg") png_path = polygon_png(regular_polygon_points(n), f"poly_{n}.png")
media_files.append(svg_path) media_files.append(png_path)
add_note(deck, model, f'<img src="poly_{n}.svg">', name, add_note(deck, model, f'<img src="poly_{n}.png">', name,
"What shape is this?", name, media_files, f"shape_img_{n}") "What shape is this?", name, media_files, f"shape_img_{n}")
elif kind == "polygon_sides": elif kind == "polygon_sides":
n = val n = val
@@ -312,9 +356,9 @@ def gen_shapes():
media_files, f"shape_sides_{n}") media_files, f"shape_sides_{n}")
else: else:
qname = val qname = val
svg_path = save_svg(polygon_svg(QUADRILATERALS[qname]), f"quad_{qname}.svg") png_path = polygon_png(QUADRILATERALS[qname], f"quad_{qname}.png")
media_files.append(svg_path) media_files.append(png_path)
add_note(deck, model, f'<img src="quad_{qname}.svg">', qname, add_note(deck, model, f'<img src="quad_{qname}.png">', qname,
"What shape is this?", qname, media_files, f"shape_quad_{qname}") "What shape is this?", qname, media_files, f"shape_quad_{qname}")
return deck, media_files, len(jobs) return deck, media_files, len(jobs)
@@ -336,8 +380,8 @@ def gen_clocks():
media_files.append(os.path.join(MEDIA_DIR, shared_qfile)) media_files.append(os.path.join(MEDIA_DIR, shared_qfile))
for hour, minute in times: for hour, minute in times:
svg_path = save_svg(clock_svg(hour, minute), f"clock_{hour}_{minute:02d}.svg") png_path = clock_png(hour, minute, f"clock_{hour}_{minute:02d}.png")
media_files.append(svg_path) media_files.append(png_path)
answer = f"{hour}:{minute:02d}" answer = f"{hour}:{minute:02d}"
afile = f"a_clock_{hour}_{minute:02d}.wav" afile = f"a_clock_{hour}_{minute:02d}.wav"
apath = os.path.join(MEDIA_DIR, afile) apath = os.path.join(MEDIA_DIR, afile)
@@ -345,7 +389,7 @@ def gen_clocks():
media_files.append(apath) media_files.append(apath)
deck.add_note(genanki.Note( deck.add_note(genanki.Note(
model=model, model=model,
fields=[f'<img src="clock_{hour}_{minute:02d}.svg">', answer, fields=[f'<img src="clock_{hour}_{minute:02d}.png">', answer,
f"[sound:{shared_qfile}]", f"[sound:{afile}]"], f"[sound:{shared_qfile}]", f"[sound:{afile}]"],
)) ))
return deck, media_files, len(times) return deck, media_files, len(times)
@@ -377,12 +421,13 @@ def gen_currency():
for coin_values in combos: for coin_values in combos:
total = sum(coin_values) total = sum(coin_values)
svg_path = save_svg(coins_svg(list(coin_values)), f"coins_{'_'.join(map(str, coin_values))}.svg") tag = "_".join(map(str, coin_values))
media_files.append(svg_path) png_path = coins_png(list(coin_values), f"coins_{tag}.png")
media_files.append(png_path)
qtext = f"How much money is {coin_list_words(list(coin_values))}?" qtext = f"How much money is {coin_list_words(list(coin_values))}?"
atext = f"{num2words(total)} cents" atext = f"{num2words(total)} cents"
add_note(deck, model, f'<img src="coins_{"_".join(map(str, coin_values))}.svg">', add_note(deck, model, f'<img src="coins_{tag}.png">',
str(total), qtext, atext, media_files, f"coins_{'_'.join(map(str, coin_values))}") str(total), qtext, atext, media_files, f"coins_{tag}")
return deck, media_files, len(combos) return deck, media_files, len(combos)