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
This commit is contained in:
cottongin 2026-01-29 19:45:58 -05:00
parent ef705d3ac6
commit dd630dcf72
No known key found for this signature in database
GPG Key ID: 0ECC91FE4655C262
11 changed files with 219 additions and 21 deletions

View File

@ -68,7 +68,9 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
// Apply fixed transforms before any per-line layout work.
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);
auto wordWidths = calculateWordWidths(renderer, fontId);
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;
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;
}
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::function<void(std::shared_ptr<TextBlock>)>& processLine) {
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);
}
// Calculate initial x position
uint16_t xpos = 0;
// Calculate initial x position (offset by left margin for blockquotes, etc.)
uint16_t xpos = static_cast<uint16_t>(leftMargin);
if (style == TextBlock::RIGHT_ALIGN) {
xpos = spareSpace - (lineWordCount - 1) * spaceWidth;
xpos += spareSpace - (lineWordCount - 1) * spaceWidth;
} else if (style == TextBlock::CENTER_ALIGN) {
xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
xpos += (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
}
// Pre-calculate X positions for words

View File

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

View File

@ -8,8 +8,8 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
// Version 12: Added content offsets to LUT for position restoration after re-indexing
constexpr uint8_t SECTION_FILE_VERSION = 12;
// Version 13: Added marginLeft and hasLeftBorder to BlockStyle serialization
constexpr uint8_t SECTION_FILE_VERSION = 13;
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(uint32_t);

View File

@ -13,5 +13,7 @@ struct BlockStyle {
int8_t marginBottom = 0; // 0-2 lines
int8_t paddingTop = 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;
}
// 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 wordStylesIt = wordStyles.begin();
auto wordXposIt = wordXpos.begin();
@ -92,6 +103,8 @@ bool TextBlock::serialize(FsFile& file) const {
serialization::writePod(file, blockStyle.paddingTop);
serialization::writePod(file, blockStyle.paddingBottom);
serialization::writePod(file, blockStyle.textIndent);
serialization::writePod(file, blockStyle.marginLeft);
serialization::writePod(file, blockStyle.hasLeftBorder);
return true;
}
@ -144,6 +157,8 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
serialization::readPod(file, blockStyle.paddingTop);
serialization::readPod(file, blockStyle.paddingBottom);
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,
blockStyle, std::move(wordUnderlines)));

View File

@ -393,6 +393,32 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
style.paddingBottom = spacing;
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 paddingTop : 1;
uint16_t paddingBottom : 1;
uint16_t reserved : 7;
uint16_t marginLeft : 1;
uint16_t reserved : 6;
CssPropertyFlags()
: alignment(0),
@ -37,16 +38,17 @@ struct CssPropertyFlags {
marginBottom(0),
paddingTop(0),
paddingBottom(0),
marginLeft(0),
reserved(0) {}
[[nodiscard]] bool anySet() const {
return alignment || fontStyle || fontWeight || decoration || indent || marginTop || marginBottom || paddingTop ||
paddingBottom;
paddingBottom || marginLeft;
}
void clearAll() {
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 paddingTop = 0; // Padding before (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
@ -105,6 +108,10 @@ struct CssStyle {
paddingBottom = base.paddingBottom;
defined.paddingBottom = 1;
}
if (base.defined.marginLeft) {
marginLeft = base.marginLeft;
defined.marginLeft = 1;
}
}
// Compatibility accessors for existing code that uses hasX pattern
@ -117,6 +124,7 @@ struct CssStyle {
[[nodiscard]] bool hasMarginBottom() const { return defined.marginBottom; }
[[nodiscard]] bool hasPaddingTop() const { return defined.paddingTop; }
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
[[nodiscard]] bool hasMarginLeft() const { return defined.marginLeft; }
// Merge another style (alias for applyOver for compatibility)
void merge(const CssStyle& other) { applyOver(other); }
@ -128,6 +136,7 @@ struct CssStyle {
decoration = CssTextDecoration::None;
indentPixels = 0.0f;
marginTop = marginBottom = paddingTop = paddingBottom = 0;
marginLeft = 0.0f;
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"};
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"};
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.paddingBottom = cssStyle.paddingBottom;
blockStyle.textIndent = static_cast<int16_t>(cssStyle.indentPixels);
blockStyle.marginLeft = static_cast<int16_t>(cssStyle.marginLeft);
return blockStyle;
}
@ -320,6 +324,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// Determine if this is a block element
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
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
self->flushPartWordBuffer();
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 {
// Determine alignment from CSS or default
auto alignment = static_cast<TextBlock::Style>(self->paragraphAlignment);
@ -387,13 +417,72 @@ 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->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();
if (strcmp(name, "li") == 0) {
// If this is a blockquote, apply italic styling
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)) {
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
@ -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) ||
matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BOLD_TAGS, NUM_BOLD_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) {
// 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;
}
// 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
if (self->boldUntilDepth == self->depth) {
self->boldUntilDepth = INT_MAX;

View File

@ -59,6 +59,22 @@ class ChapterHtmlSlimParser {
bool effectiveItalic = 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
XML_Parser xmlParser = nullptr; // Store parser for getting current byte index
size_t currentPageStartOffset = 0; // Byte offset when current page was started

View File

@ -236,6 +236,15 @@ void EpubReaderActivity::loop() {
updateRequired = true;
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;
}
@ -988,7 +997,7 @@ void EpubReaderActivity::renderEndOfBookPrompt() {
}
// 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.displayBuffer();

View File

@ -171,6 +171,14 @@ void TxtReaderActivity::loop() {
updateRequired = true;
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
// Start over from beginning
currentPage = 0;
showingEndOfBookPrompt = false;
updateRequired = true;
return;
}
return;
}
@ -909,7 +917,7 @@ void TxtReaderActivity::renderEndOfBookPrompt() {
}
// 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.displayBuffer();