<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: lxhsoluto-star</title>
    <description>The latest articles on DEV Community by lxhsoluto-star (@lxhsoluto-star).</description>
    <link>https://dev.to/lxhsoluto-star</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4191269%2F93c999fb-d9ef-4c1b-a898-3089f7a664c0.jpg</url>
      <title>DEV Community: lxhsoluto-star</title>
      <link>https://dev.to/lxhsoluto-star</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lxhsoluto-star"/>
    <language>en</language>
    <item>
      <title>Why your generated Anki image occlusion cards import as plain cloze (and the one-line fix)</title>
      <dc:creator>lxhsoluto-star</dc:creator>
      <pubDate>Sun, 18 Oct 2026 00:52:48 +0000</pubDate>
      <link>https://dev.to/lxhsoluto-star/why-your-generated-anki-image-occlusion-cards-import-as-plain-cloze-and-the-one-line-fix-3hj3</link>
      <guid>https://dev.to/lxhsoluto-star/why-your-generated-anki-image-occlusion-cards-import-as-plain-cloze-and-the-one-line-fix-3hj3</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://ankiforge.app" rel="noopener noreferrer"&gt;AnkiForge&lt;/a&gt;, a web app that turns lecture PDFs and labelled diagrams into Anki cards. For diagrams it makes image occlusion cards: every label on, say, a heart diagram gets hidden, one card per label.&lt;/p&gt;

&lt;p&gt;Getting the masks in the right place took most of the work. Getting Anki to treat them as real image occlusion took one line of config, and finding out what that line actually does took reading Anki's source twice. Here it is, along with the parts that are still broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note type named "Image Occlusion" isn't image occlusion
&lt;/h2&gt;

&lt;p&gt;Anki has had image occlusion built in since 23.10. Before that it was an add-on (Image Occlusion Enhanced) with its own note type, and plenty of shared decks still use that old format. The Anki forums have a steady trickle of threads like &lt;a href="https://forums.ankiweb.net/t/anki-deck-with-image-occlusion-shows-no-images/43854" rel="noopener noreferrer"&gt;"Anki deck with image occlusion shows no images"&lt;/a&gt; from people importing them.&lt;/p&gt;

&lt;p&gt;If you generate decks in code, the obvious approach is to copy the templates and field names from the built-in IO note type. You end up with a cloze note type that happens to be called "Image Occlusion". The cards even review fine, because the template draws the masks itself.&lt;/p&gt;

&lt;p&gt;The difference shows up when someone opens a card to fix a mask.This is how Anki desktop decides whether to show its mask editor (&lt;code&gt;aqt/editor_legacy.py&lt;/code&gt; in the 26.9 package):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;current_notetype_is_image_occlusion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;note_type&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;originalStockKind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;StockNotetype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OriginalStockKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not the name, not the fields, not the template. One value in the note type's config, and &lt;code&gt;ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION&lt;/code&gt; is &lt;code&gt;6&lt;/code&gt;. Without it the image occlusion button stays hidden and the editor shows plain text fields, with the raw &lt;code&gt;{{c1::image-occlusion:rect:...}}&lt;/code&gt; string where the masks should be.&lt;/p&gt;

&lt;p&gt;In a schema 11 collection, note types live as JSON in the &lt;code&gt;col&lt;/code&gt; table, so this is what mine looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Image Occlusion (AnkiForge)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;// cloze&lt;/span&gt;
  &lt;span class="nx"&gt;originalStockKind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// the line that matters&lt;/span&gt;
  &lt;span class="nx"&gt;flds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Occlusion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preventDeletion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preventDeletion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preventDeletion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Back Extra&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preventDeletion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Comments&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preventDeletion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;// templates copied from Anki's stock IO note type&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The field tags and &lt;code&gt;preventDeletion&lt;/code&gt; flags are copied from the stock note type too, so mine matches the built-in one field for field.&lt;/p&gt;

&lt;h2&gt;
  
  
  The occlusion field is a string, and I copied it exactly
&lt;/h2&gt;

&lt;p&gt;Each mask is a cloze deletion with a shape inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;{{c1::image-occlusion:rect:left=.2325:top=.1104:width=.1371:height=.0412}}&lt;span class="nt"&gt;&amp;lt;br&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Coordinates are fractions of the image size. Anki formats them with a small function called &lt;code&gt;floatToDisplay&lt;/code&gt;: four decimals, then strip leading and trailing zeros, so &lt;code&gt;0.2325&lt;/code&gt; becomes &lt;code&gt;.2325&lt;/code&gt;. "Hide all, guess one" mode adds &lt;code&gt;:oi=1&lt;/code&gt; to each shape.&lt;/p&gt;

&lt;p&gt;I matched that output character for character, so a note from my export and the same note after a round trip through Anki's editor are identical. I haven't tested how forgiving Anki's parser is about other formats, and I'd rather not find out from a user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing with Anki itself
&lt;/h2&gt;

&lt;p&gt;I didn't want to check this by opening Anki and clicking around after every change. &lt;code&gt;pip install anki&lt;/code&gt; gives you the same Rust backend the desktop app runs, so the test imports the generated &lt;code&gt;.apkg&lt;/code&gt; into a fresh collection and asks Anki for the occlusions with &lt;code&gt;get_image_occlusion_note&lt;/code&gt;, the call the mask editor uses to load a note.&lt;/p&gt;

&lt;p&gt;While writing this post I removed the flag from a test deck to show the failure. The test still passed. The backend parses occlusions out of any note with the right field, flag or no flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight irb"&gt;&lt;code&gt;&lt;span class="gp"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'originalStockKind'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="go"&gt;None
&lt;/span&gt;&lt;span class="gp"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_image_occlusion_note&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;nid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;note: 3 occlusions, image 'af-test.png'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the desktop UI reads the flag, so a backend-only test can't see it missing. The test now checks it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;collection.anki2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;import_anki_package&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ImportAnkiPackageRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;package_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ImportAnkiPackageOptions&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;nt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all_names_and_ids&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;nids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_notes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mid:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;nt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;nids&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Occlusion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;originalStockKind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;editor won&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t treat this as IO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;io&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_image_occlusion_note&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;nids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WhichOneof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;note&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same script renders the cloze cards and checks that the question side hides the deletion. It runs on every change to the note types or the packer. Lesson for me: a test that passes before and after you break the thing isn't testing the thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the .apkg in the browser
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;.apkg&lt;/code&gt; is a zip with a SQLite database (&lt;code&gt;collection.anki2&lt;/code&gt;), a &lt;code&gt;media&lt;/code&gt; file mapping &lt;code&gt;"0"&lt;/code&gt;, &lt;code&gt;"1"&lt;/code&gt;... to real filenames, and the media files themselves, named &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt;, ....&lt;/p&gt;

&lt;p&gt;I build it in the browser with &lt;a href="https://github.com/sql-js/sql.js" rel="noopener noreferrer"&gt;sql.js&lt;/a&gt; (SQLite compiled to WebAssembly) and &lt;a href="https://github.com/101arrowz/fflate" rel="noopener noreferrer"&gt;fflate&lt;/a&gt; for the zip. sql.js is about 1 MB of wasm, so it's only loaded when someone clicks Download. The point is that the deck never exists on my server. Diagrams do go through the server to the OCR step, but they aren't stored, and the finished deck is assembled on the user's machine.&lt;/p&gt;

&lt;p&gt;A few details that took more than one try:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Note GUIDs come from the content&lt;/strong&gt;, using the same base91 scheme genanki uses. Re-exporting the same card gives the same GUID, so importing an updated deck updates notes instead of duplicating them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deck IDs come from the deck name&lt;/strong&gt;, for the same reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Note type IDs are fixed and never change in place.&lt;/strong&gt; If I change a template, it ships as a new note type with a new name and a new ID. Changing a published note type under the same ID means people who imported the old one get a conflict on their next import.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Masks: OCR was the easy part
&lt;/h2&gt;

&lt;p&gt;OCR returns lines of text with bounding boxes. Turning those lines into one mask per label is where the work was.&lt;/p&gt;

&lt;p&gt;Labels on anatomy diagrams wrap across lines ("Superior / vena cava"), so you have to merge lines. But labels also stack in columns ("Nose / Nasal cavity / Pharynx"), and a column of short labels looks exactly like one wrapped label if all you have is geometry. Merge too little and a two-line label becomes two masks. Merge too much and one card shows five answers at once.&lt;/p&gt;

&lt;p&gt;To measure it I built an eval set: diagrams from Wikimedia Commons with every label written down by hand. For each label the question is whether a student gets exactly one mask over it, switched on, without touching anything. I call that the one-click rate.&lt;/p&gt;

&lt;p&gt;My first version merged by proximity and scored 64%. For comparison I ran &lt;a href="https://pypi.org/project/nb41/" rel="noopener noreferrer"&gt;NIOBIUM&lt;/a&gt;, a free CLI that does the same job with a 10 px proximity merge, through the same scorer. It got 67%. Both of us found about 93% of the labels. The whole gap was merging: 43 merged masks for NIOBIUM against 2 for my current version.&lt;/p&gt;

&lt;p&gt;Here's one diagram from the set, same OCR engine, before and after:&lt;/p&gt;

&lt;p&gt;![Respiratory system diagram with OCR masks. Left, merging by position only: 14 of 48 labels get their own mask, and whole columns like "Nose / Nasal cavity / Nasal conchae" sit under one red mask. Right, merging only when the text continues: 46 of 48 labels get their own mask.]&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fksbskmgr7yzjo15on9ww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fksbskmgr7yzjo15on9ww.png" alt=" " width="800" height="506"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Diagram: "Respiratory system complete" by LadyofHats (Mariana Ruiz), public domain, via Wikimedia Commons.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What fixed it was making a merge depend on the text as well as the boxes. Two lines merge only if they line up and the text reads as a continuation: the next line starts lowercase or with &lt;code&gt;(&lt;/code&gt;, the previous line ends on something like "of", or the next line is a single modifier word. Then the exceptions that the eval found:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;subclavius n.&lt;/code&gt; above &lt;code&gt;phrenic n.&lt;/code&gt; is two nerves, not one label. A line ending in a period ends the label, even if the next line starts lowercase.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;musculo-&lt;/code&gt; above &lt;code&gt;cutaneous n.&lt;/code&gt; is one word split by a hyphen and should join.&lt;/li&gt;
&lt;li&gt;On lecture slides, the title, bullet points and page number are text too. They now get masks that are off by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the 9 diagrams I tuned against (163 labels), the two OCR engines I use score 95% and 92%. On 7 diagrams I kept aside, the latest rule changes moved them from 91% to 98% and from 87% to 94%. I tuned those rule changes after looking at the failures on that held-out set, so it isn't really held out any more. The next round needs new images.&lt;/p&gt;

&lt;p&gt;One thing I didn't expect: the newer document-parsing OCR models were much worse at this. Two of them scored 7% and 25%. They see an anatomy diagram as one figure block and don't read the text inside it. They're built to turn scanned papers into Markdown, and for that they're probably great.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cloze side, briefly
&lt;/h2&gt;

&lt;p&gt;The text-to-cloze part uses an LLM, and it got its own eval, which I'll write up separately. Two things worth knowing if you're doing something similar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model I use reasons before it answers, even at the lowest reasoning setting, and those tokens count against &lt;code&gt;max_tokens&lt;/code&gt;. A sibling project had every response cut off at 400 tokens before anyone noticed. I now give it 16,000 and keep the reasoning out of the response.&lt;/li&gt;
&lt;li&gt;Models love putting a list under one cloze number: &lt;code&gt;{{c1::mitral}} and {{c1::tricuspid}}&lt;/code&gt;. That's a card asking you to recall a set, which is the thing spaced repetition is worst at. There's a prompt rule against it now, and the eval still catches it now and then.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's still broken
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Numbered diagrams.&lt;/strong&gt; If the figure has "1, 2, 3" on it and a legend elsewhere, the mask hides "3" and the answer on the back is "3". The real answer is in the legend. I need to pair numbers with legend entries, and that isn't built yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tiny old scans.&lt;/strong&gt; A 409×500 plate from the 1918 Gray's Anatomy defeats every engine I've tried. Upscaling before OCR got one engine from 0 to 5 of 11 labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phones.&lt;/strong&gt; Anki's own library says the cards are image occlusion. I haven't yet opened them on a real phone in AnkiMobile or AnkiDroid.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're generating decks yourself and Anki's mask editor won't open your IO notes, check &lt;code&gt;originalStockKind&lt;/code&gt; first. The tool is at &lt;a href="https://ankiforge.app" rel="noopener noreferrer"&gt;ankiforge.app&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>anki</category>
      <category>webdev</category>
      <category>sqlite</category>
    </item>
  </channel>
</rss>
