Compare commits

...

6 Commits

Author SHA1 Message Date
pablohc
67e42db1d6
Merge 436dc9e593 into f67c544e16 2026-02-02 19:29:20 -08:00
Aaron Cunliffe
f67c544e16
fix: webserver folder creation regex change (#653)
Some checks failed
CI / build (push) Has been cancelled
## Summary

Resolves #562 

Implements regex change to support valid characters discussed by
@daveallie in issue
[here](https://github.com/crosspoint-reader/crosspoint-reader/issues/562#issuecomment-3830809156).

Also rejects `.` and `..` as folder names which are invalid in FAT32 and
exFAT filesystems

## Additional Context
- Unsure on the wording for the alert, it feels overly explicit, but
that might be a good thing. Happy to change.

---

### 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? _**< PARTIALLY >**_
2026-02-02 21:27:02 +11:00
pablohc
436dc9e593 style: fix Serial.printf formatting in Xtc.cpp 2026-01-26 14:42:50 +01:00
pablohc
85b7e8124d style: fix formatting issues 2026-01-26 14:21:50 +01:00
pablohc
6d6559022c style: fix formatting issues in cover generation code 2026-01-26 13:47:32 +01:00
pablohc
5671b05d04 fix: correct home cover dimensions 2026-01-26 12:54:44 +01:00
9 changed files with 133 additions and 102 deletions

View File

@ -153,7 +153,7 @@ Click **File Manager** to access file management features.
1. Click the **+ Add** button in the top-right corner
2. Select **New Folder** from the dropdown menu
3. Enter a folder name (letters, numbers, underscores, and hyphens only)
3. Enter a folder name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
4. Click **Create Folder**
This is useful for organizing your ebooks by genre, author, or series.

View File

@ -428,28 +428,28 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false;
}
std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
std::string Epub::getCoverHomeBmpPath() const { return cachePath + "/cover_home.bmp"; }
bool Epub::generateThumbBmp() const {
bool Epub::generateCoverHomeBmp() const {
// Already generated, return true
if (SdMan.exists(getThumbBmpPath().c_str())) {
if (SdMan.exists(getCoverHomeBmpPath().c_str())) {
return true;
}
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] Cannot generate thumb BMP, cache not loaded\n", millis());
Serial.printf("[%lu] [EBP] Cannot generate home BMP, cache not loaded\n", millis());
return false;
}
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
if (coverImageHref.empty()) {
Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis());
Serial.printf("[%lu] [EBP] No known cover image for home screen\n", millis());
return false;
}
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis());
Serial.printf("[%lu] [EBP] Generating home BMP from JPG cover image\n", millis());
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg;
@ -463,30 +463,50 @@ bool Epub::generateThumbBmp() const {
return false;
}
FsFile thumbBmp;
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) {
FsFile homeBmp;
if (!SdMan.openFileForWrite("EBP", getCoverHomeBmpPath(), homeBmp)) {
coverJpg.close();
return false;
}
// Use smaller target size for Continue Reading card (half of screen: 240x400)
// For home screen, use 400px height with proportional width for optimal performance
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
constexpr int THUMB_TARGET_WIDTH = 240;
constexpr int THUMB_TARGET_HEIGHT = 400;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
THUMB_TARGET_HEIGHT);
constexpr int HOME_TARGET_HEIGHT = 400;
FsFile tempJpg;
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, tempJpg)) {
coverJpg.close();
return false;
}
// First get JPEG dimensions to calculate proper width
int jpegWidth, jpegHeight;
if (!JpegToBmpConverter::getJpegDimensions(tempJpg, &jpegWidth, &jpegHeight)) {
Serial.printf("[%lu] [EBP] Failed to get JPEG dimensions for home cover\n", millis());
coverJpg.close();
tempJpg.close();
return false;
}
tempJpg.close();
// Calculate proportional width for 400px height
const int targetWidth = (400 * jpegWidth) / jpegHeight;
const bool success =
JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, homeBmp, targetWidth, HOME_TARGET_HEIGHT);
coverJpg.close();
thumbBmp.close();
homeBmp.close();
SdMan.remove(coverJpgTempPath.c_str());
if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis());
SdMan.remove(getThumbBmpPath().c_str());
Serial.printf("[%lu] [EBP] Failed to generate home BMP from JPG cover image\n", millis());
SdMan.remove(getCoverHomeBmpPath().c_str());
}
Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(),
Serial.printf("[%lu] [EBP] Generated home BMP from JPG cover image, success: %s\n", millis(),
success ? "yes" : "no");
return success;
} else {
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis());
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping home screen\n", millis());
}
return false;

View File

@ -47,8 +47,9 @@ class Epub {
const std::string& getLanguage() const;
std::string getCoverBmpPath(bool cropped = false) const;
bool generateCoverBmp(bool cropped = false) const;
std::string getThumbBmpPath() const;
bool generateThumbBmp() const;
// Home screen support (optimized 400px height covers for Continue Reading card)
std::string getCoverHomeBmpPath() const;
bool generateCoverHomeBmp() const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;

View File

@ -569,3 +569,32 @@ bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print
int targetMaxHeight) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true);
}
// Get JPEG dimensions without full conversion
bool JpegToBmpConverter::getJpegDimensions(FsFile& jpegFile, int* width, int* height) {
// Reset file position to beginning
if (!jpegFile.seek(0)) {
Serial.printf("[%lu] [JPG] Failed to seek to beginning of JPEG file\n", millis());
return false;
}
// Initialize JPEG decoder
pjpeg_image_info_t imageInfo = {};
JpegReadContext context{jpegFile, {}, 0, 0};
const int decodeStatus = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, false);
if (decodeStatus != 0) {
Serial.printf("[%lu] [JPG] pjpeg_decode_init failed with status %d\n", millis(), decodeStatus);
return false;
}
// Get dimensions from image info
*width = imageInfo.m_width;
*height = imageInfo.m_height;
Serial.printf("[%lu] [JPG] Read JPEG dimensions: %dx%d\n", millis(), *width, *height);
// Reset file position after reading
jpegFile.seek(0);
return true;
}

View File

@ -16,4 +16,6 @@ class JpegToBmpConverter {
static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
// Extract JPEG dimensions without loading full image
static bool getJpegDimensions(FsFile& jpegFile, int* width, int* height);
};

View File

@ -301,11 +301,11 @@ bool Xtc::generateCoverBmp() const {
return true;
}
std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
std::string Xtc::getCoverHomeBmpPath() const { return cachePath + "/cover_home.bmp"; }
bool Xtc::generateThumbBmp() const {
bool Xtc::generateCoverHomeBmp() const {
// Already generated
if (SdMan.exists(getThumbBmpPath().c_str())) {
if (SdMan.exists(getCoverHomeBmpPath().c_str())) {
return true;
}
@ -332,43 +332,18 @@ bool Xtc::generateThumbBmp() const {
// Get bit depth
const uint8_t bitDepth = parser->getBitDepth();
// Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card)
constexpr int THUMB_TARGET_WIDTH = 240;
constexpr int THUMB_TARGET_HEIGHT = 400;
// For home screen, use 400px height with proportional width for optimal performance
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
constexpr int HOME_TARGET_HEIGHT = 400;
// Calculate scale factor
float scaleX = static_cast<float>(THUMB_TARGET_WIDTH) / pageInfo.width;
float scaleY = static_cast<float>(THUMB_TARGET_HEIGHT) / pageInfo.height;
float scale = (scaleX < scaleY) ? scaleX : scaleY;
// Calculate proportional width for 400px height
const uint16_t targetWidth = static_cast<uint16_t>((HOME_TARGET_HEIGHT * pageInfo.width) / pageInfo.height);
// Only scale down, never up
if (scale >= 1.0f) {
// Page is already small enough, just use cover.bmp
// Copy cover.bmp to thumb.bmp
if (generateCoverBmp()) {
FsFile src, dst;
if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) {
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) {
uint8_t buffer[512];
while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer));
dst.write(buffer, bytesRead);
}
dst.close();
}
src.close();
}
Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis());
return SdMan.exists(getThumbBmpPath().c_str());
}
return false;
}
uint16_t thumbWidth = static_cast<uint16_t>(pageInfo.width * scale);
uint16_t thumbHeight = static_cast<uint16_t>(pageInfo.height * scale);
Serial.printf("[%lu] [XTC] Generating home BMP: %dx%d -> %dx%d\n", millis(), pageInfo.width, pageInfo.height,
targetWidth, HOME_TARGET_HEIGHT);
Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width,
pageInfo.height, thumbWidth, thumbHeight, scale);
pageInfo.height, targetWidth, HOME_TARGET_HEIGHT);
// Allocate buffer for page data
size_t bitmapSize;
@ -393,15 +368,15 @@ bool Xtc::generateThumbBmp() const {
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
FsFile thumbBmp;
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) {
if (!SdMan.openFileForWrite("XTC", getCoverHomeBmpPath(), thumbBmp)) {
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis());
free(pageBuffer);
return false;
}
// Write 1-bit BMP header for fast home screen rendering
const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes
const uint32_t imageSize = rowSize * thumbHeight;
const uint32_t rowSize = (targetWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes
const uint32_t imageSize = rowSize * HOME_TARGET_HEIGHT;
const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette
// File header
@ -416,9 +391,9 @@ bool Xtc::generateThumbBmp() const {
// DIB header
uint32_t dibHeaderSize = 40;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
int32_t widthVal = thumbWidth;
int32_t widthVal = targetWidth;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&widthVal), 4);
int32_t heightVal = -static_cast<int32_t>(thumbHeight); // Negative for top-down
int32_t heightVal = -static_cast<int32_t>(HOME_TARGET_HEIGHT); // Negative for top-down
thumbBmp.write(reinterpret_cast<const uint8_t*>(&heightVal), 4);
uint16_t planes = 1;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
@ -451,8 +426,8 @@ bool Xtc::generateThumbBmp() const {
return false;
}
// Fixed-point scale factor (16.16)
uint32_t scaleInv_fp = static_cast<uint32_t>(65536.0f / scale);
// Fixed-point scale factor (16.16) - scale to fit 400px height
uint32_t scaleInv_fp = static_cast<uint32_t>(65536.0f * static_cast<float>(pageInfo.height) / HOME_TARGET_HEIGHT);
// Pre-calculate plane info for 2-bit mode
const size_t planeSize = (bitDepth == 2) ? ((static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) : 0;
@ -461,7 +436,7 @@ bool Xtc::generateThumbBmp() const {
const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0;
const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0;
for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) {
for (uint16_t dstY = 0; dstY < HOME_TARGET_HEIGHT; dstY++) {
memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1)
// Calculate source Y range with bounds checking
@ -472,7 +447,7 @@ bool Xtc::generateThumbBmp() const {
if (srcYEnd <= srcYStart) srcYEnd = srcYStart + 1;
if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height;
for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) {
for (uint16_t dstX = 0; dstX < targetWidth; dstX++) {
// Calculate source X range with bounds checking
uint32_t srcXStart = (static_cast<uint32_t>(dstX) * scaleInv_fp) >> 16;
uint32_t srcXEnd = (static_cast<uint32_t>(dstX + 1) * scaleInv_fp) >> 16;
@ -557,8 +532,8 @@ bool Xtc::generateThumbBmp() const {
thumbBmp.close();
free(pageBuffer);
Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight,
getThumbBmpPath().c_str());
Serial.printf("[%lu] [XTC] Generated home BMP (%dx%d): %s\n", millis(), targetWidth, HOME_TARGET_HEIGHT,
getCoverHomeBmpPath().c_str());
return true;
}

View File

@ -63,9 +63,9 @@ class Xtc {
// Cover image support (for sleep screen)
std::string getCoverBmpPath() const;
bool generateCoverBmp() const;
// Thumbnail support (for Continue Reading card)
std::string getThumbBmpPath() const;
bool generateThumbBmp() const;
// Home screen support (optimized 400px height covers for Continue Reading card)
std::string getCoverHomeBmpPath() const;
bool generateCoverHomeBmp() const;
// Page access
uint32_t getPageCount() const;

View File

@ -60,8 +60,8 @@ void HomeActivity::onEnter() {
lastBookAuthor = std::string(epub.getAuthor());
}
// Try to generate thumbnail image for Continue Reading card
if (epub.generateThumbBmp()) {
coverBmpPath = epub.getThumbBmpPath();
if (epub.generateCoverHomeBmp()) {
coverBmpPath = epub.getCoverHomeBmpPath();
hasCoverImage = true;
}
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") ||
@ -76,8 +76,8 @@ void HomeActivity::onEnter() {
lastBookAuthor = std::string(xtc.getAuthor());
}
// Try to generate thumbnail image for Continue Reading card
if (xtc.generateThumbBmp()) {
coverBmpPath = xtc.getThumbBmpPath();
if (xtc.generateCoverHomeBmp()) {
coverBmpPath = xtc.getCoverHomeBmpPath();
hasCoverImage = true;
}
}
@ -224,10 +224,32 @@ void HomeActivity::render() {
constexpr int bottomMargin = 60;
// --- Top "book" card for the current title (selectorIndex == 0) ---
const int bookWidth = pageWidth / 2;
const int bookHeight = pageHeight / 2;
const int bookX = (pageWidth - bookWidth) / 2;
// Load cover image to get its dimensions
int coverWidth = 0;
int coverHeight = 0;
if (hasContinueReading && hasCoverImage && !coverBmpPath.empty()) {
FsFile coverFile;
if (SdMan.openFileForRead("HOME", coverBmpPath, coverFile)) {
Bitmap testBitmap(coverFile);
if (testBitmap.parseHeaders() == BmpReaderError::Ok) {
coverWidth = testBitmap.getWidth();
coverHeight = testBitmap.getHeight();
}
coverFile.close();
}
}
// Calculate card dimensions based on cover image
// Use 400px height as specified, with proportional width
constexpr int CARD_HEIGHT = 400;
const int cardWidth = (coverWidth > 0 && coverHeight > 0)
? (CARD_HEIGHT * coverWidth) / coverHeight
: 240; // Fallback to 240px width if no image (maintain aspect ratio)
const int bookX = (pageWidth - cardWidth) / 2;
constexpr int bookY = 30;
const int bookWidth = cardWidth;
const int bookHeight = CARD_HEIGHT;
const bool bookSelected = hasContinueReading && selectorIndex == 0;
// Bookmark dimensions (used in multiple places)
@ -246,27 +268,9 @@ void HomeActivity::render() {
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
// Calculate position to center image within the book card
int coverX, coverY;
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
if (imgRatio > boxRatio) {
coverX = bookX;
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
} else {
coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2;
coverY = bookY;
}
} else {
coverX = bookX + (bookWidth - bitmap.getWidth()) / 2;
coverY = bookY + (bookHeight - bitmap.getHeight()) / 2;
}
// Draw the cover image centered within the book card
renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight);
// Since the book card already has the exact same size as the image,
// we can draw it at the same position with the same dimensions
renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, bookHeight);
// Draw border around the card
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);

View File

@ -1146,10 +1146,10 @@ function retryAllFailedUploads() {
return;
}
// Validate folder name (no special characters except underscore and hyphen)
const validName = /^[a-zA-Z0-9_\-]+$/.test(folderName);
// Validate folder name
const validName = /^(?!\.{1,2}$)[^"*:<>?\/\\|]+$/.test(folderName);
if (!validName) {
alert('Folder name can only contain letters, numbers, underscores, and hyphens.');
alert('Folder name cannot contain \" * : < > ? / \\ | and must not be . or ..');
return;
}