diff --git a/README.md b/README.md
index e316f09..8ee56b1 100644
--- a/README.md
+++ b/README.md
@@ -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
the same instructions three times.
-Shapes, clocks, and coin images are drawn programmatically (SVG) rather
-than AI-generated — image generation is a poor fit for content that has
-to be exactly correct (an exact clock time, an exact side count), not
-just plausible-looking; see `tools/anki-deck-visual.py`'s own docstring
-for more on that tradeoff.
+Shapes, clocks, and coin images are drawn programmatically (PNG, via
+Pillow) rather than AI-generated — image generation is a poor fit for
+content that has to be exactly correct (an exact clock time, an exact
+side count), not just plausible-looking. PNG rather than SVG specifically
+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.
## Layout
diff --git a/tools/anki-deck-math.py b/tools/anki-deck-math.py
index 4413089..0c1b1c6 100644
--- a/tools/anki-deck-math.py
+++ b/tools/anki-deck-math.py
@@ -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.
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
- random.shuffle(pairs) below for why)
- division inverse of the multiplication deck (144 facts)
+ random.shuffle(pairs) below for why). Add
+ --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]
(subtraction facts derived from the addition
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):
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 addsub --lo 3 --hi 7
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"])
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("--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("--model-path", default=None)
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 ─────────────────────────────────────────────────────
def gen_multiplication():
- deck_key = "multiplication"
+ deck_key = "multiplication" + ("_no_ones" if args.skip_ones else "")
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 = []
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.shuffle(pairs)
for a, b in pairs:
@@ -285,13 +296,16 @@ def gen_multiplication():
def gen_division():
- deck_key = "division"
+ deck_key = "division" + ("_no_ones" if args.skip_ones else "")
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 = []
# Same (a, b) pairs as multiplication: product / a = b. This is the
# direct inverse of every multiplication card in that deck.
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.shuffle(pairs)
for a, b in pairs:
@@ -393,6 +407,8 @@ def gen_decimals():
# ─── Dispatch ─────────────────────────────────────────────────────────────────
if args.deck == "addsub":
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:
deck_key = args.deck
diff --git a/tools/anki-deck-visual.py b/tools/anki-deck-visual.py
index 5b40340..1226386 100644
--- a/tools/anki-deck-visual.py
+++ b/tools/anki-deck-visual.py
@@ -3,9 +3,11 @@
shapes, clocks, and coin-counting, with Anki's built-in type-the-answer
input and Piper (offline, local neural TTS) audio. See
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
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
@@ -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
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:
shapes regular polygons (3-10 sides, image->name and name->sides)
plus 5 quadrilateral types (image->name: square, rectangle,
@@ -41,6 +51,8 @@ import genanki
import math
import os
import random
+
+from PIL import Image, ImageDraw, ImageFont
import subprocess
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)
-# ─── SVG generation ───────────────────────────────────────────────────────────
-def save_svg(svg_body, filename, viewbox="0 0 200 200"):
+# ─── PNG generation (Pillow) ─────────────────────────────────────────────────
+_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)
- with open(path, "w") as f:
- f.write(
- f''
- )
+ img.save(path)
return path
@@ -202,9 +245,10 @@ def regular_polygon_points(n_sides, cx=100, cy=100, r=80):
return points
-def polygon_svg(points):
- pts_str = " ".join(f"{x},{y}" for x, y in points)
- return f''
+def polygon_png(points, filename):
+ def draw_fn(draw):
+ draw.polygon(points, fill=(111, 168, 220), outline=(28, 69, 135), width=4)
+ return save_png(filename, draw_fn)
POLYGON_NAMES = {
@@ -221,54 +265,54 @@ QUADRILATERALS = {
}
-def clock_svg(hour, minute):
+def clock_png(hour, minute, filename):
cx, cy, r = 100, 100, 90
minute_angle = minute * 6 - 90
hour_angle = (hour % 12) * 30 + minute * 0.5 - 90
- def hand(angle_deg, length, width, color):
- rad = math.radians(angle_deg)
- x2 = cx + length * math.cos(rad)
- y2 = cy + length * math.sin(rad)
- return f''
+ def draw_fn(draw):
+ draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill="white", outline="black", width=3)
- ticks = []
- numerals = []
- for h in range(1, 13):
- angle = math.radians(h * 30 - 90)
- 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)
- ticks.append(f'')
- nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle)
- numerals.append(f'{h}')
+ font = load_font(15)
+ for h in range(1, 13):
+ angle = math.radians(h * 30 - 90)
+ 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)
+ draw.line([(tx1, ty1), (tx2, ty2)], fill="black", width=2)
+ nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle)
+ draw_text_centered(draw, (nx, ny), str(h), font)
- body = (
- f''
- + "".join(ticks) + "".join(numerals)
- + hand(hour_angle, 45, 6, "black")
- + hand(minute_angle, 70, 4, "black")
- + f''
- )
- return body
+ def hand(angle_deg, length, width):
+ rad = math.radians(angle_deg)
+ x2, y2 = cx + length * math.cos(rad), cy + length * math.sin(rad)
+ draw.line([(cx, cy), (x2, y2)], fill="black", width=width)
+
+ hand(hour_angle, 45, 6)
+ hand(minute_angle, 70, 4)
+ draw.ellipse([cx - 4, cy - 4, cx + 4, cy + 4], fill="black")
+
+ return save_png(filename, draw_fn)
COIN_INFO = {5: ("#c0c0c0", "5¢"), 10: ("#d9d9d9", "10¢"), 25: ("#b8b8b8", "25¢")}
COIN_NAMES = {5: "nickel", 10: "dime", 25: "quarter"}
-def coins_svg(coin_values):
+def coins_png(coin_values, filename):
n = len(coin_values)
spacing = 200 // (n + 1)
- parts = []
- for i, v in enumerate(coin_values):
- cx = spacing * (i + 1)
- color, label = COIN_INFO[v]
- radius = 30 if v == 25 else (26 if v == 10 else 28)
- parts.append(
- f''
- f'{label}'
- )
- return "".join(parts)
+
+ def draw_fn(draw):
+ font = load_font(14)
+ for i, v in enumerate(coin_values):
+ cx = spacing * (i + 1)
+ color, label = COIN_INFO[v]
+ radius = 30 if v == 25 else (26 if v == 10 else 28)
+ draw.ellipse([cx - radius, 100 - radius, cx + radius, 100 + radius],
+ fill=color, outline=(68, 68, 68), width=2)
+ draw_text_centered(draw, (cx, 100), label, font)
+
+ return save_png(filename, draw_fn)
def coin_list_words(coin_values):
@@ -300,9 +344,9 @@ def gen_shapes():
if kind == "polygon_image":
n = val
name = POLYGON_NAMES[n]
- svg_path = save_svg(polygon_svg(regular_polygon_points(n)), f"poly_{n}.svg")
- media_files.append(svg_path)
- add_note(deck, model, f'
', name,
+ png_path = polygon_png(regular_polygon_points(n), f"poly_{n}.png")
+ media_files.append(png_path)
+ add_note(deck, model, f'
', name,
"What shape is this?", name, media_files, f"shape_img_{n}")
elif kind == "polygon_sides":
n = val
@@ -312,9 +356,9 @@ def gen_shapes():
media_files, f"shape_sides_{n}")
else:
qname = val
- svg_path = save_svg(polygon_svg(QUADRILATERALS[qname]), f"quad_{qname}.svg")
- media_files.append(svg_path)
- add_note(deck, model, f'
', qname,
+ png_path = polygon_png(QUADRILATERALS[qname], f"quad_{qname}.png")
+ media_files.append(png_path)
+ add_note(deck, model, f'
', qname,
"What shape is this?", qname, media_files, f"shape_quad_{qname}")
return deck, media_files, len(jobs)
@@ -336,8 +380,8 @@ def gen_clocks():
media_files.append(os.path.join(MEDIA_DIR, shared_qfile))
for hour, minute in times:
- svg_path = save_svg(clock_svg(hour, minute), f"clock_{hour}_{minute:02d}.svg")
- media_files.append(svg_path)
+ png_path = clock_png(hour, minute, f"clock_{hour}_{minute:02d}.png")
+ media_files.append(png_path)
answer = f"{hour}:{minute:02d}"
afile = f"a_clock_{hour}_{minute:02d}.wav"
apath = os.path.join(MEDIA_DIR, afile)
@@ -345,7 +389,7 @@ def gen_clocks():
media_files.append(apath)
deck.add_note(genanki.Note(
model=model,
- fields=[f'
', answer,
+ fields=[f'
', answer,
f"[sound:{shared_qfile}]", f"[sound:{afile}]"],
))
return deck, media_files, len(times)
@@ -377,12 +421,13 @@ def gen_currency():
for coin_values in combos:
total = sum(coin_values)
- svg_path = save_svg(coins_svg(list(coin_values)), f"coins_{'_'.join(map(str, coin_values))}.svg")
- media_files.append(svg_path)
+ tag = "_".join(map(str, coin_values))
+ 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))}?"
atext = f"{num2words(total)} cents"
- add_note(deck, model, f'
',
- str(total), qtext, atext, media_files, f"coins_{'_'.join(map(str, coin_values))}")
+ add_note(deck, model, f'
',
+ str(total), qtext, atext, media_files, f"coins_{tag}")
return deck, media_files, len(combos)