feat: Add placeholder cover generator for books without covers

Generate styled placeholder covers (title, author, book icon) when a
book has no embedded cover image, instead of showing a blank rectangle.

- Add PlaceholderCoverGenerator lib with 1-bit BMP rendering, scaled
  fonts, word-wrap, and a book icon bitmap
- Integrate as fallback in Epub/Xtc/Txt reader activities and
  SleepActivity after format-specific cover generation fails
- Add fallback in HomeActivity::loadRecentCovers() so the home screen
  also shows placeholder thumbnails when cache is cleared
- Add Txt::getThumbBmpPath() for TXT thumbnail support
- Add helper scripts for icon and layout preview generation

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-02-14 23:38:47 -05:00
parent 5dc9d21bdb
commit 632b76c9ed
12 changed files with 939 additions and 38 deletions

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Generate a 1-bit book icon bitmap as a C header for PlaceholderCoverGenerator.
The icon is a simplified closed book with a spine on the left and 3 text lines.
Output format matches Logo120.h: MSB-first packed 1-bit, 0=black, 1=white.
"""
from PIL import Image, ImageDraw
import sys
def generate_book_icon(size=48):
"""Create a book icon at the given size."""
img = Image.new("1", (size, size), 1) # White background
draw = ImageDraw.Draw(img)
# Scale helper
s = size / 48.0
# Book body (main rectangle, leaving room for spine and pages)
body_left = int(6 * s)
body_top = int(2 * s)
body_right = int(42 * s)
body_bottom = int(40 * s)
# Draw book body outline (2px thick)
for i in range(int(2 * s)):
draw.rectangle(
[body_left + i, body_top + i, body_right - i, body_bottom - i], outline=0
)
# Spine (thicker left edge)
spine_width = int(4 * s)
draw.rectangle([body_left, body_top, body_left + spine_width, body_bottom], fill=0)
# Pages at the bottom (slight offset from body)
pages_top = body_bottom
pages_bottom = int(44 * s)
draw.rectangle(
[body_left + int(2 * s), pages_top, body_right - int(1 * s), pages_bottom],
outline=0,
)
# Page edges (a few lines)
for i in range(3):
y = pages_top + int((i + 1) * 1 * s)
if y < pages_bottom:
draw.line(
[body_left + int(3 * s), y, body_right - int(2 * s), y], fill=0
)
# Text lines on the book cover
text_left = body_left + spine_width + int(4 * s)
text_right = body_right - int(4 * s)
line_thickness = max(1, int(1.5 * s))
text_lines_y = [int(12 * s), int(18 * s), int(24 * s)]
text_widths = [1.0, 0.7, 0.85] # Relative widths for visual interest
for y, w_ratio in zip(text_lines_y, text_widths):
line_right = text_left + int((text_right - text_left) * w_ratio)
for t in range(line_thickness):
draw.line([text_left, y + t, line_right, y + t], fill=0)
return img
def image_to_c_array(img, name="BookIcon"):
"""Convert a 1-bit PIL image to a C header array."""
width, height = img.size
pixels = img.load()
bytes_per_row = width // 8
data = []
for y in range(height):
for bx in range(bytes_per_row):
byte = 0
for bit in range(8):
x = bx * 8 + bit
if x < width:
# 1 = white, 0 = black (matching Logo120.h convention)
if pixels[x, y]:
byte |= 1 << (7 - bit)
data.append(byte)
# Format as C header
lines = []
lines.append("#pragma once")
lines.append("#include <cstdint>")
lines.append("")
lines.append(f"// Book icon: {width}x{height}, 1-bit packed (MSB first)")
lines.append(f"// 0 = black, 1 = white (same format as Logo120.h)")
lines.append(f"static constexpr int BOOK_ICON_WIDTH = {width};")
lines.append(f"static constexpr int BOOK_ICON_HEIGHT = {height};")
lines.append(f"static const uint8_t {name}[] = {{")
# Format data in rows of 16 bytes
for i in range(0, len(data), 16):
chunk = data[i : i + 16]
hex_str = ", ".join(f"0x{b:02x}" for b in chunk)
lines.append(f" {hex_str},")
lines.append("};")
lines.append("")
return "\n".join(lines)
if __name__ == "__main__":
size = int(sys.argv[1]) if len(sys.argv) > 1 else 48
img = generate_book_icon(size)
# Save preview PNG
preview_path = f"mod/book_icon_{size}x{size}.png"
img.resize((size * 4, size * 4), Image.NEAREST).save(preview_path)
print(f"Preview saved to {preview_path}", file=sys.stderr)
# Generate C header
header = image_to_c_array(img, "BookIcon")
output_path = "lib/PlaceholderCover/BookIcon.h"
with open(output_path, "w") as f:
f.write(header)
print(f"C header saved to {output_path}", file=sys.stderr)

View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Generate a preview of the placeholder cover layout at full cover size (480x800).
This mirrors the C++ PlaceholderCoverGenerator layout logic for visual verification.
"""
from PIL import Image, ImageDraw, ImageFont
import sys
import os
# Reuse the book icon generator
sys.path.insert(0, os.path.dirname(__file__))
from generate_book_icon import generate_book_icon
def create_preview(width=480, height=800, title="The Great Gatsby", author="F. Scott Fitzgerald"):
img = Image.new("1", (width, height), 1) # White
draw = ImageDraw.Draw(img)
# Proportional layout constants
edge_padding = max(3, width // 48) # ~10px at 480w
border_width = max(2, width // 96) # ~5px at 480w
inner_padding = max(4, width // 32) # ~15px at 480w
title_scale = 2 if height >= 600 else 1
author_scale = 2 if height >= 600 else 1 # Author also larger on full covers
icon_scale = 2 if height >= 600 else (1 if height >= 350 else 0)
# Draw border inset from edge
bx = edge_padding
by = edge_padding
bw = width - 2 * edge_padding
bh = height - 2 * edge_padding
for i in range(border_width):
draw.rectangle([bx + i, by + i, bx + bw - 1 - i, by + bh - 1 - i], outline=0)
# Content area
content_x = edge_padding + border_width + inner_padding
content_y = edge_padding + border_width + inner_padding
content_w = width - 2 * content_x
content_h = height - 2 * content_y
# Zones
title_zone_h = content_h * 2 // 3
author_zone_h = content_h - title_zone_h
author_zone_y = content_y + title_zone_h
# Separator
sep_w = content_w // 3
sep_x = content_x + (content_w - sep_w) // 2
draw.line([sep_x, author_zone_y, sep_x + sep_w, author_zone_y], fill=0)
# Use a basic font for the preview (won't match exact Ubuntu metrics, but shows layout)
try:
title_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 12 * title_scale)
author_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 10 * author_scale)
except (OSError, IOError):
title_font = ImageFont.load_default()
author_font = ImageFont.load_default()
# Icon dimensions (needed for title text wrapping)
icon_w_px = 48 * icon_scale if icon_scale > 0 else 0
icon_h_px = 48 * icon_scale if icon_scale > 0 else 0
icon_gap = max(8, width // 40) if icon_scale > 0 else 0
title_text_w = content_w - icon_w_px - icon_gap # Title wraps in narrower area beside icon
# Wrap title (within the narrower area to the right of the icon)
title_lines = []
words = title.split()
current_line = ""
for word in words:
test = f"{current_line} {word}".strip()
bbox = draw.textbbox((0, 0), test, font=title_font)
if bbox[2] - bbox[0] <= title_text_w:
current_line = test
else:
if current_line:
title_lines.append(current_line)
current_line = word
if current_line:
title_lines.append(current_line)
title_lines = title_lines[:5]
# Line spacing: 75% of advanceY (tighter so 2-3 lines fit within icon height)
title_line_h = 29 * title_scale * 3 // 4 # Based on C++ ubuntu_12_bold advanceY
# Measure actual single-line height from the PIL font for accurate centering
sample_bbox = draw.textbbox((0, 0), "Ag", font=title_font) # Tall + descender chars
single_line_visual_h = sample_bbox[3] - sample_bbox[1]
# Visual height: line spacing between lines + actual height of last line's glyphs
num_title_lines = len(title_lines)
title_visual_h = (num_title_lines - 1) * title_line_h + single_line_visual_h if num_title_lines > 0 else 0
title_block_h = max(icon_h_px, title_visual_h)
title_start_y = content_y + (title_zone_h - title_block_h) // 2
if title_start_y < content_y:
title_start_y = content_y
# If title fits within icon height, center it vertically against the icon.
# Otherwise top-align so extra lines overflow below.
icon_y = title_start_y
if icon_h_px > 0 and title_visual_h <= icon_h_px:
title_text_y = title_start_y + (icon_h_px - title_visual_h) // 2
else:
title_text_y = title_start_y
# Horizontal centering: measure widest title line, center icon+gap+text block
max_title_line_w = 0
for line in title_lines:
bbox = draw.textbbox((0, 0), line, font=title_font)
w = bbox[2] - bbox[0]
if w > max_title_line_w:
max_title_line_w = w
title_block_w = icon_w_px + icon_gap + max_title_line_w
title_block_x = content_x + (content_w - title_block_w) // 2
# Draw icon
if icon_scale > 0:
icon_img = generate_book_icon(48)
scaled_icon = icon_img.resize((icon_w_px, icon_h_px), Image.NEAREST)
for iy in range(scaled_icon.height):
for ix in range(scaled_icon.width):
if not scaled_icon.getpixel((ix, iy)):
img.putpixel((title_block_x + ix, icon_y + iy), 0)
# Draw title (to the right of the icon)
title_text_x = title_block_x + icon_w_px + icon_gap
current_y = title_text_y
for line in title_lines:
draw.text((title_text_x, current_y), line, fill=0, font=title_font)
current_y += title_line_h
# Wrap author
author_lines = []
words = author.split()
current_line = ""
for word in words:
test = f"{current_line} {word}".strip()
bbox = draw.textbbox((0, 0), test, font=author_font)
if bbox[2] - bbox[0] <= content_w:
current_line = test
else:
if current_line:
author_lines.append(current_line)
current_line = word
if current_line:
author_lines.append(current_line)
author_lines = author_lines[:3]
# Draw author centered in bottom 1/3
author_line_h = 24 * author_scale # Ubuntu 10 regular advanceY ~24
author_block_h = len(author_lines) * author_line_h
author_start_y = author_zone_y + (author_zone_h - author_block_h) // 2
for line in author_lines:
bbox = draw.textbbox((0, 0), line, font=author_font)
line_w = bbox[2] - bbox[0]
line_x = content_x + (content_w - line_w) // 2
draw.text((line_x, author_start_y), line, fill=0, font=author_font)
author_start_y += author_line_h
return img
if __name__ == "__main__":
# Full cover
img = create_preview(480, 800, "A Really Long Book Title That Should Wrap", "Jane Doe")
img.save("mod/preview_cover_480x800.png")
print("Saved mod/preview_cover_480x800.png", file=sys.stderr)
# Medium thumbnail
img2 = create_preview(240, 400, "A Really Long Book Title That Should Wrap", "Jane Doe")
img2.save("mod/preview_thumb_240x400.png")
print("Saved mod/preview_thumb_240x400.png", file=sys.stderr)
# Small thumbnail
img3 = create_preview(136, 226, "A Really Long Book Title", "Jane Doe")
img3.save("mod/preview_thumb_136x226.png")
print("Saved mod/preview_thumb_136x226.png", file=sys.stderr)