diff --git a/scripts/generate_test_epub.py b/scripts/generate_test_epub.py
new file mode 100644
index 00000000..adfce18b
--- /dev/null
+++ b/scripts/generate_test_epub.py
@@ -0,0 +1,621 @@
+#!/usr/bin/env python3
+"""
+Generate test EPUBs for image rendering verification.
+
+Creates EPUBs with annotated JPEG and PNG images to verify:
+- Grayscale rendering (4 levels)
+- Image scaling
+- Image centering
+- Cache performance
+- Page serialization
+"""
+
+import os
+import zipfile
+from pathlib import Path
+
+try:
+ from PIL import Image, ImageDraw, ImageFont
+except ImportError:
+ print("Please install Pillow: pip install Pillow")
+ exit(1)
+
+OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs"
+SCREEN_WIDTH = 480
+SCREEN_HEIGHT = 800
+
+def get_font(size=20):
+ """Get a font, falling back to default if needed."""
+ try:
+ return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size)
+ except:
+ try:
+ return ImageFont.truetype("/usr/share/fonts/TTF/DejaVuSans.ttf", size)
+ except:
+ return ImageFont.load_default()
+
+def draw_text_centered(draw, y, text, font, fill=0):
+ """Draw centered text at given y position."""
+ bbox = draw.textbbox((0, 0), text, font=font)
+ text_width = bbox[2] - bbox[0]
+ x = (draw.im.size[0] - text_width) // 2
+ draw.text((x, y), text, font=font, fill=fill)
+
+def draw_text_wrapped(draw, x, y, text, font, max_width, fill=0):
+ """Draw text with word wrapping."""
+ words = text.split()
+ lines = []
+ current_line = []
+
+ for word in words:
+ test_line = ' '.join(current_line + [word])
+ bbox = draw.textbbox((0, 0), test_line, font=font)
+ if bbox[2] - bbox[0] <= max_width:
+ current_line.append(word)
+ else:
+ if current_line:
+ lines.append(' '.join(current_line))
+ current_line = [word]
+ if current_line:
+ lines.append(' '.join(current_line))
+
+ line_height = font.size + 4 if hasattr(font, 'size') else 20
+ for i, line in enumerate(lines):
+ draw.text((x, y + i * line_height), line, font=font, fill=fill)
+
+ return len(lines) * line_height
+
+def create_grayscale_test_image(filename, is_png=True):
+ """
+ Create image with 4 grayscale squares to verify 4-level rendering.
+ """
+ width, height = 400, 600
+ img = Image.new('L', (width, height), 255)
+ draw = ImageDraw.Draw(img)
+ font = get_font(16)
+ font_small = get_font(14)
+
+ # Title
+ draw_text_centered(draw, 10, "GRAYSCALE TEST", font, fill=0)
+ draw_text_centered(draw, 35, "Verify 4 distinct gray levels", font_small, fill=64)
+
+ # Draw 4 grayscale squares
+ square_size = 70
+ start_y = 65
+ gap = 10
+
+ # Gray levels chosen to avoid Bayer dithering threshold boundaries (±40 dither offset)
+ # Thresholds at 64, 128, 192 - use values in the middle of each band for solid output
+ # Safe zones: 0-23 (black), 88-103 (dark gray), 152-167 (light gray), 232-255 (white)
+ levels = [
+ (0, "Level 0: BLACK"),
+ (96, "Level 1: DARK GRAY"),
+ (160, "Level 2: LIGHT GRAY"),
+ (255, "Level 3: WHITE"),
+ ]
+
+ for i, (gray_value, label) in enumerate(levels):
+ y = start_y + i * (square_size + gap + 22)
+ x = (width - square_size) // 2
+
+ # Draw square with border
+ draw.rectangle([x-2, y-2, x + square_size + 2, y + square_size + 2], fill=0)
+ draw.rectangle([x, y, x + square_size, y + square_size], fill=gray_value)
+
+ # Label below square
+ bbox = draw.textbbox((0, 0), label, font=font_small)
+ label_width = bbox[2] - bbox[0]
+ draw.text(((width - label_width) // 2, y + square_size + 5), label, font=font_small, fill=0)
+
+ # Instructions at bottom (well below the last square)
+ y = height - 70
+ draw_text_centered(draw, y, "PASS: 4 distinct shades visible", font_small, fill=0)
+ draw_text_centered(draw, y + 20, "FAIL: Only black/white or", font_small, fill=64)
+ draw_text_centered(draw, y + 38, "muddy/indistinct grays", font_small, fill=64)
+
+ # Save
+ if is_png:
+ img.save(filename, 'PNG')
+ else:
+ img.save(filename, 'JPEG', quality=95)
+
+def create_centering_test_image(filename, is_png=True):
+ """
+ Create image with border markers to verify centering.
+ """
+ width, height = 350, 400
+ img = Image.new('L', (width, height), 255)
+ draw = ImageDraw.Draw(img)
+ font = get_font(16)
+ font_small = get_font(14)
+
+ # Draw border
+ draw.rectangle([0, 0, width-1, height-1], outline=0, width=3)
+
+ # Corner markers
+ marker_size = 20
+ for x, y in [(0, 0), (width-marker_size, 0), (0, height-marker_size), (width-marker_size, height-marker_size)]:
+ draw.rectangle([x, y, x+marker_size, y+marker_size], fill=0)
+
+ # Center cross
+ cx, cy = width // 2, height // 2
+ draw.line([cx - 30, cy, cx + 30, cy], fill=0, width=2)
+ draw.line([cx, cy - 30, cx, cy + 30], fill=0, width=2)
+
+ # Title
+ draw_text_centered(draw, 40, "CENTERING TEST", font, fill=0)
+
+ # Instructions
+ y = 80
+ draw_text_centered(draw, y, "Image should be centered", font_small, fill=0)
+ draw_text_centered(draw, y + 20, "horizontally on screen", font_small, fill=0)
+
+ y = 150
+ draw_text_centered(draw, y, "Check:", font_small, fill=0)
+ draw_text_centered(draw, y + 25, "- Equal margins left & right", font_small, fill=64)
+ draw_text_centered(draw, y + 45, "- All 4 corners visible", font_small, fill=64)
+ draw_text_centered(draw, y + 65, "- Border is complete rectangle", font_small, fill=64)
+
+ # Pass/fail
+ y = height - 80
+ draw_text_centered(draw, y, "PASS: Centered, all corners visible", font_small, fill=0)
+ draw_text_centered(draw, y + 20, "FAIL: Off-center or cropped", font_small, fill=64)
+
+ if is_png:
+ img.save(filename, 'PNG')
+ else:
+ img.save(filename, 'JPEG', quality=95)
+
+def create_scaling_test_image(filename, is_png=True):
+ """
+ Create large image to verify scaling works.
+ """
+ # Make image larger than screen but within decoder limits (max 2048x1536)
+ width, height = 1200, 1500
+ img = Image.new('L', (width, height), 240)
+ draw = ImageDraw.Draw(img)
+ font = get_font(48)
+ font_medium = get_font(32)
+ font_small = get_font(24)
+
+ # Border
+ draw.rectangle([0, 0, width-1, height-1], outline=0, width=8)
+ draw.rectangle([20, 20, width-21, height-21], outline=128, width=4)
+
+ # Title
+ draw_text_centered(draw, 60, "SCALING TEST", font, fill=0)
+ draw_text_centered(draw, 130, f"Original: {width}x{height} (larger than screen)", font_medium, fill=64)
+
+ # Grid pattern to verify scaling quality
+ grid_start_y = 220
+ grid_size = 400
+ cell_size = 50
+
+ draw_text_centered(draw, grid_start_y - 40, "Grid pattern (check for artifacts):", font_small, fill=0)
+
+ grid_x = (width - grid_size) // 2
+ for row in range(grid_size // cell_size):
+ for col in range(grid_size // cell_size):
+ x = grid_x + col * cell_size
+ y = grid_start_y + row * cell_size
+ if (row + col) % 2 == 0:
+ draw.rectangle([x, y, x + cell_size, y + cell_size], fill=0)
+ else:
+ draw.rectangle([x, y, x + cell_size, y + cell_size], fill=200)
+
+ # Size indicator bars
+ y = grid_start_y + grid_size + 60
+ draw_text_centered(draw, y, "Width markers (should fit on screen):", font_small, fill=0)
+
+ bar_y = y + 40
+ # Full width bar
+ draw.rectangle([50, bar_y, width - 50, bar_y + 30], fill=0)
+ draw.text((60, bar_y + 5), "FULL WIDTH", font=font_small, fill=255)
+
+ # Half width bar
+ bar_y += 60
+ half_start = width // 4
+ draw.rectangle([half_start, bar_y, width - half_start, bar_y + 30], fill=85)
+ draw.text((half_start + 10, bar_y + 5), "HALF WIDTH", font=font_small, fill=255)
+
+ # Instructions
+ y = height - 350
+ draw_text_centered(draw, y, "VERIFICATION:", font_medium, fill=0)
+ y += 50
+ instructions = [
+ "1. Image fits within screen bounds",
+ "2. All borders visible (not cropped)",
+ "3. Grid pattern clear (no moire)",
+ "4. Text readable after scaling",
+ "5. Aspect ratio preserved (not stretched)",
+ ]
+ for i, text in enumerate(instructions):
+ draw_text_centered(draw, y + i * 35, text, font_small, fill=64)
+
+ y = height - 100
+ draw_text_centered(draw, y, "PASS: Scaled down, readable, complete", font_small, fill=0)
+ draw_text_centered(draw, y + 30, "FAIL: Cropped, distorted, or unreadable", font_small, fill=64)
+
+ if is_png:
+ img.save(filename, 'PNG')
+ else:
+ img.save(filename, 'JPEG', quality=95)
+
+def create_cache_test_image(filename, page_num, is_png=True):
+ """
+ Create image for cache performance testing.
+ """
+ width, height = 400, 300
+ img = Image.new('L', (width, height), 255)
+ draw = ImageDraw.Draw(img)
+ font = get_font(18)
+ font_small = get_font(14)
+ font_large = get_font(36)
+
+ # Border
+ draw.rectangle([0, 0, width-1, height-1], outline=0, width=2)
+
+ # Page number prominent
+ draw_text_centered(draw, 30, f"CACHE TEST PAGE {page_num}", font, fill=0)
+ draw_text_centered(draw, 80, f"#{page_num}", font_large, fill=0)
+
+ # Instructions
+ y = 140
+ draw_text_centered(draw, y, "Navigate away then return", font_small, fill=64)
+ draw_text_centered(draw, y + 25, "Second load should be faster", font_small, fill=64)
+
+ y = 220
+ draw_text_centered(draw, y, "PASS: Faster reload from cache", font_small, fill=0)
+ draw_text_centered(draw, y + 20, "FAIL: Same slow decode each time", font_small, fill=64)
+
+ if is_png:
+ img.save(filename, 'PNG')
+ else:
+ img.save(filename, 'JPEG', quality=95)
+
+def create_gradient_test_image(filename, is_png=True):
+ """
+ Create horizontal gradient to test grayscale banding.
+ """
+ width, height = 400, 500
+ img = Image.new('L', (width, height), 255)
+ draw = ImageDraw.Draw(img)
+ font = get_font(16)
+ font_small = get_font(14)
+
+ draw_text_centered(draw, 10, "GRADIENT TEST", font, fill=0)
+ draw_text_centered(draw, 35, "Smooth gradient → 4 bands expected", font_small, fill=64)
+
+ # Horizontal gradient
+ gradient_y = 70
+ gradient_height = 100
+ for x in range(width):
+ gray = int(255 * x / width)
+ draw.line([(x, gradient_y), (x, gradient_y + gradient_height)], fill=gray)
+
+ # Border around gradient
+ draw.rectangle([0, gradient_y-1, width-1, gradient_y + gradient_height + 1], outline=0, width=1)
+
+ # Labels
+ y = gradient_y + gradient_height + 10
+ draw.text((5, y), "BLACK", font=font_small, fill=0)
+ draw.text((width - 50, y), "WHITE", font=font_small, fill=0)
+
+ # 4-step gradient (what it should look like)
+ y = 220
+ draw_text_centered(draw, y, "Expected result (4 distinct bands):", font_small, fill=0)
+
+ band_y = y + 25
+ band_height = 60
+ band_width = width // 4
+ for i, gray in enumerate([0, 85, 170, 255]):
+ x = i * band_width
+ draw.rectangle([x, band_y, x + band_width, band_y + band_height], fill=gray)
+ draw.rectangle([0, band_y-1, width-1, band_y + band_height + 1], outline=0, width=1)
+
+ # Vertical gradient
+ y = 340
+ draw_text_centered(draw, y, "Vertical gradient:", font_small, fill=0)
+
+ vgrad_y = y + 25
+ vgrad_height = 80
+ for row in range(vgrad_height):
+ gray = int(255 * row / vgrad_height)
+ draw.line([(50, vgrad_y + row), (width - 50, vgrad_y + row)], fill=gray)
+ draw.rectangle([49, vgrad_y-1, width-49, vgrad_y + vgrad_height + 1], outline=0, width=1)
+
+ # Pass/fail
+ y = height - 50
+ draw_text_centered(draw, y, "PASS: Clear 4-band quantization", font_small, fill=0)
+ draw_text_centered(draw, y + 20, "FAIL: Binary/noisy dithering", font_small, fill=64)
+
+ if is_png:
+ img.save(filename, 'PNG')
+ else:
+ img.save(filename, 'JPEG', quality=95)
+
+def create_format_test_image(filename, format_name, is_png=True):
+ """
+ Create simple image to verify format support.
+ """
+ width, height = 350, 250
+ img = Image.new('L', (width, height), 255)
+ draw = ImageDraw.Draw(img)
+ font = get_font(20)
+ font_large = get_font(36)
+ font_small = get_font(14)
+
+ # Border
+ draw.rectangle([0, 0, width-1, height-1], outline=0, width=3)
+
+ # Format name
+ draw_text_centered(draw, 30, f"{format_name} FORMAT TEST", font, fill=0)
+ draw_text_centered(draw, 80, format_name, font_large, fill=0)
+
+ # Checkmark area
+ y = 140
+ draw_text_centered(draw, y, "If you can read this,", font_small, fill=64)
+ draw_text_centered(draw, y + 20, f"{format_name} decoding works!", font_small, fill=64)
+
+ y = height - 40
+ draw_text_centered(draw, y, f"PASS: {format_name} image visible", font_small, fill=0)
+
+ if is_png:
+ img.save(filename, 'PNG')
+ else:
+ img.save(filename, 'JPEG', quality=95)
+
+def create_epub(epub_path, title, chapters):
+ """
+ Create an EPUB file with the given chapters.
+
+ chapters: list of (chapter_title, html_content, images)
+ images: list of (image_filename, image_data)
+ """
+ with zipfile.ZipFile(epub_path, 'w', zipfile.ZIP_DEFLATED) as epub:
+ # mimetype (must be first, uncompressed)
+ epub.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
+
+ # Container
+ container_xml = '''
+
This EPUB tests JPEG image rendering.
+Navigate through chapters to verify each test case.
+Test Plan:
+Basic JPEG decoding test.
+
+If the image above is visible, JPEG decoding works.
+"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]), + ("2. Grayscale", make_chapter("Grayscale Test", """ +Verify 4 distinct gray levels are visible.
+
+"""), [('grayscale_test.jpg', images['grayscale_test.jpg'])]),
+ ("3. Gradient", make_chapter("Gradient Test", """
+Verify gradient quantizes to 4 bands.
+
+"""), [('gradient_test.jpg', images['gradient_test.jpg'])]),
+ ("4. Centering", make_chapter("Centering Test", """
+Verify image is centered horizontally.
+
+"""), [('centering_test.jpg', images['centering_test.jpg'])]),
+ ("5. Scaling", make_chapter("Scaling Test", """
+This image is 1200x1500 pixels - larger than the screen.
+It should be scaled down to fit.
+
+"""), [('scaling_test.jpg', images['scaling_test.jpg'])]),
+ ("6. Cache Test A", make_chapter("Cache Test - Page A", """
+First cache test page. Note the load time.
+
+Navigate to next page, then come back.
+"""), [('cache_test_1.jpg', images['cache_test_1.jpg'])]), + ("7. Cache Test B", make_chapter("Cache Test - Page B", """ +Second cache test page.
+
+Navigate back to Page A - it should load faster from cache.
+"""), [('cache_test_2.jpg', images['cache_test_2.jpg'])]), + ] + + create_epub(OUTPUT_DIR / 'test_jpeg_images.epub', 'JPEG Image Tests', jpeg_chapters) + + print("Creating PNG test EPUB...") + png_chapters = [ + ("Introduction", make_chapter("PNG Image Tests", """ +This EPUB tests PNG image rendering.
+Navigate through chapters to verify each test case.
+Test Plan:
+Basic PNG decoding test.
+
+If the image above is visible and no crash occurred, PNG decoding works.
+"""), [('png_format.png', images['png_format.png'])]), + ("2. Grayscale", make_chapter("Grayscale Test", """ +Verify 4 distinct gray levels are visible.
+
+"""), [('grayscale_test.png', images['grayscale_test.png'])]),
+ ("3. Gradient", make_chapter("Gradient Test", """
+Verify gradient quantizes to 4 bands.
+
+"""), [('gradient_test.png', images['gradient_test.png'])]),
+ ("4. Centering", make_chapter("Centering Test", """
+Verify image is centered horizontally.
+
+"""), [('centering_test.png', images['centering_test.png'])]),
+ ("5. Scaling", make_chapter("Scaling Test", """
+This image is 1200x1500 pixels - larger than the screen.
+It should be scaled down to fit.
+
+"""), [('scaling_test.png', images['scaling_test.png'])]),
+ ("6. Cache Test A", make_chapter("Cache Test - Page A", """
+First cache test page. Note the load time.
+
+Navigate to next page, then come back.
+"""), [('cache_test_1.png', images['cache_test_1.png'])]), + ("7. Cache Test B", make_chapter("Cache Test - Page B", """ +Second cache test page.
+
+Navigate back to Page A - it should load faster from cache.
+"""), [('cache_test_2.png', images['cache_test_2.png'])]), + ] + + create_epub(OUTPUT_DIR / 'test_png_images.epub', 'PNG Image Tests', png_chapters) + + print("Creating mixed format test EPUB...") + mixed_chapters = [ + ("Introduction", make_chapter("Mixed Image Format Tests", """ +This EPUB contains both JPEG and PNG images.
+Tests format detection and mixed rendering.
+"""), []), + ("1. JPEG Image", make_chapter("JPEG in Mixed EPUB", """ +This is a JPEG image:
+
+"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]),
+ ("2. PNG Image", make_chapter("PNG in Mixed EPUB", """
+This is a PNG image:
+
+"""), [('png_format.png', images['png_format.png'])]),
+ ("3. Both Formats", make_chapter("Both Formats on One Page", """
+JPEG image:
+
+PNG image:
+
+Both should render with proper grayscale.
+"""), [('grayscale_test.jpg', images['grayscale_test.jpg']), + ('grayscale_test.png', images['grayscale_test.png'])]), + ] + + create_epub(OUTPUT_DIR / 'test_mixed_images.epub', 'Mixed Format Tests', mixed_chapters) + + print(f"\nTest EPUBs created in: {OUTPUT_DIR}") + print("Files:") + for f in OUTPUT_DIR.glob('*.epub'): + print(f" - {f.name}") + +if __name__ == '__main__': + main() diff --git a/test/epubs/test_jpeg_images.epub b/test/epubs/test_jpeg_images.epub new file mode 100644 index 00000000..24d31e1e Binary files /dev/null and b/test/epubs/test_jpeg_images.epub differ diff --git a/test/epubs/test_mixed_images.epub b/test/epubs/test_mixed_images.epub new file mode 100644 index 00000000..695b6409 Binary files /dev/null and b/test/epubs/test_mixed_images.epub differ diff --git a/test/epubs/test_png_images.epub b/test/epubs/test_png_images.epub new file mode 100644 index 00000000..766e8a1e Binary files /dev/null and b/test/epubs/test_png_images.epub differ