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'{svg_body}' - ) + 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)