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:
27
lib/PlaceholderCover/BookIcon.h
Normal file
27
lib/PlaceholderCover/BookIcon.h
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
// Book icon: 48x48, 1-bit packed (MSB first)
|
||||||
|
// 0 = black, 1 = white (same format as Logo120.h)
|
||||||
|
static constexpr int BOOK_ICON_WIDTH = 48;
|
||||||
|
static constexpr int BOOK_ICON_HEIGHT = 48;
|
||||||
|
static const uint8_t BookIcon[] = {
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x1f, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
|
||||||
|
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
|
||||||
|
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
|
||||||
|
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1c, 0x00, 0x00, 0x01, 0x9f, 0xfc, 0x1f,
|
||||||
|
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
|
||||||
|
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1c, 0x00, 0x01,
|
||||||
|
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
|
||||||
|
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
|
||||||
|
0xfc, 0x1c, 0x00, 0x00, 0x1f, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
|
||||||
|
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
|
||||||
|
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
|
||||||
|
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
|
||||||
|
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
|
||||||
|
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f,
|
||||||
|
0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x3f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff,
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||||
|
};
|
||||||
480
lib/PlaceholderCover/PlaceholderCoverGenerator.cpp
Normal file
480
lib/PlaceholderCover/PlaceholderCoverGenerator.cpp
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
#include "PlaceholderCoverGenerator.h"
|
||||||
|
|
||||||
|
#include <EpdFont.h>
|
||||||
|
#include <HalStorage.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
#include <Utf8.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// Include the UI fonts directly for self-contained placeholder rendering.
|
||||||
|
// These are 1-bit bitmap fonts compiled from Ubuntu TTF.
|
||||||
|
#include "builtinFonts/ubuntu_10_regular.h"
|
||||||
|
#include "builtinFonts/ubuntu_12_bold.h"
|
||||||
|
|
||||||
|
// Book icon bitmap (48x48 1-bit, generated by scripts/generate_book_icon.py)
|
||||||
|
#include "BookIcon.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// BMP writing helpers (same format as JpegToBmpConverter)
|
||||||
|
inline void write16(Print& out, const uint16_t value) {
|
||||||
|
out.write(value & 0xFF);
|
||||||
|
out.write((value >> 8) & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void write32(Print& out, const uint32_t value) {
|
||||||
|
out.write(value & 0xFF);
|
||||||
|
out.write((value >> 8) & 0xFF);
|
||||||
|
out.write((value >> 16) & 0xFF);
|
||||||
|
out.write((value >> 24) & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void write32Signed(Print& out, const int32_t value) {
|
||||||
|
out.write(value & 0xFF);
|
||||||
|
out.write((value >> 8) & 0xFF);
|
||||||
|
out.write((value >> 16) & 0xFF);
|
||||||
|
out.write((value >> 24) & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) {
|
||||||
|
const int bytesPerRow = (width + 31) / 32 * 4;
|
||||||
|
const int imageSize = bytesPerRow * height;
|
||||||
|
const uint32_t fileSize = 62 + imageSize;
|
||||||
|
|
||||||
|
// BMP File Header (14 bytes)
|
||||||
|
bmpOut.write('B');
|
||||||
|
bmpOut.write('M');
|
||||||
|
write32(bmpOut, fileSize);
|
||||||
|
write32(bmpOut, 0); // Reserved
|
||||||
|
write32(bmpOut, 62); // Offset to pixel data
|
||||||
|
|
||||||
|
// DIB Header (BITMAPINFOHEADER - 40 bytes)
|
||||||
|
write32(bmpOut, 40);
|
||||||
|
write32Signed(bmpOut, width);
|
||||||
|
write32Signed(bmpOut, -height); // Negative = top-down
|
||||||
|
write16(bmpOut, 1); // Color planes
|
||||||
|
write16(bmpOut, 1); // Bits per pixel
|
||||||
|
write32(bmpOut, 0); // BI_RGB
|
||||||
|
write32(bmpOut, imageSize);
|
||||||
|
write32(bmpOut, 2835); // xPixelsPerMeter
|
||||||
|
write32(bmpOut, 2835); // yPixelsPerMeter
|
||||||
|
write32(bmpOut, 2); // colorsUsed
|
||||||
|
write32(bmpOut, 2); // colorsImportant
|
||||||
|
|
||||||
|
// Palette: index 0 = black, index 1 = white
|
||||||
|
const uint8_t palette[8] = {
|
||||||
|
0x00, 0x00, 0x00, 0x00, // Black
|
||||||
|
0xFF, 0xFF, 0xFF, 0x00 // White
|
||||||
|
};
|
||||||
|
for (const uint8_t b : palette) {
|
||||||
|
bmpOut.write(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1-bit pixel buffer that can render text, icons, and shapes, then write as BMP.
|
||||||
|
class PixelBuffer {
|
||||||
|
public:
|
||||||
|
PixelBuffer(int width, int height) : width(width), height(height) {
|
||||||
|
bytesPerRow = (width + 31) / 32 * 4;
|
||||||
|
bufferSize = bytesPerRow * height;
|
||||||
|
buffer = static_cast<uint8_t*>(malloc(bufferSize));
|
||||||
|
if (buffer) {
|
||||||
|
memset(buffer, 0xFF, bufferSize); // White background
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
~PixelBuffer() {
|
||||||
|
if (buffer) {
|
||||||
|
free(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isValid() const { return buffer != nullptr; }
|
||||||
|
|
||||||
|
/// Set a pixel to black.
|
||||||
|
void setBlack(int x, int y) {
|
||||||
|
if (x < 0 || x >= width || y < 0 || y >= height) return;
|
||||||
|
const int byteIndex = y * bytesPerRow + x / 8;
|
||||||
|
const uint8_t bitMask = 0x80 >> (x % 8);
|
||||||
|
buffer[byteIndex] &= ~bitMask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a scaled "pixel" (scale x scale block) to black.
|
||||||
|
void setBlackScaled(int x, int y, int scale) {
|
||||||
|
for (int dy = 0; dy < scale; dy++) {
|
||||||
|
for (int dx = 0; dx < scale; dx++) {
|
||||||
|
setBlack(x + dx, y + dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a filled rectangle in black.
|
||||||
|
void fillRect(int x, int y, int w, int h) {
|
||||||
|
for (int row = y; row < y + h && row < height; row++) {
|
||||||
|
for (int col = x; col < x + w && col < width; col++) {
|
||||||
|
setBlack(col, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a rectangular border in black.
|
||||||
|
void drawBorder(int x, int y, int w, int h, int thickness) {
|
||||||
|
fillRect(x, y, w, thickness); // Top
|
||||||
|
fillRect(x, y + h - thickness, w, thickness); // Bottom
|
||||||
|
fillRect(x, y, thickness, h); // Left
|
||||||
|
fillRect(x + w - thickness, y, thickness, h); // Right
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a horizontal line in black with configurable thickness.
|
||||||
|
void drawHLine(int x, int y, int length, int thickness = 1) {
|
||||||
|
fillRect(x, y, length, thickness);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a single glyph at (cursorX, baselineY) with integer scaling. Returns advance in X (scaled).
|
||||||
|
int renderGlyph(const EpdFontData* font, uint32_t codepoint, int cursorX, int baselineY, int scale = 1) {
|
||||||
|
const EpdFont fontObj(font);
|
||||||
|
const EpdGlyph* glyph = fontObj.getGlyph(codepoint);
|
||||||
|
if (!glyph) {
|
||||||
|
glyph = fontObj.getGlyph(REPLACEMENT_GLYPH);
|
||||||
|
}
|
||||||
|
if (!glyph) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t* bitmap = &font->bitmap[glyph->dataOffset];
|
||||||
|
const int glyphW = glyph->width;
|
||||||
|
const int glyphH = glyph->height;
|
||||||
|
|
||||||
|
for (int gy = 0; gy < glyphH; gy++) {
|
||||||
|
const int screenY = baselineY - glyph->top * scale + gy * scale;
|
||||||
|
for (int gx = 0; gx < glyphW; gx++) {
|
||||||
|
const int pixelPos = gy * glyphW + gx;
|
||||||
|
const int screenX = cursorX + glyph->left * scale + gx * scale;
|
||||||
|
|
||||||
|
bool isSet = false;
|
||||||
|
if (font->is2Bit) {
|
||||||
|
const uint8_t byte = bitmap[pixelPos / 4];
|
||||||
|
const uint8_t bitIndex = (3 - pixelPos % 4) * 2;
|
||||||
|
const uint8_t val = 3 - ((byte >> bitIndex) & 0x3);
|
||||||
|
isSet = (val < 3);
|
||||||
|
} else {
|
||||||
|
const uint8_t byte = bitmap[pixelPos / 8];
|
||||||
|
const uint8_t bitIndex = 7 - (pixelPos % 8);
|
||||||
|
isSet = ((byte >> bitIndex) & 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSet) {
|
||||||
|
setBlackScaled(screenX, screenY, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return glyph->advanceX * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a UTF-8 string at (x, y) where y is the top of the text line, with integer scaling.
|
||||||
|
void drawText(const EpdFontData* font, int x, int y, const char* text, int scale = 1) {
|
||||||
|
const int baselineY = y + font->ascender * scale;
|
||||||
|
int cursorX = x;
|
||||||
|
uint32_t cp;
|
||||||
|
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||||
|
cursorX += renderGlyph(font, cp, cursorX, baselineY, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a 1-bit icon bitmap (MSB first, 0=black, 1=white) with integer scaling.
|
||||||
|
void drawIcon(const uint8_t* icon, int iconW, int iconH, int x, int y, int scale = 1) {
|
||||||
|
const int bytesPerIconRow = iconW / 8;
|
||||||
|
for (int iy = 0; iy < iconH; iy++) {
|
||||||
|
for (int ix = 0; ix < iconW; ix++) {
|
||||||
|
const int byteIdx = iy * bytesPerIconRow + ix / 8;
|
||||||
|
const uint8_t bitMask = 0x80 >> (ix % 8);
|
||||||
|
// In the icon data: 0 = black (drawn), 1 = white (skip)
|
||||||
|
if (!(icon[byteIdx] & bitMask)) {
|
||||||
|
const int sx = x + ix * scale;
|
||||||
|
const int sy = y + iy * scale;
|
||||||
|
setBlackScaled(sx, sy, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the pixel buffer to a file as a 1-bit BMP.
|
||||||
|
bool writeBmp(Print& out) const {
|
||||||
|
if (!buffer) return false;
|
||||||
|
writeBmpHeader1bit(out, width, height);
|
||||||
|
out.write(buffer, bufferSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int getWidth() const { return width; }
|
||||||
|
int getHeight() const { return height; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
|
int bytesPerRow;
|
||||||
|
size_t bufferSize;
|
||||||
|
uint8_t* buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Measure the width of a UTF-8 string in pixels (at 1x scale).
|
||||||
|
int measureTextWidth(const EpdFontData* font, const char* text) {
|
||||||
|
const EpdFont fontObj(font);
|
||||||
|
int w = 0, h = 0;
|
||||||
|
fontObj.getTextDimensions(text, &w, &h);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the advance width of a single character.
|
||||||
|
int getCharAdvance(const EpdFontData* font, uint32_t cp) {
|
||||||
|
const EpdFont fontObj(font);
|
||||||
|
const EpdGlyph* glyph = fontObj.getGlyph(cp);
|
||||||
|
if (!glyph) return 0;
|
||||||
|
return glyph->advanceX;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a string into words (splitting on spaces).
|
||||||
|
std::vector<std::string> splitWords(const std::string& text) {
|
||||||
|
std::vector<std::string> words;
|
||||||
|
std::string current;
|
||||||
|
for (size_t i = 0; i < text.size(); i++) {
|
||||||
|
if (text[i] == ' ') {
|
||||||
|
if (!current.empty()) {
|
||||||
|
words.push_back(current);
|
||||||
|
current.clear();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += text[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!current.empty()) {
|
||||||
|
words.push_back(current);
|
||||||
|
}
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Word-wrap text into lines that fit within maxWidth pixels at the given scale.
|
||||||
|
std::vector<std::string> wrapText(const EpdFontData* font, const std::string& text, int maxWidth, int scale = 1) {
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
const auto words = splitWords(text);
|
||||||
|
if (words.empty()) return lines;
|
||||||
|
|
||||||
|
const int spaceWidth = getCharAdvance(font, ' ') * scale;
|
||||||
|
std::string currentLine;
|
||||||
|
int currentWidth = 0;
|
||||||
|
|
||||||
|
for (const auto& word : words) {
|
||||||
|
const int wordWidth = measureTextWidth(font, word.c_str()) * scale;
|
||||||
|
|
||||||
|
if (currentLine.empty()) {
|
||||||
|
currentLine = word;
|
||||||
|
currentWidth = wordWidth;
|
||||||
|
} else if (currentWidth + spaceWidth + wordWidth <= maxWidth) {
|
||||||
|
currentLine += " " + word;
|
||||||
|
currentWidth += spaceWidth + wordWidth;
|
||||||
|
} else {
|
||||||
|
lines.push_back(currentLine);
|
||||||
|
currentLine = word;
|
||||||
|
currentWidth = wordWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentLine.empty()) {
|
||||||
|
lines.push_back(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate a string with "..." if it exceeds maxWidth pixels at the given scale.
|
||||||
|
std::string truncateText(const EpdFontData* font, const std::string& text, int maxWidth, int scale = 1) {
|
||||||
|
if (measureTextWidth(font, text.c_str()) * scale <= maxWidth) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string truncated = text;
|
||||||
|
const char* ellipsis = "...";
|
||||||
|
const int ellipsisWidth = measureTextWidth(font, ellipsis) * scale;
|
||||||
|
|
||||||
|
while (!truncated.empty()) {
|
||||||
|
utf8RemoveLastChar(truncated);
|
||||||
|
if (measureTextWidth(font, truncated.c_str()) * scale + ellipsisWidth <= maxWidth) {
|
||||||
|
return truncated + ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool PlaceholderCoverGenerator::generate(const std::string& outputPath, const std::string& title,
|
||||||
|
const std::string& author, int width, int height) {
|
||||||
|
LOG_DBG("PHC", "Generating placeholder cover %dx%d: \"%s\" by \"%s\"", width, height, title.c_str(), author.c_str());
|
||||||
|
|
||||||
|
const EpdFontData* titleFont = &ubuntu_12_bold;
|
||||||
|
const EpdFontData* authorFont = &ubuntu_10_regular;
|
||||||
|
|
||||||
|
PixelBuffer buf(width, height);
|
||||||
|
if (!buf.isValid()) {
|
||||||
|
LOG_ERR("PHC", "Failed to allocate %dx%d pixel buffer (%d bytes)", width, height,
|
||||||
|
(width + 31) / 32 * 4 * height);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proportional layout constants based on cover dimensions.
|
||||||
|
// The device bezel covers ~2-3px on each edge, so we pad inward from the edge.
|
||||||
|
const int edgePadding = std::max(3, width / 48); // ~10px at 480w, ~3px at 136w
|
||||||
|
const int borderWidth = std::max(2, width / 96); // ~5px at 480w, ~2px at 136w
|
||||||
|
const int innerPadding = std::max(4, width / 32); // ~15px at 480w, ~4px at 136w
|
||||||
|
|
||||||
|
// Text scaling: 2x for full-size covers, 1x for thumbnails
|
||||||
|
const int titleScale = (height >= 600) ? 2 : 1;
|
||||||
|
const int authorScale = (height >= 600) ? 2 : 1; // Author also larger on full covers
|
||||||
|
// Icon: 2x for full cover, 1x for medium thumb, skip for small
|
||||||
|
const int iconScale = (height >= 600) ? 2 : (height >= 350 ? 1 : 0);
|
||||||
|
|
||||||
|
// Draw border inset from edge
|
||||||
|
buf.drawBorder(edgePadding, edgePadding, width - 2 * edgePadding, height - 2 * edgePadding, borderWidth);
|
||||||
|
|
||||||
|
// Content area (inside border + inner padding)
|
||||||
|
const int contentX = edgePadding + borderWidth + innerPadding;
|
||||||
|
const int contentY = edgePadding + borderWidth + innerPadding;
|
||||||
|
const int contentW = width - 2 * contentX;
|
||||||
|
const int contentH = height - 2 * contentY;
|
||||||
|
|
||||||
|
if (contentW <= 0 || contentH <= 0) {
|
||||||
|
LOG_ERR("PHC", "Cover too small for content (%dx%d)", width, height);
|
||||||
|
FsFile file;
|
||||||
|
if (!Storage.openFileForWrite("PHC", outputPath, file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
buf.writeBmp(file);
|
||||||
|
file.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Layout zones ---
|
||||||
|
// Title zone: top 2/3 of content area (icon + title)
|
||||||
|
// Author zone: bottom 1/3 of content area
|
||||||
|
const int titleZoneH = contentH * 2 / 3;
|
||||||
|
const int authorZoneH = contentH - titleZoneH;
|
||||||
|
const int authorZoneY = contentY + titleZoneH;
|
||||||
|
|
||||||
|
// --- Separator line at the zone boundary ---
|
||||||
|
const int separatorWidth = contentW / 3;
|
||||||
|
const int separatorX = contentX + (contentW - separatorWidth) / 2;
|
||||||
|
buf.drawHLine(separatorX, authorZoneY, separatorWidth);
|
||||||
|
|
||||||
|
// --- Icon dimensions (needed for title text wrapping) ---
|
||||||
|
const int iconW = (iconScale > 0) ? BOOK_ICON_WIDTH * iconScale : 0;
|
||||||
|
const int iconGap = (iconScale > 0) ? std::max(8, width / 40) : 0; // Gap between icon and title text
|
||||||
|
const int titleTextW = contentW - iconW - iconGap; // Title wraps in narrower area beside icon
|
||||||
|
|
||||||
|
// --- Prepare title text (wraps within the area to the right of the icon) ---
|
||||||
|
const std::string displayTitle = title.empty() ? "Untitled" : title;
|
||||||
|
auto titleLines = wrapText(titleFont, displayTitle, titleTextW, titleScale);
|
||||||
|
|
||||||
|
constexpr int MAX_TITLE_LINES = 5;
|
||||||
|
if (static_cast<int>(titleLines.size()) > MAX_TITLE_LINES) {
|
||||||
|
titleLines.resize(MAX_TITLE_LINES);
|
||||||
|
titleLines.back() = truncateText(titleFont, titleLines.back(), titleTextW, titleScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Prepare author text (multi-line, max 3 lines) ---
|
||||||
|
std::vector<std::string> authorLines;
|
||||||
|
if (!author.empty()) {
|
||||||
|
authorLines = wrapText(authorFont, author, contentW, authorScale);
|
||||||
|
constexpr int MAX_AUTHOR_LINES = 3;
|
||||||
|
if (static_cast<int>(authorLines.size()) > MAX_AUTHOR_LINES) {
|
||||||
|
authorLines.resize(MAX_AUTHOR_LINES);
|
||||||
|
authorLines.back() = truncateText(authorFont, authorLines.back(), contentW, authorScale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Calculate title zone layout (icon LEFT of title) ---
|
||||||
|
// Tighter line spacing so 2-3 title lines fit within the icon height
|
||||||
|
const int titleLineH = titleFont->advanceY * titleScale * 3 / 4;
|
||||||
|
const int iconH = (iconScale > 0) ? BOOK_ICON_HEIGHT * iconScale : 0;
|
||||||
|
const int numTitleLines = static_cast<int>(titleLines.size());
|
||||||
|
// Visual height: distance from top of first line to bottom of last line's glyphs.
|
||||||
|
// Use ascender (not full advanceY) for the last line since trailing line-gap isn't visible.
|
||||||
|
const int titleVisualH = (numTitleLines > 0)
|
||||||
|
? (numTitleLines - 1) * titleLineH + titleFont->ascender * titleScale
|
||||||
|
: 0;
|
||||||
|
const int titleBlockH = std::max(iconH, titleVisualH); // Taller of icon or text
|
||||||
|
|
||||||
|
int titleStartY = contentY + (titleZoneH - titleBlockH) / 2;
|
||||||
|
if (titleStartY < contentY) {
|
||||||
|
titleStartY = contentY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If title fits within icon height, center it vertically against the icon.
|
||||||
|
// Otherwise top-align so extra lines overflow below.
|
||||||
|
const int iconY = titleStartY;
|
||||||
|
const int titleTextY = (iconH > 0 && titleVisualH <= iconH)
|
||||||
|
? titleStartY + (iconH - titleVisualH) / 2
|
||||||
|
: titleStartY;
|
||||||
|
|
||||||
|
// --- Horizontal centering: measure the widest title line, then center icon+gap+text block ---
|
||||||
|
int maxTitleLineW = 0;
|
||||||
|
for (const auto& line : titleLines) {
|
||||||
|
const int w = measureTextWidth(titleFont, line.c_str()) * titleScale;
|
||||||
|
if (w > maxTitleLineW) maxTitleLineW = w;
|
||||||
|
}
|
||||||
|
const int titleBlockW = iconW + iconGap + maxTitleLineW;
|
||||||
|
const int titleBlockX = contentX + (contentW - titleBlockW) / 2;
|
||||||
|
|
||||||
|
// --- Draw icon ---
|
||||||
|
if (iconScale > 0) {
|
||||||
|
buf.drawIcon(BookIcon, BOOK_ICON_WIDTH, BOOK_ICON_HEIGHT, titleBlockX, iconY, iconScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Draw title lines (to the right of the icon) ---
|
||||||
|
const int titleTextX = titleBlockX + iconW + iconGap;
|
||||||
|
int currentY = titleTextY;
|
||||||
|
for (const auto& line : titleLines) {
|
||||||
|
buf.drawText(titleFont, titleTextX, currentY, line.c_str(), titleScale);
|
||||||
|
currentY += titleLineH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Draw author lines (centered vertically in bottom 1/3, centered horizontally) ---
|
||||||
|
if (!authorLines.empty()) {
|
||||||
|
const int authorLineH = authorFont->advanceY * authorScale;
|
||||||
|
const int authorBlockH = static_cast<int>(authorLines.size()) * authorLineH;
|
||||||
|
int authorStartY = authorZoneY + (authorZoneH - authorBlockH) / 2;
|
||||||
|
if (authorStartY < authorZoneY + 4) {
|
||||||
|
authorStartY = authorZoneY + 4; // Small gap below separator
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& line : authorLines) {
|
||||||
|
const int lineWidth = measureTextWidth(authorFont, line.c_str()) * authorScale;
|
||||||
|
const int lineX = contentX + (contentW - lineWidth) / 2;
|
||||||
|
buf.drawText(authorFont, lineX, authorStartY, line.c_str(), authorScale);
|
||||||
|
authorStartY += authorLineH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Write to file ---
|
||||||
|
FsFile file;
|
||||||
|
if (!Storage.openFileForWrite("PHC", outputPath, file)) {
|
||||||
|
LOG_ERR("PHC", "Failed to open output file: %s", outputPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool success = buf.writeBmp(file);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
LOG_DBG("PHC", "Placeholder cover written to %s", outputPath.c_str());
|
||||||
|
} else {
|
||||||
|
LOG_ERR("PHC", "Failed to write placeholder BMP");
|
||||||
|
Storage.remove(outputPath.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
14
lib/PlaceholderCover/PlaceholderCoverGenerator.h
Normal file
14
lib/PlaceholderCover/PlaceholderCoverGenerator.h
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/// Generates simple 1-bit BMP placeholder covers with title/author text
|
||||||
|
/// for books that have no embedded cover image.
|
||||||
|
class PlaceholderCoverGenerator {
|
||||||
|
public:
|
||||||
|
/// Generate a placeholder cover BMP with title and author text.
|
||||||
|
/// The BMP is written to outputPath as a 1-bit black-and-white image.
|
||||||
|
/// Returns true if the file was written successfully.
|
||||||
|
static bool generate(const std::string& outputPath, const std::string& title, const std::string& author, int width,
|
||||||
|
int height);
|
||||||
|
};
|
||||||
@@ -97,6 +97,9 @@ std::string Txt::findCoverImage() const {
|
|||||||
|
|
||||||
std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
|
std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
|
||||||
|
|
||||||
|
std::string Txt::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
|
||||||
|
std::string Txt::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
|
||||||
|
|
||||||
bool Txt::generateCoverBmp() const {
|
bool Txt::generateCoverBmp() const {
|
||||||
// Already generated, return true
|
// Already generated, return true
|
||||||
if (Storage.exists(getCoverBmpPath().c_str())) {
|
if (Storage.exists(getCoverBmpPath().c_str())) {
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ class Txt {
|
|||||||
[[nodiscard]] bool generateCoverBmp() const;
|
[[nodiscard]] bool generateCoverBmp() const;
|
||||||
[[nodiscard]] std::string findCoverImage() const;
|
[[nodiscard]] std::string findCoverImage() const;
|
||||||
|
|
||||||
|
// Thumbnail paths (matching Epub/Xtc pattern for home screen covers)
|
||||||
|
[[nodiscard]] std::string getThumbBmpPath() const;
|
||||||
|
[[nodiscard]] std::string getThumbBmpPath(int height) const;
|
||||||
|
|
||||||
// Read content from file
|
// Read content from file
|
||||||
[[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const;
|
[[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const;
|
||||||
};
|
};
|
||||||
|
|||||||
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)
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
#include <PlaceholderCoverGenerator.h>
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
#include <Txt.h>
|
#include <Txt.h>
|
||||||
#include <Xtc.h>
|
#include <Xtc.h>
|
||||||
@@ -599,6 +600,11 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!lastXtc.generateCoverBmp()) {
|
if (!lastXtc.generateCoverBmp()) {
|
||||||
|
LOG_DBG("SLP", "XTC cover generation failed, trying placeholder");
|
||||||
|
PlaceholderCoverGenerator::generate(lastXtc.getCoverBmpPath(), lastXtc.getTitle(), lastXtc.getAuthor(), 480, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Storage.exists(lastXtc.getCoverBmpPath().c_str())) {
|
||||||
LOG_ERR("SLP", "Failed to generate XTC cover bmp");
|
LOG_ERR("SLP", "Failed to generate XTC cover bmp");
|
||||||
return (this->*renderNoCoverSleepScreen)();
|
return (this->*renderNoCoverSleepScreen)();
|
||||||
}
|
}
|
||||||
@@ -614,6 +620,11 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!lastTxt.generateCoverBmp()) {
|
if (!lastTxt.generateCoverBmp()) {
|
||||||
|
LOG_DBG("SLP", "TXT cover generation failed, trying placeholder");
|
||||||
|
PlaceholderCoverGenerator::generate(lastTxt.getCoverBmpPath(), lastTxt.getTitle(), "", 480, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Storage.exists(lastTxt.getCoverBmpPath().c_str())) {
|
||||||
LOG_ERR("SLP", "No cover image found for TXT file");
|
LOG_ERR("SLP", "No cover image found for TXT file");
|
||||||
return (this->*renderNoCoverSleepScreen)();
|
return (this->*renderNoCoverSleepScreen)();
|
||||||
}
|
}
|
||||||
@@ -630,6 +641,12 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!lastEpub.generateCoverBmp(cropped)) {
|
if (!lastEpub.generateCoverBmp(cropped)) {
|
||||||
|
LOG_DBG("SLP", "EPUB cover generation failed, trying placeholder");
|
||||||
|
PlaceholderCoverGenerator::generate(lastEpub.getCoverBmpPath(cropped), lastEpub.getTitle(),
|
||||||
|
lastEpub.getAuthor(), 480, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Storage.exists(lastEpub.getCoverBmpPath(cropped).c_str())) {
|
||||||
LOG_ERR("SLP", "Failed to generate cover bmp");
|
LOG_ERR("SLP", "Failed to generate cover bmp");
|
||||||
return (this->*renderNoCoverSleepScreen)();
|
return (this->*renderNoCoverSleepScreen)();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <Utf8.h>
|
#include <Utf8.h>
|
||||||
|
#include <PlaceholderCoverGenerator.h>
|
||||||
#include <Xtc.h>
|
#include <Xtc.h>
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@@ -65,45 +66,35 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
|||||||
if (!book.coverBmpPath.empty()) {
|
if (!book.coverBmpPath.empty()) {
|
||||||
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
|
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
|
||||||
if (!Storage.exists(coverPath.c_str())) {
|
if (!Storage.exists(coverPath.c_str())) {
|
||||||
// If epub, try to load the metadata for title/author and cover
|
if (!showingLoading) {
|
||||||
|
showingLoading = true;
|
||||||
|
popupRect = GUI.drawPopup(renderer, "Loading...");
|
||||||
|
}
|
||||||
|
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
|
||||||
|
|
||||||
|
bool success = false;
|
||||||
|
|
||||||
|
// Try format-specific thumbnail generation first
|
||||||
if (StringUtils::checkFileExtension(book.path, ".epub")) {
|
if (StringUtils::checkFileExtension(book.path, ".epub")) {
|
||||||
Epub epub(book.path, "/.crosspoint");
|
Epub epub(book.path, "/.crosspoint");
|
||||||
// Skip loading css since we only need metadata here
|
|
||||||
epub.load(false, true);
|
epub.load(false, true);
|
||||||
|
success = epub.generateThumbBmp(coverHeight);
|
||||||
// Try to generate thumbnail image for Continue Reading card
|
|
||||||
if (!showingLoading) {
|
|
||||||
showingLoading = true;
|
|
||||||
popupRect = GUI.drawPopup(renderer, "Loading...");
|
|
||||||
}
|
|
||||||
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
|
|
||||||
bool success = epub.generateThumbBmp(coverHeight);
|
|
||||||
if (!success) {
|
|
||||||
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
|
|
||||||
book.coverBmpPath = "";
|
|
||||||
}
|
|
||||||
coverRendered = false;
|
|
||||||
updateRequired = true;
|
|
||||||
} else if (StringUtils::checkFileExtension(book.path, ".xtch") ||
|
} else if (StringUtils::checkFileExtension(book.path, ".xtch") ||
|
||||||
StringUtils::checkFileExtension(book.path, ".xtc")) {
|
StringUtils::checkFileExtension(book.path, ".xtc")) {
|
||||||
// Handle XTC file
|
|
||||||
Xtc xtc(book.path, "/.crosspoint");
|
Xtc xtc(book.path, "/.crosspoint");
|
||||||
if (xtc.load()) {
|
if (xtc.load()) {
|
||||||
// Try to generate thumbnail image for Continue Reading card
|
success = xtc.generateThumbBmp(coverHeight);
|
||||||
if (!showingLoading) {
|
|
||||||
showingLoading = true;
|
|
||||||
popupRect = GUI.drawPopup(renderer, "Loading...");
|
|
||||||
}
|
|
||||||
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
|
|
||||||
bool success = xtc.generateThumbBmp(coverHeight);
|
|
||||||
if (!success) {
|
|
||||||
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
|
|
||||||
book.coverBmpPath = "";
|
|
||||||
}
|
|
||||||
coverRendered = false;
|
|
||||||
updateRequired = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: generate a placeholder thumbnail with title/author
|
||||||
|
if (!success && !Storage.exists(coverPath.c_str())) {
|
||||||
|
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
|
||||||
|
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
coverRendered = false;
|
||||||
|
updateRequired = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
progress++;
|
progress++;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
|
||||||
|
#include <PlaceholderCoverGenerator.h>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "EpubReaderBookmarkSelectionActivity.h"
|
#include "EpubReaderBookmarkSelectionActivity.h"
|
||||||
@@ -126,15 +128,31 @@ void EpubReaderActivity::onEnter() {
|
|||||||
|
|
||||||
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
|
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
|
||||||
epub->generateCoverBmp(false);
|
epub->generateCoverBmp(false);
|
||||||
|
// Fallback: generate placeholder if real cover extraction failed
|
||||||
|
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
|
||||||
|
PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(false), epub->getTitle(), epub->getAuthor(), 480,
|
||||||
|
800);
|
||||||
|
}
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
|
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
|
||||||
epub->generateCoverBmp(true);
|
epub->generateCoverBmp(true);
|
||||||
|
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
|
||||||
|
PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(true), epub->getTitle(), epub->getAuthor(), 480,
|
||||||
|
800);
|
||||||
|
}
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||||
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
||||||
epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
|
epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
|
||||||
|
// Fallback: generate placeholder thumbnail
|
||||||
|
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
||||||
|
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
|
||||||
|
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
|
||||||
|
PlaceholderCoverGenerator::generate(epub->getThumbBmpPath(thumbHeight), epub->getTitle(),
|
||||||
|
epub->getAuthor(), thumbWidth, thumbHeight);
|
||||||
|
}
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
#include <Utf8.h>
|
#include <Utf8.h>
|
||||||
|
|
||||||
|
#include <PlaceholderCoverGenerator.h>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
@@ -57,13 +59,43 @@ void TxtReaderActivity::onEnter() {
|
|||||||
|
|
||||||
txt->setupCacheDir();
|
txt->setupCacheDir();
|
||||||
|
|
||||||
// Prerender cover on first open so the Sleep screen is instant.
|
// Prerender covers and thumbnails on first open so Home and Sleep screens are instant.
|
||||||
// generateCoverBmp() is a no-op if the file already exists, so this only does work once.
|
// Each generate* call is a no-op if the file already exists, so this only does work once.
|
||||||
// TXT has no thumbnail support, so only the sleep screen cover is generated.
|
{
|
||||||
if (!Storage.exists(txt->getCoverBmpPath().c_str())) {
|
int totalSteps = 0;
|
||||||
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
|
if (!Storage.exists(txt->getCoverBmpPath().c_str())) totalSteps++;
|
||||||
txt->generateCoverBmp();
|
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||||
GUI.fillPopupProgress(renderer, popupRect, 100);
|
if (!Storage.exists(txt->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) totalSteps++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSteps > 0) {
|
||||||
|
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
|
||||||
|
int completedSteps = 0;
|
||||||
|
|
||||||
|
auto updateProgress = [&]() {
|
||||||
|
completedSteps++;
|
||||||
|
GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Storage.exists(txt->getCoverBmpPath().c_str())) {
|
||||||
|
const bool coverGenerated = txt->generateCoverBmp();
|
||||||
|
// Fallback: generate placeholder if no cover image was found
|
||||||
|
if (!coverGenerated) {
|
||||||
|
PlaceholderCoverGenerator::generate(txt->getCoverBmpPath(), txt->getTitle(), "", 480, 800);
|
||||||
|
}
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||||
|
if (!Storage.exists(txt->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
||||||
|
// TXT has no native thumbnail generation, always use placeholder
|
||||||
|
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
|
||||||
|
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
|
||||||
|
PlaceholderCoverGenerator::generate(txt->getThumbBmpPath(thumbHeight), txt->getTitle(), "", thumbWidth,
|
||||||
|
thumbHeight);
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current txt as last opened file and add to recent books
|
// Save current txt as last opened file and add to recent books
|
||||||
@@ -71,7 +103,7 @@ void TxtReaderActivity::onEnter() {
|
|||||||
auto fileName = filePath.substr(filePath.rfind('/') + 1);
|
auto fileName = filePath.substr(filePath.rfind('/') + 1);
|
||||||
APP_STATE.openEpubPath = filePath;
|
APP_STATE.openEpubPath = filePath;
|
||||||
APP_STATE.saveToFile();
|
APP_STATE.saveToFile();
|
||||||
RECENT_BOOKS.addBook(filePath, fileName, "", "");
|
RECENT_BOOKS.addBook(filePath, fileName, "", txt->getThumbBmpPath());
|
||||||
|
|
||||||
// Trigger first update
|
// Trigger first update
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
|
|
||||||
|
#include <PlaceholderCoverGenerator.h>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
@@ -63,11 +65,22 @@ void XtcReaderActivity::onEnter() {
|
|||||||
|
|
||||||
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) {
|
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) {
|
||||||
xtc->generateCoverBmp();
|
xtc->generateCoverBmp();
|
||||||
|
// Fallback: generate placeholder if first-page cover extraction failed
|
||||||
|
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) {
|
||||||
|
PlaceholderCoverGenerator::generate(xtc->getCoverBmpPath(), xtc->getTitle(), xtc->getAuthor(), 480, 800);
|
||||||
|
}
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||||
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
||||||
xtc->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
|
xtc->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
|
||||||
|
// Fallback: generate placeholder thumbnail
|
||||||
|
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
||||||
|
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
|
||||||
|
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
|
||||||
|
PlaceholderCoverGenerator::generate(xtc->getThumbBmpPath(thumbHeight), xtc->getTitle(), xtc->getAuthor(),
|
||||||
|
thumbWidth, thumbHeight);
|
||||||
|
}
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user