From dd630dcf72408fc71dafadc6c9ccacc4155e4098 Mon Sep 17 00:00:00 2001 From: cottongin Date: Thu, 29 Jan 2026 19:45:58 -0500 Subject: [PATCH] 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

inside

  • 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 --- lib/Epub/Epub/ParsedText.cpp | 16 +-- lib/Epub/Epub/ParsedText.h | 4 +- lib/Epub/Epub/Section.cpp | 4 +- lib/Epub/Epub/blocks/BlockStyle.h | 4 +- lib/Epub/Epub/blocks/TextBlock.cpp | 15 +++ lib/Epub/Epub/css/CssParser.cpp | 26 ++++ lib/Epub/Epub/css/CssStyle.h | 15 ++- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 119 +++++++++++++++++- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 16 +++ src/activities/reader/EpubReaderActivity.cpp | 11 +- src/activities/reader/TxtReaderActivity.cpp | 10 +- 11 files changed, 219 insertions(+), 21 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index b7cb606..c381d7e 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -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 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& wordWidths, const std::vector& lineBreakIndices, const std::function)>& 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(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 diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 053cf49..125066b 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -28,8 +28,8 @@ class ParsedText { int spaceWidth, std::vector& wordWidths); bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId, std::vector& wordWidths, bool allowFallbackBreaks); - void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector& wordWidths, - const std::vector& lineBreakIndices, + void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, int leftMargin, + const std::vector& wordWidths, const std::vector& lineBreakIndices, const std::function)>& processLine); std::vector calculateWordWidths(const GfxRenderer& renderer, int fontId); diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index b8f38df..0cf3a9d 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -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); diff --git a/lib/Epub/Epub/blocks/BlockStyle.h b/lib/Epub/Epub/blocks/BlockStyle.h index 2b073b6..a52d0c7 100644 --- a/lib/Epub/Epub/blocks/BlockStyle.h +++ b/lib/Epub/Epub/blocks/BlockStyle.h @@ -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) }; diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index c6bdc8f..2d9c29d 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -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::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(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style, blockStyle, std::move(wordUnderlines))); diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index b62f0b5..825e50e 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -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; + } + } } } diff --git a/lib/Epub/Epub/css/CssStyle.h b/lib/Epub/Epub/css/CssStyle.h index 8333161..340b9f2 100644 --- a/lib/Epub/Epub/css/CssStyle.h +++ b/lib/Epub/Epub/css/CssStyle.h @@ -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(); } }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 63483f4..168a847 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -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(cssStyle.indentPixels); + blockStyle.marginLeft = static_cast(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
    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
  • content

  • + 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(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->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) { - self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); + // 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)) { @@ -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; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 0573d0e..5d25648 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -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
      , false for
        + int counter = 0; // Current item number (for ordered lists) + int depth = 0; // Depth at which list was opened + }; + std::vector listStack; + bool insideListItem = false; // True when we're inside an
      • element + int listItemDepth = INT_MAX; // Depth at which
      • 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 diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 41dad88..583fa93 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -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(); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 4aaf765..de81d10 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -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();