Solving the "Blurry Line" Problem: Engineering a Python CLI for E-Ink Devices
Introduction
I love my Supernote Manta. It’s a fantastic e-ink writing tablet. But like many e-ink devices (reMarkable, Boox), it suffers from a specific hardware constraint: no sub-pixel anti-aliasing.
If you take a standard PDF template —say, a ruled notebook page generated by a generic tool— and load it onto the device, the lines often look gray, fuzzy, or inconsistent. This happens because the lines land on fractional pixel coordinates (e.g., \(y = 10.4\)), forcing the display controller to dither the pixels. On a crisp 300 DPI e-ink screen, this blur is immediately noticeable and reduces the “paper-like” contrast.
I spent the last few weeks building eink-template-gen, a robust Python CLI tool designed to solve this exact problem using pixel-perfect integer math.
The Challenge: The “Half-Pixel” Problem
Standard graphic design tools operate in vector space (infinite resolution) or floating-point coordinates. When you ask for lines spaced exactly “6mm” apart, the software calculates the position mathematically:
dpi = 300
mm_to_px = 300 / 25.4 # ~11.81 px/mm
y_pos = 6 * mm_to_px # 70.866 px
To a printer, \(70.866\) is fine. To an e-ink screen, that \(0.866\) results in aliasing. The line isn’t black; it’s a smear of gray pixels trying to represent a fraction.
My goal was to build a generator that respects the hardware reality of ANY e-ink device.
The Solution: Architecture Overview
I architected the application with three core layers:
- Hardware Abstraction: A data-driven definition of device constraints.
- The Math Layer: Utilities to snap floating-point requests to integer grids.
- The Logic Layer: A registry-based system for template rendering.
Tech Stack:** Python 3.13, PyCairo (for rendering), Pytest, GitHub Actions.
Hardware Abstraction (Data-Driven Design)
I didn’t want to hardcode screen resolutions into Python classes. Instead, I implemented a data-driven approach where devices are defined in a simple JSON file. This makes the tool device-agnostic and extensible without code changes; users can add support for new devices just by editing a config file.
// src/eink_template_gen/devices.json
[
{
"id": "manta",
"width": 1920,
"height": 2560,
"dpi": 300,
"name": "Supernote Manta",
"default_margin_mm": 10
},
{
"id": "a5x",
"width": 1404,
"height": 1872,
"dpi": 226,
"name": "Supernote A5 X"
}
]
The “Pixel Snapping” Algorithm
This is the heart of the engine. When a user requests “6mm spacing,” the tool doesn’t just draw lines every 6mm. It performs a “snap-and-recalculate” operation:
- Convert requested MM to Pixels.
- Round to the nearest whole integer pixel.
- Convert that integer back to MM for reporting.
- Recalculate the total page margins to ensure the grid is perfectly centered.
Here is the core logic from src/eink_template_gen/utils.py:
def snap_spacing_to_clean_pixels(spacing_mm, dpi, tolerance_mm=0.5):
"""
Adjust spacing to nearest value that produces integer pixels
"""
mm2px = dpi / 25.4
ideal_px = spacing_mm * mm2px
# Try rounding to nearest integer
rounded_px = round(ideal_px)
adjusted_mm = rounded_px / mm2px
# Check if adjustment is within tolerance
adjustment = abs(adjusted_mm - spacing_mm)
if adjustment <= tolerance_mm:
return adjusted_mm, float(rounded_px), adjustment > 0.001
else:
# Keep original if adjustment would be too large
return spacing_mm, ideal_px, False
This ensures that if you ask for a grid, every single line lands on an exact pixel coordinate, rendering as pure black (0x00) rather than dithered gray.
Advanced Geometry: Solving the “Half-Cell” Issue
Solving the blurry line problem was only step one. The second major issue with generic generators is grid misalignment.
If your device screen height is 1872 pixels, and your grid spacing is 71 pixels, you can fit 26.36 squares vertically. Most generators simply start at the top margin and draw until they hit the bottom, resulting in an ugly, cut-off “half-cell” at the bottom of the page. Even worse, if you use thicker “major lines” (e.g., every 5 squares), the page might end abruptly before completing a major section.
I extended the math layer to treat margins not as fluid buffers instead of fixed boundaries.
The tool calculates exactly how many complete cells (or major blocks) can fit within the safe area. It then takes the leftover space and distributes it evenly to the top and bottom or left and right margins.
def calculate_major_aligned_margins(content_dimension, spacing_px, base_margin, major_every):
"""
Calculate margins that force grid to end on major lines
"""
major_unit_px = major_every * spacing_px
# How many full major blocks fit?
num_complete_units = int(content_dimension / major_unit_px)
# How much space is actually needed?
needed_space = num_complete_units * major_unit_px
# Calculate the leftover space
leftover_space = content_dimension - needed_space
# Distribute leftover space to margins
start_addition = int(leftover_space / 2)
end_addition = int(leftover_space - start_addition)
return (base_margin + start_addition, base_margin + end_addition)
By calculating the layout “inside-out”—determining content first, then margins—the tool guarantees that every grid ends perfectly on a line. The result is a visually balanced page that feels like it was natively designed for the device.
Escape Hatches: Designing for Diverse Workflows
While “pixel-perfect” is the default opinion of this tool, I recognized that software engineering requires handling edge cases where the default opinion is wrong.
I implemented “escape hatches” for users who prioritize physical accuracy over visual crispness.
1. The “True Scale” Flag
Engineers or architects might need a grid where “5mm” means exactly 5.000mm, because they are scaling physical drawings on the screen. For them, pixel snapping is a bug, not a feature.
I added the --true-scale flag, which bypasses the integer rounding logic entirely. It accepts the anti-aliasing blur in exchange for dimensional precision.
2. Enforcing Margins
Sometimes, a user wants a strict margin for printing, specific toolbar clearance or just a consistent presentation across pages and therefor wouldn’t want the “fluid buffer” adjustment I described above.
The --enforce-margins flag locks the margins to the user’s input, forcing the grid to cut off if necessary.
This flexibility ensures the tool serves both the aesthetic perfectionist and the technical pragmatist.
The Registry Pattern (Open/Closed Principle)
To support a growing library of templates (Lined, Grid, Dotgrid, Music Staves, Isometric, etc.) without turning main.py into a spaghetti-code nightmare, I used the Registry Pattern.
New templates can be added by defining a draw function and registering it in src/eink_template_gen/templates.py. The rest of the application (CLI, Wizard, JSON engine) automatically discovers the new capability.
TEMPLATE_REGISTRY = {
"lined": {
"draw_func": drawing.draw_lined_section,
"decorations": ["line_numbers"],
"specific_args_map": {
"line_width_px": "line_width",
"major_every": "major_every",
},
},
"isometric": {
"draw_func": drawing.draw_isometric_grid,
"decorations": [],
"specific_args_map": { ... },
},
# Adding a new template only requires adding an entry here
}
I used this same pattern for the implementation of Cover pages and Divider lines.
User Experience: The State Machine Wizard
While CLI flags are powerful (`eink-template-gen grid –spacing 5mm`), they are intimidating for non-technical users. I wanted this tool to be accessible to the general Supernote community.
I implemented an interactive “Wizard” (src/eink_template_gen/wizard.py) using a State Machine approach. Instead of a linear script of `input()` calls, the wizard advances through discrete states (`_select_device`, `_select_template_type`, `_configure_spacing`).
This architecture allows for complex navigation logic, such as:
- Conditional Branches: If the user selects “Multi-Grid,” ask for Rows/Columns. If “Lined,” ask for Line Numbers.
- “Back” Functionality: Users can type ‘b’ at any prompt to return to the previous state without restarting the script.
def run(self):
steps = [
self._select_device,
self._select_template_type,
self._configure_spacing,
# ...
]
current_step = 0
while 0 <= current_step < len(steps):
step_function = steps[current_step]
result = step_function()
if result == "back":
current_step -= 1
elif result == "next":
current_step += 1
Parametric Design: Templates as Code
In the DevOps world, we rarely configure servers manually; we define the desired state in code (IaC) and let an engine build it. I applied this same “Configuration as Code” philosophy to graphic design.
Instead of forcing users to manually draw complex layouts, I built a parametric JSON engine. Users define a “manifest” describing the page structure—ratios, regions, and styles—and the tool renders it deterministically.
For example, a user can define a “Cornell Notes” layout structurally, without ever touching a drawing tool:
// examples/json_layouts/cornell_notes.json
{
"device": "manta",
"master_spacing_mm": 7,
"page_layout": [
{
"name": "Cue Column",
"region_rect": [0, 0.12, 0.25, 0.68], // x, y, width, height (percentages)
"template": "lined",
"kwargs": { "line_width_px": 0.5 }
},
{
"name": "Summary Footer",
"region_rect": [0, 0.8, 1.0, 0.2],
"template": "grid",
"kwargs": { "major_every": 5 }
}
]
}
This approach decouples the definition of the template from its rendering. It allows users to version-control their notebook layouts just like they would version-control a Kubernetes manifest.
Algorithmic Art: L-Systems & Truchet Tiles
Beyond utility templates, I wanted users to leverage the capabilities of high-res e-ink for personalization and intimacy. I implemented several generative algorithms for cover pages and divider lines:
- Truchet Tiles: Uses randomized rotations of simple arc/line tiles to create complex, maze-like patterns.
- L-Systems (Fractals): I implemented a string-rewriting engine to generate fractals like the Hilbert Curve and Koch Snowflake.
The L-System engine (src/eink_template_gen/lsystem.py) generates a command string based on axioms and rules, which is then interpreted by a “turtle” renderer in PyCairo.
L_SYSTEM_DEFINITIONS = {
"hilbert_curve": {
"axiom": "A",
"rules": {"A": "+BF-AFA-FB+", "B": "-AF+BFB+FA-"},
"angle": 90,
}
}
Engineering for Reliability
This isn’t just a script; it’s a software product. I ensured reliability through:
- Automated Testing: A comprehensive `pytest` suite covering everything from the math utilities to the CLI argument parsing.
- CI/CD Pipeline: A GitHub Actions workflow (`ci.yml`) runs linting (Ruff/Black) and executes tests across a matrix of Python versions (3.8, 3.11) to ensure backward compatibility and robust environment management.
- Automated Publishing: Releases are automatically built and pushed to PyPI when a new Release is created in GitHub (`publish.yml`).
Conclusion
Visual artifacts on e-ink screens are a small annoyance, but fixing them required a deep dive into coordinate geometry and careful software architecture. By respecting the hardware limitations and building a flexible, data-driven architecture, eink-template-gen provides a tool that is both powerful for developers and accessible for users.
You can check out the code or install the tool yourself:
- GitHub: calebc42/eink-template-gen
- PyPI:
pip install eink-template-gen
“The details are not the details. They make the design.” – Charles Eames