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
This commit is contained in:
Jon Stieglitz
2026-04-30 13:56:45 -05:00
committed by GitHub
parent 14e1ce2d04
commit 2f969a93b4
14 changed files with 181 additions and 9 deletions
+18
View File
@@ -86,4 +86,22 @@ std::string extractFolderPath(const std::string& filePath) {
return filePath.substr(0, lastSlash); return filePath.substr(0, lastSlash);
} }
void sanitizePathComponentForFat32(const char* input, char* output, size_t maxLen) {
if (maxLen == 0) {
return;
}
size_t i = 0;
for (; i < maxLen - 1 && input[i] != '\0'; i++) {
const char c = input[i];
if (c == '\\' || c == '/' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|' ||
c == ' ' || (c > 0x00 && c <= 0x1f)) {
output[i] = '-';
} else {
output[i] = c;
}
}
output[i] = '\0';
}
} // namespace FsHelpers } // namespace FsHelpers
+6
View File
@@ -57,4 +57,10 @@ bool hasMarkdownExtension(std::string_view fileName);
std::string extractFolderPath(const std::string& filePath); std::string extractFolderPath(const std::string& filePath);
/**
* Sanitize a filename/path component for FAT32 in a caller-provided buffer.
* Replaces invalid path characters, spaces, and control characters with '-'.
*/
void sanitizePathComponentForFat32(const char* input, char* output, size_t maxLen);
} // namespace FsHelpers } // namespace FsHelpers
+2
View File
@@ -11,6 +11,7 @@
#include "GfxRenderer.h" #include "GfxRenderer.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "RenderLock.h" #include "RenderLock.h"
#include "util/ScreenshotInfo.h"
class Activity { class Activity {
friend class ActivityManager; friend class ActivityManager;
@@ -43,6 +44,7 @@ class Activity {
virtual bool skipLoopDelay() { return false; } virtual bool skipLoopDelay() { return false; }
virtual bool preventAutoSleep() { return false; } virtual bool preventAutoSleep() { return false; }
virtual bool isReaderActivity() const { return false; } virtual bool isReaderActivity() const { return false; }
virtual ScreenshotInfo getScreenshotInfo() const { return {}; }
// Start a new activity without destroying the current one // Start a new activity without destroying the current one
// Note: requestUpdate() will be invoked automatically once resultHandler finishes // Note: requestUpdate() will be invoked automatically once resultHandler finishes
+7
View File
@@ -234,6 +234,13 @@ bool ActivityManager::isReaderActivity() const { return currentActivity && curre
bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); } bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); }
ScreenshotInfo ActivityManager::getScreenshotInfo() const {
if (currentActivity) {
return currentActivity->getScreenshotInfo();
}
return {};
}
void ActivityManager::requestUpdate(bool immediate) { void ActivityManager::requestUpdate(bool immediate) {
if (immediate) { if (immediate) {
if (renderTaskHandle) { if (renderTaskHandle) {
+2
View File
@@ -11,6 +11,7 @@
#include "GfxRenderer.h" #include "GfxRenderer.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "util/ScreenshotInfo.h"
class Activity; // forward declaration class Activity; // forward declaration
class RenderLock; // forward declaration class RenderLock; // forward declaration
@@ -99,6 +100,7 @@ class ActivityManager {
bool preventAutoSleep() const; bool preventAutoSleep() const;
bool isReaderActivity() const; bool isReaderActivity() const;
bool skipLoopDelay() const; bool skipLoopDelay() const;
ScreenshotInfo getScreenshotInfo() const;
// If immediate is true, the update will be triggered immediately. // If immediate is true, the update will be triggered immediately.
// Otherwise, it will be deferred until the end of the current loop iteration. // Otherwise, it will be deferred until the end of the current loop iteration.
@@ -928,3 +928,24 @@ void EpubReaderActivity::restoreSavedPosition() {
} }
requestUpdate(); requestUpdate();
} }
ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
ScreenshotInfo info;
info.readerType = ScreenshotInfo::ReaderType::Epub;
if (epub) {
snprintf(info.title, sizeof(info.title), "%s", epub->getTitle().c_str());
info.spineIndex = currentSpineIndex;
}
if (section) {
info.currentPage = section->currentPage + 1;
info.totalPages = section->pageCount;
if (epub && epub->getBookSize() > 0 && section->pageCount > 0) {
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
int pct = static_cast<int>(epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f + 0.5f);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
info.progressPercent = pct;
}
}
return info;
}
@@ -65,4 +65,5 @@ class EpubReaderActivity final : public Activity {
void loop() override; void loop() override;
void render(RenderLock&& lock) override; void render(RenderLock&& lock) override;
bool isReaderActivity() const override { return true; } bool isReaderActivity() const override { return true; }
ScreenshotInfo getScreenshotInfo() const override;
}; };
@@ -546,3 +546,17 @@ void TxtReaderActivity::savePageIndexCache() const {
LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages); LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages);
} }
ScreenshotInfo TxtReaderActivity::getScreenshotInfo() const {
ScreenshotInfo info;
info.readerType = ScreenshotInfo::ReaderType::Txt;
if (txt) {
const std::string t = txt->getTitle();
snprintf(info.title, sizeof(info.title), "%s", t.c_str());
}
info.currentPage = currentPage + 1;
info.totalPages = totalPages;
info.progressPercent = totalPages > 0 ? static_cast<int>((currentPage + 1) * 100.0f / totalPages + 0.5f) : 0;
if (info.progressPercent > 100) info.progressPercent = 100;
return info;
}
@@ -49,4 +49,5 @@ class TxtReaderActivity final : public Activity {
void loop() override; void loop() override;
void render(RenderLock&&) override; void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; } bool isReaderActivity() const override { return true; }
ScreenshotInfo getScreenshotInfo() const override;
}; };
@@ -360,3 +360,21 @@ void XtcReaderActivity::loadProgress() {
f.close(); f.close();
} }
} }
ScreenshotInfo XtcReaderActivity::getScreenshotInfo() const {
ScreenshotInfo info;
info.readerType = ScreenshotInfo::ReaderType::Xtc;
if (xtc) {
const std::string t = xtc->getTitle();
snprintf(info.title, sizeof(info.title), "%s", t.c_str());
const uint32_t pageCount = xtc->getPageCount();
info.totalPages = pageCount;
// Clamp to last valid page to avoid sentinel value (currentPage == pageCount)
uint32_t clampedPage = (pageCount > 0 && currentPage >= pageCount) ? pageCount - 1 : currentPage;
info.progressPercent = pageCount > 0 ? xtc->calculateProgress(clampedPage) : 0;
info.currentPage = static_cast<int>(clampedPage) + 1;
} else {
info.currentPage = currentPage + 1;
}
return info;
}
@@ -29,4 +29,5 @@ class XtcReaderActivity final : public Activity {
void loop() override; void loop() override;
void render(RenderLock&&) override; void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; } bool isReaderActivity() const override { return true; }
ScreenshotInfo getScreenshotInfo() const override;
}; };
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <cstdint>
struct ScreenshotInfo {
enum class ReaderType : uint8_t { None, Epub, Txt, Xtc };
ReaderType readerType = ReaderType::None;
char title[64] = {}; // Sanitized, truncated book title (null-terminated)
int spineIndex = -1; // EPUB only: current spine/chapter index
int currentPage = 0; // 1-based page number
int totalPages = 0; // Total pages in chapter (EPUB) or book (TXT/XTC)
int progressPercent = 0; // 0-100 whole-book progress
};
+70 -8
View File
@@ -2,26 +2,88 @@
#include <Arduino.h> #include <Arduino.h>
#include <BitmapHelpers.h> #include <BitmapHelpers.h>
#include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <Logging.h> #include <Logging.h>
#include <cstring>
#include <string> #include <string>
#include "Bitmap.h" // Required for BmpHeader struct definition #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) { void ScreenshotUtil::takeScreenshot(GfxRenderer& renderer) {
const uint8_t* fb = renderer.getFrameBuffer(); const uint8_t* fb = renderer.getFrameBuffer();
if (fb) { if (!fb) {
String filename_str = "/screenshots/screenshot-" + String(millis()) + ".bmp"; LOG_ERR("SCR", "Framebuffer not available");
if (ScreenshotUtil::saveFramebufferAsBmp(filename_str.c_str(), fb, renderer.getDisplayWidth(), return;
renderer.getDisplayHeight())) { }
LOG_DBG("SCR", "Screenshot saved to %s", filename_str.c_str());
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 { } else {
LOG_ERR("SCR", "Failed to save screenshot"); LOG_ERR("SCR", "Failed to save screenshot");
} return;
} else {
LOG_ERR("SCR", "Framebuffer not available");
} }
// Display a border around the screen to indicate a screenshot was taken // Display a border around the screen to indicate a screenshot was taken
+7
View File
@@ -1,8 +1,15 @@
#pragma once #pragma once
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <cstddef>
#include "ScreenshotInfo.h"
class ScreenshotUtil { class ScreenshotUtil {
public: public:
static void takeScreenshot(GfxRenderer& renderer); static void takeScreenshot(GfxRenderer& renderer);
static bool saveFramebufferAsBmp(const char* filename, const uint8_t* framebuffer, int width, int height); static bool saveFramebufferAsBmp(const char* filename, const uint8_t* framebuffer, int width, int height);
private:
static void buildFilename(const ScreenshotInfo& info, char* buf, size_t bufSize);
}; };