Compare commits

...

4 Commits

Author SHA1 Message Date
cottongin
48267ad848
release: ef-1.0.4
All checks were successful
CI / build (push) Successful in 4m11s
Compile Release / build-release (push) Successful in 1m14s
New Features:
- End-of-book "Start Over" option to wrap to first page

EPUB Rendering:
- CSS margin-left/padding-left parsing for block indentation
- Vertical bar and italic styling for blockquotes
- Left margin indentation for list items
- Fix ordered lists showing bullets instead of numbers
- Fix nested <p> inside <li> marker placement

Bug Fixes:
- Webserver: flow control and connection checking for file listing
- Webserver: memory optimization for File Transfer mode
- Dictionary: allocation order fix for zip dictionary buffer
2026-01-29 19:59:17 -05:00
cottongin
dd630dcf72
Improve EPUB rendering and add end-of-book Start Over
EPUB rendering improvements:
- Add margin-left/padding-left CSS parsing for block indentation
- Add vertical bar and italic styling for blockquotes
- Add left margin indentation for list items (ol/ul)
- Fix ordered lists showing bullets instead of numbers
- Fix nested <p> inside <li> causing marker on separate line

End-of-book improvements:
- Add "Start Over" option to wrap to first page when pressing next
- Show "Start Over" button hint on finished book prompt
2026-01-29 19:45:58 -05:00
cottongin
ef705d3ac6
Fix zip dictionary allocation 2026-01-29 18:54:01 -05:00
cottongin
bab374a675
fixes webserver uploads and general stability 2026-01-29 17:57:56 -05:00
19 changed files with 379 additions and 45 deletions

View File

@ -6,6 +6,30 @@ Base: CrossPoint Reader 0.15.0
--- ---
## ef-1.0.4
**EPUB Rendering & Stability**
### New Features
- **End-of-Book "Start Over"**: Press next at end of book to wrap to first page
### EPUB Rendering Improvements
- CSS `margin-left`/`padding-left` parsing for block indentation
- Vertical bar and italic styling for blockquotes
- Left margin indentation for list items (`<ol>`/`<ul>`)
- Fixed ordered lists showing bullets instead of numbers
- Fixed nested `<p>` inside `<li>` causing marker on separate line
### Bug Fixes
- **Webserver**: Fixed file listing disconnection issues with flow control
- **Webserver**: Memory optimization for File Transfer mode (frees heap before starting)
- **Dictionary**: Fixed zip dictionary allocation order for better memory allocation success
---
## ef-1.0.3 ## ef-1.0.3
**Maintenance Release** **Maintenance Release**

View File

@ -68,7 +68,9 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
// Apply fixed transforms before any per-line layout work. // Apply fixed transforms before any per-line layout work.
applyParagraphIndent(); applyParagraphIndent();
const int pageWidth = viewportWidth; // Apply horizontal margin (for blockquotes, nested content, etc.)
const int leftMargin = blockStyle.marginLeft;
const int pageWidth = viewportWidth - leftMargin;
const int spaceWidth = renderer.getSpaceWidth(fontId); const int spaceWidth = renderer.getSpaceWidth(fontId);
auto wordWidths = calculateWordWidths(renderer, fontId); auto wordWidths = calculateWordWidths(renderer, fontId);
std::vector<size_t> lineBreakIndices; std::vector<size_t> lineBreakIndices;
@ -81,7 +83,7 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1; const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
for (size_t i = 0; i < lineCount; ++i) { for (size_t i = 0; i < lineCount; ++i) {
extractLine(i, pageWidth, spaceWidth, wordWidths, lineBreakIndices, processLine); extractLine(i, pageWidth, spaceWidth, leftMargin, wordWidths, lineBreakIndices, processLine);
} }
} }
@ -336,7 +338,7 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
return true; return true;
} }
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth, void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth, const int leftMargin,
const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices, const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine) { const std::function<void(std::shared_ptr<TextBlock>)>& processLine) {
const size_t lineBreak = lineBreakIndices[breakIndex]; const size_t lineBreak = lineBreakIndices[breakIndex];
@ -359,12 +361,12 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
spacing = spareSpace / (lineWordCount - 1); spacing = spareSpace / (lineWordCount - 1);
} }
// Calculate initial x position // Calculate initial x position (offset by left margin for blockquotes, etc.)
uint16_t xpos = 0; uint16_t xpos = static_cast<uint16_t>(leftMargin);
if (style == TextBlock::RIGHT_ALIGN) { if (style == TextBlock::RIGHT_ALIGN) {
xpos = spareSpace - (lineWordCount - 1) * spaceWidth; xpos += spareSpace - (lineWordCount - 1) * spaceWidth;
} else if (style == TextBlock::CENTER_ALIGN) { } else if (style == TextBlock::CENTER_ALIGN) {
xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2; xpos += (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
} }
// Pre-calculate X positions for words // Pre-calculate X positions for words

View File

@ -28,8 +28,8 @@ class ParsedText {
int spaceWidth, std::vector<uint16_t>& wordWidths); int spaceWidth, std::vector<uint16_t>& wordWidths);
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId, bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks); std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths, void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, int leftMargin,
const std::vector<size_t>& lineBreakIndices, const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine); const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId); std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);

View File

@ -8,8 +8,8 @@
#include "parsers/ChapterHtmlSlimParser.h" #include "parsers/ChapterHtmlSlimParser.h"
namespace { namespace {
// Version 12: Added content offsets to LUT for position restoration after re-indexing // Version 13: Added marginLeft and hasLeftBorder to BlockStyle serialization
constexpr uint8_t SECTION_FILE_VERSION = 12; constexpr uint8_t SECTION_FILE_VERSION = 13;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) +
sizeof(uint32_t); sizeof(uint32_t);

View File

@ -13,5 +13,7 @@ struct BlockStyle {
int8_t marginBottom = 0; // 0-2 lines int8_t marginBottom = 0; // 0-2 lines
int8_t paddingTop = 0; // 0-2 lines (treated same as margin) int8_t paddingTop = 0; // 0-2 lines (treated same as margin)
int8_t paddingBottom = 0; // 0-2 lines (treated same as margin) int8_t paddingBottom = 0; // 0-2 lines (treated same as margin)
int16_t textIndent = 0; // pixels int16_t textIndent = 0; // pixels (first line indent)
int16_t marginLeft = 0; // pixels (horizontal indent for entire block)
bool hasLeftBorder = false; // draw vertical bar in left margin (for blockquotes)
}; };

View File

@ -11,6 +11,17 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
return; return;
} }
// Draw left border (vertical bar) for blockquotes
if (blockStyle.hasLeftBorder && blockStyle.marginLeft > 0) {
const int lineHeight = renderer.getLineHeight(fontId);
const int barX = x + 4; // Small offset from left edge
const int barTop = y;
const int barBottom = y + lineHeight;
// Draw a 2-pixel wide vertical bar
renderer.drawLine(barX, barTop, barX, barBottom, true);
renderer.drawLine(barX + 1, barTop, barX + 1, barBottom, true);
}
auto wordIt = words.begin(); auto wordIt = words.begin();
auto wordStylesIt = wordStyles.begin(); auto wordStylesIt = wordStyles.begin();
auto wordXposIt = wordXpos.begin(); auto wordXposIt = wordXpos.begin();
@ -92,6 +103,8 @@ bool TextBlock::serialize(FsFile& file) const {
serialization::writePod(file, blockStyle.paddingTop); serialization::writePod(file, blockStyle.paddingTop);
serialization::writePod(file, blockStyle.paddingBottom); serialization::writePod(file, blockStyle.paddingBottom);
serialization::writePod(file, blockStyle.textIndent); serialization::writePod(file, blockStyle.textIndent);
serialization::writePod(file, blockStyle.marginLeft);
serialization::writePod(file, blockStyle.hasLeftBorder);
return true; return true;
} }
@ -144,6 +157,8 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
serialization::readPod(file, blockStyle.paddingTop); serialization::readPod(file, blockStyle.paddingTop);
serialization::readPod(file, blockStyle.paddingBottom); serialization::readPod(file, blockStyle.paddingBottom);
serialization::readPod(file, blockStyle.textIndent); serialization::readPod(file, blockStyle.textIndent);
serialization::readPod(file, blockStyle.marginLeft);
serialization::readPod(file, blockStyle.hasLeftBorder);
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style, return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style,
blockStyle, std::move(wordUnderlines))); blockStyle, std::move(wordUnderlines)));

View File

@ -393,6 +393,32 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
style.paddingBottom = spacing; style.paddingBottom = spacing;
style.defined.paddingBottom = 1; style.defined.paddingBottom = 1;
} }
} else if (propName == "margin-left" || propName == "padding-left") {
// Horizontal indentation for blockquotes and nested content
const float pixels = interpretLength(propValue);
if (pixels > 0) {
style.marginLeft += pixels; // Accumulate margin-left and padding-left
style.defined.marginLeft = 1;
}
} else if (propName == "margin") {
// Shorthand: margin: top right bottom left OR margin: vertical horizontal
const auto values = splitWhitespace(propValue);
if (values.size() >= 2) {
// At least 2 values: first is vertical (top/bottom), second is horizontal (left/right)
const float horizontal = interpretLength(values[1]);
if (horizontal > 0) {
style.marginLeft = horizontal;
style.defined.marginLeft = 1;
}
}
if (values.size() == 4) {
// 4 values: top right bottom left - use the 4th value for left
const float left = interpretLength(values[3]);
if (left > 0) {
style.marginLeft = left;
style.defined.marginLeft = 1;
}
}
} }
} }

View File

@ -25,7 +25,8 @@ struct CssPropertyFlags {
uint16_t marginBottom : 1; uint16_t marginBottom : 1;
uint16_t paddingTop : 1; uint16_t paddingTop : 1;
uint16_t paddingBottom : 1; uint16_t paddingBottom : 1;
uint16_t reserved : 7; uint16_t marginLeft : 1;
uint16_t reserved : 6;
CssPropertyFlags() CssPropertyFlags()
: alignment(0), : alignment(0),
@ -37,16 +38,17 @@ struct CssPropertyFlags {
marginBottom(0), marginBottom(0),
paddingTop(0), paddingTop(0),
paddingBottom(0), paddingBottom(0),
marginLeft(0),
reserved(0) {} reserved(0) {}
[[nodiscard]] bool anySet() const { [[nodiscard]] bool anySet() const {
return alignment || fontStyle || fontWeight || decoration || indent || marginTop || marginBottom || paddingTop || return alignment || fontStyle || fontWeight || decoration || indent || marginTop || marginBottom || paddingTop ||
paddingBottom; paddingBottom || marginLeft;
} }
void clearAll() { void clearAll() {
alignment = fontStyle = fontWeight = decoration = indent = 0; alignment = fontStyle = fontWeight = decoration = indent = 0;
marginTop = marginBottom = paddingTop = paddingBottom = 0; marginTop = marginBottom = paddingTop = paddingBottom = marginLeft = 0;
} }
}; };
@ -63,6 +65,7 @@ struct CssStyle {
int8_t marginBottom = 0; // Vertical spacing after block (in lines, 0-2) int8_t marginBottom = 0; // Vertical spacing after block (in lines, 0-2)
int8_t paddingTop = 0; // Padding before (in lines, 0-2) int8_t paddingTop = 0; // Padding before (in lines, 0-2)
int8_t paddingBottom = 0; // Padding after (in lines, 0-2) int8_t paddingBottom = 0; // Padding after (in lines, 0-2)
float marginLeft = 0.0f; // Horizontal indent in pixels (for blockquotes, etc.)
CssPropertyFlags defined; // Tracks which properties were explicitly set CssPropertyFlags defined; // Tracks which properties were explicitly set
@ -105,6 +108,10 @@ struct CssStyle {
paddingBottom = base.paddingBottom; paddingBottom = base.paddingBottom;
defined.paddingBottom = 1; defined.paddingBottom = 1;
} }
if (base.defined.marginLeft) {
marginLeft = base.marginLeft;
defined.marginLeft = 1;
}
} }
// Compatibility accessors for existing code that uses hasX pattern // Compatibility accessors for existing code that uses hasX pattern
@ -117,6 +124,7 @@ struct CssStyle {
[[nodiscard]] bool hasMarginBottom() const { return defined.marginBottom; } [[nodiscard]] bool hasMarginBottom() const { return defined.marginBottom; }
[[nodiscard]] bool hasPaddingTop() const { return defined.paddingTop; } [[nodiscard]] bool hasPaddingTop() const { return defined.paddingTop; }
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; } [[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
[[nodiscard]] bool hasMarginLeft() const { return defined.marginLeft; }
// Merge another style (alias for applyOver for compatibility) // Merge another style (alias for applyOver for compatibility)
void merge(const CssStyle& other) { applyOver(other); } void merge(const CssStyle& other) { applyOver(other); }
@ -128,6 +136,7 @@ struct CssStyle {
decoration = CssTextDecoration::None; decoration = CssTextDecoration::None;
indentPixels = 0.0f; indentPixels = 0.0f;
marginTop = marginBottom = paddingTop = paddingBottom = 0; marginTop = marginBottom = paddingTop = paddingBottom = 0;
marginLeft = 0.0f;
defined.clearAll(); defined.clearAll();
} }
}; };

View File

@ -19,6 +19,9 @@ constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
const char* LIST_TAGS[] = {"ol", "ul"};
constexpr int NUM_LIST_TAGS = sizeof(LIST_TAGS) / sizeof(LIST_TAGS[0]);
const char* BOLD_TAGS[] = {"b", "strong"}; const char* BOLD_TAGS[] = {"b", "strong"};
constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]); constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]);
@ -55,6 +58,7 @@ BlockStyle createBlockStyleFromCss(const CssStyle& cssStyle) {
blockStyle.paddingTop = cssStyle.paddingTop; blockStyle.paddingTop = cssStyle.paddingTop;
blockStyle.paddingBottom = cssStyle.paddingBottom; blockStyle.paddingBottom = cssStyle.paddingBottom;
blockStyle.textIndent = static_cast<int16_t>(cssStyle.indentPixels); blockStyle.textIndent = static_cast<int16_t>(cssStyle.indentPixels);
blockStyle.marginLeft = static_cast<int16_t>(cssStyle.marginLeft);
return blockStyle; return blockStyle;
} }
@ -320,6 +324,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// Determine if this is a block element // Determine if this is a block element
bool isBlockElement = matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS); bool isBlockElement = matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
bool isListTag = matches(name, LIST_TAGS, NUM_LIST_TAGS);
// Handle list container tags (ol, ul)
if (isListTag) {
ListContext ctx;
ctx.isOrdered = strcmp(name, "ol") == 0;
ctx.counter = 0;
ctx.depth = self->depth;
self->listStack.push_back(ctx);
self->depth += 1;
return; // Lists themselves don't create text blocks
}
// Compute CSS style for this element // Compute CSS style for this element
CssStyle cssStyle; CssStyle cssStyle;
@ -365,6 +381,20 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// This fixes issue where <br/> incorrectly wrapped the preceding word to a new line // This fixes issue where <br/> incorrectly wrapped the preceding word to a new line
self->flushPartWordBuffer(); self->flushPartWordBuffer();
self->startNewTextBlock(self->currentTextBlock->getStyle()); self->startNewTextBlock(self->currentTextBlock->getStyle());
} else if (strcmp(name, "li") == 0) {
// For list items, DON'T create a text block yet - wait for the first content element
// This prevents the marker from being on its own line when <li><p>content</p></li>
self->insideListItem = true;
self->listItemDepth = self->depth;
self->listItemHasContent = false;
// Increment counter now (so nested lists work correctly)
if (!self->listStack.empty()) {
self->listStack.back().counter++;
}
// Don't create text block or add marker yet - will be done when first content arrives
self->depth += 1;
return;
} else { } else {
// Determine alignment from CSS or default // Determine alignment from CSS or default
auto alignment = static_cast<TextBlock::Style>(self->paragraphAlignment); auto alignment = static_cast<TextBlock::Style>(self->paragraphAlignment);
@ -387,12 +417,71 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} }
} }
// Apply default styling for blockquote if no CSS margin is specified
const bool isBlockquote = strcmp(name, "blockquote") == 0;
if (isBlockquote) {
if (!cssStyle.hasMarginLeft()) {
// Default left indent for blockquotes (~1.5em at 16px base = 24px)
cssStyle.marginLeft = 24.0f;
cssStyle.defined.marginLeft = 1;
}
// Also make blockquotes italic by default if not specified
if (!cssStyle.hasFontStyle()) {
cssStyle.fontStyle = CssFontStyle::Italic;
cssStyle.defined.fontStyle = 1;
}
// Track blockquote context for child elements
self->insideBlockquote = true;
self->blockquoteDepth = self->depth;
self->blockquoteMarginLeft = cssStyle.marginLeft;
}
// Apply blockquote styling to child block elements
if (self->insideBlockquote && !isBlockquote) {
// Inherit margin and border from parent blockquote
if (!cssStyle.hasMarginLeft()) {
cssStyle.marginLeft = self->blockquoteMarginLeft;
cssStyle.defined.marginLeft = 1;
}
}
// Apply left margin to list items (indent the whole block)
if (self->insideListItem && !cssStyle.hasMarginLeft()) {
// Default left indent for list items (~1.5em at 16px base = 24px)
cssStyle.marginLeft = 24.0f;
cssStyle.defined.marginLeft = 1;
}
self->currentBlockStyle = cssStyle; self->currentBlockStyle = cssStyle;
self->startNewTextBlock(alignment, createBlockStyleFromCss(cssStyle)); BlockStyle blockStyleForElement = createBlockStyleFromCss(cssStyle);
if (isBlockquote || self->insideBlockquote) {
blockStyleForElement.hasLeftBorder = true; // Draw vertical bar for blockquotes
}
self->startNewTextBlock(alignment, blockStyleForElement);
self->updateEffectiveInlineStyle(); self->updateEffectiveInlineStyle();
if (strcmp(name, "li") == 0) { // If this is a blockquote, apply italic styling
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); if (isBlockquote && cssStyle.hasFontStyle() && cssStyle.fontStyle == CssFontStyle::Italic) {
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
}
// If this is the first block element inside a list item, add the marker
if (self->insideListItem && !self->listItemHasContent) {
if (!self->listStack.empty()) {
const ListContext& ctx = self->listStack.back();
if (ctx.isOrdered) {
// Ordered list: use number (counter was already incremented)
std::string marker = std::to_string(ctx.counter) + ". ";
self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR);
} else {
// Unordered list: use bullet
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
} else {
// No list context (orphan li), use bullet as fallback
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
self->listItemHasContent = true;
} }
} }
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) { } else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
@ -566,7 +655,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
const bool shouldFlush = styleWillChange || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) || const bool shouldFlush = styleWillChange || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) ||
matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) ||
matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) ||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || self->depth == 1; matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) ||
matches(name, LIST_TAGS, NUM_LIST_TAGS) || self->depth == 1;
if (shouldFlush) { if (shouldFlush) {
// Use combined depth-based and CSS-based style // Use combined depth-based and CSS-based style
@ -596,6 +686,27 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
self->skipUntilDepth = INT_MAX; self->skipUntilDepth = INT_MAX;
} }
// Leaving list container (ol, ul)
if (matches(name, LIST_TAGS, NUM_LIST_TAGS)) {
if (!self->listStack.empty() && self->listStack.back().depth == self->depth) {
self->listStack.pop_back();
}
}
// Leaving list item (li)
if (strcmp(name, "li") == 0 && self->listItemDepth == self->depth) {
self->insideListItem = false;
self->listItemDepth = INT_MAX;
self->listItemHasContent = false;
}
// Leaving blockquote
if (strcmp(name, "blockquote") == 0 && self->blockquoteDepth == self->depth) {
self->insideBlockquote = false;
self->blockquoteDepth = INT_MAX;
self->blockquoteMarginLeft = 0.0f;
}
// Leaving bold tag // Leaving bold tag
if (self->boldUntilDepth == self->depth) { if (self->boldUntilDepth == self->depth) {
self->boldUntilDepth = INT_MAX; self->boldUntilDepth = INT_MAX;

View File

@ -59,6 +59,22 @@ class ChapterHtmlSlimParser {
bool effectiveItalic = false; bool effectiveItalic = false;
bool effectiveUnderline = false; bool effectiveUnderline = false;
// List context tracking for ordered/unordered lists
struct ListContext {
bool isOrdered = false; // true for <ol>, false for <ul>
int counter = 0; // Current item number (for ordered lists)
int depth = 0; // Depth at which list was opened
};
std::vector<ListContext> listStack;
bool insideListItem = false; // True when we're inside an <li> element
int listItemDepth = INT_MAX; // Depth at which <li> was opened
bool listItemHasContent = false; // True if we've added content to the current list item
// Blockquote context tracking (for left border on child elements)
bool insideBlockquote = false;
int blockquoteDepth = INT_MAX;
float blockquoteMarginLeft = 0.0f; // Inherit margin from blockquote to child elements
// Byte offset tracking for position restoration after re-indexing // Byte offset tracking for position restoration after re-indexing
XML_Parser xmlParser = nullptr; // Store parser for getting current byte index XML_Parser xmlParser = nullptr; // Store parser for getting current byte index
size_t currentPageStartOffset = 0; // Byte offset when current page was started size_t currentPageStartOffset = 0; // Byte offset when current page was started

View File

@ -529,10 +529,24 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
} }
if (fileStat.method == MZ_DEFLATED) { if (fileStat.method == MZ_DEFLATED) {
// Setup inflator // Allocate largest buffer first to maximize chance of finding contiguous block
// Dictionary buffer (32KB) - needed for DEFLATE sliding window
const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE));
if (!outputBuffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for dictionary (need %d bytes)\n", millis(),
TINFL_LZ_DICT_SIZE);
if (!wasOpen) {
close();
}
return false;
}
memset(outputBuffer, 0, TINFL_LZ_DICT_SIZE);
// Setup inflator (~11KB)
const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor))); const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
if (!inflator) { if (!inflator) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for inflator\n", millis()); Serial.printf("[%lu] [ZIP] Failed to allocate memory for inflator\n", millis());
free(outputBuffer);
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@ -541,29 +555,18 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
memset(inflator, 0, sizeof(tinfl_decompressor)); memset(inflator, 0, sizeof(tinfl_decompressor));
tinfl_init(inflator); tinfl_init(inflator);
// Setup file read buffer // Setup file read buffer (smallest allocation last)
const auto fileReadBuffer = static_cast<uint8_t*>(malloc(chunkSize)); const auto fileReadBuffer = static_cast<uint8_t*>(malloc(chunkSize));
if (!fileReadBuffer) { if (!fileReadBuffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for zip file read buffer\n", millis()); Serial.printf("[%lu] [ZIP] Failed to allocate memory for zip file read buffer\n", millis());
free(inflator); free(inflator);
free(outputBuffer);
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
return false; return false;
} }
const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE));
if (!outputBuffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for dictionary\n", millis());
free(inflator);
free(fileReadBuffer);
if (!wasOpen) {
close();
}
return false;
}
memset(outputBuffer, 0, TINFL_LZ_DICT_SIZE);
size_t fileRemainingBytes = deflatedDataSize; size_t fileRemainingBytes = deflatedDataSize;
size_t processedOutputBytes = 0; size_t processedOutputBytes = 0;
size_t fileReadBufferFilledBytes = 0; size_t fileReadBufferFilledBytes = 0;

View File

@ -3,7 +3,7 @@ default_envs = default
[crosspoint] [crosspoint]
# 0.15.0 CrossPoint base, ef-1.0.0 is the first release of the ef branch # 0.15.0 CrossPoint base, ef-1.0.0 is the first release of the ef branch
version = 0.15.ef-1.0.3 version = 0.15.ef-1.0.4
[base] [base]
platform = espressif32 @ 6.12.0 platform = espressif32 @ 6.12.0

View File

@ -52,6 +52,13 @@ void RecentBooksStore::clearAll() {
Serial.printf("[%lu] [RBS] Cleared all recent books\n", millis()); Serial.printf("[%lu] [RBS] Cleared all recent books\n", millis());
} }
void RecentBooksStore::clearFromMemory() {
const size_t count = recentBooks.size();
recentBooks.clear();
recentBooks.shrink_to_fit(); // Actually free the vector capacity
Serial.printf("[%lu] [RBS] Cleared %d recent books from memory (not saved)\n", millis(), count);
}
bool RecentBooksStore::saveToFile() const { bool RecentBooksStore::saveToFile() const {
// Make sure the directory exists // Make sure the directory exists
SdMan.mkdir("/.crosspoint"); SdMan.mkdir("/.crosspoint");

View File

@ -29,9 +29,14 @@ class RecentBooksStore {
// Returns true if the book was found and removed // Returns true if the book was found and removed
bool removeBook(const std::string& path); bool removeBook(const std::string& path);
// Clear all recent books from the list // Clear all recent books from the list (and save to file)
void clearAll(); void clearAll();
// Clear recent books from memory without saving to file
// Used to free memory when entering modes that don't need this data (e.g., File Transfer)
// Call loadFromFile() to restore the data when needed again
void clearFromMemory();
// Get the list of recent books (most recent first) // Get the list of recent books (most recent first)
const std::vector<RecentBook>& getBooks() const { return recentBooks; } const std::vector<RecentBook>& getBooks() const { return recentBooks; }

View File

@ -236,6 +236,15 @@ void EpubReaderActivity::loop() {
updateRequired = true; updateRequired = true;
return; return;
} }
if (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
// Start over from beginning
currentSpineIndex = 0;
nextPageNumber = 0;
showingEndOfBookPrompt = false;
updateRequired = true;
return;
}
return; return;
} }
@ -988,7 +997,7 @@ void EpubReaderActivity::renderEndOfBookPrompt() {
} }
// Button hints // Button hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Select", "", "Start Over");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();

View File

@ -171,6 +171,14 @@ void TxtReaderActivity::loop() {
updateRequired = true; updateRequired = true;
return; return;
} }
if (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
// Start over from beginning
currentPage = 0;
showingEndOfBookPrompt = false;
updateRequired = true;
return;
}
return; return;
} }
@ -909,7 +917,7 @@ void TxtReaderActivity::renderEndOfBookPrompt() {
} }
// Button hints // Button hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Select", "", "Start Over");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();

View File

@ -381,6 +381,15 @@ void onGoToListsOrPinned() {
void onGoToFileTransfer() { void onGoToFileTransfer() {
exitActivity(); exitActivity();
// Free memory not needed during file transfer to maximize heap for webserver
RECENT_BOOKS.clearFromMemory();
APP_STATE.openBookTitle.clear();
APP_STATE.openBookTitle.shrink_to_fit();
APP_STATE.openBookAuthor.clear();
APP_STATE.openBookAuthor.shrink_to_fit();
Serial.printf("[%lu] [FT] Cleared non-essential memory before File Transfer\n", millis());
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome)); enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
} }
@ -396,12 +405,24 @@ void onGoToClearCache() {
void onGoToMyLibrary() { void onGoToMyLibrary() {
exitActivity(); exitActivity();
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
if (RECENT_BOOKS.getCount() == 0) {
RECENT_BOOKS.loadFromFile();
}
enterNewActivity( enterNewActivity(
new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList)); new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList));
} }
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) { void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) {
exitActivity(); exitActivity();
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
if (RECENT_BOOKS.getCount() == 0) {
RECENT_BOOKS.loadFromFile();
}
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView,
onGoToBookmarkList, tab, path)); onGoToBookmarkList, tab, path));
} }
@ -413,6 +434,12 @@ void onGoToBrowser() {
void onGoHome() { void onGoHome() {
exitActivity(); exitActivity();
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
if (RECENT_BOOKS.getCount() == 0) {
RECENT_BOOKS.loadFromFile();
}
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToListsOrPinned, enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToListsOrPinned,
onGoToMyLibrary, onGoToSettings, onGoToFileTransfer, onGoToBrowser)); onGoToMyLibrary, onGoToSettings, onGoToFileTransfer, onGoToBrowser));
} }

View File

@ -441,17 +441,33 @@ void CrossPointWebServer::handleFileListData() const {
showHidden = server->arg("showHidden") == "true"; showHidden = server->arg("showHidden") == "true";
} }
// Check client connection before starting
if (!server->client().connected()) {
Serial.printf("[%lu] [WEB] Client disconnected before file list could start\n", millis());
return;
}
server->setContentLength(CONTENT_LENGTH_UNKNOWN); server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", ""); server->send(200, "application/json", "");
server->sendContent("["); if (!sendContentSafe("[")) {
Serial.printf("[%lu] [WEB] Client disconnected at start of file list\n", millis());
return;
}
char output[512]; char output[512];
constexpr size_t outputSize = sizeof(output); constexpr size_t outputSize = sizeof(output);
bool seenFirst = false; bool seenFirst = false;
bool clientDisconnected = false;
JsonDocument doc; JsonDocument doc;
scanFiles( scanFiles(
currentPath.c_str(), currentPath.c_str(),
[this, &output, &doc, seenFirst](const FileInfo& info) mutable { [this, &output, &doc, &seenFirst, &clientDisconnected](const FileInfo& info) mutable {
// Skip remaining files if client already disconnected
if (clientDisconnected) {
return;
}
doc.clear(); doc.clear();
doc["name"] = info.name; doc["name"] = info.name;
doc["size"] = info.size; doc["size"] = info.size;
@ -475,18 +491,33 @@ void CrossPointWebServer::handleFileListData() const {
return; return;
} }
// Send comma separator before all entries except the first
if (seenFirst) { if (seenFirst) {
server->sendContent(","); if (!sendContentSafe(",")) {
clientDisconnected = true;
Serial.printf("[%lu] [WEB] Client disconnected during file list\n", millis());
return;
}
} else { } else {
seenFirst = true; seenFirst = true;
} }
server->sendContent(output);
// Send the JSON entry with flow control
if (!sendContentSafe(output)) {
clientDisconnected = true;
Serial.printf("[%lu] [WEB] Client disconnected during file list\n", millis());
return;
}
}, },
showHidden); showHidden);
server->sendContent("]");
// End of streamed response, empty chunk to signal client // Only send closing bracket if client is still connected
server->sendContent(""); if (!clientDisconnected) {
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str()); sendContentSafe("]");
// End of streamed response, empty chunk to signal client
server->sendContent("");
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
}
} }
// Static variables for upload handling // Static variables for upload handling
@ -1264,6 +1295,40 @@ void CrossPointWebServer::handleRename() const {
} }
} }
// Counter for flow control pacing
static uint8_t sendContentCounter = 0;
bool CrossPointWebServer::sendContentSafe(const char* content) const {
if (!server || !server->client().connected()) {
return false;
}
// Send the content
server->sendContent(content);
// Flow control: give TCP stack time to transmit data and drain the send buffer
// The ESP32 TCP buffer is limited and fills quickly when streaming many small chunks.
// We use progressive delays:
// - yield() after every send to allow WiFi processing
// - delay(5ms) every send to allow buffer draining
// - delay(50ms) every 10 sends to allow larger buffer flush
yield();
sendContentCounter++;
if (sendContentCounter >= 10) {
sendContentCounter = 0;
delay(50); // Longer pause every 10 sends for buffer catchup
} else {
delay(5); // Short pause each send
}
return server->client().connected();
}
bool CrossPointWebServer::sendContentSafe(const String& content) const {
return sendContentSafe(content.c_str());
}
bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const { bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const {
FsFile srcFile; FsFile srcFile;
FsFile destFile; FsFile destFile;

View File

@ -108,6 +108,11 @@ class CrossPointWebServer {
bool copyFile(const String& srcPath, const String& destPath) const; bool copyFile(const String& srcPath, const String& destPath) const;
bool copyFolder(const String& srcPath, const String& destPath) const; bool copyFolder(const String& srcPath, const String& destPath) const;
// Helper for safe content sending with flow control
// Returns false if client disconnected, true otherwise
bool sendContentSafe(const char* content) const;
bool sendContentSafe(const String& content) const;
// List management handlers // List management handlers
void handleListGet() const; void handleListGet() const;
void handleListPost() const; void handleListPost() const;