<?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: Kavindu Kavishka Premarathna</title>
    <description>The latest articles on DEV Community by Kavindu Kavishka Premarathna (@kavindu_kp).</description>
    <link>https://dev.to/kavindu_kp</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%2F4059142%2F21c71735-bebf-4fb6-91ea-fd9fd4aff1e5.jpg</url>
      <title>DEV Community: Kavindu Kavishka Premarathna</title>
      <link>https://dev.to/kavindu_kp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kavindu_kp"/>
    <language>en</language>
    <item>
      <title>Building a Custom AI PDF Reader in Python: From a Jupyter Prototype to Tested Modules</title>
      <dc:creator>Kavindu Kavishka Premarathna</dc:creator>
      <pubDate>Fri, 14 Aug 2026 16:11:33 +0000</pubDate>
      <link>https://dev.to/kavindu_kp/building-a-custom-ai-pdf-reader-in-python-from-a-jupyter-prototype-to-tested-modules-58ld</link>
      <guid>https://dev.to/kavindu_kp/building-a-custom-ai-pdf-reader-in-python-from-a-jupyter-prototype-to-tested-modules-58ld</guid>
      <description>&lt;p&gt;Research papers are much easier to read when the reader fits the way you work. I wanted a PDF reader that could eventually support bookmarks, notes, annotations, summaries, question answering, text-to-speech, and voice commands.&lt;/p&gt;

&lt;p&gt;Rather than trying to write a complete PDF engine from scratch, I started with a small Python prototype. The goal was simple: learn each layer of the application properly, build a working foundation, and only then move toward a desktop application.&lt;/p&gt;

&lt;p&gt;This article documents the first stage of that journey: an interactive PDF reader in Jupyter, local persistence for bookmarks and notes, a cleaner module structure, automated tests, and the lessons learned along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The project goal
&lt;/h2&gt;

&lt;p&gt;The long-term goal is a customizable desktop PDF reader for research reading. The eventual application may include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PDF navigation and zoom&lt;/li&gt;
&lt;li&gt;Text search with highlighted matches&lt;/li&gt;
&lt;li&gt;Bookmarks and reading progress&lt;/li&gt;
&lt;li&gt;Page-linked notes and annotations&lt;/li&gt;
&lt;li&gt;Local or cloud LLM summaries and question answering&lt;/li&gt;
&lt;li&gt;Text-to-speech&lt;/li&gt;
&lt;li&gt;Voice commands&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the first milestone, I deliberately kept the scope smaller. I focused on PDF rendering, navigation, search, persistence, and tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the first technology stack
&lt;/h2&gt;

&lt;p&gt;I chose Python because it allowed me to experiment quickly. My first stack was:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PDF rendering, text extraction, and search&lt;/td&gt;
&lt;td&gt;PyMuPDF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interactive prototype interface&lt;/td&gt;
&lt;td&gt;JupyterLab and ipywidgets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image handling&lt;/td&gt;
&lt;td&gt;Pillow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local saved data&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version control&lt;/td&gt;
&lt;td&gt;Git and GitHub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automated tests&lt;/td&gt;
&lt;td&gt;pytest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The future desktop UI will use PySide6, but Jupyter was a useful place to learn the reader logic before dealing with desktop-window layouts, signals, menus, and packaging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the first reader
&lt;/h2&gt;

&lt;p&gt;The prototype opens a local PDF with PyMuPDF:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pymupdf&lt;/span&gt;

&lt;span class="n"&gt;PDF_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pymupdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PDF_PATH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;page_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A PDF page is not automatically an image. PyMuPDF renders a page into a pixmap, and Pillow converts the pixel data into an image that Jupyter can display.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;

&lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;document&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="n"&gt;pixmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_pixmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;frombytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pixmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pixmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;pixmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;image&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gave me the basic page view. From there, I added buttons for Previous, Next, Go to page, Zoom In, and Zoom Out.&lt;/p&gt;

&lt;h2&gt;
  
  
  A key lesson: application state
&lt;/h2&gt;

&lt;p&gt;One important concept I learned was application state. Instead of letting each button manage unrelated variables, I kept the reader's current information together:&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;reader_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;current_page&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;zoom_dpi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_index&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;bookmarks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI follows a simple pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A button changes &lt;code&gt;reader_state&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The application saves important changes if necessary.&lt;/li&gt;
&lt;li&gt;A refresh function renders the current state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For example, page navigation uses one shared function:&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;change_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_page&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="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;new_page&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;page_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="n"&gt;reader_state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;current_page&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="n"&gt;new_page&lt;/span&gt;
    &lt;span class="nf"&gt;save_current_reader_data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;refresh_reader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using a single function for navigation prevents different controls from handling page changes in slightly different ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Searching and highlighting PDF text
&lt;/h2&gt;

&lt;p&gt;PyMuPDF can find the rectangles where a text query appears on a page.&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;rectangles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;research&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The returned rectangles use PDF coordinates, measured in points. The rendered page image uses pixels. Since PDF points are based on 72 points per inch, I learned to scale each search rectangle with this formula:&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;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;
&lt;span class="n"&gt;pixel_x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdf_x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That conversion lets the reader draw highlights in the correct position on the rendered image.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ImageDraw&lt;/span&gt;

&lt;span class="n"&gt;draw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ImageDraw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGBA&lt;/span&gt;&lt;span class="sh"&gt;"&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;rect&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rectangles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rectangle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;fill&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;235&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="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;outline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was one of the most useful lessons in the project: PDF document coordinates and screen-image coordinates are not the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding persistent bookmarks and notes
&lt;/h2&gt;

&lt;p&gt;Bookmarks and notes should survive a restart. I did not want to modify the original PDF for this first version, so I stored personal reader data in a JSON file.&lt;/p&gt;

&lt;p&gt;A document entry looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"documents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"/absolute/path/to/test.pdf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"file_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bookmarks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"page_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Important result"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"page_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Review this figure before the presentation."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-11 15:30"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"last_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bookmark points to a page and has a label. A note points to a page, has text, and stores when it was created. The current page is also saved whenever the user navigates.&lt;/p&gt;

&lt;p&gt;I used a temporary file before replacing the main JSON file:&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;save_all_reader_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;temporary_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DATA_PATH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_suffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.tmp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;temporary_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temporary_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DATA_PATH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is safer than directly overwriting the main file because it reduces the risk of leaving a partially written JSON file if a save is interrupted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refactoring the notebook into modules
&lt;/h2&gt;

&lt;p&gt;The first notebook worked, but it was becoming one large cell. That is acceptable for exploration, but difficult to maintain.&lt;/p&gt;

&lt;p&gt;I moved reusable code into separate modules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;custom-ai-pdf-reader/
├── data/
│   └── reader_data.json
├── notebooks/
│   └── Untitled.ipynb
├── src/
│   ├── __init__.py
│   ├── pdf_service.py
│   ├── reader_state.py
│   └── storage_service.py
├── tests/
│   └── test_storage.py
├── .gitignore
├── pytest.ini
└── requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;pdf_service.py&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This module is responsible for PDF-specific tasks:&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;open_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_page_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;highlight_rectangles&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;storage_service.py&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This module handles JSON persistence:&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;load_all_reader_data&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;save_all_reader_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_document_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdf_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;save_document_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdf_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bookmarks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;reader_state.py&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This module stores the reader's active state:&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;reader_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;current_page&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;zoom_dpi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_index&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;bookmarks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The notebook now focuses on the interface and event handlers, while the reusable logic lives in Python files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing automated tests
&lt;/h2&gt;

&lt;p&gt;I added pytest tests for the storage layer. The tests use temporary folders, so they do not touch my real bookmarks, notes, or &lt;code&gt;reader_data.json&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;Examples of what the tests verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Missing storage files return empty reader data.&lt;/li&gt;
&lt;li&gt;Saved JSON can be loaded again.&lt;/li&gt;
&lt;li&gt;A new document gets the expected default fields.&lt;/li&gt;
&lt;li&gt;Bookmarks, notes, and last-read page are saved correctly.&lt;/li&gt;
&lt;li&gt;The final JSON output is valid.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first successful test run was a good milestone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;collected 5 items

5 passed in 0.02s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was also my first practical lesson in why automated tests matter. The interface can look correct while a save or load function still has a hidden problem. Tests give the project a repeatable safety net before making larger changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problems I encountered
&lt;/h2&gt;

&lt;p&gt;Building this project involved several useful mistakes and fixes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tkinter was unavailable
&lt;/h3&gt;

&lt;p&gt;I initially considered a Tkinter desktop interface, but the Linux Python environment did not include the required Tk bindings. Instead of spending the first phase on GUI installation problems, I switched to Jupyter widgets for the prototype and chose PySide6 for the future desktop application.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ubuntu had an APT lock
&lt;/h3&gt;

&lt;p&gt;An Ubuntu background update held the package-manager lock. The correct response was to wait and inspect the running update, not to delete lock files or force-stop the process.&lt;/p&gt;

&lt;h3&gt;
  
  
  Widgets displayed incorrectly
&lt;/h3&gt;

&lt;p&gt;At one stage, interactive Jupyter widgets appeared as plain text or did not respond to clicks. The problem was environment setup: Jupyter, the Python kernel, and ipywidgets need to be connected to the same project environment. Restarting the kernel and testing a minimal button helped isolate the issue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Old notebook functions conflicted with new modules
&lt;/h3&gt;

&lt;p&gt;When I moved code into modules, I accidentally kept old versions of functions such as &lt;code&gt;render_page()&lt;/code&gt;, &lt;code&gt;search_document()&lt;/code&gt;, and JSON storage functions inside the notebook. This created duplicate names and confusing behavior. The fix was to keep the reusable function in one module and import it into the notebook.&lt;/p&gt;

&lt;h3&gt;
  
  
  Git repository inside another Git repository
&lt;/h3&gt;

&lt;p&gt;I created a new project folder inside an existing Git repository and accidentally ran &lt;code&gt;git init&lt;/code&gt; inside the nested folder. That created a second &lt;code&gt;.git&lt;/code&gt; directory. The correct approach was to remove only the accidental nested &lt;code&gt;.git&lt;/code&gt; folder and use the original repository at the parent level.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pytest could not import &lt;code&gt;src&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;My tests initially failed with &lt;code&gt;ModuleNotFoundError: No module named 'src'&lt;/code&gt;. I fixed that by adding &lt;code&gt;src/__init__.py&lt;/code&gt; and configuring pytest with a &lt;code&gt;pytest.ini&lt;/code&gt; file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[pytest]&lt;/span&gt;
&lt;span class="py"&gt;pythonpath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
&lt;span class="py"&gt;testpaths&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;tests&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests then passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub workflow
&lt;/h2&gt;

&lt;p&gt;I learned to use a safer Git workflow for each tested milestone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git status
git add custom-ai-pdf-reader/src/
git add custom-ai-pdf-reader/tests/
git add custom-ai-pdf-reader/notebooks/
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Add modular PDF reader prototype and storage tests"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.gitignore&lt;/code&gt; file is important because local PDFs, the virtual environment, temporary cache files, and personal reader data should not be uploaded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;__pycache__/
*.py[cod]
.pytest_cache/
.venv/
.ipynb_checkpoints/
data/
*.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;This project taught me more than how to display a PDF in Python. The main lessons were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A PDF is a document description, not simply an image.&lt;/li&gt;
&lt;li&gt;Rendering DPI affects both clarity and memory usage.&lt;/li&gt;
&lt;li&gt;PDF points must be scaled when drawing highlights on a pixel image.&lt;/li&gt;
&lt;li&gt;Application state makes UI behavior easier to understand and maintain.&lt;/li&gt;
&lt;li&gt;Event-driven interfaces use callbacks that run when users click buttons.&lt;/li&gt;
&lt;li&gt;JSON serialization makes application data survive a restart.&lt;/li&gt;
&lt;li&gt;Temporary-file replacement is safer than directly overwriting data files.&lt;/li&gt;
&lt;li&gt;Zero-based page indexes in Python must be translated into human page numbers.&lt;/li&gt;
&lt;li&gt;Reusable modules make code easier to test and later reuse in a desktop application.&lt;/li&gt;
&lt;li&gt;Tests catch storage problems before they affect real user data.&lt;/li&gt;
&lt;li&gt;Git repositories should have one clear root; accidentally nesting repositories causes confusion.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;The Jupyter prototype now has a tested storage layer. The next milestone is a PySide6 desktop MVP with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A real desktop window&lt;/li&gt;
&lt;li&gt;An Open PDF action&lt;/li&gt;
&lt;li&gt;A scrollable rendered PDF page&lt;/li&gt;
&lt;li&gt;Previous and Next controls&lt;/li&gt;
&lt;li&gt;Page-number navigation&lt;/li&gt;
&lt;li&gt;Zoom controls&lt;/li&gt;
&lt;li&gt;A status bar&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After the desktop foundation is stable, I plan to migrate persistence from JSON to SQLite, add text selection and annotations, then explore AI summaries, PDF question answering with citations, text-to-speech, and voice commands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Starting small was the right choice. I did not begin by trying to build an AI system or a complete commercial PDF application. I started with one page, then navigation, then search, then bookmarks, notes, saved progress, modules, tests, and version control.&lt;/p&gt;

&lt;p&gt;Each feature made the next one easier to understand. The project is still at an early stage, but it already has a working and tested foundation for a more capable custom research reader.&lt;/p&gt;

&lt;p&gt;If you are building your own developer project, my advice is simple: keep the first version small, make each milestone testable, and commit working progress often.&lt;/p&gt;

</description>
      <category>python</category>
      <category>jupyter</category>
      <category>productivity</category>
      <category>testing</category>
    </item>
    <item>
      <title>Progress Update: I Turned My Python PDF Prototype into a Desktop Research Reader</title>
      <dc:creator>Kavindu Kavishka Premarathna</dc:creator>
      <pubDate>Fri, 14 Aug 2026 16:11:14 +0000</pubDate>
      <link>https://dev.to/kavindu_kp/progress-update-i-turned-my-python-pdf-prototype-into-a-desktop-research-reader-1off</link>
      <guid>https://dev.to/kavindu_kp/progress-update-i-turned-my-python-pdf-prototype-into-a-desktop-research-reader-1off</guid>
      <description>&lt;p&gt;My previous milestone was an interactive PDF-reader prototype running in Jupyter. It could render PDF pages, navigate, search text, save bookmarks and notes, and restore reading progress.&lt;/p&gt;

&lt;p&gt;This update is about the next major step: moving that prototype into a real desktop application with PySide6. The project is still not an AI reader yet, but it now has the core features I need before adding an LLM: reading, navigation, search, notes, bookmarks, and saved annotations.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;The reader is now a desktop application launched with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; src.main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The desktop version currently supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open any local PDF with a native file picker&lt;/li&gt;
&lt;li&gt;Render pages in a scrollable window&lt;/li&gt;
&lt;li&gt;Previous, Next, and Go-to-page navigation&lt;/li&gt;
&lt;li&gt;Zoom controls and a Fit page view&lt;/li&gt;
&lt;li&gt;Mouse-wheel scrolling within pages&lt;/li&gt;
&lt;li&gt;Mouse-wheel page changes at the top or bottom of a page&lt;/li&gt;
&lt;li&gt;Persistent last-read-page restoration&lt;/li&gt;
&lt;li&gt;Dockable Bookmarks panel&lt;/li&gt;
&lt;li&gt;Dockable Notes panel&lt;/li&gt;
&lt;li&gt;Text search with highlighted results&lt;/li&gt;
&lt;li&gt;Persistent highlight, underline, and strike-through annotations&lt;/li&gt;
&lt;li&gt;Annotations panel with open and delete actions&lt;/li&gt;
&lt;li&gt;Keyboard shortcuts for common reading actions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important design choice is that bookmarks, notes, reading progress, and annotations remain in local reader data rather than modifying the original PDF. That keeps the original paper safe and lets the application later provide an explicit export option.&lt;/p&gt;

&lt;h2&gt;
  
  
  From notebook UI to desktop UI
&lt;/h2&gt;

&lt;p&gt;The Jupyter prototype used &lt;code&gt;ipywidgets&lt;/code&gt;. It was useful for learning the reader logic, but it was not the experience of a normal PDF application.&lt;/p&gt;

&lt;p&gt;I moved the UI into PySide6 and kept the underlying reader services separate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;custom-ai-pdf-reader/
├── notebooks/
│   └── Untitled.ipynb
├── src/
│   ├── main.py
│   ├── pdf_service.py
│   ├── reader_state.py
│   └── storage_service.py
├── tests/
│   └── test_storage.py
├── pytest.ini
└── requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This separation has been useful:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;main.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Desktop interface, controls, panels, events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pdf_service.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open PDFs, render pages, extract and search text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reader_state.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current page, zoom level, search state, bookmarks, notes, annotations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;storage_service.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Save and load reader data from JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The UI can change from Jupyter to PySide6 without rewriting the PDF and persistence logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the first desktop reader
&lt;/h2&gt;

&lt;p&gt;The desktop reader uses a &lt;code&gt;QMainWindow&lt;/code&gt; as the application shell. The PDF page is rendered with PyMuPDF, converted into a Qt image, and shown through a &lt;code&gt;QLabel&lt;/code&gt; inside a &lt;code&gt;QScrollArea&lt;/code&gt;.&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;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;render_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="o"&gt;=&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;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;page_number&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dpi&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;qimage&lt;/span&gt; &lt;span class="o"&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;pil_to_qimage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image&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="n"&gt;original_pixmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;QPixmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qimage&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;update_page_display&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A scroll area is important because zoomed pages can become larger than the available window. It allows normal vertical and horizontal scrolling instead of forcing a page to remain at one fixed size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fit page and mouse-wheel navigation
&lt;/h2&gt;

&lt;p&gt;A reader should not start by showing an oversized or cropped page. I added a &lt;code&gt;Fit page&lt;/code&gt; mode that scales the rendered page to the available viewport while preserving its aspect ratio.&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;fitted_pixmap&lt;/span&gt; &lt;span class="o"&gt;=&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;original_pixmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scaled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;viewport_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Qt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AspectRatioMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KeepAspectRatio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Qt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TransformationMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SmoothTransformation&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fit mode automatically updates when the window is resized. Manual zoom disables fit mode, and the &lt;code&gt;Fit page&lt;/code&gt; button restores it.&lt;/p&gt;

&lt;p&gt;I also added a more natural mouse-wheel behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scroll normally while reading a large page.&lt;/li&gt;
&lt;li&gt;Scroll down at the bottom of the page to move to the next PDF page.&lt;/li&gt;
&lt;li&gt;Scroll up at the top of the page to move to the previous PDF page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes the reader feel closer to a standard document viewer while keeping page-based persistence and navigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bookmarks and notes in dockable panels
&lt;/h2&gt;

&lt;p&gt;Bookmarks and notes were already stored by the prototype. The desktop reader now exposes them through dockable side panels.&lt;/p&gt;

&lt;p&gt;The Bookmarks panel supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add a custom label to the current page&lt;/li&gt;
&lt;li&gt;Double-click a bookmark to open its page&lt;/li&gt;
&lt;li&gt;Delete a selected bookmark&lt;/li&gt;
&lt;li&gt;Restore bookmarks for the opened document&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Notes panel supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write a multi-line note for the current page&lt;/li&gt;
&lt;li&gt;Save a timestamp with the note&lt;/li&gt;
&lt;li&gt;Double-click a note to return to its page&lt;/li&gt;
&lt;li&gt;Delete a selected note&lt;/li&gt;
&lt;li&gt;Restore notes when the same PDF is opened again&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I used &lt;code&gt;QDockWidget&lt;/code&gt; so the panels can be docked at either side of the reader or closed temporarily. The &lt;code&gt;View&lt;/code&gt; menu includes toggle actions, so a closed panel can always be reopened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Desktop search
&lt;/h2&gt;

&lt;p&gt;The reader can search the entire document and move through matching pages. PyMuPDF returns page rectangles for each search match, which are useful because they are measured in PDF coordinates.&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;rectangles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the app renders a page at a specific DPI, PDF rectangles must be converted to pixels:&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;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;
&lt;span class="n"&gt;pixel_x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdf_x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allows search highlights to remain aligned with the rendered text at different zoom levels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persistent annotations
&lt;/h2&gt;

&lt;p&gt;The newest feature is a first annotation workflow.&lt;/p&gt;

&lt;p&gt;Because the current reader renders pages as images, it does not yet provide arbitrary click-and-drag text selection. Instead, the user searches for a word or phrase, navigates to a search result, and saves that result as one of three annotation types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Highlight&lt;/li&gt;
&lt;li&gt;Underline&lt;/li&gt;
&lt;li&gt;Strike-through&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each annotation stores page number, PDF-coordinate rectangles, annotation type, searched text, and timestamp.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"page_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rectangles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;80.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;120.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;240.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;138.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"highlight"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"recognition over recall"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-14 21:00"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Storing rectangles in PDF coordinates is important. Screen pixels change when DPI changes, but PDF coordinates remain stable. Every time a page is rendered, the reader scales the saved rectangles using the current DPI.&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;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;

&lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
&lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;y0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
&lt;span class="n"&gt;right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
&lt;span class="n"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Highlights use a semi-transparent yellow fill, underlines use a blue line near the bottom of the match, and strike-through annotations use a red line through the middle of the match.&lt;/p&gt;

&lt;p&gt;The Annotations panel lists saved annotations, opens their associated page on double-click, and lets the user delete a selected annotation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why annotations are stored outside the PDF
&lt;/h2&gt;

&lt;p&gt;For this stage, annotations are saved in &lt;code&gt;data/reader_data.json&lt;/code&gt; with bookmarks, notes, and reading progress.&lt;/p&gt;

&lt;p&gt;This is intentional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A paper may be read-only or signed.&lt;/li&gt;
&lt;li&gt;The original PDF should remain unchanged.&lt;/li&gt;
&lt;li&gt;Annotations can be deleted or edited later.&lt;/li&gt;
&lt;li&gt;The project can eventually offer an explicit &lt;code&gt;Export Annotated PDF&lt;/code&gt; action that writes to a separate copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reader data now has this structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"documents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"/absolute/path/to/paper.pdf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"file_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paper.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bookmarks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"last_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Keyboard shortcuts
&lt;/h2&gt;

&lt;p&gt;To improve reading productivity, I added shortcuts for common actions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shortcut&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Ctrl + O&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open PDF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Ctrl + Q&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Exit reader&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Left Arrow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Previous page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Right Arrow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Next page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Ctrl + F&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Focus search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Ctrl + B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Add bookmark&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Ctrl + Plus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Zoom in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Ctrl + Minus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Zoom out&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The goal is not only to add features, but also to reduce unnecessary mouse movement while reading research papers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing and version control
&lt;/h2&gt;

&lt;p&gt;The JSON storage layer has automated pytest coverage. The suite checks empty storage, saving and loading data, default document data, bookmark/note/progress persistence, and valid JSON output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5 passed in 0.02s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also learned to be selective when staging files. The repository ignores virtual environments, temporary Python files, local PDFs, and personal reader data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;__pycache__/
*.py[cod]
.pytest_cache/
.venv/
.ipynb_checkpoints/
data/
*.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tested milestone is committed separately, making it easier to return to a working version if a future feature introduces a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned in this milestone
&lt;/h2&gt;

&lt;p&gt;This desktop stage taught me several practical lessons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A notebook prototype and a desktop application can share core services while using completely different interfaces.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;QScrollArea&lt;/code&gt; is essential for readable zoomed document pages.&lt;/li&gt;
&lt;li&gt;Fit-to-window and manual zoom are different modes and need clear behavior.&lt;/li&gt;
&lt;li&gt;Dockable panels are a useful pattern for reader tools such as bookmarks, notes, and annotations.&lt;/li&gt;
&lt;li&gt;A panel closed with its X button needs a View-menu toggle action so the user can restore it.&lt;/li&gt;
&lt;li&gt;PDF coordinates and image pixels must be converted carefully to keep search and annotations aligned.&lt;/li&gt;
&lt;li&gt;Local JSON storage is still suitable for a small personal reader, but SQLite will become useful as annotations and document collections grow.&lt;/li&gt;
&lt;li&gt;A simple, reliable annotation workflow is better than attempting complex text selection too early.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;The basic reader is much closer to being ready for AI features. Before integrating an LLM, I still want to improve a few core areas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add an explicit clear-search button.&lt;/li&gt;
&lt;li&gt;Add edit support for bookmarks and notes.&lt;/li&gt;
&lt;li&gt;Add a recent-documents list.&lt;/li&gt;
&lt;li&gt;Improve annotation text selection beyond search results.&lt;/li&gt;
&lt;li&gt;Add export to a separate annotated PDF copy.&lt;/li&gt;
&lt;li&gt;Add tests for PDF rendering and storage with annotations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After these basics are stable, the next major phase will be LLM-assisted reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paper and chapter summaries&lt;/li&gt;
&lt;li&gt;Questions and answers over document text&lt;/li&gt;
&lt;li&gt;Page-number citations for answers&lt;/li&gt;
&lt;li&gt;Cached local summaries&lt;/li&gt;
&lt;li&gt;Privacy controls for local versus cloud models&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;The project started with a single rendered page in a notebook. It now has a desktop reader with persistent research tools and a growing codebase that is modular, tested, and version controlled.&lt;/p&gt;

&lt;p&gt;The biggest lesson has been to build the reading workflow first. An LLM can be useful, but it becomes far more useful when the application already knows which document is open, which page the user is reading, what they bookmarked, what they noted, and what they annotated.&lt;/p&gt;

&lt;p&gt;The next phase is AI—but the reader now has a foundation worth building AI on.&lt;/p&gt;

</description>
      <category>python</category>
      <category>pyside</category>
      <category>productivity</category>
      <category>testing</category>
    </item>
    <item>
      <title>Building "AXIS" (Step 1): Creating My Own Autonomous AI Agent to Organize Files</title>
      <dc:creator>Kavindu Kavishka Premarathna</dc:creator>
      <pubDate>Sun, 02 Aug 2026 14:21:39 +0000</pubDate>
      <link>https://dev.to/kavindu_kp/building-axis-step-1-creating-my-own-autonomous-ai-agent-to-organize-files-13ep</link>
      <guid>https://dev.to/kavindu_kp/building-axis-step-1-creating-my-own-autonomous-ai-agent-to-organize-files-13ep</guid>
      <description>&lt;p&gt;Ever since I started coding, I’ve had one massive, ultimate goal: To build my own personal AI Agent.&lt;/p&gt;

&lt;p&gt;I don't just want a standard chatbot. I want an autonomous LLM-powered assistant that lives in my computer, understands my workflow, and handles the heavy lifting for me. I call this agent &lt;strong&gt;AXIS (Automated eXtraction and Intelligence System)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Building a fully autonomous AI agent is a massive project, so I decided to break it down. &lt;br&gt;
This is Step 1: Giving AXIS the ability to see, understand, and automatically organize my local file system.&lt;/p&gt;

&lt;p&gt;Here is a look at my progress so far, what I’ve learned, the hurdles I faced, and where this project is heading next.&lt;/p&gt;

&lt;p&gt;📂 &lt;strong&gt;The First Mission: Conquering the Downloads Folder&lt;/strong&gt;&lt;br&gt;
Like any Computer Engineering student, developer, my folders were a nightmare. It was a chaotic mix of assignment PDFs and functional React code files. Here you can see a sample messy directory.&lt;/p&gt;

&lt;p&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%2Fa752yqgz3jipji78ckcl.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%2Fa752yqgz3jipji78ckcl.png" alt="The " width="800" height="212"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instead of manually sorting them every week, I wanted AXIS to do the dirty work for me.&lt;/p&gt;

&lt;p&gt;🛠️ &lt;strong&gt;What I Built and Learned So Far&lt;/strong&gt;&lt;br&gt;
To start, I needed to learn how to make a Python script run silently in the background and react to new files without destroying my computer's performance.&lt;/p&gt;

&lt;p&gt;Event-Driven Architecture: Instead of writing a while True loop that constantly checks for new files (which burns CPU), I learned how to use the Python watchdog library. It acts as an event listener, meaning AXIS sleeps at 0% CPU usage and only wakes up when the OS tells it a new file has arrived.&lt;/p&gt;

&lt;p&gt;Data Extraction &amp;amp; Keyword Matching: For PDFs, I used pypdf to extract text. If the script read technical words related to my studies, it moved the file to my Engineering folder. If it saw "React" or "code", it instantly went to my Software Projects folder.&lt;/p&gt;

&lt;p&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%2Fkdhkpylckdv1zmbak4ed.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%2Fkdhkpylckdv1zmbak4ed.png" alt="Terminal view showing the A.X.I.S. Python script running successfully inside a virtual environment. The system has identified the target directory and is actively monitoring for new files." width="714" height="145"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🚧 **The Major Hurdle: Rule-Based Systems Are Blind&lt;br&gt;
**The keyword system was great, but it hit a massive wall: Media files.&lt;/p&gt;

&lt;p&gt;When I downloaded cinematic reference videos, 3D animated assets, or custom banners for my gaming channel, standard OCR (text recognition) completely failed. A rule-based system can't categorize a file if it doesn't know what it's looking at. AXIS was essentially blind to visual context.&lt;/p&gt;

&lt;p&gt;🧠 &lt;strong&gt;The Solution: Giving AXIS "Eyes"&lt;/strong&gt;&lt;br&gt;
To solve this, I introduced my first Multimodal LLM integration: The Gemini Vision API.&lt;/p&gt;

&lt;p&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%2F5eybzxxgp5mcvor1egvn.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%2F5eybzxxgp5mcvor1egvn.png" alt="A.X.I.S. in action! The terminal log shows the agent categorizing files using two methods: [KEYWORD-MOVED] for text-based sorting of PDFs, and [AI-VISION] (powered by Gemini) for visually analyzing and sorting the image files into their respective folders." width="733" height="238"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instead of searching for text, I configured AXIS to pass image files directly to the AI with a strict prompt, asking it to classify the visual content into my predefined categories.&lt;/p&gt;

&lt;p&gt;The result was pure magic. AXIS could now look at a screenshot of a React UI, a complex system architecture diagram, or a highly stylized gaming logo and instantly move it to the correct folder—without relying on a single extracted keyword.&lt;/p&gt;

&lt;p&gt;🚀 &lt;strong&gt;What’s Next? (The Ultimate Goal)&lt;/strong&gt;&lt;br&gt;
Right now, AXIS is smart, but it is restricted. It relies on a list of predefined folders I hardcoded into the script.&lt;/p&gt;

&lt;p&gt;The next step for AXIS is true autonomy.&lt;br&gt;
In Phase 2, I will remove the hardcoded categories completely. When I drop any file—whether it's an .exe, a movie, a random document, or a complex folder structure—AXIS will analyze the file types and contents, and dynamically generate the perfect folder structure on the fly.&lt;/p&gt;

&lt;p&gt;It won't just sort files into boxes I made; it will build the boxes itself based on context.&lt;/p&gt;

&lt;p&gt;This is just the beginning of my journey to building a fully-fledged personal AI agent. If you want to follow along, share ideas, or check out the code for Step 1, I’ve made it completely open-source!&lt;/p&gt;

&lt;p&gt;💻 Check out the code on GitHub: &lt;a href="https://github.com/KKPremarathna/A.X.I.S" rel="noopener noreferrer"&gt;https://github.com/KKPremarathna/A.X.I.S&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you ever tried building your own automation tools or AI agents? Let me know in the comments! Stay tuned for Phase 2.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
