Why not just build the deck by hand in Anki
Anki’s own editor is fine for a deck of twenty cards. It stops being fine once the content is bilingual grammar cards for something like Matn al-Ājurrūmiyyah, or a growing set of French/Arabic vocabulary — at that point every card has multiple fields (Arabic text, transliteration, translation, grammatical note, audio reference), and hand-entering them in Anki’s GUI is slow and error-prone in exactly the way that repetitive data entry always is: a field goes in the wrong slot on card 40 and nobody notices until review.
genanki is a Python library that builds .apkg files programmatically — decks, note types, and cards defined in code instead of clicked together in a UI. The cards still live in Anki once generated; only the authoring step changes.
The schema comes first
Before touching genanki, the actual design decision is the YAML schema the cards will be authored in. A flat list of question/answer pairs works for simple vocab but falls apart for grammar cards that need multiple fields:
deck_name: "Ājurrūmiyyah - Grammar"
note_type: "Bilingual Grammar"
cards:
- arabic: "الكَلاَمُ"
transliteration: "al-kalām"
translation: "speech / a complete sentence"
note: "Defined as an utterance conveying complete meaning"
tags: ["ajurrumiyyah", "chapter-1"]
- arabic: "مَرْفُوعٌ"
transliteration: "marfūʿ"
translation: "in the nominative case"
note: "One of the four grammatical states (i'rab)"
tags: ["ajurrumiyyah", "chapter-2", "irab"]
Keeping this as plain YAML rather than inventing a custom format means the content is reviewable and editable without any tooling at all — a text editor is sufficient to add or fix a card.
Defining the note type in genanki
genanki requires two things: a Model (Anki’s note type — the fields and card templates) and a Deck (the actual container). The model is defined once and reused across every deck of the same kind:
import genanki
GRAMMAR_MODEL = genanki.Model(
1607392319, # arbitrary but must stay constant across regenerations
'Bilingual Grammar',
fields=[
{'name': 'Arabic'},
{'name': 'Transliteration'},
{'name': 'Translation'},
{'name': 'Note'},
],
templates=[
{
'name': 'Card 1',
'qfmt': '<div class="arabic">{{Arabic}}</div>',
'afmt': '''{{FrontSide}}
<hr id="answer">
<div class="translit">{{Transliteration}}</div>
<div class="translation">{{Translation}}</div>
<div class="note">{{Note}}</div>''',
},
],
css='.arabic { font-size: 32px; direction: rtl; text-align: right; }'
)
The model ID (1607392319 above) is the detail most likely to cause quiet problems later — it has to stay the same every time the deck is regenerated, or Anki treats it as an entirely new note type on the next import and duplicates every card instead of updating them. Picking a random constant once and hardcoding it, rather than generating a fresh one per run, is what makes the pipeline idempotent.
Generating the deck
With the model and schema in place, the generator script is mostly a loop:
import yaml
import genanki
def build_deck(yaml_path, output_path):
with open(yaml_path) as f:
data = yaml.safe_load(f)
deck = genanki.Deck(
2059400110, # also must stay constant
data['deck_name']
)
for card in data['cards']:
note = genanki.Note(
model=GRAMMAR_MODEL,
fields=[
card['arabic'],
card['transliteration'],
card['translation'],
card.get('note', ''),
],
tags=card.get('tags', [])
)
deck.add_note(note)
genanki.Package(deck).write_to_file(output_path)
Running this against the YAML file above produces a .apkg ready to import into Anki or sync via AnkiWeb — no manual card entry involved, and re-running it after editing the YAML regenerates the whole deck cleanly because both IDs are pinned.
Where AnkiDroid import broke, and why
The desktop Anki import worked without issue; AnkiDroid was the one that surfaced problems, and both traced back to the same root cause: AnkiDroid’s importer is stricter about a couple of things the desktop client tolerates.
- Media file references need to exist at import time, not just be named correctly. A card field referencing
[sound:intro.mp3]will import fine on desktop even if the file isn’t packaged, then silently fail to play on AnkiDroid with no error surfaced. The fix is making suregenanki.Packageis constructed with themedia_filesargument pointing at the actual files, not just relying on the filename string being correct in a field.
package = genanki.Package(deck)
package.media_files = ['audio/intro.mp3', 'audio/card2.mp3']
package.write_to_file(output_path)
- RTL text in fields needs explicit
dir="rtl"in the template, not just in the deck’s CSS. Thedirection: rtlCSS rule above renders correctly in desktop Anki’s Qt-based webview, but AnkiDroid’s WebView implementation has been inconsistent about honoring CSSdirectionon some Android versions without an explicit HTMLdirattribute on the element itself. Adding it directly to the template markup rather than relying on CSS alone fixed it:
'qfmt': '<div class="arabic" dir="rtl">{{Arabic}}</div>',
Neither of these surfaced in desktop testing, which is the general lesson here: if a deck is going to be used on AnkiDroid at all, it has to actually be tested on AnkiDroid before considering the pipeline done — desktop Anki’s renderer is meaningfully more forgiving than the mobile one.
Why this is worth the setup cost
For a deck of a few dozen cards, hand-entry in Anki’s editor is genuinely faster than writing a genanki pipeline. The pipeline earns its cost at the point where the deck needs to be regenerated — a translation gets corrected, a new chapter of cards gets added, a template styling change needs to apply to every existing card. At that point, editing one YAML file and one CSS string and re-running the script beats manually re-editing dozens of individual notes in the GUI, and the model/deck ID pinning means regeneration updates in place instead of duplicating.