diff --git a/.skills/SKILL.md b/.skills/SKILL.md index 9ebda0a1..55e72d23 100644 --- a/.skills/SKILL.md +++ b/.skills/SKILL.md @@ -7,7 +7,7 @@ Mission: Provide a lightweight, high-performance reading experience focused on E * Role: Senior Embedded Systems Engineer (ESP-IDF/Arduino-ESP32 specialized). * Primary Constraint: 380KB RAM is the hard ceiling. Stability is non-negotiable. * Evidence-Based Reasoning: Before proposing a change, you MUST cite the specific file path and line numbers that justify the modification. -* Anti-Hallucination: Do not assume the existence of libraries or ESP-IDF functions. If you are unsure of an API's availability for the ESP32-C3 RISC-V target, check the open-x4-sdk or official docs first. +* Anti-Hallucination: Do not assume the existence of libraries or ESP-IDF functions. If you are unsure of an API's availability for the ESP32-C3 RISC-V target, check the freeink-sdk source or the FreeInk SDK docs (https://freeink.org/llms.txt for an LLM-readable index) first. * No Unfounded Claims: Do not claim performance gains or memory savings without explaining the technical mechanism (e.g., DRAM vs IRAM usage). * Resource Justification: You must justify any new heap allocation (new, malloc, std::vector) or explain why a stack/static alternative was rejected. * Verification: After suggesting a fix, instruct the user on how to verify it (e.g., monitoring heap via Serial or checking a specific cache file). @@ -127,7 +127,7 @@ These flags in `platformio.ini` fundamentally affect firmware behavior: * lib/hal/: Hardware Abstraction Layer (HalDisplay, HalGPIO, HalStorage) * lib/I18n/: Internationalization (translations in `translations/*.yaml`, generated string tables) * src/activities/: UI logic using the Activity Lifecycle (onEnter, loop, onExit) -* open-x4-sdk/: Low-level SDK (EInkDisplay, InputManager, BatteryMonitor, SDCardManager) +* freeink-sdk/: Low-level SDK (EInkDisplay, InputManager, BatteryMonitor, SDCardManager) * .crosspoint/: SD-based binary cache for EPUB metadata and pre-rendered layout sections ### Hardware Abstraction Layer (HAL) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 037b6504..7bfcf3de 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -122,19 +122,43 @@ A **Wi-Fi signal strength indicator** (dBm) is displayed on-screen during joined CrossPoint supports sending books from Calibre using the CrossPoint Reader device plugin. -1. Install the plugin in Calibre: - - - Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin. - - - Download the zip file. - - - Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file. +#### Installing the Plugin in Calibre -2. On the device: File Transfer -> Calibre Wireless, then join a network. +If you don't already have the plugin installed: -3. Make sure your computer is on the same Wi-Fi network. +1. Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin. +2. Download the zip file. +3. Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file. +4. Restart Calibre. -4. In Calibre, click "Send to device" to transfer books. +#### Configuring the CrossPoint Plugin in Calibre +1. In Calibre select Preferences. +2. In the Preferences dialog select Plugins. +3. In Plugins search for "crosspoint". +4. Click on "Customize plugin". +5. Update the value for "Host" to match the IP for your device. +6. Leave the other settings as they are. +7. [optional] Modify the "Upload path" to point to a subfolder other than the root "/" folder. Enter this as a path relative to the root folder. Example: `/mybooks` +8. Restart Calibre. + +Image + +#### Uploading Books + +To upload a book using the CrossPoint plugin in Calibre: + +1. On the device: File Transfer -> Calibre Wireless, then join a network. +2. Select one or more books. +3. Right-click on that selection. +4. Select "Send to Device" > "Send to main memory" + +The CrossPoint plugin will connect to your device, create a folder for the book's author in the root folder (or the folder you configured for the plugin), then copy the book into that folder. + +Image + +#### Removing a Book + +Books cannot be removed from your device through Calibre. Use the web interface instead. ### 3.6 Settings @@ -150,6 +174,7 @@ The Settings screen allows you to configure the device's behavior. There are a f - "Cover" - The book cover image (Note: this is experimental and may not work as expected) - "None" - A blank screen - "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise + - "Quick resume" - The text of the last page read will be displayed on the sleep screen and a moon icon is shown on the edge of the screen. Waking up the device will return to the same page of the opened book. This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book. - **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected: @@ -162,6 +187,8 @@ The Settings screen allows you to configure the device's behavior. There are a f - "Contrast" - The image will be displayed as a black & white image without grayscale conversion - "Inverted" - The image will be inverted as in white & black and will be displayed without grayscale conversion +- **Quick Resume on Timeout**: Whether to enable the "Quick Resume" sleep screen when the device goes to sleep due to inactivity (System > Time to Sleep). This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book. This overwrites the Sleep Screen Cover Mode when enabled. + - **Status Bar**: Configure the status bar displayed while reading: - "None" - No status bar @@ -527,6 +554,8 @@ On the **Xteink X3**, the gyroscope can be used to turn pages by tilting the dev When reading an EPUB that contains footnotes, you can navigate to the footnote text by selecting the footnote reference in the book. From the footnote, you can return to your original reading position. +If the device goes to sleep or you close the book while viewing a footnote, the book reopens to your original reading position, not the footnote. + ### System Navigation * **Return to Home:** Press the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen. diff --git a/bin/clang-format-fix.ps1 b/bin/clang-format-fix.ps1 index f90a81b7..28e9394c 100644 --- a/bin/clang-format-fix.ps1 +++ b/bin/clang-format-fix.ps1 @@ -4,7 +4,7 @@ .DESCRIPTION Formats all C/C++ source and header files in the repository, excluding - generated, vendored, and build directories (open-x4-sdk, builtinFonts, + generated, vendored, and build directories (freeink-sdk, builtinFonts, hyphenation tries, uzlib, .pio, *.generated.h). The clang-format binary path is resolved once and cached in @@ -92,7 +92,7 @@ function Resolve-ClangFormat { $clangFormat = Resolve-ClangFormat $exclude = @( - 'open-x4-sdk' + 'freeink-sdk' 'lib\EpdFont\builtinFonts' 'lib\Epub\Epub\hyphenation\generated' 'lib\uzlib' diff --git a/docs/contributing/architecture.md b/docs/contributing/architecture.md index 3e1729a5..b0a85056 100644 --- a/docs/contributing/architecture.md +++ b/docs/contributing/architecture.md @@ -8,7 +8,7 @@ At a high level, it is firmware that uses an activity-driven application archite ```mermaid graph TD - A[Hardware: ESP32-C3 + SD + E-ink + Buttons] --> B[open-x4-sdk] + A[Hardware: ESP32-C3 + SD + E-ink + Buttons] --> B[freeink-sdk] B --> C[lib/hal wrappers] C --> D[src/main.cpp runtime loop] D --> E[Activities layer] @@ -195,10 +195,10 @@ When editing related source assets, regenerate via normal build steps/scripts. - `src/`: app orchestration, settings/state, and activity implementations - `src/network/`: web server and OTA/update networking - `src/components/`: theming and shared UI components -- `lib/hal/`: hardware abstraction wrappers around open-x4-sdk +- `lib/hal/`: hardware abstraction wrappers around freeink-sdk - `lib/Epub/`: EPUB parser, layout, CSS handling, and hyphenation - `lib/`: supporting libraries (fonts, text, filesystem helpers, etc.) -- `open-x4-sdk/`: hardware SDK submodule (display, input, storage, battery) +- `freeink-sdk/`: hardware SDK submodule (display, input, storage, battery). Docs: https://freeink.org/docs - `docs/`: user and technical documentation ## Embedded constraints that shape design diff --git a/freeink-sdk b/freeink-sdk index c3b3ab3d..329a4beb 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit c3b3ab3d117c7f168f398e48580d6cf79dc67c44 +Subproject commit 329a4bebef0df10aecd3737f5676c832f03ba277 diff --git a/lib/EpdFont/SdCardFontRegistry.cpp b/lib/EpdFont/SdCardFontRegistry.cpp index 07966a85..c452bb29 100644 --- a/lib/EpdFont/SdCardFontRegistry.cpp +++ b/lib/EpdFont/SdCardFontRegistry.cpp @@ -19,6 +19,26 @@ const SdCardFontFileInfo* SdCardFontFamilyInfo::findClosestReaderSize(const uint const uint8_t style) const { if (files.empty()) return nullptr; + // Collect sizes matching the requested style, sorted ascending. + std::vector sizes; + for (const auto& f : files) { + if (f.style != style) continue; + sizes.push_back(f.pointSize); + } + if (sizes.empty()) return nullptr; + std::sort(sizes.begin(), sizes.end()); + + // When the family provides at least 4 sizes, use ordinal (index-based) + // selection so custom-built font sets (e.g. 10/12/14/16) map SMALL to + // the smallest file, not to a hardcoded 12pt target. + if (sizes.size() >= 4) { + uint8_t idx = fontSizeEnum; + if (idx >= sizes.size()) idx = sizes.size() - 1; + return findFile(sizes[idx], style); + } + + // Fewer sizes than enum slots (e.g. CJK packs with only 2-3 sizes): + // fall back to closest-match against the built-in reader targets. uint8_t target = 14; switch (fontSizeEnum) { case 0: diff --git a/lib/EpdFont/scripts/fontconvert_sdcard.py b/lib/EpdFont/scripts/fontconvert_sdcard.py index e2125be4..28373b58 100755 --- a/lib/EpdFont/scripts/fontconvert_sdcard.py +++ b/lib/EpdFont/scripts/fontconvert_sdcard.py @@ -40,7 +40,8 @@ INTERVAL_PRESETS = { "ascii": [(0x0020, 0x007E)], "latin1": [(0x0080, 0x00FF)], "latin-ext": [(0x0020, 0x007E), (0x0080, 0x00FF), (0x0100, 0x024F), - (0x1E00, 0x1EFF), (0x2000, 0x206F), (0xFB00, 0xFB06)], + (0x02B0, 0x02FF), (0x1E00, 0x1EFF), (0x2000, 0x206F), + (0xFB00, 0xFB06)], "greek": [(0x0370, 0x03FF), (0x1F00, 0x1FFF)], "cyrillic": [(0x0400, 0x04FF), (0x0500, 0x052F)], "hebrew": [(0x0590, 0x05FF), (0xFB1D, 0xFB4F)], @@ -62,7 +63,7 @@ INTERVAL_PRESETS = { # Composite preset for English-language literary fiction including scifi/popsci. # Greek for physics terms, math operators, geometric shapes, uncommon # dialogue punctuation, CJK quote marks, miscellaneous symbols (♪♫♬), dingbats. - "reading": [(0x0020, 0x024F), (0x0300, 0x036F), (0x0370, 0x03FF), + "reading": [(0x0020, 0x024F), (0x02B0, 0x02FF), (0x0300, 0x036F), (0x0370, 0x03FF), (0x0400, 0x04FF), (0x1E00, 0x1EFF), (0x2000, 0x206F), (0x2070, 0x209F), (0x20A0, 0x20CF), (0x2150, 0x218F), (0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F), diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 69caa30c..5032056c 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -6,6 +6,20 @@ #include +namespace { + +template +void renderFilteredPageElements(const std::vector>& elements, GfxRenderer& renderer, + const int fontId, const int xOffset, const int yOffset, Predicate&& predicate) { + for (const auto& element : elements) { + if (predicate(*element)) { + element->render(renderer, fontId, xOffset, yOffset); + } + } +} + +} // namespace + void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { block->render(renderer, fontId, xPos + xOffset, yPos + yOffset); } @@ -93,9 +107,12 @@ std::unique_ptr PageHorizontalRule::deserialize(HalFile& fil } void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const { - for (auto& element : elements) { - element->render(renderer, fontId, xOffset, yOffset); - } + renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset, [](const PageElement&) { return true; }); +} + +void Page::renderImages(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const { + renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset, + [](const PageElement& element) { return element.getTag() == TAG_PageImage; }); } bool Page::serialize(HalFile& file) const { diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index f9ad2603..09474699 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -88,6 +88,7 @@ class Page { } void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; + void renderImages(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; bool serialize(HalFile& file) const; static std::unique_ptr deserialize(HalFile& file); diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index e57d1743..ebe758db 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -1006,7 +1006,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int advance = renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]), firstCodepoint(reorderedWordsScratch[wordIdx + 1]), reorderedStylesScratch[wordIdx]); - if (reorderedWordsScratch[wordIdx] == " " && reorderedContinuesScratch[wordIdx] && + // wordIdx > 0 mirrors the gap accounting above (which skips index 0): a leading + // no-break space must not receive justifyExtra, or the line over-stretches by one + // gap and the last word is pushed past the right margin (issue #2185). + if (wordIdx > 0 && reorderedWordsScratch[wordIdx] == " " && reorderedContinuesScratch[wordIdx] && effectiveAlignment == CssTextAlign::Justify && !isLastLine) { advance += reorderedJustifyExtra; } @@ -1048,7 +1051,8 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const // Cross-boundary kerning for continuation words int advance = renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]), firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); - if (lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] && + // wordIdx > 0: see the LTR branch — a leading no-break space is not a justifiable gap. + if (wordIdx > 0 && lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] && effectiveAlignment == CssTextAlign::Justify && !isLastLine) { advance += justifyExtra; } @@ -1086,7 +1090,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int advance = wordWidths[lastBreakAt + wordIdx]; advance += renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]), firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); - if (lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] && + // wordIdx > 0 mirrors the gap accounting above (which skips index 0): a leading + // no-break space must not receive justifyExtra, or the line over-stretches by one + // gap and the last word is pushed past the right margin (issue #2185). + if (wordIdx > 0 && lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] && effectiveAlignment == CssTextAlign::Justify && !isLastLine) { advance += justifyExtra; } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index c4b0ccd5..bd51f9d2 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -317,6 +317,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* const bool isTocAnchor = std::find(self->tocAnchors.begin(), self->tocAnchors.end(), idValue) != self->tocAnchors.end(); if (isTocAnchor || (!isNonNavigableInlineElement(name) && self->anchorData.size() < MAX_ANCHORS_PER_CHAPTER)) { + // Flush a displaced anchor before overwriting. Consecutive non-block elements + // (e.g.