#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; }