From 632b76c9ed3101fb66d6b80e29d5f22534e37fdc Mon Sep 17 00:00:00 2001 From: cottongin Date: Sat, 14 Feb 2026 23:38:47 -0500 Subject: [PATCH] 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 --- lib/PlaceholderCover/BookIcon.h | 27 + .../PlaceholderCoverGenerator.cpp | 480 ++++++++++++++++++ .../PlaceholderCoverGenerator.h | 14 + lib/Txt/Txt.cpp | 3 + lib/Txt/Txt.h | 4 + scripts/generate_book_icon.py | 123 +++++ scripts/preview_placeholder_cover.py | 179 +++++++ src/activities/boot_sleep/SleepActivity.cpp | 17 + src/activities/home/HomeActivity.cpp | 51 +- src/activities/reader/EpubReaderActivity.cpp | 18 + src/activities/reader/TxtReaderActivity.cpp | 48 +- src/activities/reader/XtcReaderActivity.cpp | 13 + 12 files changed, 939 insertions(+), 38 deletions(-) create mode 100644 lib/PlaceholderCover/BookIcon.h create mode 100644 lib/PlaceholderCover/PlaceholderCoverGenerator.cpp create mode 100644 lib/PlaceholderCover/PlaceholderCoverGenerator.h create mode 100644 scripts/generate_book_icon.py create mode 100644 scripts/preview_placeholder_cover.py diff --git a/lib/PlaceholderCover/BookIcon.h b/lib/PlaceholderCover/BookIcon.h new file mode 100644 index 00000000..344efaf0 --- /dev/null +++ b/lib/PlaceholderCover/BookIcon.h @@ -0,0 +1,27 @@ +#pragma once +#include + +// 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, +}; diff --git a/lib/PlaceholderCover/PlaceholderCoverGenerator.cpp b/lib/PlaceholderCover/PlaceholderCoverGenerator.cpp new file mode 100644 index 00000000..b0dfa7a9 --- /dev/null +++ b/lib/PlaceholderCover/PlaceholderCoverGenerator.cpp @@ -0,0 +1,480 @@ +#include "PlaceholderCoverGenerator.h" + +#include +#include +#include +#include + +#include +#include +#include + +// 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(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(&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 splitWords(const std::string& text) { + std::vector 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 wrapText(const EpdFontData* font, const std::string& text, int maxWidth, int scale = 1) { + std::vector 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(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 authorLines; + if (!author.empty()) { + authorLines = wrapText(authorFont, author, contentW, authorScale); + constexpr int MAX_AUTHOR_LINES = 3; + if (static_cast(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(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(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; +} diff --git a/lib/PlaceholderCover/PlaceholderCoverGenerator.h b/lib/PlaceholderCover/PlaceholderCoverGenerator.h new file mode 100644 index 00000000..bf0f4e35 --- /dev/null +++ b/lib/PlaceholderCover/PlaceholderCoverGenerator.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +/// 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); +}; diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp index bb20a2bc..61ab0b41 100644 --- a/lib/Txt/Txt.cpp +++ b/lib/Txt/Txt.cpp @@ -97,6 +97,9 @@ std::string Txt::findCoverImage() const { 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 { // Already generated, return true if (Storage.exists(getCoverBmpPath().c_str())) { diff --git a/lib/Txt/Txt.h b/lib/Txt/Txt.h index b342ca88..e7579474 100644 --- a/lib/Txt/Txt.h +++ b/lib/Txt/Txt.h @@ -28,6 +28,10 @@ class Txt { [[nodiscard]] bool generateCoverBmp() 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 [[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const; }; diff --git a/scripts/generate_book_icon.py b/scripts/generate_book_icon.py new file mode 100644 index 00000000..1d60560c --- /dev/null +++ b/scripts/generate_book_icon.py @@ -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 ") + 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) diff --git a/scripts/preview_placeholder_cover.py b/scripts/preview_placeholder_cover.py new file mode 100644 index 00000000..9f686984 --- /dev/null +++ b/scripts/preview_placeholder_cover.py @@ -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) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index e5d2add9..7bb92c3d 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -599,6 +600,11 @@ void SleepActivity::renderCoverSleepScreen() const { } 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"); return (this->*renderNoCoverSleepScreen)(); } @@ -614,6 +620,11 @@ void SleepActivity::renderCoverSleepScreen() const { } 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"); return (this->*renderNoCoverSleepScreen)(); } @@ -630,6 +641,12 @@ void SleepActivity::renderCoverSleepScreen() const { } 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"); return (this->*renderNoCoverSleepScreen)(); } diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 5ae4ea5d..d64f260b 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -65,45 +66,35 @@ void HomeActivity::loadRecentCovers(int coverHeight) { if (!book.coverBmpPath.empty()) { std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); 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")) { Epub epub(book.path, "/.crosspoint"); - // Skip loading css since we only need metadata here epub.load(false, true); - - // 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; + success = epub.generateThumbBmp(coverHeight); } else if (StringUtils::checkFileExtension(book.path, ".xtch") || StringUtils::checkFileExtension(book.path, ".xtc")) { - // Handle XTC file Xtc xtc(book.path, "/.crosspoint"); if (xtc.load()) { - // 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 = xtc.generateThumbBmp(coverHeight); - if (!success) { - RECENT_BOOKS.updateBook(book.path, book.title, book.author, ""); - book.coverBmpPath = ""; - } - coverRendered = false; - updateRequired = true; + success = xtc.generateThumbBmp(coverHeight); } } + + // Fallback: generate a placeholder thumbnail with title/author + if (!success && !Storage.exists(coverPath.c_str())) { + const int thumbWidth = static_cast(coverHeight * 0.6); + PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight); + } + + coverRendered = false; + updateRequired = true; } } progress++; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 98b317a4..044c5470 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -6,6 +6,8 @@ #include #include +#include + #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderBookmarkSelectionActivity.h" @@ -126,15 +128,31 @@ void EpubReaderActivity::onEnter() { if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) { 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(); } if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) { epub->generateCoverBmp(true); + if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) { + PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(true), epub->getTitle(), epub->getAuthor(), 480, + 800); + } updateProgress(); } for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) { if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) { 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(thumbHeight * 0.6); + PlaceholderCoverGenerator::generate(epub->getThumbBmpPath(thumbHeight), epub->getTitle(), + epub->getAuthor(), thumbWidth, thumbHeight); + } updateProgress(); } } diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 88f26900..010a1774 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -5,6 +5,8 @@ #include #include +#include + #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" @@ -57,13 +59,43 @@ void TxtReaderActivity::onEnter() { txt->setupCacheDir(); - // Prerender cover on first open so the Sleep screen is instant. - // generateCoverBmp() 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())) { - Rect popupRect = GUI.drawPopup(renderer, "Preparing book..."); - txt->generateCoverBmp(); - GUI.fillPopupProgress(renderer, popupRect, 100); + // Prerender covers and thumbnails on first open so Home and Sleep screens are instant. + // Each generate* call is a no-op if the file already exists, so this only does work once. + { + int totalSteps = 0; + if (!Storage.exists(txt->getCoverBmpPath().c_str())) totalSteps++; + for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) { + 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(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 @@ -71,7 +103,7 @@ void TxtReaderActivity::onEnter() { auto fileName = filePath.substr(filePath.rfind('/') + 1); APP_STATE.openEpubPath = filePath; APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(filePath, fileName, "", ""); + RECENT_BOOKS.addBook(filePath, fileName, "", txt->getThumbBmpPath()); // Trigger first update updateRequired = true; diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 108611e5..d042ab3e 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -11,6 +11,8 @@ #include #include +#include + #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" @@ -63,11 +65,22 @@ void XtcReaderActivity::onEnter() { if (!Storage.exists(xtc->getCoverBmpPath().c_str())) { 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(); } for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) { if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) { 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(thumbHeight * 0.6); + PlaceholderCoverGenerator::generate(xtc->getThumbBmpPath(thumbHeight), xtc->getTitle(), xtc->getAuthor(), + thumbWidth, thumbHeight); + } updateProgress(); } }