From 91777a9023a9c57a21edd2a53e820fde66ac502f Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Fri, 6 Feb 2026 03:18:47 +1100 Subject: [PATCH 01/15] release: 1.0.0 --- .github/workflows/release_candidate.yml | 48 +++++++++++++++++++++++++ platformio.ini | 8 ++++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release_candidate.yml diff --git a/.github/workflows/release_candidate.yml b/.github/workflows/release_candidate.yml new file mode 100644 index 00000000..8f704484 --- /dev/null +++ b/.github/workflows/release_candidate.yml @@ -0,0 +1,48 @@ +name: Compile Release Candidate + +on: + pull_request: + +jobs: + build-release-candidate: + if: startsWith(github.head_ref, 'release/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - uses: actions/cache@v5 + with: + path: | + ~/.cache/pip + ~/.platformio/.cache + key: ${{ runner.os }}-pio + + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + + - name: Install PlatformIO Core + run: pip install --upgrade platformio + + - name: Extract env + run: | + echo "SHORT_SHA=${GITHUB_SHA::6}" >> $GITHUB_ENV + echo "BRANCH_SUFFIX=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV + + - name: Build CrossPoint Release Candidate + env: + CROSSPOINT_RC_HASH: ${{ env.SHORT_SHA }} + run: pio run -e gh_release_rc + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: CrossPoint-RC-${{ env.BRANCH_SUFFIX }} + path: | + .pio/build/gh_release_rc/bootloader.bin + .pio/build/gh_release_rc/firmware.bin + .pio/build/gh_release_rc/firmware.elf + .pio/build/gh_release_rc/firmware.map + .pio/build/gh_release_rc/partitions.bin diff --git a/platformio.ini b/platformio.ini index e8574470..c4b992e8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -2,7 +2,7 @@ default_envs = default [crosspoint] -version = 0.16.0 +version = 1.0.0 [base] platform = espressif32 @ 6.12.0 @@ -60,3 +60,9 @@ extends = base build_flags = ${base.build_flags} -DCROSSPOINT_VERSION=\"${crosspoint.version}\" + +[env:gh_release_rc] +extends = base +build_flags = + ${base.build_flags} + -DCROSSPOINT_VERSION=\"${crosspoint.version}-rc-${sysenv.CROSSPOINT_RC_HASH}\" From 211153fcd5d9e6d01cb2b61847d8e401e67c5e96 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Fri, 6 Feb 2026 03:49:00 +1100 Subject: [PATCH 02/15] Use GITHUB_HEAD_REF --- .github/workflows/release_candidate.yml | 4 ++-- platformio.ini | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release_candidate.yml b/.github/workflows/release_candidate.yml index 8f704484..e9bbc636 100644 --- a/.github/workflows/release_candidate.yml +++ b/.github/workflows/release_candidate.yml @@ -28,8 +28,8 @@ jobs: - name: Extract env run: | - echo "SHORT_SHA=${GITHUB_SHA::6}" >> $GITHUB_ENV - echo "BRANCH_SUFFIX=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV + echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV + echo "BRANCH_SUFFIX=${GITHUB_HEAD_REF#release/}" >> $GITHUB_ENV - name: Build CrossPoint Release Candidate env: diff --git a/platformio.ini b/platformio.ini index c4b992e8..c441e526 100644 --- a/platformio.ini +++ b/platformio.ini @@ -65,4 +65,4 @@ build_flags = extends = base build_flags = ${base.build_flags} - -DCROSSPOINT_VERSION=\"${crosspoint.version}-rc-${sysenv.CROSSPOINT_RC_HASH}\" + -DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\" From 3223e85ea5f64e45429495bcd274080d03220887 Mon Sep 17 00:00:00 2001 From: Jake Kenneally Date: Fri, 6 Feb 2026 02:49:04 -0500 Subject: [PATCH 03/15] feat: Add Settings for toggling CSS on or off (#717) Closes #712 ## Summary **What is the goal of this PR?** - To add new settings for toggling on/off embedded CSS styles in the reader. This gives more control and customization to the user over how the ereader experience looks. **What changes are included?** - Added new "Embedded Style" option to the Reader settings - Added new "Book's Style" option for "Paragraph Alignment" - User's selected "Paragraph Alignment" will take precedence and override the embedded CSS `text-align` property, _unless_ the user has "Book's Style" set as their "Paragraph Alignment" ## Additional Context ![IMG_6336](https://github.com/user-attachments/assets/dff619ef-986d-465e-b352-73a76baae334) https://github.com/user-attachments/assets/9e404b13-c7e0-41c7-9406-4715f389166a Addresses feedback from the community about the new CSS feature: https://github.com/crosspoint-reader/crosspoint-reader/pull/700 --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**YES**_, Claude Code --- lib/Epub/Epub/Section.cpp | 24 +++++++++++-------- lib/Epub/Epub/Section.h | 7 +++--- lib/Epub/Epub/blocks/BlockStyle.h | 5 ++-- lib/Epub/Epub/css/CssStyle.h | 2 +- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 18 ++++++++++---- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 7 ++++-- src/CrossPointSettings.cpp | 5 +++- src/CrossPointSettings.h | 3 +++ src/activities/reader/EpubReaderActivity.cpp | 4 ++-- src/activities/settings/SettingsActivity.cpp | 5 ++-- 10 files changed, 53 insertions(+), 27 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 9cb70027..18e0faef 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -8,9 +8,9 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -constexpr uint8_t SECTION_FILE_VERSION = 11; +constexpr uint8_t SECTION_FILE_VERSION = 12; 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(bool) + sizeof(uint32_t); } // namespace @@ -33,7 +33,8 @@ uint32_t Section::onPageComplete(std::unique_ptr page) { void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, - const uint16_t viewportHeight, const bool hyphenationEnabled) { + const uint16_t viewportHeight, const bool hyphenationEnabled, + const bool embeddedStyle) { if (!file) { Serial.printf("[%lu] [SCT] File not open for writing header\n", millis()); return; @@ -41,7 +42,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) + sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) + sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) + - sizeof(uint32_t), + sizeof(embeddedStyle) + sizeof(uint32_t), "Header size mismatch"); serialization::writePod(file, SECTION_FILE_VERSION); serialization::writePod(file, fontId); @@ -51,13 +52,14 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi serialization::writePod(file, viewportWidth); serialization::writePod(file, viewportHeight); serialization::writePod(file, hyphenationEnabled); + serialization::writePod(file, embeddedStyle); serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written) serialization::writePod(file, static_cast(0)); // Placeholder for LUT offset } bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, - const uint16_t viewportHeight, const bool hyphenationEnabled) { + const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle) { if (!SdMan.openFileForRead("SCT", filePath, file)) { return false; } @@ -79,6 +81,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con bool fileExtraParagraphSpacing; uint8_t fileParagraphAlignment; bool fileHyphenationEnabled; + bool fileEmbeddedStyle; serialization::readPod(file, fileFontId); serialization::readPod(file, fileLineCompression); serialization::readPod(file, fileExtraParagraphSpacing); @@ -86,11 +89,12 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con serialization::readPod(file, fileViewportWidth); serialization::readPod(file, fileViewportHeight); serialization::readPod(file, fileHyphenationEnabled); + serialization::readPod(file, fileEmbeddedStyle); if (fontId != fileFontId || lineCompression != fileLineCompression || extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment || viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight || - hyphenationEnabled != fileHyphenationEnabled) { + hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle) { file.close(); Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis()); clearCache(); @@ -122,7 +126,7 @@ bool Section::clearCache() const { bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, - const uint16_t viewportHeight, const bool hyphenationEnabled, + const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, const std::function& popupFn) { const auto localPath = epub->getSpineItem(spineIndex).href; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; @@ -173,14 +177,14 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c return false; } writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, - viewportHeight, hyphenationEnabled); + viewportHeight, hyphenationEnabled, embeddedStyle); std::vector lut = {}; ChapterHtmlSlimParser visitor( tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight, hyphenationEnabled, - [this, &lut](std::unique_ptr page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, popupFn, - epub->getCssParser()); + [this, &lut](std::unique_ptr page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, + embeddedStyle, popupFn, embeddedStyle ? epub->getCssParser() : nullptr); Hyphenator::setPreferredLanguage(epub->getLanguage()); success = visitor.parseAndBuildPages(); diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 5fdf210a..42a6d993 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -15,7 +15,8 @@ class Section { FsFile file; void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, - uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled); + uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, + bool embeddedStyle); uint32_t onPageComplete(std::unique_ptr page); public: @@ -29,10 +30,10 @@ class Section { filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {} ~Section() = default; bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, - uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled); + uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle); bool clearCache() const; bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, - uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, + uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, const std::function& popupFn = nullptr); std::unique_ptr loadPageFromSectionFile(); }; diff --git a/lib/Epub/Epub/blocks/BlockStyle.h b/lib/Epub/Epub/blocks/BlockStyle.h index 5c26a21d..63b054c9 100644 --- a/lib/Epub/Epub/blocks/BlockStyle.h +++ b/lib/Epub/Epub/blocks/BlockStyle.h @@ -80,8 +80,9 @@ struct BlockStyle { blockStyle.textIndent = cssStyle.textIndent.toPixelsInt16(emSize); blockStyle.textIndentDefined = cssStyle.hasTextIndent(); blockStyle.textAlignDefined = cssStyle.hasTextAlign(); - if (blockStyle.textAlignDefined) { - blockStyle.alignment = cssStyle.textAlign; + // User setting overrides CSS, unless "Book's Style" alignment setting is selected + if (paragraphAlignment == CssTextAlign::None) { + blockStyle.alignment = blockStyle.textAlignDefined ? cssStyle.textAlign : CssTextAlign::Justify; } else { blockStyle.alignment = paragraphAlignment; } diff --git a/lib/Epub/Epub/css/CssStyle.h b/lib/Epub/Epub/css/CssStyle.h index 7b83da3f..adbc19e2 100644 --- a/lib/Epub/Epub/css/CssStyle.h +++ b/lib/Epub/Epub/css/CssStyle.h @@ -3,7 +3,7 @@ #include // Matches order of PARAGRAPH_ALIGNMENT in CrossPointSettings -enum class CssTextAlign : uint8_t { Justify = 0, Left = 1, Center = 2, Right = 3 }; +enum class CssTextAlign : uint8_t { Justify = 0, Left = 1, Center = 2, Right = 3, None = 4 }; enum class CssUnit : uint8_t { Pixels = 0, Em = 1, Rem = 2, Points = 3 }; // Represents a CSS length value with its unit, allowing deferred resolution to pixels diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index ab93d9cb..cb5625c1 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -211,11 +211,17 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } const float emSize = static_cast(self->renderer.getLineHeight(self->fontId)) * self->lineCompression; - const auto userAlignment = static_cast(self->paragraphAlignment); + const auto userAlignmentBlockStyle = + BlockStyle::fromCssStyle(cssStyle, emSize, static_cast(self->paragraphAlignment)); if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) { self->currentCssStyle = cssStyle; - self->startNewTextBlock(BlockStyle::fromCssStyle(cssStyle, emSize, userAlignment)); + auto headerBlockStyle = BlockStyle::fromCssStyle(cssStyle, emSize, CssTextAlign::Center); + headerBlockStyle.textAlignDefined = true; + if (self->embeddedStyle && cssStyle.hasTextAlign()) { + headerBlockStyle.alignment = cssStyle.textAlign; + } + self->startNewTextBlock(headerBlockStyle); self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); self->updateEffectiveInlineStyle(); } else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) { @@ -227,7 +233,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->startNewTextBlock(self->currentTextBlock->getBlockStyle()); } else { self->currentCssStyle = cssStyle; - self->startNewTextBlock(BlockStyle::fromCssStyle(cssStyle, emSize, userAlignment)); + self->startNewTextBlock(userAlignmentBlockStyle); self->updateEffectiveInlineStyle(); if (strcmp(name, "li") == 0) { @@ -430,7 +436,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n bool ChapterHtmlSlimParser::parseAndBuildPages() { auto paragraphAlignmentBlockStyle = BlockStyle(); paragraphAlignmentBlockStyle.textAlignDefined = true; - paragraphAlignmentBlockStyle.alignment = static_cast(this->paragraphAlignment); + // Resolve None sentinel to Justify for initial block (no CSS context yet) + const auto align = (this->paragraphAlignment == static_cast(CssTextAlign::None)) + ? CssTextAlign::Justify + : static_cast(this->paragraphAlignment); + paragraphAlignmentBlockStyle.alignment = align; startNewTextBlock(paragraphAlignmentBlockStyle); const XML_Parser parser = XML_ParserCreate(nullptr); diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 92a9838a..261d8f4e 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -41,6 +41,7 @@ class ChapterHtmlSlimParser { uint16_t viewportHeight; bool hyphenationEnabled; const CssParser* cssParser; + bool embeddedStyle; // Style tracking (replaces depth-based approach) struct StyleStackEntry { @@ -70,7 +71,8 @@ class ChapterHtmlSlimParser { const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const std::function)>& completePageFn, - const std::function& popupFn = nullptr, const CssParser* cssParser = nullptr) + const bool embeddedStyle, const std::function& popupFn = nullptr, + const CssParser* cssParser = nullptr) : filepath(filepath), renderer(renderer), @@ -83,7 +85,8 @@ class ChapterHtmlSlimParser { hyphenationEnabled(hyphenationEnabled), completePageFn(completePageFn), popupFn(popupFn), - cssParser(cssParser) {} + cssParser(cssParser), + embeddedStyle(embeddedStyle) {} ~ChapterHtmlSlimParser() = default; bool parseAndBuildPages(); diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index da287046..28a357e9 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) { namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 29; +constexpr uint8_t SETTINGS_COUNT = 30; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; // Validate front button mapping to ensure each hardware button is unique. @@ -117,6 +117,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, frontButtonLeft); serialization::writePod(outputFile, frontButtonRight); serialization::writePod(outputFile, fadingFix); + serialization::writePod(outputFile, embeddedStyle); // New fields added at end for backward compatibility outputFile.close(); @@ -220,6 +221,8 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, fadingFix); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, embeddedStyle); + if (++settingsRead >= fileSettingsCount) break; // New fields added at end for backward compatibility } while (false); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 86700cad..1348519f 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -86,6 +86,7 @@ class CrossPointSettings { LEFT_ALIGN = 1, CENTER_ALIGN = 2, RIGHT_ALIGN = 3, + BOOK_STYLE = 4, PARAGRAPH_ALIGNMENT_COUNT }; @@ -168,6 +169,8 @@ class CrossPointSettings { uint8_t uiTheme = LYRA; // Sunlight fading compensation uint8_t fadingFix = 0; + // Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled) + uint8_t embeddedStyle = 1; ~CrossPointSettings() = default; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 72600c57..5428a00c 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -575,14 +575,14 @@ void EpubReaderActivity::renderScreen() { if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled)) { + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) { Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); const auto popupFn = [this]() { GUI.drawPopup(renderer, "Indexing..."); }; if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, popupFn)) { + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, popupFn)) { Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); section.reset(); return; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 893eb108..efe5b5ed 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -35,14 +35,15 @@ const SettingInfo displaySettings[displaySettingsCount] = { SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix), }; -constexpr int readerSettingsCount = 9; +constexpr int readerSettingsCount = 10; const SettingInfo readerSettings[readerSettingsCount] = { SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}), SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}), SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}), SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}), SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment, - {"Justify", "Left", "Center", "Right"}), + {"Justify", "Left", "Center", "Right", "Book's Style"}), + SettingInfo::Toggle("Embedded Style", &CrossPointSettings::embeddedStyle), SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled), SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), From d8632eae0861c624e4f17f4bebe781f8ba777d51 Mon Sep 17 00:00:00 2001 From: CaptainFrito Date: Fri, 6 Feb 2026 14:58:32 +0700 Subject: [PATCH 04/15] fix: Lag before displaying covers on home screen (#721) ## Summary Reduce/fix the lag on the home screen before recent book covers are rendered ## Additional Context We were previously rendering the screen in two steps, delaying the recent book covers render to avoid a lag before the screen loads. In this PR, we are now doing that only if at least one book doesn't have the cover thumbnail generated yet. If all thumbs are already generated, we load and display them right away, with no lag. --- ### 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? _**NO **_ --- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 2 +- lib/Xtc/Xtc.cpp | 2 +- src/RecentBooksStore.cpp | 13 +++ src/RecentBooksStore.h | 3 + src/activities/home/HomeActivity.cpp | 83 +++++++++---------- src/activities/home/HomeActivity.h | 5 +- src/components/themes/BaseTheme.cpp | 7 +- src/components/themes/lyra/LyraTheme.cpp | 49 ++++++----- 8 files changed, 95 insertions(+), 69 deletions(-) diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 837bfd27..0b1541b4 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -567,5 +567,5 @@ bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bm // Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight) { - return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true, false); + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true, true); } diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 05f6651d..717bb670 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -340,7 +340,7 @@ bool Xtc::generateThumbBmp(int height) const { // Calculate scale factor float scaleX = static_cast(THUMB_TARGET_WIDTH) / pageInfo.width; float scaleY = static_cast(THUMB_TARGET_HEIGHT) / pageInfo.height; - float scale = (scaleX < scaleY) ? scaleX : scaleY; + float scale = (scaleX > scaleY) ? scaleX : scaleY; // for cropping // Only scale down, never up if (scale >= 1.0f) { diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index 276eb522..99586b79 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -38,6 +38,19 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title saveToFile(); } +void RecentBooksStore::updateBook(const std::string& path, const std::string& title, const std::string& author, + const std::string& coverBmpPath) { + auto it = + std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); + if (it != recentBooks.end()) { + RecentBook& book = *it; + book.title = title; + book.author = author; + book.coverBmpPath = coverBmpPath; + saveToFile(); + } +} + bool RecentBooksStore::saveToFile() const { // Make sure the directory exists SdMan.mkdir("/.crosspoint"); diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index 6f6a164a..8dbf0813 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -27,6 +27,9 @@ class RecentBooksStore { void addBook(const std::string& path, const std::string& title, const std::string& author, const std::string& coverBmpPath); + void updateBook(const std::string& path, const std::string& title, const std::string& author, + const std::string& coverBmpPath); + // Get the list of recent books (most recent first) const std::vector& getBooks() const { return recentBooks; } diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index ef5cc98f..15a59bfc 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -35,16 +35,11 @@ int HomeActivity::getMenuItemCount() const { return count; } -void HomeActivity::loadRecentBooks(int maxBooks, int coverHeight) { - recentsLoading = true; - bool showingLoading = false; - Rect popupRect; - +void HomeActivity::loadRecentBooks(int maxBooks) { recentBooks.clear(); const auto& books = RECENT_BOOKS.getBooks(); recentBooks.reserve(std::min(static_cast(books.size()), maxBooks)); - int progress = 0; for (const RecentBook& book : books) { // Limit to maximum number of recent books if (recentBooks.size() >= maxBooks) { @@ -56,19 +51,22 @@ void HomeActivity::loadRecentBooks(int maxBooks, int coverHeight) { continue; } + recentBooks.push_back(book); + } +} + +void HomeActivity::loadRecentCovers(int coverHeight) { + recentsLoading = true; + bool showingLoading = false; + Rect popupRect; + + int progress = 0; + for (RecentBook& book : recentBooks) { if (!book.coverBmpPath.empty()) { std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); if (!SdMan.exists(coverPath.c_str())) { - std::string lastBookFileName = ""; - const size_t lastSlash = book.path.find_last_of('/'); - if (lastSlash != std::string::npos) { - lastBookFileName = book.path.substr(lastSlash + 1); - } - - Serial.printf("Loading recent book: %s\n", book.path.c_str()); - // If epub, try to load the metadata for title/author and cover - if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) { + if (StringUtils::checkFileExtension(book.path, ".epub")) { Epub epub(book.path, "/.crosspoint"); // Skip loading css since we only need metadata here epub.load(false, true); @@ -78,10 +76,16 @@ void HomeActivity::loadRecentBooks(int maxBooks, int coverHeight) { showingLoading = true; popupRect = GUI.drawPopup(renderer, "Loading..."); } - GUI.fillPopupProgress(renderer, popupRect, progress * 30); - epub.generateThumbBmp(coverHeight); - } else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") || - StringUtils::checkFileExtension(lastBookFileName, ".xtc")) { + GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size())); + bool success = epub.generateThumbBmp(coverHeight); + if (!success) { + RECENT_BOOKS.updateBook(book.path, book.title, book.author, ""); + book.coverBmpPath = ""; + } + coverRendered = false; + updateRequired = true; + } else if (StringUtils::checkFileExtension(book.path, ".xtch") || + StringUtils::checkFileExtension(book.path, ".xtc")) { // Handle XTC file Xtc xtc(book.path, "/.crosspoint"); if (xtc.load()) { @@ -90,21 +94,23 @@ void HomeActivity::loadRecentBooks(int maxBooks, int coverHeight) { showingLoading = true; popupRect = GUI.drawPopup(renderer, "Loading..."); } - GUI.fillPopupProgress(renderer, popupRect, progress * 30); - xtc.generateThumbBmp(coverHeight); + GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size())); + bool success = xtc.generateThumbBmp(coverHeight); + if (!success) { + RECENT_BOOKS.updateBook(book.path, book.title, book.author, ""); + book.coverBmpPath = ""; + } + coverRendered = false; + updateRequired = true; } } } } - - recentBooks.push_back(book); progress++; } - Serial.printf("Recent books loaded: %d\n", recentBooks.size()); recentsLoaded = true; recentsLoading = false; - updateRequired = true; } void HomeActivity::onEnter() { @@ -112,14 +118,14 @@ void HomeActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); - // Check if we have a book to continue reading - hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str()); - // Check if OPDS browser URL is configured hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; selectorIndex = 0; + auto metrics = UITheme::getInstance().getMetrics(); + loadRecentBooks(metrics.homeRecentBooksCount); + // Trigger first update updateRequired = true; @@ -246,24 +252,14 @@ void HomeActivity::render() { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); + renderer.clearScreen(); bool bufferRestored = coverBufferStored && restoreCoverBuffer(); - if (!firstRenderDone || (recentsLoaded && !recentsDisplayed)) { - renderer.clearScreen(); - } GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr); - if (hasContinueReading) { - if (recentsLoaded) { - recentsDisplayed = true; - GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, - recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored, - std::bind(&HomeActivity::storeCoverBuffer, this)); - } else if (!recentsLoading && firstRenderDone) { - recentsLoading = true; - loadRecentBooks(metrics.homeRecentBooksCount, metrics.homeCoverHeight); - } - } + GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, + recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored, + std::bind(&HomeActivity::storeCoverBuffer, this)); // Build menu items dynamically std::vector menuItems = {"Browse Files", "Recents", "File Transfer", "Settings"}; @@ -288,5 +284,8 @@ void HomeActivity::render() { if (!firstRenderDone) { firstRenderDone = true; updateRequired = true; + } else if (!recentsLoaded && !recentsLoading) { + recentsLoading = true; + loadRecentCovers(metrics.homeCoverHeight); } } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index f27a8f93..1f714217 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -17,10 +17,8 @@ class HomeActivity final : public Activity { SemaphoreHandle_t renderingMutex = nullptr; int selectorIndex = 0; bool updateRequired = false; - bool hasContinueReading = false; bool recentsLoading = false; bool recentsLoaded = false; - bool recentsDisplayed = false; bool firstRenderDone = false; bool hasOpdsUrl = false; bool coverRendered = false; // Track if cover has been rendered once @@ -41,7 +39,8 @@ class HomeActivity final : public Activity { bool storeCoverBuffer(); // Store frame buffer for cover image bool restoreCoverBuffer(); // Restore frame buffer from stored cover void freeCoverBuffer(); // Free the stored cover buffer - void loadRecentBooks(int maxBooks, int coverHeight); + void loadRecentBooks(int maxBooks); + void loadRecentCovers(int coverHeight); public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 3b23e3fe..2e8ddbc8 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -301,6 +301,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: { // Draw cover image as background if available (inside the box) // Only load from SD on first render, then use stored buffer + if (hasContinueReading && !recentBooks[0].coverBmpPath.empty() && !coverRendered) { const std::string coverBmpPath = UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight); @@ -310,6 +311,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { + Serial.printf("Rendering bmp\n"); // Calculate position to center image within the book card int coverX, coverY; @@ -343,13 +345,16 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: // First render: if selected, draw selection indicators now if (bookSelected) { + Serial.printf("Drawing selection\n"); renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); } } file.close(); } - } else if (!bufferRestored && !coverRendered) { + } + + if (!bufferRestored && !coverRendered) { // No cover image: draw border or fill, plus bookmark as visual flair if (bookSelected) { renderer.fillRect(bookX, bookY, bookWidth, bookHeight); diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 71d6a15d..c07e7a5f 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -274,30 +274,37 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: for (int i = 0; i < std::min(static_cast(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); i++) { std::string coverPath = recentBooks[i].coverBmpPath; + bool hasCover = true; + int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i; if (coverPath.empty()) { - continue; + hasCover = false; + } else { + const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight); + + // First time: load cover from SD and render + FsFile file; + if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + float coverHeight = static_cast(bitmap.getHeight()); + float coverWidth = static_cast(bitmap.getWidth()); + float ratio = coverWidth / coverHeight; + const float tileRatio = static_cast(tileWidth - 2 * hPaddingInSelection) / + static_cast(LyraMetrics::values.homeCoverHeight); + float cropX = 1.0f - (tileRatio / ratio); + + renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, + tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX); + } else { + hasCover = false; + } + file.close(); + } } - const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight); - - int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i; - - // First time: load cover from SD and render - FsFile file; - if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { - Bitmap bitmap(file); - if (bitmap.parseHeaders() == BmpReaderError::Ok) { - float coverHeight = static_cast(bitmap.getHeight()); - float coverWidth = static_cast(bitmap.getWidth()); - float ratio = coverWidth / coverHeight; - const float tileRatio = static_cast(tileWidth - 2 * hPaddingInSelection) / - static_cast(LyraMetrics::values.homeCoverHeight); - float cropX = 1.0f - (tileRatio / ratio); - - renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, - tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX); - } - file.close(); + if (!hasCover) { + renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, + tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight); } } From 47f3137dee5b6865d7cddb40ae1c906490fa208b Mon Sep 17 00:00:00 2001 From: Jake Kenneally Date: Fri, 6 Feb 2026 03:10:37 -0500 Subject: [PATCH 05/15] fix: Remove separations after style changes (#720) Closes #182. Closes #710. Closes #711. ## Summary **What is the goal of this PR?** - A longer-term, more robust fix for the issue with spurious spaces appearing after style changes. Replaces solution from #694. **What changes are included?** - Add continuation flags to determine if to add a space after a word or if the word connects to the previous word. Replaces simple solution that only considered ending punctuation. - Fixed an issue with greedy line-breaking algorithm where punctuation could appear on the next line, separated from the word, if there was a style change between the word and punctuation --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**YES**_, Claude Code --- lib/Epub/Epub/ParsedText.cpp | 132 +++++++++--------- lib/Epub/Epub/ParsedText.h | 13 +- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 32 ++++- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 1 + 4 files changed, 108 insertions(+), 70 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 3af85a1b..82ddaecd 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -19,38 +19,6 @@ namespace { constexpr char SOFT_HYPHEN_UTF8[] = "\xC2\xAD"; constexpr size_t SOFT_HYPHEN_BYTES = 2; -// Known attaching punctuation (including UTF-8 sequences) -const std::vector punctuation = { - ".", - ",", - "!", - "?", - ";", - ":", - "\"", - "'", - "\xE2\x80\x99", // ’ (U+2019 right single quote) - "\xE2\x80\x9D" // ” (U+201D right double quote) -}; - -bool isAttachingPunctuationWord(const std::string& word) { - if (word.empty()) return false; - - size_t pos = 0; - while (pos < word.size()) { - bool matched = false; - for (const auto& p : punctuation) { - if (word.compare(pos, p.size(), p) == 0) { - pos += p.size(); - matched = true; - break; - } - } - if (!matched) return false; - } - return true; -} - bool containsSoftHyphen(const std::string& word) { return word.find(SOFT_HYPHEN_UTF8) != std::string::npos; } // Removes every soft hyphen in-place so rendered glyphs match measured widths. @@ -81,15 +49,17 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s } // namespace -void ParsedText::addWord(std::string word, const EpdFontFamily::Style style, const bool underline) { +void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline, + const bool attachToPrevious) { if (word.empty()) return; words.push_back(std::move(word)); - EpdFontFamily::Style combinedStyle = style; + EpdFontFamily::Style combinedStyle = fontStyle; if (underline) { combinedStyle = static_cast(combinedStyle | EpdFontFamily::UNDERLINE); } wordStyles.push_back(combinedStyle); + wordContinues.push_back(attachToPrevious); } // Consumes data to minimize memory usage @@ -106,17 +76,21 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo const int pageWidth = viewportWidth; const int spaceWidth = renderer.getSpaceWidth(fontId); auto wordWidths = calculateWordWidths(renderer, fontId); + + // Build indexed continues vector from the parallel list for O(1) access during layout + std::vector continuesVec(wordContinues.begin(), wordContinues.end()); + std::vector lineBreakIndices; if (hyphenationEnabled) { // Use greedy layout that can split words mid-loop when a hyphenated prefix fits. - lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths); + lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, continuesVec); } else { - lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths); + lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, continuesVec); } 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, wordWidths, continuesVec, lineBreakIndices, processLine); } } @@ -140,7 +114,8 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere } std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth, - const int spaceWidth, std::vector& wordWidths) { + const int spaceWidth, std::vector& wordWidths, + std::vector& continuesVec) { if (words.empty()) { return {}; } @@ -157,7 +132,8 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c // First word needs to fit in reduced width if there's an indent const int effectiveWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth; while (wordWidths[i] > effectiveWidth) { - if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true)) { + if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true, + &continuesVec)) { break; } } @@ -175,20 +151,26 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c ans[totalWordCount - 1] = totalWordCount - 1; for (int i = totalWordCount - 2; i >= 0; --i) { - int currlen = -spaceWidth; + int currlen = 0; dp[i] = MAX_COST; // First line has reduced width due to text-indent const int effectivePageWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth; for (size_t j = i; j < totalWordCount; ++j) { - // Current line length: previous width + space + current word width - currlen += wordWidths[j] + spaceWidth; + // Add space before word j, unless it's the first word on the line or a continuation + const int gap = j > static_cast(i) && !continuesVec[j] ? spaceWidth : 0; + currlen += wordWidths[j] + gap; if (currlen > effectivePageWidth) { break; } + // Cannot break after word j if the next word attaches to it (continuation group) + if (j + 1 < totalWordCount && continuesVec[j + 1]) { + continue; + } + int cost; if (j == totalWordCount - 1) { cost = 0; // Last line @@ -260,7 +242,8 @@ void ParsedText::applyParagraphIndent() { // Builds break indices while opportunistically splitting the word that would overflow the current line. std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth, const int spaceWidth, - std::vector& wordWidths) { + std::vector& wordWidths, + std::vector& continuesVec) { // Calculate first line indent (only for left/justified text without extra paragraph spacing) const int firstLineIndent = blockStyle.textIndent > 0 && !extraParagraphSpacing && @@ -282,7 +265,7 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r // Consume as many words as possible for current line, splitting when prefixes fit while (currentIndex < wordWidths.size()) { const bool isFirstWord = currentIndex == lineStart; - const int spacing = isFirstWord ? 0 : spaceWidth; + const int spacing = isFirstWord || continuesVec[currentIndex] ? 0 : spaceWidth; const int candidateWidth = spacing + wordWidths[currentIndex]; // Word fits on current line @@ -296,8 +279,8 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r const int availableWidth = effectivePageWidth - lineWidth - spacing; const bool allowFallbackBreaks = isFirstWord; // Only for first word on line - if (availableWidth > 0 && - hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, allowFallbackBreaks)) { + if (availableWidth > 0 && hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, + allowFallbackBreaks, &continuesVec)) { // Prefix now fits; append it to this line and move to next line lineWidth += spacing + wordWidths[currentIndex]; ++currentIndex; @@ -312,6 +295,12 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r break; } + // Don't break before a continuation word (e.g., orphaned "?" after "question"). + // Backtrack to the start of the continuation group so the whole group moves to the next line. + while (currentIndex > lineStart + 1 && currentIndex < wordWidths.size() && continuesVec[currentIndex]) { + --currentIndex; + } + lineBreakIndices.push_back(currentIndex); isFirstLine = false; } @@ -323,7 +312,7 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r // available width. bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer, const int fontId, std::vector& wordWidths, - const bool allowFallbackBreaks) { + const bool allowFallbackBreaks, std::vector* continuesVec) { // Guard against invalid indices or zero available width before attempting to split. if (availableWidth <= 0 || wordIndex >= words.size()) { return false; @@ -378,12 +367,28 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl wordIt->push_back('-'); } - // Insert the remainder word (with matching style) directly after the prefix. + // Insert the remainder word (with matching style and continuation flag) directly after the prefix. auto insertWordIt = std::next(wordIt); auto insertStyleIt = std::next(styleIt); words.insert(insertWordIt, remainder); wordStyles.insert(insertStyleIt, style); + // The remainder inherits whatever continuation status the original word had with the word after it. + // Find the continues entry for the original word and insert the remainder's entry after it. + auto continuesIt = wordContinues.begin(); + std::advance(continuesIt, wordIndex); + const bool originalContinuedToNext = *continuesIt; + // The original word (now prefix) does NOT continue to remainder (hyphen separates them) + *continuesIt = false; + const auto insertContinuesIt = std::next(continuesIt); + wordContinues.insert(insertContinuesIt, originalContinuedToNext); + + // Keep the indexed vector in sync if provided + if (continuesVec) { + (*continuesVec)[wordIndex] = false; + continuesVec->insert(continuesVec->begin() + wordIndex + 1, originalContinuedToNext); + } + // Update cached widths to reflect the new prefix/remainder pairing. wordWidths[wordIndex] = static_cast(chosenWidth); const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, style); @@ -392,7 +397,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl } void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth, - const std::vector& wordWidths, const std::vector& lineBreakIndices, + const std::vector& wordWidths, const std::vector& continuesVec, + const std::vector& lineBreakIndices, const std::function)>& processLine) { const size_t lineBreak = lineBreakIndices[breakIndex]; const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0; @@ -407,19 +413,16 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const : 0; // Calculate total word width for this line and count actual word gaps - // (punctuation that attaches to previous word doesn't count as a gap) - // Note: words list starts at the beginning because previous lines were spliced out + // (continuation words attach to previous word with no gap) int lineWordWidthSum = 0; size_t actualGapCount = 0; - auto countWordIt = words.begin(); for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) { lineWordWidthSum += wordWidths[lastBreakAt + wordIdx]; - // Count gaps: each word after the first creates a gap, unless it's attaching punctuation - if (wordIdx > 0 && !isAttachingPunctuationWord(*countWordIt)) { + // Count gaps: each word after the first creates a gap, unless it's a continuation + if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) { actualGapCount++; } - ++countWordIt; } // Calculate spacing (account for indent reducing effective page width on first line) @@ -443,30 +446,27 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const } // Pre-calculate X positions for words - // Punctuation that attaches to the previous word doesn't get space before it - // Note: words list starts at the beginning because previous lines were spliced out + // Continuation words attach to the previous word with no space before them std::list lineXPos; - auto wordIt = words.begin(); for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) { const uint16_t currentWordWidth = wordWidths[lastBreakAt + wordIdx]; lineXPos.push_back(xpos); - // Add spacing after this word, unless the next word is attaching punctuation - auto nextWordIt = wordIt; - ++nextWordIt; - const bool nextIsAttachingPunctuation = wordIdx + 1 < lineWordCount && isAttachingPunctuationWord(*nextWordIt); + // Add spacing after this word, unless the next word is a continuation + const bool nextIsContinuation = wordIdx + 1 < lineWordCount && continuesVec[lastBreakAt + wordIdx + 1]; - xpos += currentWordWidth + (nextIsAttachingPunctuation ? 0 : spacing); - ++wordIt; + xpos += currentWordWidth + (nextIsContinuation ? 0 : spacing); } // Iterators always start at the beginning as we are moving content with splice below auto wordEndIt = words.begin(); auto wordStyleEndIt = wordStyles.begin(); + auto wordContinuesEndIt = wordContinues.begin(); std::advance(wordEndIt, lineWordCount); std::advance(wordStyleEndIt, lineWordCount); + std::advance(wordContinuesEndIt, lineWordCount); // *** CRITICAL STEP: CONSUME DATA USING SPLICE *** std::list lineWords; @@ -474,6 +474,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const std::list lineWordStyles; lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt); + // Consume continues flags (not passed to TextBlock, but must be consumed to stay in sync) + std::list lineContinues; + lineContinues.splice(lineContinues.begin(), wordContinues, wordContinues.begin(), wordContinuesEndIt); + for (auto& word : lineWords) { if (containsSoftHyphen(word)) { stripSoftHyphensInPlace(word); diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index a13d13b5..8dd040cb 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -16,19 +16,22 @@ class GfxRenderer; class ParsedText { std::list words; std::list wordStyles; + std::list wordContinues; // true = word attaches to previous (no space before it) BlockStyle blockStyle; bool extraParagraphSpacing; bool hyphenationEnabled; void applyParagraphIndent(); std::vector computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, int spaceWidth, - std::vector& wordWidths); + std::vector& wordWidths, std::vector& continuesVec); std::vector computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, - int spaceWidth, std::vector& wordWidths); + int spaceWidth, std::vector& wordWidths, + std::vector& continuesVec); bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId, - std::vector& wordWidths, bool allowFallbackBreaks); + std::vector& wordWidths, bool allowFallbackBreaks, + std::vector* continuesVec = nullptr); void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector& wordWidths, - const std::vector& lineBreakIndices, + const std::vector& continuesVec, const std::vector& lineBreakIndices, const std::function)>& processLine); std::vector calculateWordWidths(const GfxRenderer& renderer, int fontId); @@ -38,7 +41,7 @@ class ParsedText { : blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {} ~ParsedText() = default; - void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false); + void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false); void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } BlockStyle& getBlockStyle() { return blockStyle; } size_t size() const { return words.size(); } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index cb5625c1..043cfca7 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -90,12 +90,14 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() { // flush the buffer partWordBuffer[partWordBufferIndex] = '\0'; - currentTextBlock->addWord(partWordBuffer, fontStyle); + currentTextBlock->addWord(partWordBuffer, fontStyle, false, nextWordContinues); partWordBufferIndex = 0; + nextWordContinues = false; } // start a new text block if needed void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { + nextWordContinues = false; // New block = new paragraph, no continuation if (currentTextBlock) { // already have a text block running and it is empty - just reuse it if (currentTextBlock->isEmpty()) { @@ -241,6 +243,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } } } else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) { + // Flush buffer before style change so preceding text gets current style + if (self->partWordBufferIndex > 0) { + self->flushPartWordBuffer(); + self->nextWordContinues = true; + } self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth); // Push inline style entry for underline tag StyleStackEntry entry; @@ -258,6 +265,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->inlineStyleStack.push_back(entry); self->updateEffectiveInlineStyle(); } else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { + // Flush buffer before style change so preceding text gets current style + if (self->partWordBufferIndex > 0) { + self->flushPartWordBuffer(); + self->nextWordContinues = true; + } self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); // Push inline style entry for bold tag StyleStackEntry entry; @@ -275,6 +287,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->inlineStyleStack.push_back(entry); self->updateEffectiveInlineStyle(); } else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) { + // Flush buffer before style change so preceding text gets current style + if (self->partWordBufferIndex > 0) { + self->flushPartWordBuffer(); + self->nextWordContinues = true; + } self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth); // Push inline style entry for italic tag StyleStackEntry entry; @@ -294,6 +311,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } else if (strcmp(name, "span") == 0 || !isHeaderOrBlock(name)) { // Handle span and other inline elements for CSS styling if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) { + // Flush buffer before style change so preceding text gets current style + if (self->partWordBufferIndex > 0) { + self->flushPartWordBuffer(); + self->nextWordContinues = true; + } StyleStackEntry entry; entry.depth = self->depth; // Track depth for matching pop if (cssStyle.hasFontWeight()) { @@ -331,6 +353,8 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char if (self->partWordBufferIndex > 0) { self->flushPartWordBuffer(); } + // Whitespace is a real word boundary — reset continuation state + self->nextWordContinues = false; // Skip the whitespace char continue; } @@ -387,6 +411,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n // Flush buffer with current style BEFORE any style changes if (self->partWordBufferIndex > 0) { // Flush if style will change OR if we're closing a block/structural element + const bool isInlineTag = !headerOrBlockTag && strcmp(name, "table") != 0 && + !matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) && self->depth != 1; const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || strcmp(name, "table") == 0 || @@ -394,6 +420,10 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n if (shouldFlush) { self->flushPartWordBuffer(); + // If closing an inline element, the next word fragment continues the same visual word + if (isInlineTag) { + self->nextWordContinues = true; + } } } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 261d8f4e..909913b1 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -30,6 +30,7 @@ class ChapterHtmlSlimParser { // leave one char at end for null pointer char partWordBuffer[MAX_WORD_SIZE + 1] = {}; int partWordBufferIndex = 0; + bool nextWordContinues = false; // true when next flushed word attaches to previous (inline element boundary) std::unique_ptr currentTextBlock = nullptr; std::unique_ptr currentPage = nullptr; int16_t currentPageNextY = 0; From 75b0ed778123ae1835e552490c85bb4fc4dae281 Mon Sep 17 00:00:00 2001 From: James Whyte Date: Sat, 7 Feb 2026 15:15:41 +0000 Subject: [PATCH 06/15] fix: increase lyra sideButtonHintsWidth to 30 (#727) ## Summary Increase the width of Lyra's side button hints. It has been set to 30, the same width as the classic theme. Before: image After: image ## Additional Context Resolves https://github.com/crosspoint-reader/crosspoint-reader/pull/700#issuecomment-3856983832 --- ### 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? No --- src/components/themes/lyra/LyraTheme.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index f00758cf..93ec0579 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -26,7 +26,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .homeCoverTileHeight = 287, .homeRecentBooksCount = 3, .buttonHintsHeight = 40, - .sideButtonHintsWidth = 19, + .sideButtonHintsWidth = 30, .versionTextRightX = 20, .versionTextY = 55, .bookProgressBarHeight = 4}; From b45eaf7dedac84f1b91be2de07d5d1f4ff1c9e52 Mon Sep 17 00:00:00 2001 From: Xuan-Son Nguyen Date: Sun, 8 Feb 2026 12:59:13 +0100 Subject: [PATCH 07/15] feat: optimize fillRectDither (#737) ## Summary This PR optimizes the `fillRectDither` function, making it as fast as a normal `fillRect` Testing code: ```cpp { auto start_t = millis(); renderer.fillRectDither(0, 0, renderer.getScreenWidth(), renderer.getScreenHeight(), Color::LightGray); auto elapsed = millis() - start_t; Serial.printf("[%lu] [ ] Test fillRectDither drawn in %lu ms\n", millis(), elapsed); } { auto start_t = millis(); renderer.fillRect(0, 0, renderer.getScreenWidth(), renderer.getScreenHeight(), true); auto elapsed = millis() - start_t; Serial.printf("[%lu] [ ] Test fillRect drawn in %lu ms\n", millis(), elapsed); } ``` Before: ``` [1125] [ ] Test fillRectDither drawn in 327 ms [1347] [ ] Test fillRect drawn in 222 ms ``` After: ``` [1065] [ ] Test fillRectDither drawn in 238 ms [1287] [ ] Test fillRect drawn in 222 ms ``` ## Visual validation Before: Screenshot 2026-02-07 at 01 04 19 After: Screenshot 2026-02-07 at 01 36 30 ## Details The original version is quite slow because it does quite a lot of computations. A single pixel needs around 20 instructions just to know if it's black or white: Screenshot 2026-02-07 at 00 15 54 With the new, templated and more light-weight approach, each pixel takes only 3-4 instructions, the modulo operator is translated into bitwise ops: Screenshot 2026-02-07 at 01 47 51 --- ### 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? **NO** --- lib/GfxRenderer/GfxRenderer.cpp | 97 ++++++++++++++++++++------------- lib/GfxRenderer/GfxRenderer.h | 6 +- 2 files changed, 63 insertions(+), 40 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 98cc0ee9..8abb16ea 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -237,54 +237,56 @@ void GfxRenderer::fillRect(const int x, const int y, const int width, const int } } -static constexpr uint8_t bayer4x4[4][4] = { - {0, 8, 2, 10}, - {12, 4, 14, 6}, - {3, 11, 1, 9}, - {15, 7, 13, 5}, -}; -static constexpr int matrixSize = 4; -static constexpr int matrixLevels = matrixSize * matrixSize; - -void GfxRenderer::drawPixelDither(const int x, const int y, Color color) const { - if (color == Color::Clear) { - } else if (color == Color::Black) { - drawPixel(x, y, true); - } else if (color == Color::White) { - drawPixel(x, y, false); - } else { - // Use dithering - const int greyLevel = static_cast(color) - 1; // 0-15 - const int normalizedGrey = (greyLevel * 255) / (matrixLevels - 1); - const int clampedGrey = std::max(0, std::min(normalizedGrey, 255)); - const int threshold = (clampedGrey * (matrixLevels + 1)) / 256; - - const int matrixX = x & (matrixSize - 1); - const int matrixY = y & (matrixSize - 1); - const uint8_t patternValue = bayer4x4[matrixY][matrixX]; - const bool black = patternValue < threshold; - drawPixel(x, y, black); - } +// NOTE: Those are in critical path, and need to be templated to avoid runtime checks for every pixel. +// Any branching must be done outside the loops to avoid performance degradation. +template <> +void GfxRenderer::drawPixelDither(const int x, const int y) const { + // Do nothing +} + +template <> +void GfxRenderer::drawPixelDither(const int x, const int y) const { + drawPixel(x, y, true); +} + +template <> +void GfxRenderer::drawPixelDither(const int x, const int y) const { + drawPixel(x, y, false); +} + +template <> +void GfxRenderer::drawPixelDither(const int x, const int y) const { + drawPixel(x, y, x % 2 == 0 && y % 2 == 0); +} + +template <> +void GfxRenderer::drawPixelDither(const int x, const int y) const { + drawPixel(x, y, (x + y) % 2 == 0); // TODO: maybe find a better pattern? } -// Use Bayer matrix 4x4 dithering to fill the rectangle with a grey level void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const { if (color == Color::Clear) { } else if (color == Color::Black) { fillRect(x, y, width, height, true); } else if (color == Color::White) { fillRect(x, y, width, height, false); - } else { + } else if (color == Color::LightGray) { for (int fillY = y; fillY < y + height; fillY++) { for (int fillX = x; fillX < x + width; fillX++) { - drawPixelDither(fillX, fillY, color); + drawPixelDither(fillX, fillY); + } + } + } else if (color == Color::DarkGray) { + for (int fillY = y; fillY < y + height; fillY++) { + for (int fillX = x; fillX < x + width; fillX++) { + drawPixelDither(fillX, fillY); } } } } -void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir, - Color color) const { +template +void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir) const { const int radiusSq = maxRadius * maxRadius; for (int dy = 0; dy <= maxRadius; ++dy) { for (int dx = 0; dx <= maxRadius; ++dx) { @@ -292,7 +294,7 @@ void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const const int px = cx + xDir * dx; const int py = cy + yDir * dy; if (distSq <= radiusSq) { - drawPixelDither(px, py, color); + drawPixelDither(px, py); } } } @@ -327,26 +329,45 @@ void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, con fillRectDither(x + width - maxRadius - 1, y + maxRadius + 1, maxRadius + 1, verticalHeight, color); } + auto fillArcTemplated = [this](int maxRadius, int cx, int cy, int xDir, int yDir, Color color) { + switch (color) { + case Color::Clear: + break; + case Color::Black: + fillArc(maxRadius, cx, cy, xDir, yDir); + break; + case Color::White: + fillArc(maxRadius, cx, cy, xDir, yDir); + break; + case Color::LightGray: + fillArc(maxRadius, cx, cy, xDir, yDir); + break; + case Color::DarkGray: + fillArc(maxRadius, cx, cy, xDir, yDir); + break; + } + }; + if (roundTopLeft) { - fillArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, color); + fillArcTemplated(maxRadius, x + maxRadius, y + maxRadius, -1, -1, color); } else { fillRectDither(x, y, maxRadius + 1, maxRadius + 1, color); } if (roundTopRight) { - fillArc(maxRadius, x + width - maxRadius - 1, y + maxRadius, 1, -1, color); + fillArcTemplated(maxRadius, x + width - maxRadius - 1, y + maxRadius, 1, -1, color); } else { fillRectDither(x + width - maxRadius - 1, y, maxRadius + 1, maxRadius + 1, color); } if (roundBottomRight) { - fillArc(maxRadius, x + width - maxRadius - 1, y + height - maxRadius - 1, 1, 1, color); + fillArcTemplated(maxRadius, x + width - maxRadius - 1, y + height - maxRadius - 1, 1, 1, color); } else { fillRectDither(x + width - maxRadius - 1, y + height - maxRadius - 1, maxRadius + 1, maxRadius + 1, color); } if (roundBottomLeft) { - fillArc(maxRadius, x + maxRadius, y + height - maxRadius - 1, -1, 1, color); + fillArcTemplated(maxRadius, x + maxRadius, y + height - maxRadius - 1, -1, 1, color); } else { fillRectDither(x, y + height - maxRadius - 1, maxRadius + 1, maxRadius + 1, color); } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index b84e7993..014349dc 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -39,8 +39,10 @@ class GfxRenderer { EpdFontFamily::Style style) const; void freeBwBufferChunks(); void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; - void drawPixelDither(int x, int y, Color color) const; - void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir, Color color) const; + template + void drawPixelDither(int x, int y) const; + template + void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const; public: explicit GfxRenderer(HalDisplay& halDisplay) From bb983d0ef444d0168d0cb9b95abdbecc60982d46 Mon Sep 17 00:00:00 2001 From: CaptainFrito Date: Mon, 9 Feb 2026 00:58:46 +0700 Subject: [PATCH 08/15] fix: Scrolling page items calculation (#716) ## Summary Fix for the page skip issue detected https://github.com/crosspoint-reader/crosspoint-reader/pull/700#issuecomment-3856374323 by user @whyte-j Skipping down on the last page now skips to the last item, and up on the first page to the first item, rather than wrapping around the list in a weird way. ## Additional Context The calculation was outdated after several changes were added afterwards --- ### 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? _**NO**_ --- src/activities/home/MyLibraryActivity.cpp | 10 ++++++---- src/activities/home/RecentBooksActivity.cpp | 8 +++++--- src/components/UITheme.cpp | 4 ++-- src/components/themes/BaseTheme.cpp | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index 35fbd44f..7523e542 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -3,6 +3,8 @@ #include #include +#include + #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" @@ -114,7 +116,7 @@ void MyLibraryActivity::loop() { mappedInput.wasReleased(MappedInputManager::Button::Down); const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; - const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true); + const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false); if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (files.empty()) { @@ -157,14 +159,14 @@ void MyLibraryActivity::loop() { int listSize = static_cast(files.size()); if (upReleased) { if (skipPage) { - selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + listSize) % listSize; + selectorIndex = std::max(static_cast((selectorIndex / pageItems - 1) * pageItems), 0); } else { selectorIndex = (selectorIndex + listSize - 1) % listSize; } updateRequired = true; } else if (downReleased) { if (skipPage) { - selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % listSize; + selectorIndex = std::min(static_cast((selectorIndex / pageItems + 1) * pageItems), listSize - 1); } else { selectorIndex = (selectorIndex + 1) % listSize; } @@ -195,7 +197,7 @@ void MyLibraryActivity::render() const { GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName); const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; - const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing; if (files.empty()) { renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, "No books found"); } else { diff --git a/src/activities/home/RecentBooksActivity.cpp b/src/activities/home/RecentBooksActivity.cpp index 9d22ae16..1be0e1e0 100644 --- a/src/activities/home/RecentBooksActivity.cpp +++ b/src/activities/home/RecentBooksActivity.cpp @@ -3,6 +3,8 @@ #include #include +#include + #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "components/UITheme.h" @@ -92,14 +94,14 @@ void RecentBooksActivity::loop() { int listSize = static_cast(recentBooks.size()); if (upReleased) { if (skipPage) { - selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + listSize) % listSize; + selectorIndex = std::max(static_cast((selectorIndex / pageItems - 1) * pageItems), 0); } else { selectorIndex = (selectorIndex + listSize - 1) % listSize; } updateRequired = true; } else if (downReleased) { if (skipPage) { - selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % listSize; + selectorIndex = std::min(static_cast((selectorIndex / pageItems + 1) * pageItems), listSize - 1); } else { selectorIndex = (selectorIndex + 1) % listSize; } @@ -129,7 +131,7 @@ void RecentBooksActivity::render() const { GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Recent Books"); const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; - const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing; // Recent tab if (recentBooks.empty()) { diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 45dfefde..9dbe429e 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -40,10 +40,10 @@ int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); int reservedHeight = metrics.topPadding; if (hasHeader) { - reservedHeight += metrics.headerHeight; + reservedHeight += metrics.headerHeight + metrics.verticalSpacing; } if (hasTabBar) { - reservedHeight += metrics.tabBarHeight + metrics.verticalSpacing; + reservedHeight += metrics.tabBarHeight; } if (hasButtonHints) { reservedHeight += metrics.verticalSpacing + metrics.buttonHintsHeight; diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 2e8ddbc8..9253953e 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -174,7 +174,7 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, const int centerX = rect.x + rect.width - indicatorWidth / 2 - margin; const int indicatorTop = rect.y; // Offset to avoid overlapping side button hints - const int indicatorBottom = rect.y + rect.height - 30; + const int indicatorBottom = rect.y + rect.height - arrowSize; // Draw up arrow at top (^) - narrow point at top, wide base at bottom for (int i = 0; i < arrowSize; ++i) { From 4f0a3aa4dda56b2a2c96754493d95fcacca7d7ed Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Sun, 8 Feb 2026 21:01:30 +0300 Subject: [PATCH 09/15] feat: wakeup target detection (#731) ## Summary * If going to sleep was from the Reader view, wake up to the same book. Otherwise, wakeup to the Home view --- src/CrossPointState.cpp | 9 ++++++++- src/CrossPointState.h | 1 + src/activities/Activity.h | 1 + src/activities/reader/ReaderActivity.h | 1 + src/main.cpp | 9 ++++++--- 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index d578945c..d97344f2 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -5,7 +5,7 @@ #include namespace { -constexpr uint8_t STATE_FILE_VERSION = 3; +constexpr uint8_t STATE_FILE_VERSION = 4; constexpr char STATE_FILE[] = "/.crosspoint/state.bin"; } // namespace @@ -21,6 +21,7 @@ bool CrossPointState::saveToFile() const { serialization::writeString(outputFile, openEpubPath); serialization::writePod(outputFile, lastSleepImage); serialization::writePod(outputFile, readerActivityLoadCount); + serialization::writePod(outputFile, lastSleepFromReader); outputFile.close(); return true; } @@ -50,6 +51,12 @@ bool CrossPointState::loadFromFile() { serialization::readPod(inputFile, readerActivityLoadCount); } + if (version >= 4) { + serialization::readPod(inputFile, lastSleepFromReader); + } else { + lastSleepFromReader = false; + } + inputFile.close(); return true; } diff --git a/src/CrossPointState.h b/src/CrossPointState.h index e8c65c10..68fa1d68 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -10,6 +10,7 @@ class CrossPointState { std::string openEpubPath; uint8_t lastSleepImage; uint8_t readerActivityLoadCount = 0; + bool lastSleepFromReader = false; ~CrossPointState() = default; // Get singleton instance diff --git a/src/activities/Activity.h b/src/activities/Activity.h index 4a60607b..632d396d 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -23,4 +23,5 @@ class Activity { virtual void loop() {} virtual bool skipLoopDelay() { return false; } virtual bool preventAutoSleep() { return false; } + virtual bool isReaderActivity() const { return false; } }; diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 6ecd6f34..5a2c1012 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -34,4 +34,5 @@ class ReaderActivity final : public ActivityWithSubactivity { onGoBack(onGoBack), onGoToLibrary(onGoToLibrary) {} void onEnter() override; + bool isReaderActivity() const override { return true; } }; diff --git a/src/main.cpp b/src/main.cpp index e1c74038..f18c47e0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -194,6 +194,8 @@ void waitForPowerRelease() { // Enter deep sleep mode void enterDeepSleep() { + APP_STATE.lastSleepFromReader = currentActivity && currentActivity->isReaderActivity(); + APP_STATE.saveToFile(); exitActivity(); enterNewActivity(new SleepActivity(renderer, mappedInputManager)); @@ -331,9 +333,10 @@ void setup() { APP_STATE.loadFromFile(); RECENT_BOOKS.loadFromFile(); - // Boot to home screen directly when back button is held or when reader activity crashes 3 times - if (APP_STATE.openEpubPath.empty() || mappedInputManager.isPressed(MappedInputManager::Button::Back) || - APP_STATE.readerActivityLoadCount > 0) { + // Boot to home screen if no book is open, last sleep was not from reader, back button is held, or reader activity + // crashed (indicated by readerActivityLoadCount > 0) + if (APP_STATE.openEpubPath.empty() || !APP_STATE.lastSleepFromReader || + mappedInputManager.isPressed(MappedInputManager::Button::Back) || APP_STATE.readerActivityLoadCount > 0) { onGoHome(); } else { // Clear app state to avoid getting into a boot loop if the epub doesn't load From 6909f127b4395764572f4af34ffc18c1293c19c3 Mon Sep 17 00:00:00 2001 From: Xuan-Son Nguyen Date: Sun, 8 Feb 2026 19:05:42 +0100 Subject: [PATCH 10/15] perf: optimize drawPixel() (#748) ## Summary Ref https://github.com/crosspoint-reader/crosspoint-reader/pull/737 This PR further reduce ~25ms from rendering time, testing inside the Setting screen: ``` master: [68440] [GFX] Time = 73 ms from clearScreen to displayBuffer PR: [97806] [GFX] Time = 47 ms from clearScreen to displayBuffer ``` And in extreme case (fill the entire screen with black or gray color): ``` master: [1125] [ ] Test fillRectDither drawn in 327 ms [1347] [ ] Test fillRect drawn in 222 ms PR: [1334] [ ] Test fillRectDither drawn in 225 ms [1455] [ ] Test fillRect drawn in 121 ms ``` Note that https://github.com/crosspoint-reader/crosspoint-reader/pull/737 is NOT applied on top of this PR. But with 2 of them combined, it should reduce from 47ms --> 42ms ## Details This PR based on the fact that function calls are costly if the function is small enough. For example, this simple call: ``` int rotatedX = 0; int rotatedY = 0; rotateCoordinates(x, y, &rotatedX, &rotatedY); ``` Generated assembly code: image This adds ~10 instructions just to prepare the registers prior to the function call, plus some more instructions for the function's epilogue/prologue. Inlining it removing all of these: image Of course, this optimization is not magic. It's only beneficial under 3 conditions: - The function is small, not in size, but in terms of effective instructions. For example, the `rotateCoordinates` is simply a jump table, where each branch is just 3-4 inst - The function has multiple input arguments, which requires some move to put it onto the correct place - The function is called very frequently (i.e. critical path) --- ### 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? **NO** --- lib/GfxRenderer/GfxRenderer.cpp | 100 +++++++++++++++----------------- lib/GfxRenderer/GfxRenderer.h | 6 +- src/main.cpp | 1 + 3 files changed, 52 insertions(+), 55 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 8abb16ea..14024bc4 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -2,61 +2,68 @@ #include +void GfxRenderer::begin() { + frameBuffer = display.getFrameBuffer(); + if (!frameBuffer) { + Serial.printf("[%lu] [GFX] !! No framebuffer\n", millis()); + assert(false); + } +} + void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); } -void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int* rotatedY) const { +// Translate logical (x,y) coordinates to physical panel coordinates based on current orientation +// This should always be inlined for better performance +static inline void rotateCoordinates(const GfxRenderer::Orientation orientation, const int x, const int y, int* phyX, + int* phyY) { switch (orientation) { - case Portrait: { + case GfxRenderer::Portrait: { // Logical portrait (480x800) → panel (800x480) // Rotation: 90 degrees clockwise - *rotatedX = y; - *rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - x; + *phyX = y; + *phyY = HalDisplay::DISPLAY_HEIGHT - 1 - x; break; } - case LandscapeClockwise: { + case GfxRenderer::LandscapeClockwise: { // Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right) - *rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - x; - *rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - y; + *phyX = HalDisplay::DISPLAY_WIDTH - 1 - x; + *phyY = HalDisplay::DISPLAY_HEIGHT - 1 - y; break; } - case PortraitInverted: { + case GfxRenderer::PortraitInverted: { // Logical portrait (480x800) → panel (800x480) // Rotation: 90 degrees counter-clockwise - *rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - y; - *rotatedY = x; + *phyX = HalDisplay::DISPLAY_WIDTH - 1 - y; + *phyY = x; break; } - case LandscapeCounterClockwise: { + case GfxRenderer::LandscapeCounterClockwise: { // Logical landscape (800x480) aligned with panel orientation - *rotatedX = x; - *rotatedY = y; + *phyX = x; + *phyY = y; break; } } } +// IMPORTANT: This function is in critical rendering path and is called for every pixel. Please keep it as simple and +// efficient as possible. void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { - uint8_t* frameBuffer = display.getFrameBuffer(); + int phyX = 0; + int phyY = 0; - // Early return if no framebuffer is set - if (!frameBuffer) { - Serial.printf("[%lu] [GFX] !! No framebuffer\n", millis()); - return; - } - - int rotatedX = 0; - int rotatedY = 0; - rotateCoordinates(x, y, &rotatedX, &rotatedY); + // Note: this call should be inlined for better performance + rotateCoordinates(orientation, x, y, &phyX, &phyY); // Bounds checking against physical panel dimensions - if (rotatedX < 0 || rotatedX >= HalDisplay::DISPLAY_WIDTH || rotatedY < 0 || rotatedY >= HalDisplay::DISPLAY_HEIGHT) { - Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY); + if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) { + Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, phyX, phyY); return; } // Calculate byte position and bit position - const uint16_t byteIndex = rotatedY * HalDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8); - const uint8_t bitPosition = 7 - (rotatedX % 8); // MSB first + const uint16_t byteIndex = phyY * HalDisplay::DISPLAY_WIDTH_BYTES + (phyX / 8); + const uint8_t bitPosition = 7 - (phyX % 8); // MSB first if (state) { frameBuffer[byteIndex] &= ~(1 << bitPosition); // Clear bit @@ -376,7 +383,7 @@ void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, con void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { int rotatedX = 0; int rotatedY = 0; - rotateCoordinates(x, y, &rotatedX, &rotatedY); + rotateCoordinates(orientation, x, y, &rotatedX, &rotatedY); // Rotate origin corner switch (orientation) { case Portrait: @@ -632,20 +639,23 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi free(nodeX); } -void GfxRenderer::clearScreen(const uint8_t color) const { display.clearScreen(color); } +// For performance measurement (using static to allow "const" methods) +static unsigned long start_ms = 0; + +void GfxRenderer::clearScreen(const uint8_t color) const { + start_ms = millis(); + display.clearScreen(color); +} void GfxRenderer::invertScreen() const { - uint8_t* buffer = display.getFrameBuffer(); - if (!buffer) { - Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis()); - return; - } for (int i = 0; i < HalDisplay::BUFFER_SIZE; i++) { - buffer[i] = ~buffer[i]; + frameBuffer[i] = ~frameBuffer[i]; } } void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const { + auto elapsed = millis() - start_ms; + Serial.printf("[%lu] [GFX] Time = %lu ms from clearScreen to displayBuffer\n", millis(), elapsed); display.displayBuffer(refreshMode, fadingFix); } @@ -829,16 +839,16 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y } } -uint8_t* GfxRenderer::getFrameBuffer() const { return display.getFrameBuffer(); } +uint8_t* GfxRenderer::getFrameBuffer() const { return frameBuffer; } size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; } // unused // void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); } -void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuffers(display.getFrameBuffer()); } +void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuffers(frameBuffer); } -void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(display.getFrameBuffer()); } +void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(frameBuffer); } void GfxRenderer::displayGrayBuffer() const { display.displayGrayBuffer(fadingFix); } @@ -858,12 +868,6 @@ void GfxRenderer::freeBwBufferChunks() { * Returns true if buffer was stored successfully, false if allocation failed. */ bool GfxRenderer::storeBwBuffer() { - const uint8_t* frameBuffer = display.getFrameBuffer(); - if (!frameBuffer) { - Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis()); - return false; - } - // Allocate and copy each chunk for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { // Check if any chunks are already allocated @@ -913,13 +917,6 @@ void GfxRenderer::restoreBwBuffer() { return; } - uint8_t* frameBuffer = display.getFrameBuffer(); - if (!frameBuffer) { - Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis()); - freeBwBufferChunks(); - return; - } - for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { // Check if chunk is missing if (!bwBufferChunks[i]) { @@ -943,7 +940,6 @@ void GfxRenderer::restoreBwBuffer() { * Use this when BW buffer was re-rendered instead of stored/restored. */ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const { - uint8_t* frameBuffer = display.getFrameBuffer(); if (frameBuffer) { display.cleanupGrayscaleBuffers(frameBuffer); } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 014349dc..4540774e 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -33,12 +33,12 @@ class GfxRenderer { RenderMode renderMode; Orientation orientation; bool fadingFix; + uint8_t* frameBuffer = nullptr; uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; std::map fontMap; void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState, EpdFontFamily::Style style) const; void freeBwBufferChunks(); - void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; template void drawPixelDither(int x, int y) const; template @@ -55,6 +55,7 @@ class GfxRenderer { static constexpr int VIEWABLE_MARGIN_LEFT = 3; // Setup + void begin(); // must be called right after display.begin() void insertFont(int fontId, EpdFontFamily font); // Orientation control (affects logical width/height and coordinate transforms) @@ -72,6 +73,7 @@ class GfxRenderer { // void displayWindow(int x, int y, int width, int height) const; void invertScreen() const; void clearScreen(uint8_t color = 0xFF) const; + void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const; // Drawing void drawPixel(int x, int y, bool state = true) const; @@ -125,6 +127,4 @@ class GfxRenderer { // Low level functions uint8_t* getFrameBuffer() const; static size_t getBufferSize(); - void grayscaleRevert() const; - void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const; }; diff --git a/src/main.cpp b/src/main.cpp index f18c47e0..4895be38 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -253,6 +253,7 @@ void onGoHome() { void setupDisplayAndFonts() { display.begin(); + renderer.begin(); Serial.printf("[%lu] [ ] Display initialized\n", millis()); renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily); #ifndef OMIT_FONTS From 5e52a4683719bc6f2ec8e3615a83b2905e4d15c5 Mon Sep 17 00:00:00 2001 From: Jake Kenneally Date: Sun, 8 Feb 2026 12:34:06 -0500 Subject: [PATCH 11/15] refactor: Rename "Embedded Style" to "Book's Embedded Style" (#746) ## Summary **What is the goal of this PR?** - Just a simple rename after feedback in #738 **What changes are included?** - Renamed "Embedded Style" to "Book's Embedded Style" to more clearly associate it with "Book's Style" option in "Paragraph Alignment" settings --- ### 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? _**NO**_ --- src/activities/settings/SettingsActivity.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index efe5b5ed..7a8fc261 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -43,7 +43,7 @@ const SettingInfo readerSettings[readerSettingsCount] = { SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}), SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment, {"Justify", "Left", "Center", "Right", "Book's Style"}), - SettingInfo::Toggle("Embedded Style", &CrossPointSettings::embeddedStyle), + SettingInfo::Toggle("Book's Embedded Style", &CrossPointSettings::embeddedStyle), SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled), SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), From 21e7d29286a81c58d749e1ac37b6f807879b46ab Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 9 Feb 2026 08:08:19 +1100 Subject: [PATCH 12/15] fix: Allow OTA update from RC build to full release (#778) ## Summary * Allow OTA update from RC build to full release * If all the segments match, then also check if the current version contains "-rc" --- ### 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? No --- src/network/OtaUpdater.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp index 1733e136..3c2dda8a 100644 --- a/src/network/OtaUpdater.cpp +++ b/src/network/OtaUpdater.cpp @@ -185,7 +185,16 @@ bool OtaUpdater::isUpdateNewer() const { /* * Check patch versions. */ - return latestPatch > currentPatch; + if (latestPatch != currentPatch) return latestPatch > currentPatch; + + // If we reach here, it means all segments are equal. + // One final check, if we're on an RC build (contains "-rc"), we should consider the latest version as newer even if + // the segments are equal, since RC builds are pre-release versions. + if (strstr(currentVersion, "-rc") != nullptr) { + return true; + } + + return false; } const std::string& OtaUpdater::getLatestVersion() const { return latestVersion; } From 7538e557959260f2f8a5a171bc92a70ac8d116fc Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 9 Feb 2026 08:15:13 +1100 Subject: [PATCH 13/15] Move release candidate workflow to manual dispatch --- .github/workflows/release_candidate.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release_candidate.yml b/.github/workflows/release_candidate.yml index e9bbc636..4df20d02 100644 --- a/.github/workflows/release_candidate.yml +++ b/.github/workflows/release_candidate.yml @@ -1,11 +1,11 @@ name: Compile Release Candidate on: - pull_request: + workflow_dispatch: jobs: build-release-candidate: - if: startsWith(github.head_ref, 'release/') + if: startsWith(github.ref_name, 'release/') runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 From def1094411f32c5323c486f9c09eb791a441963e Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 9 Feb 2026 08:22:20 +1100 Subject: [PATCH 14/15] Use GITHUB_REF_NAME over GITHUB_HEAD_REF in release candidate workflow --- .github/workflows/release_candidate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release_candidate.yml b/.github/workflows/release_candidate.yml index 4df20d02..d247fa78 100644 --- a/.github/workflows/release_candidate.yml +++ b/.github/workflows/release_candidate.yml @@ -29,7 +29,7 @@ jobs: - name: Extract env run: | echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV - echo "BRANCH_SUFFIX=${GITHUB_HEAD_REF#release/}" >> $GITHUB_ENV + echo "BRANCH_SUFFIX=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV - name: Build CrossPoint Release Candidate env: From 9e04eec072b1db251cca6dc570b619c3e39b5c36 Mon Sep 17 00:00:00 2001 From: Jake Kenneally Date: Sun, 8 Feb 2026 16:31:52 -0500 Subject: [PATCH 15/15] feat: Add percentage support to CSS properties (#738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Closes #730 **What is the goal of this PR?** - Adds percentage-based value support to CSS properties that accept percentages (padding, margin, text-indent) **What changes are included?** - Adds `Percent` as another CSS unit - Passes the viewport width to `fromCssStyle` so that we can resolve percentage-based values - Adds a fallback of using an emspace for text-indent if we have an unresolvable value for whatever reason ## Additional Context - This was missed in my CSS support feature, and the fallback when we encounter a percentage value is to use px instead. This means 5% (which would be ~30px on the screen) turns into 5px. When percentages are used in `text-indent`, this fallback behavior makes the indent look like a single space character. Whoops! 😬 My test EPUB has been updated [here](https://github.com/jdk2pq/css-test-epub) with percentage based CSS values at the end of the book. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**YES**_, Claude Code --- lib/Epub/Epub/blocks/BlockStyle.h | 30 +++++++++++-------- lib/Epub/Epub/css/CssParser.cpp | 4 ++- lib/Epub/Epub/css/CssStyle.h | 17 +++++++++-- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 6 ++-- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/lib/Epub/Epub/blocks/BlockStyle.h b/lib/Epub/Epub/blocks/BlockStyle.h index 63b054c9..a5a616bf 100644 --- a/lib/Epub/Epub/blocks/BlockStyle.h +++ b/lib/Epub/Epub/blocks/BlockStyle.h @@ -64,21 +64,27 @@ struct BlockStyle { // Create a BlockStyle from CSS style properties, resolving CssLength values to pixels // emSize is the current font line height, used for em/rem unit conversion // paragraphAlignment is the user's paragraphAlignment setting preference - static BlockStyle fromCssStyle(const CssStyle& cssStyle, const float emSize, const CssTextAlign paragraphAlignment) { + static BlockStyle fromCssStyle(const CssStyle& cssStyle, const float emSize, const CssTextAlign paragraphAlignment, + const uint16_t viewportWidth = 0) { BlockStyle blockStyle; - // Resolve all CssLength values to pixels using the current font's em size - blockStyle.marginTop = cssStyle.marginTop.toPixelsInt16(emSize); - blockStyle.marginBottom = cssStyle.marginBottom.toPixelsInt16(emSize); - blockStyle.marginLeft = cssStyle.marginLeft.toPixelsInt16(emSize); - blockStyle.marginRight = cssStyle.marginRight.toPixelsInt16(emSize); + const float vw = viewportWidth; + // Resolve all CssLength values to pixels using the current font's em size and viewport width + blockStyle.marginTop = cssStyle.marginTop.toPixelsInt16(emSize, vw); + blockStyle.marginBottom = cssStyle.marginBottom.toPixelsInt16(emSize, vw); + blockStyle.marginLeft = cssStyle.marginLeft.toPixelsInt16(emSize, vw); + blockStyle.marginRight = cssStyle.marginRight.toPixelsInt16(emSize, vw); - blockStyle.paddingTop = cssStyle.paddingTop.toPixelsInt16(emSize); - blockStyle.paddingBottom = cssStyle.paddingBottom.toPixelsInt16(emSize); - blockStyle.paddingLeft = cssStyle.paddingLeft.toPixelsInt16(emSize); - blockStyle.paddingRight = cssStyle.paddingRight.toPixelsInt16(emSize); + blockStyle.paddingTop = cssStyle.paddingTop.toPixelsInt16(emSize, vw); + blockStyle.paddingBottom = cssStyle.paddingBottom.toPixelsInt16(emSize, vw); + blockStyle.paddingLeft = cssStyle.paddingLeft.toPixelsInt16(emSize, vw); + blockStyle.paddingRight = cssStyle.paddingRight.toPixelsInt16(emSize, vw); - blockStyle.textIndent = cssStyle.textIndent.toPixelsInt16(emSize); - blockStyle.textIndentDefined = cssStyle.hasTextIndent(); + // For textIndent: if it's a percentage we can't resolve (no viewport width), + // leave textIndentDefined=false so the EmSpace fallback in applyParagraphIndent() is used + if (cssStyle.hasTextIndent() && cssStyle.textIndent.isResolvable(vw)) { + blockStyle.textIndent = cssStyle.textIndent.toPixelsInt16(emSize, vw); + blockStyle.textIndentDefined = true; + } blockStyle.textAlignDefined = cssStyle.hasTextAlign(); // User setting overrides CSS, unless "Book's Style" alignment setting is selected if (paragraphAlignment == CssTextAlign::None) { diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index d51ebba7..ba187e4c 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -283,6 +283,8 @@ CssLength CssParser::interpretLength(const std::string& val) { unit = CssUnit::Rem; } else if (unitPart == "pt") { unit = CssUnit::Points; + } else if (unitPart == "%") { + unit = CssUnit::Percent; } // px and unitless default to Pixels @@ -518,7 +520,7 @@ CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return par // Cache serialization // Cache format version - increment when format changes -constexpr uint8_t CSS_CACHE_VERSION = 1; +constexpr uint8_t CSS_CACHE_VERSION = 2; bool CssParser::saveToCache(FsFile& file) const { if (!file) { diff --git a/lib/Epub/Epub/css/CssStyle.h b/lib/Epub/Epub/css/CssStyle.h index adbc19e2..b90fa7ab 100644 --- a/lib/Epub/Epub/css/CssStyle.h +++ b/lib/Epub/Epub/css/CssStyle.h @@ -4,7 +4,7 @@ // Matches order of PARAGRAPH_ALIGNMENT in CrossPointSettings enum class CssTextAlign : uint8_t { Justify = 0, Left = 1, Center = 2, Right = 3, None = 4 }; -enum class CssUnit : uint8_t { Pixels = 0, Em = 1, Rem = 2, Points = 3 }; +enum class CssUnit : uint8_t { Pixels = 0, Em = 1, Rem = 2, Points = 3, Percent = 4 }; // Represents a CSS length value with its unit, allowing deferred resolution to pixels struct CssLength { @@ -17,21 +17,32 @@ struct CssLength { // Convenience constructor for pixel values (most common case) explicit CssLength(const float pixels) : value(pixels) {} + // Returns true if this length can be resolved to pixels with the given context. + // Percentage units require a non-zero containerWidth to resolve. + [[nodiscard]] bool isResolvable(const float containerWidth = 0) const { + return unit != CssUnit::Percent || containerWidth > 0; + } + // Resolve to pixels given the current em size (font line height) - [[nodiscard]] float toPixels(const float emSize) const { + // containerWidth is needed for percentage units (e.g. viewport width) + [[nodiscard]] float toPixels(const float emSize, const float containerWidth = 0) const { switch (unit) { case CssUnit::Em: case CssUnit::Rem: return value * emSize; case CssUnit::Points: return value * 1.33f; // Approximate pt to px conversion + case CssUnit::Percent: + return value * containerWidth / 100.0f; default: return value; } } // Resolve to int16_t pixels (for BlockStyle fields) - [[nodiscard]] int16_t toPixelsInt16(const float emSize) const { return static_cast(toPixels(emSize)); } + [[nodiscard]] int16_t toPixelsInt16(const float emSize, const float containerWidth = 0) const { + return static_cast(toPixels(emSize, containerWidth)); + } }; // Font style options matching CSS font-style property diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 043cfca7..a4d10832 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -213,12 +213,12 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } const float emSize = static_cast(self->renderer.getLineHeight(self->fontId)) * self->lineCompression; - const auto userAlignmentBlockStyle = - BlockStyle::fromCssStyle(cssStyle, emSize, static_cast(self->paragraphAlignment)); + const auto userAlignmentBlockStyle = BlockStyle::fromCssStyle( + cssStyle, emSize, static_cast(self->paragraphAlignment), self->viewportWidth); if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) { self->currentCssStyle = cssStyle; - auto headerBlockStyle = BlockStyle::fromCssStyle(cssStyle, emSize, CssTextAlign::Center); + auto headerBlockStyle = BlockStyle::fromCssStyle(cssStyle, emSize, CssTextAlign::Center, self->viewportWidth); headerBlockStyle.textAlignDefined = true; if (self->embeddedStyle && cssStyle.hasTextAlign()) { headerBlockStyle.alignment = cssStyle.textAlign;