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:
123
scripts/generate_book_icon.py
Normal file
123
scripts/generate_book_icon.py
Normal 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)
|
||||
179
scripts/preview_placeholder_cover.py
Normal file
179
scripts/preview_placeholder_cover.py
Normal 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)
|
||||
Reference in New Issue
Block a user