Files
Crosspoint/src/util/ScreenshotUtil.cpp
T
Jon Stieglitz 2f969a93b4 feat: context-aware screenshot filenames with book title (#1589)
## Summary

I love the new screenshot feature but I can tell that soon I'm gonna
have a folder full of screenshots and not know what's what. It would be
great if the folder could self organize.

When a screenshot is taken while reading, the filename would now include
the book title, chapter (EPUB), page number, and progress percentage.

Example: My-Great-Book_ch3_p5_42pct_12345.bmp

* **What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)
* **What changes are included?**

## Additional Context

* Non-reader screens keep the existing screenshot-<millis>.bmp naming.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< YES >**_

I reviewed all the changes but I don't have any experience with this
project so this is my best guess
2026-04-30 13:56:45 -05:00

183 lines
5.9 KiB
C++

#include "ScreenshotUtil.h"
#include <Arduino.h>
#include <BitmapHelpers.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <Logging.h>
#include <cstring>
#include <string>
#include "Bitmap.h" // Required for BmpHeader struct definition
#include "activities/Activity.h"
void ScreenshotUtil::buildFilename(const ScreenshotInfo& info, char* buf, size_t bufSize) {
const unsigned long ts = millis();
if (info.readerType == ScreenshotInfo::ReaderType::None || info.title[0] == '\0') {
snprintf(buf, bufSize, "/screenshots/screenshot-%lu.bmp", ts);
return;
}
char sanitizedTitle[64];
FsHelpers::sanitizePathComponentForFat32(info.title, sanitizedTitle, sizeof(sanitizedTitle));
if (sanitizedTitle[0] == '\0') {
snprintf(buf, bufSize, "/screenshots/screenshot-%lu.bmp", ts);
return;
}
int pct = info.progressPercent;
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
// Display spine index as 1-based for user-facing filenames
const int chapterNum = info.spineIndex + 1;
if (info.readerType == ScreenshotInfo::ReaderType::Epub && info.spineIndex >= 0) {
snprintf(buf, bufSize, "/screenshots/%s/%s_ch%d_p%d_%dpct_%lu.bmp", sanitizedTitle, sanitizedTitle, chapterNum,
info.currentPage, pct, ts);
} else {
snprintf(buf, bufSize, "/screenshots/%s/%s_p%d_%dpct_%lu.bmp", sanitizedTitle, sanitizedTitle, info.currentPage,
pct, ts);
}
// Truncate title if total path exceeds FAT32 limit
if (strlen(buf) > 255) {
size_t titleLen = strlen(sanitizedTitle);
size_t overhead = strlen(buf) - 2 * titleLen;
if (overhead < 255) {
size_t maxTitleLen = (255 - overhead) / 2;
// Walk back to a valid UTF-8 boundary to avoid corrupting multibyte characters
while (maxTitleLen > 0 && (sanitizedTitle[maxTitleLen] & 0xC0) == 0x80) {
maxTitleLen--;
}
sanitizedTitle[maxTitleLen] = '\0';
if (info.readerType == ScreenshotInfo::ReaderType::Epub && info.spineIndex >= 0) {
snprintf(buf, bufSize, "/screenshots/%s/%s_ch%d_p%d_%dpct_%lu.bmp", sanitizedTitle, sanitizedTitle, chapterNum,
info.currentPage, pct, ts);
} else {
snprintf(buf, bufSize, "/screenshots/%s/%s_p%d_%dpct_%lu.bmp", sanitizedTitle, sanitizedTitle, info.currentPage,
pct, ts);
}
} else {
snprintf(buf, bufSize, "/screenshots/screenshot-%lu.bmp", ts);
}
}
}
void ScreenshotUtil::takeScreenshot(GfxRenderer& renderer) {
const uint8_t* fb = renderer.getFrameBuffer();
if (!fb) {
LOG_ERR("SCR", "Framebuffer not available");
return;
}
ScreenshotInfo info = activityManager.getScreenshotInfo();
char filename[256];
buildFilename(info, filename, sizeof(filename));
bool saved = saveFramebufferAsBmp(filename, fb, renderer.getDisplayWidth(), renderer.getDisplayHeight());
if (saved) {
LOG_DBG("SCR", "Screenshot saved to %s", filename);
} else {
LOG_ERR("SCR", "Failed to save screenshot");
return;
}
// Display a border around the screen to indicate a screenshot was taken
if (renderer.storeBwBuffer()) {
renderer.drawRect(6, 6, renderer.getDisplayHeight() - 12, renderer.getDisplayWidth() - 12, 2, true);
renderer.displayBuffer();
delay(1000);
renderer.restoreBwBuffer();
renderer.displayBuffer(HalDisplay::RefreshMode::HALF_REFRESH);
}
}
bool ScreenshotUtil::saveFramebufferAsBmp(const char* filename, const uint8_t* framebuffer, int width, int height) {
if (!framebuffer) {
return false;
}
// Note: the width and height, we rotate the image 90d counter-clockwise to match the default display orientation
int phyWidth = height;
int phyHeight = width;
std::string path(filename);
size_t last_slash = path.find_last_of('/');
if (last_slash != std::string::npos) {
std::string dir = path.substr(0, last_slash);
if (!Storage.exists(dir.c_str())) {
if (!Storage.mkdir(dir.c_str())) {
return false;
}
}
}
FsFile file;
if (!Storage.openFileForWrite("SCR", filename, file)) {
LOG_ERR("SCR", "Failed to save screenshot");
return false;
}
BmpHeader header;
createBmpHeader(&header, phyWidth, phyHeight, BmpRowOrder::BottomUp);
bool write_error = false;
if (file.write(reinterpret_cast<uint8_t*>(&header), sizeof(header)) != sizeof(header)) {
write_error = true;
}
if (write_error) {
// Explicitly close() file before calling Storage.remove()
file.close();
Storage.remove(filename);
return false;
}
const uint32_t rowSizePadded = (phyWidth + 31) / 32 * 4;
// Max row size for 528px height (X3) after rotation = 68 bytes; use fixed buffer to avoid VLA
constexpr size_t kMaxRowSize = 68;
if (rowSizePadded > kMaxRowSize) {
LOG_ERR("SCR", "Row size %u exceeds buffer capacity", rowSizePadded);
// Explicitly close() file before calling Storage.remove()
file.close();
Storage.remove(filename);
return false;
}
// rotate the image 90d counter-clockwise on-the-fly while writing to save memory
uint8_t rowBuffer[kMaxRowSize];
memset(rowBuffer, 0, rowSizePadded);
for (int outY = 0; outY < phyHeight; outY++) {
for (int outX = 0; outX < phyWidth; outX++) {
// 90d counter-clockwise: source (srcX, srcY)
// BMP rows are bottom-to-top, so outY=0 is the bottom of the displayed image
int srcX = width - 1 - outY; // phyHeight == width
int srcY = phyWidth - 1 - outX; // phyWidth == height
int fbIndex = srcY * (width / 8) + (srcX / 8);
uint8_t pixel = (framebuffer[fbIndex] >> (7 - (srcX % 8))) & 0x01;
rowBuffer[outX / 8] |= pixel << (7 - (outX % 8));
}
if (file.write(rowBuffer, rowSizePadded) != rowSizePadded) {
write_error = true;
break;
}
memset(rowBuffer, 0, rowSizePadded); // Clear the buffer for the next row
}
// Explicitly close() file before calling Storage.remove()
file.close();
if (write_error) {
Storage.remove(filename);
return false;
}
return true;
}