From 402e887f73a9c3f2dc3ba258cde155f6580b3c6d Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Fri, 20 Feb 2026 00:23:14 +1100 Subject: [PATCH 01/15] chore: Bump version to 1.1.0 --- platformio.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/platformio.ini b/platformio.ini index f6ad828b..a7300081 100644 --- a/platformio.ini +++ b/platformio.ini @@ -2,7 +2,7 @@ default_envs = default [crosspoint] -version = 1.0.0 +version = 1.1.0 [base] platform = espressif32 @ 6.12.0 @@ -90,4 +90,3 @@ build_flags = -DCROSSPOINT_VERSION=\"${crosspoint.version}-slim\" ; serial output is disabled in slim builds to save space -UENABLE_SERIAL_LOG - \ No newline at end of file From 3e2c518b8eba6e670ece126e56a22d5ca5f0de49 Mon Sep 17 00:00:00 2001 From: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:44:46 +0200 Subject: [PATCH 02/15] fix: Crash (Load access fault) when indexing chapters containing characters unsupported by bold/italic font variants (#997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * **What is the goal of this PR?** I flashed the last revision before commit f1740dbe, and chapter indexing worked without any crashes. After applying f1740dbe, the same chapter consistently triggered a device reboot during indexing. The affected chapter contains inline equation images surrounded by styled (bold/italic) text that includes special math/symbol characters. ## Additional Context Prior to f1740dbe, both `getTextAdvanceX()` and `getSpaceWidth()` always measured text using `EpdFontFamily::REGULAR`, regardless of the actual style. Commit f1740dbe improved correctness by passing the active style so spacing is calculated using the actual bold/italic font variant. However, bold and italic variants have narrower Unicode coverage than the regular font. When a character exists in the regular font but not in the selected styled variant, `pdFont::getGlyph()` returns `nullptr`. The updated measurement functions did not check for this and immediately dereferenced the pointer: `width += font.getGlyph(cp, style)->advanceX; // nullptr->advanceX` Because `advanceX` is located at byte offset 2 within `EpdGlyph`, dereferencing a null pointer caused the CPU to attempt a load from address `0x00000002`, resulting in a RISC-V: Load access fault MCAUSE = 5 MTVAL = 2 ## Fix Added null-safety checks to both `getTextAdvanceX()` and `getSpaceWidth()`, following the same pattern used in the rendering path: If the glyph is missing in the selected style → fall back to the replacement glyph. If the replacement glyph is also unavailable → treat the character as zero-width. This preserves the improved style-correct spacing while preventing crashes. No behavioral changes occur for characters that are supported by the selected font variant. --- ### AI Usage Did you use AI tools to help write this code? _**< YES >**_ I encounter this bug while testing 1.1.0 RC. I pasted the serial log to Claude, which identify the bug and fixed it. I can confirm now the chapter in question is indexed and loaded correctly. --- lib/GfxRenderer/GfxRenderer.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 77dd810c..5126ed36 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -744,7 +744,8 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl return 0; } - return fontIt->second.getGlyph(' ', style)->advanceX; + const EpdGlyph* spaceGlyph = fontIt->second.getGlyph(' ', style); + return spaceGlyph ? spaceGlyph->advanceX : 0; } int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, const EpdFontFamily::Style style) const { @@ -758,7 +759,9 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, const EpdFo int width = 0; const auto& font = fontIt->second; while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { - width += font.getGlyph(cp, style)->advanceX; + const EpdGlyph* glyph = font.getGlyph(cp, style); + if (!glyph) glyph = font.getGlyph(REPLACEMENT_GLYPH, style); + if (glyph) width += glyph->advanceX; } return width; } From b8e743ef801f3ca3c4ca9c95315f754c9f20be21 Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Thu, 19 Feb 2026 19:51:38 +0300 Subject: [PATCH 03/15] fix: Increase PNGdec buffer size to support wide images (#995) ## Summary * Increased `PNG_MAX_BUFFERED_PIXELS` from 6402 to 16416 in `platformio.ini` to support up to 2048px wide RGBA images * adds a check to abort decoding and log an error if the required PNG scanline buffer exceeds the configured `PNG_MAX_BUFFERED_PIXELS`, preventing possible buffer overruns. * fixes https://github.com/crosspoint-reader/crosspoint-reader/issues/993 --- ### 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 >**_ --- .../converters/PngToFramebufferConverter.cpp | 38 +++++++++++++++++++ platformio.ini | 4 +- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp index f54e5e66..7925fa62 100644 --- a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -90,6 +90,32 @@ int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) { constexpr size_t PNG_DECODER_APPROX_SIZE = 44 * 1024; // ~42 KB + overhead constexpr size_t MIN_FREE_HEAP_FOR_PNG = PNG_DECODER_APPROX_SIZE + 16 * 1024; // decoder + 16 KB headroom +// PNGdec keeps TWO scanlines in its internal ucPixels buffer (current + previous) +// and each scanline includes a leading filter byte. +// Required storage is therefore approximately: 2 * (pitch + 1) + alignment slack. +// If PNG_MAX_BUFFERED_PIXELS is smaller than this requirement for a given image, +// PNGdec can overrun its internal buffer before our draw callback executes. +int bytesPerPixelFromType(int pixelType) { + switch (pixelType) { + case PNG_PIXEL_TRUECOLOR: + return 3; + case PNG_PIXEL_GRAY_ALPHA: + return 2; + case PNG_PIXEL_TRUECOLOR_ALPHA: + return 4; + case PNG_PIXEL_GRAYSCALE: + case PNG_PIXEL_INDEXED: + default: + return 1; + } +} + +int requiredPngInternalBufferBytes(int srcWidth, int pixelType) { + // +1 filter byte per scanline, *2 for current+previous lines, +32 for alignment margin. + int pitch = srcWidth * bytesPerPixelFromType(pixelType); + return ((pitch + 1) * 2) + 32; +} + // Convert entire source line to grayscale with alpha blending to white background. // For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards. // Processing the whole line at once improves cache locality and reduces per-pixel overhead. @@ -304,6 +330,18 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth, ctx.dstHeight, ctx.scale, png->getBpp()); + const int pixelType = png->getPixelType(); + const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType); + if (requiredInternal > PNG_MAX_BUFFERED_PIXELS) { + LOG_ERR("PNG", + "PNG row buffer too small: need %d bytes for width=%d type=%d, configured PNG_MAX_BUFFERED_PIXELS=%d", + requiredInternal, ctx.srcWidth, pixelType, PNG_MAX_BUFFERED_PIXELS); + LOG_ERR("PNG", "Aborting decode to avoid PNGdec internal buffer overflow"); + png->close(); + delete png; + return false; + } + if (png->getBpp() != 8) { warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath); } diff --git a/platformio.ini b/platformio.ini index a7300081..61ec7592 100644 --- a/platformio.ini +++ b/platformio.ini @@ -31,9 +31,9 @@ build_flags = -std=gnu++2a # Enable UTF-8 long file names in SdFat -DUSE_UTF8_LONG_NAMES=1 -# Increase PNG scanline buffer to support up to 800px wide images +# Increase PNG scanline buffer to support up to 2048px wide images # Default is (320*4+1)*2=2562, we need more for larger images - -DPNG_MAX_BUFFERED_PIXELS=6402 + -DPNG_MAX_BUFFERED_PIXELS=16416 build_unflags = -std=gnu++11 From 588984ec3062bf0d43d8f00920690e518acc4030 Mon Sep 17 00:00:00 2001 From: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com> Date: Fri, 20 Feb 2026 02:08:37 +0200 Subject: [PATCH 04/15] fix: Fix dangling pointer (#1010) ## Summary * **What is the goal of this PR?** Small fix for bug I found. ## Additional Context 1. `RecentBooksActivity::loop()` calls `onSelectBook(recentBooks[selectorIndex].path)` - passing a **reference** to the path 2. `onSelectBook` is `onGoToReader` which first calls `exitActivity()` 3. `exitActivity()` triggers `RecentBooksActivity::onExit()` which call `recentBooks.clear()` 4. The string reference `initialEpubPath` is now a **dangling reference** - the underlying string has been destroyed 5. When the reference is then used in `new ReaderActivity(...)`, it reads garbage memory 6. The same issue occurs in `HomeActivity` at line 200 with the same pattern The fix is to make a copy of the string in `onGoToReader` before calling `exitActivity()`, so the path data persists even after the activity clears its data structures. --- ### AI Usage Did you use AI tools to help write this code? _**< YES >**_ Claude found the bug, after I shared with it a serial log. --- src/main.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 6e145d10..59f75a1e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -216,9 +216,9 @@ void onGoHome(); void onGoToMyLibraryWithPath(const std::string& path); void onGoToRecentBooks(); void onGoToReader(const std::string& initialEpubPath) { + const std::string bookPath = initialEpubPath; // Copy before exitActivity() invalidates the reference exitActivity(); - enterNewActivity( - new ReaderActivity(renderer, mappedInputManager, initialEpubPath, onGoHome, onGoToMyLibraryWithPath)); + enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, onGoHome, onGoToMyLibraryWithPath)); } void onGoToFileTransfer() { From 87d9d1dc2aef70cf034cc0c60bc8b3aeb36c19fb Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 20 Feb 2026 01:12:05 +0100 Subject: [PATCH 05/15] perf: Improve font drawing performance (#978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * ``renderChar`` checked ``is2Bit`` on every pixel inside the inner loop, even though the value is constant for the lifetime of a single glyph * Moved the branch above both loops so each path (2-bit antialiased / 1-bit monochrome) runs without a per-pixel conditional * Eliminates redundant work in the two inner loops that render font glyphs to the frame buffer, targeting ``renderChar`` and ``drawTextRotated90CW`` in ``GfxRenderer.cpp`` ## Additional Context * Measured on device using a dedicated framebuffer benchmark (no display refresh). 100 repetitions of "The quick brown fox jumps". | Test | Before | After | Change | |-----------------|-----------------|-----------------|---------| | drawText UI12 | 1,337 µs/call | 1,024 µs/call | −23%| | drawText Bookerly14 | 2.174 µs / call | 1,847 µs/call | −15% | --- ### 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? _**PARTIALLY**_ Claude did the analysis and wrote the benchmarks --- lib/GfxRenderer/GfxRenderer.cpp | 241 +++++++++++++++----------------- lib/GfxRenderer/GfxRenderer.h | 6 +- 2 files changed, 117 insertions(+), 130 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 5126ed36..b385bc03 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -59,6 +59,111 @@ static inline void rotateCoordinates(const GfxRenderer::Orientation orientation, } } +enum class TextRotation { None, Rotated90CW }; + +// Shared glyph rendering logic for normal and rotated text. +// Coordinate mapping and cursor advance direction are selected at compile time via the template parameter. +template +static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode renderMode, + const EpdFontFamily& fontFamily, const uint32_t cp, int* cursorX, int* cursorY, + const bool pixelState, const EpdFontFamily::Style style) { + const EpdGlyph* glyph = fontFamily.getGlyph(cp, style); + if (!glyph) { + glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, style); + } + + if (!glyph) { + LOG_ERR("GFX", "No glyph for codepoint %d", cp); + return; + } + + const EpdFontData* fontData = fontFamily.getData(style); + const bool is2Bit = fontData->is2Bit; + const uint8_t width = glyph->width; + const uint8_t height = glyph->height; + const int left = glyph->left; + const int top = glyph->top; + + const uint8_t* bitmap = renderer.getGlyphBitmap(fontData, glyph); + + if (bitmap != nullptr) { + // For Normal: outer loop advances screenY, inner loop advances screenX + // For Rotated: outer loop advances screenX, inner loop advances screenY (in reverse) + int outerBase, innerBase; + if constexpr (rotation == TextRotation::Rotated90CW) { + outerBase = *cursorX + fontData->ascender - top; // screenX = outerBase + glyphY + innerBase = *cursorY - left; // screenY = innerBase - glyphX + } else { + outerBase = *cursorY - top; // screenY = outerBase + glyphY + innerBase = *cursorX + left; // screenX = innerBase + glyphX + } + + if (is2Bit) { + int pixelPosition = 0; + for (int glyphY = 0; glyphY < height; glyphY++) { + const int outerCoord = outerBase + glyphY; + for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++) { + int screenX, screenY; + if constexpr (rotation == TextRotation::Rotated90CW) { + screenX = outerCoord; + screenY = innerBase - glyphX; + } else { + screenX = innerBase + glyphX; + screenY = outerCoord; + } + + const uint8_t byte = bitmap[pixelPosition >> 2]; + const uint8_t bit_index = (3 - (pixelPosition & 3)) * 2; + // the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> dark gray, 3 -> black + // we swap this to better match the way images and screen think about colors: + // 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white + const uint8_t bmpVal = 3 - ((byte >> bit_index) & 0x3); + + if (renderMode == GfxRenderer::BW && bmpVal < 3) { + // Black (also paints over the grays in BW mode) + renderer.drawPixel(screenX, screenY, pixelState); + } else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { + // Light gray (also mark the MSB if it's going to be a dark gray too) + // We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update + renderer.drawPixel(screenX, screenY, false); + } else if (renderMode == GfxRenderer::GRAYSCALE_LSB && bmpVal == 1) { + // Dark gray + renderer.drawPixel(screenX, screenY, false); + } + } + } + } else { + int pixelPosition = 0; + for (int glyphY = 0; glyphY < height; glyphY++) { + const int outerCoord = outerBase + glyphY; + for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++) { + int screenX, screenY; + if constexpr (rotation == TextRotation::Rotated90CW) { + screenX = outerCoord; + screenY = innerBase - glyphX; + } else { + screenX = innerBase + glyphX; + screenY = outerCoord; + } + + const uint8_t byte = bitmap[pixelPosition >> 3]; + const uint8_t bit_index = 7 - (pixelPosition & 7); + + if ((byte >> bit_index) & 1) { + renderer.drawPixel(screenX, screenY, pixelState); + } + } + } + } + } + + if constexpr (rotation == TextRotation::Rotated90CW) { + *cursorY -= glyph->advanceX; + } else { + *cursorX += glyph->advanceX; + } +} + // 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 { @@ -105,7 +210,7 @@ void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* te void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black, const EpdFontFamily::Style style) const { - const int yPos = y + getFontAscenderSize(fontId); + int yPos = y + getFontAscenderSize(fontId); int xpos = x; // cannot draw a NULL / empty string @@ -810,68 +915,12 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y const auto& font = fontIt->second; - // For 90° clockwise rotation: - // Original (glyphX, glyphY) -> Rotated (glyphY, -glyphX) - // Text reads from bottom to top - - int yPos = y; // Current Y position (decreases as we draw characters) + int xPos = x; + int yPos = y; uint32_t cp; while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { - const EpdGlyph* glyph = font.getGlyph(cp, style); - if (!glyph) { - glyph = font.getGlyph(REPLACEMENT_GLYPH, style); - } - if (!glyph) { - continue; - } - - const EpdFontData* fontData = font.getData(style); - const int is2Bit = fontData->is2Bit; - const uint8_t width = glyph->width; - const uint8_t height = glyph->height; - const int left = glyph->left; - const int top = glyph->top; - - const uint8_t* bitmap = getGlyphBitmap(fontData, glyph); - - if (bitmap != nullptr) { - for (int glyphY = 0; glyphY < height; glyphY++) { - for (int glyphX = 0; glyphX < width; glyphX++) { - const int pixelPosition = glyphY * width + glyphX; - - // 90° clockwise rotation transformation: - // screenX = x + (ascender - top + glyphY) - // screenY = yPos - (left + glyphX) - const int screenX = x + (fontData->ascender - top + glyphY); - const int screenY = yPos - left - glyphX; - - if (is2Bit) { - const uint8_t byte = bitmap[pixelPosition / 4]; - const uint8_t bit_index = (3 - pixelPosition % 4) * 2; - const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3; - - if (renderMode == BW && bmpVal < 3) { - drawPixel(screenX, screenY, black); - } else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { - drawPixel(screenX, screenY, false); - } else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) { - drawPixel(screenX, screenY, false); - } - } else { - const uint8_t byte = bitmap[pixelPosition / 8]; - const uint8_t bit_index = 7 - (pixelPosition % 8); - - if ((byte >> bit_index) & 1) { - drawPixel(screenX, screenY, black); - } - } - } - } - } - - // Move to next character position (going up, so decrease Y) - yPos -= glyph->advanceX; + renderCharImpl(*this, renderMode, font, cp, &xPos, &yPos, black, style); } } @@ -936,7 +985,7 @@ bool GfxRenderer::storeBwBuffer() { * Uses chunked restoration to match chunked storage. */ void GfxRenderer::restoreBwBuffer() { - // Check if any all chunks are allocated + // Check if all chunks are allocated bool missingChunks = false; for (const auto& bwBufferChunk : bwBufferChunks) { if (!bwBufferChunk) { @@ -951,13 +1000,6 @@ void GfxRenderer::restoreBwBuffer() { } for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { - // Check if chunk is missing - if (!bwBufferChunks[i]) { - LOG_ERR("GFX", "!! BW buffer chunks not stored - this is likely a bug"); - freeBwBufferChunks(); - return; - } - const size_t offset = i * BW_BUFFER_CHUNK_SIZE; memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE); } @@ -978,66 +1020,9 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const { } } -void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y, - const bool pixelState, const EpdFontFamily::Style style) const { - const EpdGlyph* glyph = fontFamily.getGlyph(cp, style); - if (!glyph) { - glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, style); - } - - // no glyph? - if (!glyph) { - LOG_ERR("GFX", "No glyph for codepoint %d", cp); - return; - } - - const EpdFontData* fontData = fontFamily.getData(style); - const int is2Bit = fontData->is2Bit; - const uint8_t width = glyph->width; - const uint8_t height = glyph->height; - const int left = glyph->left; - - const uint8_t* bitmap = getGlyphBitmap(fontData, glyph); - - if (bitmap != nullptr) { - for (int glyphY = 0; glyphY < height; glyphY++) { - const int screenY = *y - glyph->top + glyphY; - for (int glyphX = 0; glyphX < width; glyphX++) { - const int pixelPosition = glyphY * width + glyphX; - const int screenX = *x + left + glyphX; - - if (is2Bit) { - const uint8_t byte = bitmap[pixelPosition / 4]; - const uint8_t bit_index = (3 - pixelPosition % 4) * 2; - // the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> dark gray, 3 -> black - // we swap this to better match the way images and screen think about colors: - // 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white - const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3; - - if (renderMode == BW && bmpVal < 3) { - // Black (also paints over the grays in BW mode) - drawPixel(screenX, screenY, pixelState); - } else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { - // Light gray (also mark the MSB if it's going to be a dark gray too) - // We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update - drawPixel(screenX, screenY, false); - } else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) { - // Dark gray - drawPixel(screenX, screenY, false); - } - } else { - const uint8_t byte = bitmap[pixelPosition / 8]; - const uint8_t bit_index = 7 - (pixelPosition % 8); - - if ((byte >> bit_index) & 1) { - drawPixel(screenX, screenY, pixelState); - } - } - } - } - } - - *x += glyph->advanceX; +void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState, + EpdFontFamily::Style style) const { + renderCharImpl(*this, renderMode, fontFamily, cp, x, y, pixelState, style); } void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 68364499..e2d05d03 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -38,10 +38,9 @@ class GfxRenderer { uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; std::map fontMap; FontDecompressor* fontDecompressor = nullptr; - void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState, + void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState, EpdFontFamily::Style style) const; void freeBwBufferChunks(); - const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const; template void drawPixelDither(int x, int y) const; template @@ -132,6 +131,9 @@ class GfxRenderer { void restoreBwBuffer(); // Restore and free the stored buffer void cleanupGrayscaleWithFrameBuffer() const; + // Font helpers + const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const; + // Low level functions uint8_t* getFrameBuffer() const; static size_t getBufferSize(); From 8db3542e90f04f36aef8479b8e507df70f2eba1c Mon Sep 17 00:00:00 2001 From: Lev Roland-Kalb <114942703+Levrk@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:56:19 -0500 Subject: [PATCH 06/15] fix: re-implementing Cover Outlines for the new Lyra Themes (#1017) ## Summary * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) Improve legibility of Cover Icons on the home page and elsewhere. Fixes #898 Re implements the changes made in #907 that were overwritten by the new lyra themes * **What changes are included?** Cover outline is now shown even when cover is found to prevent issues with low contrast covers blending into the background. Photo is attached below: Untitled (4) ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). Re implements the changes made in #907 that were overwritten by the new lyra themes --- ### 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**_ --- src/components/themes/lyra/Lyra3CoversTheme.cpp | 5 +++-- src/components/themes/lyra/LyraTheme.cpp | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/themes/lyra/Lyra3CoversTheme.cpp b/src/components/themes/lyra/Lyra3CoversTheme.cpp index 54c745b6..55d5dda5 100644 --- a/src/components/themes/lyra/Lyra3CoversTheme.cpp +++ b/src/components/themes/lyra/Lyra3CoversTheme.cpp @@ -64,11 +64,12 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con file.close(); } } + // Draw either way + renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, tileWidth - 2 * hPaddingInSelection, + Lyra3CoversMetrics::values.homeCoverHeight, true); if (!hasCover) { // Render empty cover - renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, - tileWidth - 2 * hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight, true); renderer.fillRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection + (Lyra3CoversMetrics::values.homeCoverHeight / 3), tileWidth - 2 * hPaddingInSelection, 2 * Lyra3CoversMetrics::values.homeCoverHeight / 3, diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 38a8aff2..2cde6cc6 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -448,10 +448,12 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: } } + // Draw either way + renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth, + LyraMetrics::values.homeCoverHeight, true); + if (!hasCover) { // Render empty cover - renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth, - LyraMetrics::values.homeCoverHeight, true); renderer.fillRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection + (LyraMetrics::values.homeCoverHeight / 3), coverWidth, 2 * LyraMetrics::values.homeCoverHeight / 3, true); From 2cc497cdcabf25c84a8c9089cdd22b9c3cf24704 Mon Sep 17 00:00:00 2001 From: martin brook Date: Fri, 20 Feb 2026 01:05:15 +0000 Subject: [PATCH 07/15] fix: use double FAST_REFRESH to prevent washout on large grey images (#957) ## Summary Fixes https://github.com/crosspoint-reader/crosspoint-reader/issues/1011 Use double FAST_REFRESH for image pages to prevent grayscale washout, HALF_REFRESH sets e-ink particles too firmly for the grayscale LUT to adjust, causing washed-out images (especially large, light-gray ones). Replace HALF_REFRESH with @pablohc's double FAST_REFRESH technique: blank only the image bounding box area, then re-render with images. This clears ghosting while keeping particles loosely set for grayscale. ## Additional Context --- ### 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? _**< PARTIALLY >**_ --- lib/Epub/Epub/Page.h | 29 ++++++++++++++++++++ src/activities/reader/EpubReaderActivity.cpp | 26 +++++++++++++++--- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 88f98dc7..af792f6f 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -49,6 +49,7 @@ class PageImage final : public PageElement { bool serialize(FsFile& file) override; PageElementTag getTag() const override { return TAG_PageImage; } static std::unique_ptr deserialize(FsFile& file); + const ImageBlock& getImageBlock() const { return *imageBlock; } }; class Page { @@ -64,4 +65,32 @@ class Page { return std::any_of(elements.begin(), elements.end(), [](const std::shared_ptr& el) { return el->getTag() == TAG_PageImage; }); } + + // Get bounding box of all images on the page (union of image rects) + // Returns false if no images. Coordinates are relative to page origin. + bool getImageBoundingBox(int16_t& outX, int16_t& outY, int16_t& outW, int16_t& outH) const { + bool found = false; + int16_t minX = INT16_MAX, minY = INT16_MAX, maxX = INT16_MIN, maxY = INT16_MIN; + for (const auto& el : elements) { + if (el->getTag() == TAG_PageImage) { + const auto& img = static_cast(*el); + int16_t x = img.xPos; + int16_t y = img.yPos; + int16_t right = x + img.getImageBlock().getWidth(); + int16_t bottom = y + img.getImageBlock().getHeight(); + minX = std::min(minX, x); + minY = std::min(minY, y); + maxX = std::max(maxX, right); + maxY = std::max(maxY, bottom); + found = true; + } + } + if (found) { + outX = minX; + outY = minY; + outW = maxX - minX; + outH = maxY - minY; + } + return found; + } }; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 029847c1..7f66d3bf 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -638,13 +638,31 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC void EpubReaderActivity::renderContents(std::unique_ptr page, const int orientedMarginTop, const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) { - // Force full refresh for pages with images when anti-aliasing is on, - // as grayscale tones require half refresh to display correctly - bool forceFullRefresh = page->hasImages() && SETTINGS.textAntiAliasing; + // Force special handling for pages with images when anti-aliasing is on + bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing; page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); - if (forceFullRefresh || pagesUntilFullRefresh <= 1) { + if (imagePageWithAA) { + // Double FAST_REFRESH with selective image blanking (pablohc's technique): + // HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust. + // Instead, blank only the image area and do two fast refreshes. + // Step 1: Display page with image area blanked (text appears, image area white) + // Step 2: Re-render with images and display again (images appear clean) + int16_t imgX, imgY, imgW, imgH; + if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) { + renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, false); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + + // Re-render page content to restore images into the blanked area + page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + } else { + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + } + // Double FAST_REFRESH handles ghosting for image pages; don't count toward full refresh cadence + } else if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { From 7c4f69680ccca1ce6c88180a31047e6d8275c3f3 Mon Sep 17 00:00:00 2001 From: DestinySpeaker Date: Thu, 19 Feb 2026 21:34:28 -0800 Subject: [PATCH 08/15] fix: Fixed Image Sizing When No Width is Set (#1002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) When no width is set for an image, the image currently automatically sets to the width of the page. However, with this fix, the parser will use the height and aspect ratio of the image to properly set a height for it. See below example: Before: ![IMG_8862](https://github.com/user-attachments/assets/64b3b92f-1165-45ca-8bdb-8e69613d9725) After: ![IMG_8863](https://github.com/user-attachments/assets/5cb99b12-d150-4b37-ae4c-c8a20eb9f3a0) * **What changes are included?✱ Changes to the CSS parser ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- ### 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, Cursor --- lib/Epub/Epub.cpp | 23 +++-- lib/Epub/Epub/Section.cpp | 1 + lib/Epub/Epub/css/CssParser.cpp | 58 ++++++++--- lib/Epub/Epub/css/CssParser.h | 5 + lib/Epub/Epub/css/CssStyle.h | 23 ++++- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 95 +++++++++++++++++-- 6 files changed, 172 insertions(+), 33 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index dd949e7b..da23dfe6 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -339,14 +339,22 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { // Try to load existing cache first if (bookMetadataCache->load()) { - if (!skipLoadingCss && !cssParser->hasCache()) { - LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files"); - // to get CSS file list - if (!parseContentOpf(bookMetadataCache->coreMetadata)) { - LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files"); - // continue anyway - book will work without CSS and we'll still load any inline style CSS + if (!skipLoadingCss) { + // Rebuild CSS cache when missing or when cache version changed (loadFromCache removes stale file) + bool needCssRebuild = !cssParser->hasCache(); + if (cssParser->hasCache() && !cssParser->loadFromCache()) { + needCssRebuild = true; + } + if (needCssRebuild) { + LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files"); + if (!parseContentOpf(bookMetadataCache->coreMetadata)) { + LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files"); + // continue anyway - book will work without CSS and we'll still load any inline style CSS + } + parseCssFiles(); + // Invalidate section caches so they are rebuilt with the new CSS + Storage.removeDir((cachePath + "/sections").c_str()); } - parseCssFiles(); } LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str()); return true; @@ -447,6 +455,7 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { if (!skipLoadingCss) { // Parse CSS files after cache reload parseCssFiles(); + Storage.removeDir((cachePath + "/sections").c_str()); } LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str()); diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index cc93de72..d2ef2779 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -4,6 +4,7 @@ #include #include +#include "Epub/css/CssParser.h" #include "Page.h" #include "hyphenation/Hyphenator.h" #include "parsers/ChapterHtmlSlimParser.h" diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index 590b2215..8bdd0f1a 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -189,10 +189,18 @@ CssTextDecoration CssParser::interpretDecoration(const std::string& val) { } CssLength CssParser::interpretLength(const std::string& val) { - const std::string v = normalized(val); - if (v.empty()) return CssLength{}; + CssLength result; + tryInterpretLength(val, result); + return result; +} + +bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) { + const std::string v = normalized(val); + if (v.empty()) { + out = CssLength{}; + return false; + } - // Find where the number ends size_t unitStart = v.size(); for (size_t i = 0; i < v.size(); ++i) { const char c = v[i]; @@ -205,12 +213,13 @@ CssLength CssParser::interpretLength(const std::string& val) { const std::string numPart = v.substr(0, unitStart); const std::string unitPart = v.substr(unitStart); - // Parse numeric value char* endPtr = nullptr; const float numericValue = std::strtof(numPart.c_str(), &endPtr); - if (endPtr == numPart.c_str()) return CssLength{}; // No number parsed + if (endPtr == numPart.c_str()) { + out = CssLength{}; + return false; // No number parsed (e.g. auto, inherit, initial) + } - // Determine unit type (preserve for deferred resolution) auto unit = CssUnit::Pixels; if (unitPart == "em") { unit = CssUnit::Em; @@ -221,10 +230,11 @@ CssLength CssParser::interpretLength(const std::string& val) { } else if (unitPart == "%") { unit = CssUnit::Percent; } - // px and unitless default to Pixels - return CssLength{numericValue, unit}; + out = CssLength{numericValue, unit}; + return true; } + // Declaration parsing void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf, @@ -295,6 +305,18 @@ void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& sty style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingLeft = 1; } + } else if (propNameBuf == "height") { + CssLength len; + if (tryInterpretLength(propValueBuf, len)) { + style.imageHeight = len; + style.defined.imageHeight = 1; + } + } else if (propNameBuf == "width") { + CssLength len; + if (tryInterpretLength(propValueBuf, len)) { + style.imageWidth = len; + style.defined.imageWidth = 1; + } } } @@ -561,8 +583,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 = 2; +// Cache file name (version is CssParser::CSS_CACHE_VERSION) constexpr char rulesCache[] = "/css_rules.cache"; bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); } @@ -578,7 +599,7 @@ bool CssParser::saveToCache() const { } // Write version - file.write(CSS_CACHE_VERSION); + file.write(CssParser::CSS_CACHE_VERSION); // Write rule count const auto ruleCount = static_cast(rulesBySelector_.size()); @@ -613,6 +634,8 @@ bool CssParser::saveToCache() const { writeLength(style.paddingBottom); writeLength(style.paddingLeft); writeLength(style.paddingRight); + writeLength(style.imageHeight); + writeLength(style.imageWidth); // Write defined flags as uint16_t uint16_t definedBits = 0; @@ -629,6 +652,8 @@ bool CssParser::saveToCache() const { if (style.defined.paddingBottom) definedBits |= 1 << 10; if (style.defined.paddingLeft) definedBits |= 1 << 11; if (style.defined.paddingRight) definedBits |= 1 << 12; + if (style.defined.imageHeight) definedBits |= 1 << 13; + if (style.defined.imageWidth) definedBits |= 1 << 14; file.write(reinterpret_cast(&definedBits), sizeof(definedBits)); } @@ -652,9 +677,11 @@ bool CssParser::loadFromCache() { // Read and verify version uint8_t version = 0; - if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) { - LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION); + if (file.read(&version, 1) != 1 || version != CssParser::CSS_CACHE_VERSION) { + LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u), removing stale cache for rebuild", version, + CssParser::CSS_CACHE_VERSION); file.close(); + Storage.remove((cachePath + rulesCache).c_str()); return false; } @@ -730,7 +757,8 @@ bool CssParser::loadFromCache() { if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) || !readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) || - !readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight)) { + !readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight) || + !readLength(style.imageHeight) || !readLength(style.imageWidth)) { rulesBySelector_.clear(); file.close(); return false; @@ -756,6 +784,8 @@ bool CssParser::loadFromCache() { style.defined.paddingBottom = (definedBits & 1 << 10) != 0; style.defined.paddingLeft = (definedBits & 1 << 11) != 0; style.defined.paddingRight = (definedBits & 1 << 12) != 0; + style.defined.imageHeight = (definedBits & 1 << 13) != 0; + style.defined.imageWidth = (definedBits & 1 << 14) != 0; rulesBySelector_[selector] = style; } diff --git a/lib/Epub/Epub/css/CssParser.h b/lib/Epub/Epub/css/CssParser.h index 60f70d23..0c6ce61d 100644 --- a/lib/Epub/Epub/css/CssParser.h +++ b/lib/Epub/Epub/css/CssParser.h @@ -30,6 +30,9 @@ */ class CssParser { public: + // Bump when CSS cache format or rules change; section caches are invalidated when this changes + static constexpr uint8_t CSS_CACHE_VERSION = 3; + explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {} ~CssParser() = default; @@ -113,6 +116,8 @@ class CssParser { static CssFontWeight interpretFontWeight(const std::string& val); static CssTextDecoration interpretDecoration(const std::string& val); static CssLength interpretLength(const std::string& val); + /** Returns true only when a numeric length was parsed (e.g. 2em, 50%). False for auto/inherit/initial. */ + static bool tryInterpretLength(const std::string& val, CssLength& out); // String utilities static std::string normalized(const std::string& s); diff --git a/lib/Epub/Epub/css/CssStyle.h b/lib/Epub/Epub/css/CssStyle.h index b90fa7ab..bac858e0 100644 --- a/lib/Epub/Epub/css/CssStyle.h +++ b/lib/Epub/Epub/css/CssStyle.h @@ -69,6 +69,8 @@ struct CssPropertyFlags { uint16_t paddingBottom : 1; uint16_t paddingLeft : 1; uint16_t paddingRight : 1; + uint16_t imageHeight : 1; + uint16_t imageWidth : 1; CssPropertyFlags() : textAlign(0), @@ -83,17 +85,21 @@ struct CssPropertyFlags { paddingTop(0), paddingBottom(0), paddingLeft(0), - paddingRight(0) {} + paddingRight(0), + imageHeight(0), + imageWidth(0) {} [[nodiscard]] bool anySet() const { return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom || - marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight; + marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || imageHeight || + imageWidth; } void clearAll() { textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0; marginTop = marginBottom = marginLeft = marginRight = 0; paddingTop = paddingBottom = paddingLeft = paddingRight = 0; + imageHeight = imageWidth = 0; } }; @@ -115,6 +121,8 @@ struct CssStyle { CssLength paddingBottom; // Padding after CssLength paddingLeft; // Padding left CssLength paddingRight; // Padding right + CssLength imageHeight; // Height for img (e.g. 2em) – width derived from aspect ratio when only height set + CssLength imageWidth; // Width for img when both or only width set CssPropertyFlags defined; // Tracks which properties were explicitly set @@ -173,6 +181,14 @@ struct CssStyle { paddingRight = base.paddingRight; defined.paddingRight = 1; } + if (base.hasImageHeight()) { + imageHeight = base.imageHeight; + defined.imageHeight = 1; + } + if (base.hasImageWidth()) { + imageWidth = base.imageWidth; + defined.imageWidth = 1; + } } [[nodiscard]] bool hasTextAlign() const { return defined.textAlign; } @@ -188,6 +204,8 @@ struct CssStyle { [[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; } [[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; } [[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; } + [[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; } + [[nodiscard]] bool hasImageWidth() const { return defined.imageWidth; } void reset() { textAlign = CssTextAlign::Left; @@ -197,6 +215,7 @@ struct CssStyle { textIndent = CssLength{}; marginTop = marginBottom = marginLeft = marginRight = CssLength{}; paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{}; + imageHeight = imageWidth = CssLength{}; defined.clearAll(); } }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 7bada8f2..4fbba8af 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -257,18 +257,93 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (decoder && decoder->getDimensions(cachedImagePath, dims)) { LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height); - // Scale to fit viewport while maintaining aspect ratio - int maxWidth = self->viewportWidth; - int maxHeight = self->viewportHeight; - float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f; - float scaleY = (dims.height > maxHeight) ? (float)maxHeight / dims.height : 1.0f; - float scale = (scaleX < scaleY) ? scaleX : scaleY; - if (scale > 1.0f) scale = 1.0f; + int displayWidth = 0; + int displayHeight = 0; + const float emSize = + static_cast(self->renderer.getLineHeight(self->fontId)) * self->lineCompression; + CssStyle imgStyle = self->cssParser ? self->cssParser->resolveStyle("img", classAttr) : CssStyle{}; + // Merge inline style (e.g. style="height: 2em") so it overrides stylesheet rules + if (!styleAttr.empty()) { + imgStyle.applyOver(CssParser::parseInlineStyle(styleAttr)); + } + const bool hasCssHeight = imgStyle.hasImageHeight(); + const bool hasCssWidth = imgStyle.hasImageWidth(); - int displayWidth = (int)(dims.width * scale); - int displayHeight = (int)(dims.height * scale); + if (hasCssHeight && hasCssWidth && dims.width > 0 && dims.height > 0) { + // Both CSS height and width set: resolve both, then clamp to viewport preserving requested ratio + displayHeight = static_cast( + imgStyle.imageHeight.toPixels(emSize, static_cast(self->viewportHeight)) + 0.5f); + displayWidth = static_cast( + imgStyle.imageWidth.toPixels(emSize, static_cast(self->viewportWidth)) + 0.5f); + if (displayHeight < 1) displayHeight = 1; + if (displayWidth < 1) displayWidth = 1; + if (displayWidth > self->viewportWidth || displayHeight > self->viewportHeight) { + float scaleX = (displayWidth > self->viewportWidth) + ? static_cast(self->viewportWidth) / displayWidth + : 1.0f; + float scaleY = (displayHeight > self->viewportHeight) + ? static_cast(self->viewportHeight) / displayHeight + : 1.0f; + float scale = (scaleX < scaleY) ? scaleX : scaleY; + displayWidth = static_cast(displayWidth * scale + 0.5f); + displayHeight = static_cast(displayHeight * scale + 0.5f); + if (displayWidth < 1) displayWidth = 1; + if (displayHeight < 1) displayHeight = 1; + } + LOG_DBG("EHP", "Display size from CSS height+width: %dx%d", displayWidth, displayHeight); + } else if (hasCssHeight && !hasCssWidth && dims.width > 0 && dims.height > 0) { + // Use CSS height (resolve % against viewport height) and derive width from aspect ratio + displayHeight = static_cast( + imgStyle.imageHeight.toPixels(emSize, static_cast(self->viewportHeight)) + 0.5f); + if (displayHeight < 1) displayHeight = 1; + displayWidth = + static_cast(displayHeight * (static_cast(dims.width) / dims.height) + 0.5f); + if (displayHeight > self->viewportHeight) { + displayHeight = self->viewportHeight; + // Rescale width to preserve aspect ratio when height is clamped + displayWidth = + static_cast(displayHeight * (static_cast(dims.width) / dims.height) + 0.5f); + if (displayWidth < 1) displayWidth = 1; + } + if (displayWidth > self->viewportWidth) { + displayWidth = self->viewportWidth; + // Rescale height to preserve aspect ratio when width is clamped + displayHeight = + static_cast(displayWidth * (static_cast(dims.height) / dims.width) + 0.5f); + if (displayHeight < 1) displayHeight = 1; + } + if (displayWidth < 1) displayWidth = 1; + LOG_DBG("EHP", "Display size from CSS height: %dx%d", displayWidth, displayHeight); + } else if (hasCssWidth && !hasCssHeight && dims.width > 0 && dims.height > 0) { + // Use CSS width (resolve % against viewport width) and derive height from aspect ratio + displayWidth = static_cast( + imgStyle.imageWidth.toPixels(emSize, static_cast(self->viewportWidth)) + 0.5f); + if (displayWidth > self->viewportWidth) displayWidth = self->viewportWidth; + if (displayWidth < 1) displayWidth = 1; + displayHeight = + static_cast(displayWidth * (static_cast(dims.height) / dims.width) + 0.5f); + if (displayHeight > self->viewportHeight) { + displayHeight = self->viewportHeight; + // Rescale width to preserve aspect ratio when height is clamped + displayWidth = + static_cast(displayHeight * (static_cast(dims.width) / dims.height) + 0.5f); + if (displayWidth < 1) displayWidth = 1; + } + if (displayHeight < 1) displayHeight = 1; + LOG_DBG("EHP", "Display size from CSS width: %dx%d", displayWidth, displayHeight); + } else { + // Scale to fit viewport while maintaining aspect ratio + int maxWidth = self->viewportWidth; + int maxHeight = self->viewportHeight; + float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f; + float scaleY = (dims.height > maxHeight) ? (float)maxHeight / dims.height : 1.0f; + float scale = (scaleX < scaleY) ? scaleX : scaleY; + if (scale > 1.0f) scale = 1.0f; - LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, scale); + displayWidth = (int)(dims.width * scale); + displayHeight = (int)(dims.height * scale); + LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, scale); + } // Create page for image - only break if image won't fit remaining space if (self->currentPage && !self->currentPage->elements.empty() && From 57267f5372034a7ff91f09e8ab05c3501fc9e368 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Fri, 20 Feb 2026 16:35:49 +1100 Subject: [PATCH 09/15] fix: Strip unused CSS rules (#1014) ## Summary * In a sample book I loaded, it had 900+ CSS rules, and took up 180kB of memory loading the cache in * Looking at the rules, a lot of them were completely useless as we only ever apply look for 3 kinds of CSS rules: * `tag` * `tag.class1` * `.class1` * Stripping out CSS rules with descendant, nested, attribute matching, sibling matching, pseudo element selection (as we never actually read these from the cache) reduced the rule count down to 200 ## Additional Context * I've left in `.class1.class2` rules for now, even though we technically can never match on them as they're likely to be addressed soonest out of the all the CSS expansion * Because we don't ever delete the CSS cache, users will need to delete the book cache through the menu in order to get this new logic * A new PR should be done up to address this - tracked here https://github.com/crosspoint-reader/crosspoint-reader/issues/1015 --- ### 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/Epub/Epub/css/CssParser.cpp | 54 ++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index 8bdd0f1a..9415f155 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -74,7 +74,7 @@ std::string CssParser::normalized(const std::string& s) { } // Remove trailing space - if (!result.empty() && result.back() == ' ') { + while (!result.empty() && (result.back() == ' ' || result.back() == '\n')) { result.pop_back(); } return result; @@ -365,6 +365,56 @@ void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, cons std::string key = normalized(sel); if (key.empty()) continue; + // TODO: Consider adding support for sibling css selectors in the future + // Ensure no + in selector as we don't support adjacent CSS selectors for now + if (key.find('+') != std::string_view::npos) { + continue; + } + + // TODO: Consider adding support for direct nested css selectors in the future + // Ensure no > in selector as we don't support nested CSS selectors for now + if (key.find('>') != std::string_view::npos) { + continue; + } + + // TODO: Consider adding support for attribute css selectors in the future + // Ensure no [ in selector as we don't support attribute CSS selectors for now + if (key.find('[') != std::string_view::npos) { + continue; + } + + // TODO: Consider adding support for pseudo selectors in the future + // Ensure no : in selector as we don't support pseudo CSS selectors for now + if (key.find(':') != std::string_view::npos) { + continue; + } + + // TODO: Consider adding support for ID css selectors in the future + // Ensure no # in selector as we don't support ID CSS selectors for now + if (key.find('#') != std::string_view::npos) { + continue; + } + + // TODO: Consider adding support for general sibling combinator selectors in the future + // Ensure no ~ in selector as we don't support general sibling combinator CSS selectors for now + if (key.find('~') != std::string_view::npos) { + continue; + } + + // TODO: Consider adding support for wildcard css selectors in the future + // Ensure no * in selector as we don't support wildcard CSS selectors for now + if (key.find('*') != std::string_view::npos) { + continue; + } + + // TODO: Add support for more complex selectors in the future + // At the moment, we only ever check for `tag`, `tag.class1` or `.class1` + // If the selector has whitespace in it, then it's either a CSS selector for a descendant element (e.g. `tag1 tag2`) + // or some other slightly more advanced CSS selector which we don't support yet + if (key.find(' ') != std::string_view::npos) { + continue; + } + // Skip if this would exceed the rule limit if (rulesBySelector_.size() >= MAX_RULES) { LOG_DBG("CSS", "Reached max rules limit, stopping selector processing"); @@ -550,6 +600,7 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& result.applyOver(tagIt->second); } + // TODO: Support combinations of classes (e.g. style on .class1.class2) // 2. Apply class styles (medium priority) if (!classAttr.empty()) { const auto classes = splitWhitespace(classAttr); @@ -563,6 +614,7 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& } } + // TODO: Support combinations of classes (e.g. style on p.class1.class2) // 3. Apply element.class styles (higher priority) for (const auto& cls : classes) { std::string combinedKey = tag + "." + normalized(cls); From bc1ba7277f2e6cf30af4d78f9d3f6d0a56db2c26 Mon Sep 17 00:00:00 2001 From: pablohc Date: Fri, 20 Feb 2026 06:35:58 +0100 Subject: [PATCH 10/15] fix: continue reading card classic theme (#990) ## Summary * **What is the goal of this PR?** * **What changes are included?** - Adapt card width to cover image aspect ratio in Classic theme - Increase homeTopPadding from 20px to 40px to avoid overlap with battery icon - Card width now calculated from BMP dimensions instead of fixed 240px - Maximum card width limited to 90% of screen width - Falls back to original behavior (half screen width) when no cover available ## Additional Context * Solve conflicts in PR #683 Before: image PR: ![Screenshot_2026-02-19-14-22-36-68_99c04817c0de5652397fc8b56c3b3817](https://github.com/user-attachments/assets/81505728-d42e-41bd-bd77-44848e05b1eb) --- ### 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 >**_ --- src/components/themes/BaseTheme.cpp | 79 +++++++++++++++++++---------- src/components/themes/BaseTheme.h | 2 +- 2 files changed, 53 insertions(+), 28 deletions(-) diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 950b79f3..15f613d3 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -341,14 +341,57 @@ void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const s void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer) const { - // --- Top "book" card for the current title (selectorIndex == 0) --- - const int bookWidth = rect.width / 2; - const int bookHeight = rect.height; - const int bookX = (rect.width - bookWidth) / 2; - const int bookY = rect.y; const bool hasContinueReading = !recentBooks.empty(); const bool bookSelected = hasContinueReading && selectorIndex == 0; + // --- Top "book" card for the current title (selectorIndex == 0) --- + // When there's no cover image, use fixed size (half screen) + // When there's cover image, adapt width to image aspect ratio, keep height fixed at 400px + const int baseHeight = rect.height; // Fixed height (400px) + + int bookWidth, bookX; + bool hasCoverImage = false; + + if (hasContinueReading && !recentBooks[0].coverBmpPath.empty()) { + // Try to get actual image dimensions from BMP header + const std::string coverBmpPath = + UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight); + + FsFile file; + if (Storage.openFileForRead("HOME", coverBmpPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + hasCoverImage = true; + const int imgWidth = bitmap.getWidth(); + const int imgHeight = bitmap.getHeight(); + + // Calculate width based on aspect ratio, maintaining baseHeight + if (imgWidth > 0 && imgHeight > 0) { + const float aspectRatio = static_cast(imgWidth) / static_cast(imgHeight); + bookWidth = static_cast(baseHeight * aspectRatio); + + // Ensure width doesn't exceed reasonable limits (max 90% of screen width) + const int maxWidth = static_cast(rect.width * 0.9f); + if (bookWidth > maxWidth) { + bookWidth = maxWidth; + } + } else { + bookWidth = rect.width / 2; // Fallback + } + } + file.close(); + } + } + + if (!hasCoverImage) { + // No cover: use half screen size + bookWidth = rect.width / 2; + } + + bookX = rect.x + (rect.width - bookWidth) / 2; + const int bookY = rect.y; + const int bookHeight = baseHeight; + // Bookmark dimensions (used in multiple places) const int bookmarkWidth = bookWidth / 8; const int bookmarkHeight = bookHeight / 5; @@ -370,27 +413,9 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("THEME", "Rendering bmp"); - // Calculate position to center image within the book card - int coverX, coverY; - if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) { - const float imgRatio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); - const float boxRatio = static_cast(bookWidth) / static_cast(bookHeight); - - if (imgRatio > boxRatio) { - coverX = bookX; - coverY = bookY + (bookHeight - static_cast(bookWidth / imgRatio)) / 2; - } else { - coverX = bookX + (bookWidth - static_cast(bookHeight * imgRatio)) / 2; - coverY = bookY; - } - } else { - coverX = bookX + (bookWidth - bitmap.getWidth()) / 2; - coverY = bookY + (bookHeight - bitmap.getHeight()) / 2; - } - - // Draw the cover image centered within the book card - renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight); + // Draw the cover image (bookWidth and bookHeight already match image aspect ratio) + renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, bookHeight); // Draw border around the card renderer.drawRect(bookX, bookY, bookWidth, bookHeight); @@ -573,7 +598,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: const int boxWidth = maxTextWidth + boxPadding * 2; const int boxHeight = totalTextHeight + boxPadding * 2; - const int boxX = (rect.width - boxWidth) / 2; + const int boxX = rect.x + (rect.width - boxWidth) / 2; const int boxY = titleYStart - boxPadding; // Draw box (inverted when selected: black box instead of white) @@ -616,7 +641,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: constexpr int continuePadding = 6; const int continueBoxWidth = continueTextWidth + continuePadding * 2; const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; - const int continueBoxX = (rect.width - continueBoxWidth) / 2; + const int continueBoxX = rect.x + (rect.width - continueBoxWidth) / 2; const int continueBoxY = continueY - continuePadding / 2; renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected); renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected); diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 91193d39..92397f5b 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -82,7 +82,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .tabBarHeight = 50, .scrollBarWidth = 4, .scrollBarRightOffset = 5, - .homeTopPadding = 20, + .homeTopPadding = 40, .homeCoverHeight = 400, .homeCoverTileHeight = 400, .homeRecentBooksCount = 1, From 8f33bee6efa37622ca27eceee982f934d9b92837 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Fri, 20 Feb 2026 17:04:50 +1100 Subject: [PATCH 11/15] fix: Destroy CSS Cache file when invalid (#1018) ## Summary * Destroy CSS Cache file when invalid ## Additional Context * Fixes issue where it would attempt to rebuild every book open --- ### 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/Epub/Epub.cpp | 107 ++++++++++++++++---------------- lib/Epub/Epub/css/CssParser.cpp | 4 ++ lib/Epub/Epub/css/CssParser.h | 5 ++ 3 files changed, 64 insertions(+), 52 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index da23dfe6..3be770fe 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -268,64 +268,69 @@ void Epub::parseCssFiles() const { LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles"); } + LOG_DBG("EBP", "CSS files to parse: %zu", cssFiles.size()); + // See if we have a cached version of the CSS rules - if (!cssParser->hasCache()) { - // No cache yet - parse CSS files - for (const auto& cssPath : cssFiles) { - LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str()); + if (cssParser->hasCache()) { + LOG_DBG("EBP", "CSS cache exists, skipping parseCssFiles"); + return; + } - // Check heap before parsing - CSS parsing allocates heavily - const uint32_t freeHeap = ESP.getFreeHeap(); - if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) { - LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap, - MIN_HEAP_FOR_CSS_PARSING, cssPath.c_str()); + // No cache yet - parse CSS files + for (const auto& cssPath : cssFiles) { + LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str()); + + // Check heap before parsing - CSS parsing allocates heavily + const uint32_t freeHeap = ESP.getFreeHeap(); + if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) { + LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap, + MIN_HEAP_FOR_CSS_PARSING, cssPath.c_str()); + continue; + } + + // Check CSS file size before decompressing - skip files that are too large + size_t cssFileSize = 0; + if (getItemSize(cssPath, &cssFileSize)) { + if (cssFileSize > MAX_CSS_FILE_SIZE) { + LOG_ERR("EBP", "CSS file too large (%zu bytes > %zu max), skipping: %s", cssFileSize, MAX_CSS_FILE_SIZE, + cssPath.c_str()); continue; } + } - // Check CSS file size before decompressing - skip files that are too large - size_t cssFileSize = 0; - if (getItemSize(cssPath, &cssFileSize)) { - if (cssFileSize > MAX_CSS_FILE_SIZE) { - LOG_ERR("EBP", "CSS file too large (%zu bytes > %zu max), skipping: %s", cssFileSize, MAX_CSS_FILE_SIZE, - cssPath.c_str()); - continue; - } - } - - // Extract CSS file to temp location - const auto tmpCssPath = getCachePath() + "/.tmp.css"; - FsFile tempCssFile; - if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) { - LOG_ERR("EBP", "Could not create temp CSS file"); - continue; - } - if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) { - LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str()); - tempCssFile.close(); - Storage.remove(tmpCssPath.c_str()); - continue; - } - tempCssFile.close(); - - // Parse the CSS file - if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) { - LOG_ERR("EBP", "Could not open temp CSS file for reading"); - Storage.remove(tmpCssPath.c_str()); - continue; - } - cssParser->loadFromStream(tempCssFile); + // Extract CSS file to temp location + const auto tmpCssPath = getCachePath() + "/.tmp.css"; + FsFile tempCssFile; + if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) { + LOG_ERR("EBP", "Could not create temp CSS file"); + continue; + } + if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) { + LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str()); tempCssFile.close(); Storage.remove(tmpCssPath.c_str()); + continue; } + tempCssFile.close(); - // Save to cache for next time - if (!cssParser->saveToCache()) { - LOG_ERR("EBP", "Failed to save CSS rules to cache"); + // Parse the CSS file + if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) { + LOG_ERR("EBP", "Could not open temp CSS file for reading"); + Storage.remove(tmpCssPath.c_str()); + continue; } - cssParser->clear(); - - LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size()); + cssParser->loadFromStream(tempCssFile); + tempCssFile.close(); + Storage.remove(tmpCssPath.c_str()); } + + // Save to cache for next time + if (!cssParser->saveToCache()) { + LOG_ERR("EBP", "Failed to save CSS rules to cache"); + } + cssParser->clear(); + + LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size()); } // load in the meta data for the epub file @@ -341,12 +346,10 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { if (bookMetadataCache->load()) { if (!skipLoadingCss) { // Rebuild CSS cache when missing or when cache version changed (loadFromCache removes stale file) - bool needCssRebuild = !cssParser->hasCache(); - if (cssParser->hasCache() && !cssParser->loadFromCache()) { - needCssRebuild = true; - } - if (needCssRebuild) { + if (!cssParser->hasCache() || !cssParser->loadFromCache()) { LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files"); + cssParser->deleteCache(); + if (!parseContentOpf(bookMetadataCache->coreMetadata)) { LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files"); // continue anyway - book will work without CSS and we'll still load any inline style CSS diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index 9415f155..8ad59148 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -640,6 +640,10 @@ constexpr char rulesCache[] = "/css_rules.cache"; bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); } +void CssParser::deleteCache() const { + if (hasCache()) Storage.remove((cachePath + rulesCache).c_str()); +} + bool CssParser::saveToCache() const { if (cachePath.empty()) { return false; diff --git a/lib/Epub/Epub/css/CssParser.h b/lib/Epub/Epub/css/CssParser.h index 0c6ce61d..74dfaef1 100644 --- a/lib/Epub/Epub/css/CssParser.h +++ b/lib/Epub/Epub/css/CssParser.h @@ -85,6 +85,11 @@ class CssParser { */ bool hasCache() const; + /** + * Delete CSS rules cache file exists + */ + void deleteCache() const; + /** * Save parsed CSS rules to a cache file. * @return true if cache was written successfully From 45d19f6e1b6ab53ff79fe85eedc0c59ab3a3eac6 Mon Sep 17 00:00:00 2001 From: Luke Stein <44452336+lukestein@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:05:03 -0500 Subject: [PATCH 12/15] fix: Shorten "Forget Wifi" button labels to fit on button (#1045) --- lib/I18n/translations/czech.yaml | 2 +- lib/I18n/translations/english.yaml | 2 +- lib/I18n/translations/french.yaml | 2 +- lib/I18n/translations/german.yaml | 2 +- lib/I18n/translations/portuguese.yaml | 2 +- lib/I18n/translations/russian.yaml | 2 +- lib/I18n/translations/spanish.yaml | 2 +- lib/I18n/translations/swedish.yaml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 25d70aed..3c4f1a88 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -266,7 +266,7 @@ STR_MENU_RECENT_BOOKS: "Nedávné knihy" STR_NO_RECENT_BOOKS: "Žádné nedávné knihy" STR_CALIBRE_DESC: "Používat přenosy bezdrátových zařízení Calibre" STR_FORGET_AND_REMOVE: "Zapomenout síť a odstranit uložené heslo?" -STR_FORGET_BUTTON: "Zapomenout na síť" +STR_FORGET_BUTTON: "Zapomenout" STR_CALIBRE_STARTING: "Spuštění Calibre..." STR_CALIBRE_SETUP: "Nastavení" STR_CALIBRE_STATUS: "Stav" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 87825549..a47c8ab7 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -266,7 +266,7 @@ STR_MENU_RECENT_BOOKS: "Recent Books" STR_NO_RECENT_BOOKS: "No recent books" STR_CALIBRE_DESC: "Use Calibre wireless device transfers" STR_FORGET_AND_REMOVE: "Forget network and remove saved password?" -STR_FORGET_BUTTON: "Forget network" +STR_FORGET_BUTTON: "Forget" STR_CALIBRE_STARTING: "Starting Calibre..." STR_CALIBRE_SETUP: "Setup" STR_CALIBRE_STATUS: "Status" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 49613b53..1796c2f3 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -266,7 +266,7 @@ STR_MENU_RECENT_BOOKS: "Livres récents" STR_NO_RECENT_BOOKS: "Aucun livre récent" STR_CALIBRE_DESC: "Utiliser les transferts sans fil Calibre" STR_FORGET_AND_REMOVE: "Oublier le réseau et supprimer le mot de passe enregistré ?" -STR_FORGET_BUTTON: "Oublier le réseau" +STR_FORGET_BUTTON: "Oublier" STR_CALIBRE_STARTING: "Démarrage de Calibre..." STR_CALIBRE_SETUP: "Configuration" STR_CALIBRE_STATUS: "Statut" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 6e66ca05..eddc5ff9 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -266,7 +266,7 @@ STR_MENU_RECENT_BOOKS: "Zuletzt gelesen" STR_NO_RECENT_BOOKS: "Keine Bücher" STR_CALIBRE_DESC: "Calibre-Übertragung (WLAN)" STR_FORGET_AND_REMOVE: "WLAN entfernen & Passwort löschen?" -STR_FORGET_BUTTON: "WLAN entfernen" +STR_FORGET_BUTTON: "Entfernen" STR_CALIBRE_STARTING: "Calibre starten…" STR_CALIBRE_SETUP: "Installation" STR_CALIBRE_STATUS: "Status" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 7947361a..69ba4a91 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -266,7 +266,7 @@ STR_MENU_RECENT_BOOKS: "Livros recentes" STR_NO_RECENT_BOOKS: "Sem livros recentes" STR_CALIBRE_DESC: "Usar transferências sem fio Calibre" STR_FORGET_AND_REMOVE: "Esquecer a rede e remover a senha salva?" -STR_FORGET_BUTTON: "Esquecer rede" +STR_FORGET_BUTTON: "Esquecer" STR_CALIBRE_STARTING: "Iniciando Calibre..." STR_CALIBRE_SETUP: "Configuração" STR_CALIBRE_STATUS: "Status" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index b71856bf..2771e14c 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -266,7 +266,7 @@ STR_MENU_RECENT_BOOKS: "Недавние книги" STR_NO_RECENT_BOOKS: "Нет недавних книг" STR_CALIBRE_DESC: "Использовать беспроводную передачу Calibre" STR_FORGET_AND_REMOVE: "Забыть сеть и удалить сохранённый пароль?" -STR_FORGET_BUTTON: "Забыть сеть" +STR_FORGET_BUTTON: "Забыть" STR_CALIBRE_STARTING: "Запуск Calibre..." STR_CALIBRE_SETUP: "Настройка" STR_CALIBRE_STATUS: "Статус" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 3b0fc7a7..f57afb7d 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -266,7 +266,7 @@ STR_MENU_RECENT_BOOKS: "Libros recientes" STR_NO_RECENT_BOOKS: "No hay libros recientes" STR_CALIBRE_DESC: "Utilice las transferencias dispositivos inalámbricos de calibre" STR_FORGET_AND_REMOVE: "Olvidar la red y eliminar la contraseña guardada?" -STR_FORGET_BUTTON: "Olvidar la red" +STR_FORGET_BUTTON: "Olvidar" STR_CALIBRE_STARTING: "Iniciando calibre..." STR_CALIBRE_SETUP: "Configuración" STR_CALIBRE_STATUS: "Estado" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 38abd108..a4b812e4 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -266,7 +266,7 @@ STR_MENU_RECENT_BOOKS: "Senaste böckerna" STR_NO_RECENT_BOOKS: "Inga senaste böcker" STR_CALIBRE_DESC: "Använd Calibres trådlösa enhetsöverföring" STR_FORGET_AND_REMOVE: "Glöm nätverk och ta bort sparat lösenord?" -STR_FORGET_BUTTON: "Glöm nätverk" +STR_FORGET_BUTTON: "Glöm" STR_CALIBRE_STARTING: "Starar Calibre…" STR_CALIBRE_SETUP: "Inställning" STR_CALIBRE_STATUS: "Status" From e44c004be61ce649f146b392b7f4c18df7db0b0d Mon Sep 17 00:00:00 2001 From: pablohc Date: Sat, 21 Feb 2026 12:30:42 +0100 Subject: [PATCH 13/15] fix: improve Spanish translations (#1054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * **What is the goal of this PR?** * improve Spanish translations * **What changes are included?** - Fix typos and accents (Librería, conexión, etc.) - Translate untranslated strings (BOOTING, SLEEPING, etc.) - Improve consistency and conciseness - Fix question mark placement (¿...?) - Standardize terminology (Punto de Acceso, Suspensión, etc.) --- ### 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 >**_ --- docs/translators.md | 1 + lib/I18n/translations/spanish.yaml | 292 ++++++++++++++--------------- 2 files changed, 147 insertions(+), 146 deletions(-) diff --git a/docs/translators.md b/docs/translators.md index f43b8260..3989c0b1 100644 --- a/docs/translators.md +++ b/docs/translators.md @@ -30,6 +30,7 @@ If you'd like to add your name to this list, please open a PR adding yourself an ## Spanish - [yeyeto2788](https://github.com/yeyeto2788) - [Skrzakk](https://github.com/Skrzakk) +- [pablohc](https://github.com/pablohc) ## Swedish - [dawiik](https://github.com/dawiik) diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index f57afb7d..c624242d 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -3,16 +3,16 @@ _language_code: "SPANISH" _order: "1" STR_CROSSPOINT: "CrossPoint" -STR_BOOTING: "BOOTING" -STR_SLEEPING: "SLEEPING" -STR_ENTERING_SLEEP: "ENTERING SLEEP" -STR_BROWSE_FILES: "Buscar archivos" -STR_FILE_TRANSFER: "Transferencia de archivos" -STR_SETTINGS_TITLE: "Configuración" -STR_CALIBRE_LIBRARY: "Libreria Calibre" +STR_BOOTING: "Iniciando..." +STR_SLEEPING: "Suspendido" +STR_ENTERING_SLEEP: "Entrando en suspensión" +STR_BROWSE_FILES: "Explorador de Archivos" +STR_FILE_TRANSFER: "Transferir archivos" +STR_SETTINGS_TITLE: "Ajustes" +STR_CALIBRE_LIBRARY: "Biblioteca de Calibre" STR_CONTINUE_READING: "Continuar leyendo" STR_NO_OPEN_BOOK: "No hay libros abiertos" -STR_START_READING: "Start reading below" +STR_START_READING: "Comenzar a leer" STR_BOOKS: "Libros" STR_NO_BOOKS_FOUND: "No se encontraron libros" STR_SELECT_CHAPTER: "Seleccionar capítulo" @@ -23,129 +23,129 @@ STR_INDEXING: "Indexando" STR_MEMORY_ERROR: "Error de memoria" STR_PAGE_LOAD_ERROR: "Error al cargar la página" STR_EMPTY_FILE: "Archivo vacío" -STR_OUT_OF_BOUNDS: "Out of bounds" +STR_OUT_OF_BOUNDS: "Fuera de rango" STR_LOADING: "Cargando..." STR_LOADING_POPUP: "Cargando" STR_LOAD_XTC_FAILED: "Error al cargar XTC" STR_LOAD_TXT_FAILED: "Error al cargar TXT" STR_LOAD_EPUB_FAILED: "Error al cargar EPUB" -STR_SD_CARD_ERROR: "Error en la tarjeta SD" +STR_SD_CARD_ERROR: "Error en la tarjeta microSD" STR_WIFI_NETWORKS: "Redes Wi-Fi" STR_NO_NETWORKS: "No hay redes disponibles" STR_NETWORKS_FOUND: "%zu redes encontradas" STR_SCANNING: "Buscando..." STR_CONNECTING: "Conectando..." -STR_CONNECTED: "Conectado!" -STR_CONNECTION_FAILED: "Error de conexion" -STR_CONNECTION_TIMEOUT: "Connection timeout" -STR_FORGET_NETWORK: "Olvidar la red?" -STR_SAVE_PASSWORD: "Guardar contraseña para la próxima vez?" -STR_REMOVE_PASSWORD: "Borrar contraseñas guardadas?" -STR_PRESS_OK_SCAN: "Presione OK para buscar de nuevo" -STR_PRESS_ANY_CONTINUE: "Presione cualquier botón para continuar" -STR_SELECT_HINT: "Izquierda/Derecha: Seleccionar | OK: Confirmar" -STR_HOW_CONNECT: "Cómo te gustaría conectarte?" +STR_CONNECTED: "¡Conectado!" +STR_CONNECTION_FAILED: "Error de conexión" +STR_CONNECTION_TIMEOUT: "Tiempo de espera agotado" +STR_FORGET_NETWORK: "¿Olvidar la red?" +STR_SAVE_PASSWORD: "¿Guardar contraseña?" +STR_REMOVE_PASSWORD: "¿Olvidar contraseña?" +STR_PRESS_OK_SCAN: "Pulse OK para buscar de nuevo" +STR_PRESS_ANY_CONTINUE: "Pulse cualquier botón para continuar" +STR_SELECT_HINT: "Izq./Der.: Seleccionar | OK: Confirmar" +STR_HOW_CONNECT: "¿Cómo desea conectarse?" STR_JOIN_NETWORK: "Unirse a una red" -STR_CREATE_HOTSPOT: "Crear punto de acceso" +STR_CREATE_HOTSPOT: "Crear Punto de Acceso" STR_JOIN_DESC: "Conectarse a una red Wi-Fi existente" -STR_HOTSPOT_DESC: "Crear una red Wi-Fi para que otros se unan" -STR_STARTING_HOTSPOT: "Iniciando punto de acceso..." -STR_HOTSPOT_MODE: "Modo punto de acceso" -STR_CONNECT_WIFI_HINT: "Conectar su dispositivo a esta red Wi-Fi" -STR_OPEN_URL_HINT: "Abre esta dirección en tu navegador" +STR_HOTSPOT_DESC: "Conectarse a este dispositivo" +STR_STARTING_HOTSPOT: "Iniciando Punto de Acceso..." +STR_HOTSPOT_MODE: "Modo Punto de Acceso" +STR_CONNECT_WIFI_HINT: "Conecte su dispositivo a esta red Wi-Fi" +STR_OPEN_URL_HINT: "Abra esta dirección en su navegador" STR_OR_HTTP_PREFIX: "o http://" -STR_SCAN_QR_HINT: "o escanee este código QR con su móvil:" +STR_SCAN_QR_HINT: "o escanee el código QR con su móvil:" STR_CALIBRE_WIRELESS: "Calibre inalámbrico" STR_CALIBRE_WEB_URL: "URL del sitio web de Calibre" STR_CONNECT_WIRELESS: "Conectar como dispositivo inalámbrico" -STR_NETWORK_LEGEND: "* = Cifrado | + = Guardado" -STR_MAC_ADDRESS: "Dirección MAC:" +STR_NETWORK_LEGEND: "* (Cifrado) | + (Guardado)" +STR_MAC_ADDRESS: "MAC Address:" STR_CHECKING_WIFI: "Verificando Wi-Fi..." -STR_ENTER_WIFI_PASSWORD: "Introduzca la contraseña de Wi-Fi" +STR_ENTER_WIFI_PASSWORD: "Introduzca la contraseña del Wi-Fi" STR_ENTER_TEXT: "Introduzca el texto" STR_TO_PREFIX: "a " -STR_CALIBRE_DISCOVERING: "Discovering Calibre..." +STR_CALIBRE_DISCOVERING: "Buscando Calibre..." STR_CALIBRE_CONNECTING_TO: "Conectándose a" STR_CALIBRE_CONNECTED_TO: "Conectado a " STR_CALIBRE_WAITING_COMMANDS: "Esperando comandos..." -STR_CONNECTION_FAILED_RETRYING: "(Error de conexión, intentándolo nuevamente)" +STR_CONNECTION_FAILED_RETRYING: "(Error de conexión, reintentando...)" STR_CALIBRE_DISCONNECTED: "Calibre desconectado" STR_CALIBRE_WAITING_TRANSFER: "Esperando transferencia..." -STR_CALIBRE_TRANSFER_HINT: "Si la transferencia falla, habilite \\n'Ignorar espacio libre' en las configuraciones del \\nplugin smartdevice de calibre." +STR_CALIBRE_TRANSFER_HINT: "Si la transferencia falla, active \\n'Ignorar espacio libre' en la configuración del \\nPlugin Smart Device de Calibre." STR_CALIBRE_RECEIVING: "Recibiendo: " STR_CALIBRE_RECEIVED: "Recibido: " STR_CALIBRE_WAITING_MORE: "Esperando más..." STR_CALIBRE_FAILED_CREATE_FILE: "Error al crear el archivo" STR_CALIBRE_PASSWORD_REQUIRED: "Contraseña requerida" STR_CALIBRE_TRANSFER_INTERRUPTED: "Transferencia interrumpida" -STR_CALIBRE_INSTRUCTION_1: "1) Instala CrossPoint Reader plugin" +STR_CALIBRE_INSTRUCTION_1: "1) Instale el Plugin CrossPoint Reader" STR_CALIBRE_INSTRUCTION_2: "2) Conéctese a la misma red Wi-Fi" -STR_CALIBRE_INSTRUCTION_3: "3) En Calibre: \"Enviar a dispotivo\"" +STR_CALIBRE_INSTRUCTION_3: "3) Desde Calibre seleccione: \"Enviar a dispositivo\"" STR_CALIBRE_INSTRUCTION_4: "\"Permanezca en esta pantalla mientras se envía\"" STR_CAT_DISPLAY: "Pantalla" STR_CAT_READER: "Lector" -STR_CAT_CONTROLS: "Control" +STR_CAT_CONTROLS: "Controles" STR_CAT_SYSTEM: "Sistema" -STR_SLEEP_SCREEN: "Salva Pantallas" -STR_SLEEP_COVER_MODE: "Modo de salva pantallas" +STR_SLEEP_SCREEN: "Pantalla de suspensión" +STR_SLEEP_COVER_MODE: "Modo de pantalla de suspensión" STR_STATUS_BAR: "Barra de estado" -STR_HIDE_BATTERY: "Ocultar porcentaje de batería" -STR_EXTRA_SPACING: "Espaciado extra de párrafos" -STR_TEXT_AA: "Suavizado de bordes de texto" -STR_SHORT_PWR_BTN: "Clic breve del botón de encendido" -STR_ORIENTATION: "Orientación de la lectura" +STR_HIDE_BATTERY: "Ocultar % de batería" +STR_EXTRA_SPACING: "Espaciado entre párrafos" +STR_TEXT_AA: "Suavizado de texto" +STR_SHORT_PWR_BTN: "Función especial botón Power" +STR_ORIENTATION: "Orientación" STR_FRONT_BTN_LAYOUT: "Diseño de los botones frontales" -STR_SIDE_BTN_LAYOUT: "Diseño de los botones laterales (Lector)" -STR_LONG_PRESS_SKIP: "Pasar a la capítulo al presiónar largamente" -STR_FONT_FAMILY: "Familia de tipografía del lector" +STR_SIDE_BTN_LAYOUT: "Función botones laterales (Lector)" +STR_LONG_PRESS_SKIP: "Saltar capítulo (pulsación larga)" +STR_FONT_FAMILY: "Tipografía" STR_EXT_READER_FONT: "Tipografía externa" -STR_EXT_CHINESE_FONT: "Tipografía (Lectura)" +STR_EXT_CHINESE_FONT: "Tipografía" STR_EXT_UI_FONT: "Tipografía (Pantalla)" -STR_FONT_SIZE: "Tamaño de la fuente (Pantalla)" -STR_LINE_SPACING: "Interlineado (Lectura)" -STR_ASCII_LETTER_SPACING: "Espaciado de letras ASCII" -STR_ASCII_DIGIT_SPACING: "Espaciado de dígitos ASCII" -STR_CJK_SPACING: "Espaciado CJK" +STR_FONT_SIZE: "Tamaño" +STR_LINE_SPACING: "Interlineado" +STR_ASCII_LETTER_SPACING: "Espaciado entre letras ASCII" +STR_ASCII_DIGIT_SPACING: "Espaciado entre dígitos ASCII" +STR_CJK_SPACING: "Espaciado entre caracteres CJK" STR_COLOR_MODE: "Modo de color" STR_SCREEN_MARGIN: "Margen de lectura" -STR_PARA_ALIGNMENT: "Ajuste de parágrafo del lector" -STR_HYPHENATION: "Hyphenation" -STR_TIME_TO_SLEEP: "Tiempo para dormir" -STR_REFRESH_FREQ: "Frecuencia de actualización" -STR_CALIBRE_SETTINGS: "Configuraciones de Calibre" -STR_KOREADER_SYNC: "Síncronización de KOReader" +STR_PARA_ALIGNMENT: "Ajuste de párrafo" +STR_HYPHENATION: "División de palabras" +STR_TIME_TO_SLEEP: "Auto suspensión" +STR_REFRESH_FREQ: "Frecuencia de refresco" +STR_CALIBRE_SETTINGS: "Ajustes de Calibre" +STR_KOREADER_SYNC: "Sincronización de KOReader" STR_CHECK_UPDATES: "Verificar actualizaciones" STR_LANGUAGE: "Idioma" STR_SELECT_WALLPAPER: "Seleccionar fondo" STR_CLEAR_READING_CACHE: "Borrar caché de lectura" STR_CALIBRE: "Calibre" -STR_USERNAME: "Nombre de usuario" +STR_USERNAME: "Usuario" STR_PASSWORD: "Contraseña" -STR_SYNC_SERVER_URL: "URL del servidor de síncronización" -STR_DOCUMENT_MATCHING: "Coincidencia de documentos" -STR_AUTHENTICATE: "Autentificar" -STR_KOREADER_USERNAME: "Nombre de usuario de KOReader" +STR_SYNC_SERVER_URL: "URL del servidor de sinc." +STR_DOCUMENT_MATCHING: "Coincidencia de doc." +STR_AUTHENTICATE: "Autenticar" +STR_KOREADER_USERNAME: "Usuario de KOReader" STR_KOREADER_PASSWORD: "Contraseña de KOReader" STR_FILENAME: "Nombre del archivo" STR_BINARY: "Binario" -STR_SET_CREDENTIALS_FIRST: "Configurar credenciales primero" -STR_WIFI_CONN_FAILED: "Falló la conexión Wi-Fi" -STR_AUTHENTICATING: "Autentificando..." -STR_AUTH_SUCCESS: "Autenticación exitsosa!" +STR_SET_CREDENTIALS_FIRST: "Configurar credenciales" +STR_WIFI_CONN_FAILED: "Fallo de conexión Wi-Fi" +STR_AUTHENTICATING: "Autenticando..." +STR_AUTH_SUCCESS: "¡Autenticación exitosa!" STR_KOREADER_AUTH: "Autenticación KOReader" -STR_SYNC_READY: "La síncronización de KOReader está lista para usarse" -STR_AUTH_FAILED: "Falló la autenticación" +STR_SYNC_READY: "La sincronización de KOReader está lista para usarse" +STR_AUTH_FAILED: "Error de autenticación" STR_DONE: "Hecho" -STR_CLEAR_CACHE_WARNING_1: "Esto borrará todos los datos en cache del libro." -STR_CLEAR_CACHE_WARNING_2: " ¡Se perderá todo el avance de leer!" -STR_CLEAR_CACHE_WARNING_3: "Los libros deberán ser reíndexados" -STR_CLEAR_CACHE_WARNING_4: "cuando se abran de nuevo." +STR_CLEAR_CACHE_WARNING_1: "Esto borrará todos los datos del libro en caché." +STR_CLEAR_CACHE_WARNING_2: "¡Se perderá todo el progreso de lectura!" +STR_CLEAR_CACHE_WARNING_3: "Los libros deberán ser reindexados" +STR_CLEAR_CACHE_WARNING_4: "cuando se vuelvan a abrir." STR_CLEARING_CACHE: "Borrando caché..." -STR_CACHE_CLEARED: "Cache limpia" +STR_CACHE_CLEARED: "Caché borrada" STR_ITEMS_REMOVED: "Elementos eliminados" -STR_FAILED_LOWER: "Falló" -STR_CLEAR_CACHE_FAILED: "No se pudo borrar la cache" -STR_CHECK_SERIAL_OUTPUT: "Verifique la salida serial para detalles" +STR_FAILED_LOWER: "Error" +STR_CLEAR_CACHE_FAILED: "No se pudo borrar la caché" +STR_CHECK_SERIAL_OUTPUT: "Consulte los registros del puerto serie" STR_DARK: "Oscuro" STR_LIGHT: "Claro" STR_CUSTOM: "Personalizado" @@ -159,34 +159,34 @@ STR_NEVER: "Nunca" STR_IN_READER: "En el lector" STR_ALWAYS: "Siempre" STR_IGNORE: "Ignorar" -STR_SLEEP: "Dormir" -STR_PAGE_TURN: "Paso de página" -STR_PORTRAIT: "Portrato" -STR_LANDSCAPE_CW: "Paisaje sentido horario" +STR_SLEEP: "Suspender" +STR_PAGE_TURN: "Pasar página" +STR_PORTRAIT: "Vertical" +STR_LANDSCAPE_CW: "Horizontal (horario)" STR_INVERTED: "Invertido" -STR_LANDSCAPE_CCW: "Paisaje sentido antihorario" -STR_FRONT_LAYOUT_BCLR: "Atrás, Confirmar, Izquierda, Derecha" -STR_FRONT_LAYOUT_LRBC: "Izquierda, Derecha, Atrás, Confirmar" -STR_FRONT_LAYOUT_LBCR: "Izquierda, Atrás, Confirmar, Derecha" -STR_PREV_NEXT: "Anterior/Siguiente" -STR_NEXT_PREV: "Siguiente/Anterior" -STR_BOOKERLY: "Relacionado con libros" +STR_LANDSCAPE_CCW: "Horizontal (antihorario)" +STR_FRONT_LAYOUT_BCLR: "Atrás, Confirmar, Izq., Der." +STR_FRONT_LAYOUT_LRBC: "Izq., Der., Atrás, Confirmar" +STR_FRONT_LAYOUT_LBCR: "Izq., Atrás, Confirmar, Der." +STR_PREV_NEXT: "Ant./Sig." +STR_NEXT_PREV: "Sig./Ant." +STR_BOOKERLY: "Bookerly" STR_NOTO_SANS: "Noto Sans" STR_OPEN_DYSLEXIC: "Open Dyslexic" STR_SMALL: "Pequeño" -STR_MEDIUM: "Medio" +STR_MEDIUM: "Mediano" STR_LARGE: "Grande" STR_X_LARGE: "Extra grande" -STR_TIGHT: "Ajustado" +STR_TIGHT: "Estrecho" STR_NORMAL: "Normal" -STR_WIDE: "Ancho" -STR_JUSTIFY: "Justificar" +STR_WIDE: "Amplio" +STR_JUSTIFY: "Justificado" STR_ALIGN_LEFT: "Izquierda" STR_CENTER: "Centro" STR_ALIGN_RIGHT: "Derecha" STR_MIN_1: "1 Minuto" -STR_MIN_5: "10 Minutos" -STR_MIN_10: "5 Minutos" +STR_MIN_5: "5 Minutos" +STR_MIN_10: "10 Minutos" STR_MIN_15: "15 Minutos" STR_MIN_30: "30 Minutos" STR_PAGES_1: "1 Página" @@ -194,38 +194,38 @@ STR_PAGES_5: "5 Páginas" STR_PAGES_10: "10 Páginas" STR_PAGES_15: "15 Páginas" STR_PAGES_30: "30 Páginas" -STR_UPDATE: "ActualizaR" +STR_UPDATE: "Actualizar" STR_CHECKING_UPDATE: "Verificando actualización..." STR_NEW_UPDATE: "¡Nueva actualización disponible!" STR_CURRENT_VERSION: "Versión actual:" STR_NEW_VERSION: "Nueva versión:" STR_UPDATING: "Actualizando..." STR_NO_UPDATE: "No hay actualizaciones disponibles" -STR_UPDATE_FAILED: "Falló la actualización" +STR_UPDATE_FAILED: "Fallo de actualización" STR_UPDATE_COMPLETE: "Actualización completada" -STR_POWER_ON_HINT: "Presione y mantenga presionado el botón de encendido para volver a encender" +STR_POWER_ON_HINT: "Pulse y mantenga presionado el botón de encendido para volver a encender" STR_EXTERNAL_FONT: "Fuente externa" STR_BUILTIN_DISABLED: "Incorporado (Desactivado)" STR_NO_ENTRIES: "No se encontraron elementos" STR_DOWNLOADING: "Descargando..." -STR_DOWNLOAD_FAILED: "Falló la descarga" +STR_DOWNLOAD_FAILED: "Fallo de descarga" STR_ERROR_MSG: "Error" STR_UNNAMED: "Sin nombre" -STR_NO_SERVER_URL: "No se ha configurado la url del servidor" -STR_FETCH_FEED_FAILED: "Failed to fetch feed" -STR_PARSE_FEED_FAILED: "Failed to parse feed" +STR_NO_SERVER_URL: "No se ha configurado la URL del servidor" +STR_FETCH_FEED_FAILED: "Fallo al obtener el feed" +STR_PARSE_FEED_FAILED: "Fallo al procesar el feed" STR_NETWORK_PREFIX: "Red: " -STR_IP_ADDRESS_PREFIX: "Dirección IP: " -STR_SCAN_QR_WIFI_HINT: "O escanee el código QR con su teléfono para conectarse a WI-FI." +STR_IP_ADDRESS_PREFIX: "IP: " +STR_SCAN_QR_WIFI_HINT: "O escanee el código QR con su teléfono para conectarse a Wi-Fi." STR_ERROR_GENERAL_FAILURE: "Error: Fallo general" STR_ERROR_NETWORK_NOT_FOUND: "Error: Red no encontrada" -STR_ERROR_CONNECTION_TIMEOUT: "Error: Connection timeout" -STR_SD_CARD: "Tarjeta SD" +STR_ERROR_CONNECTION_TIMEOUT: "Error: Tiempo de conexión agotado" +STR_SD_CARD: "Tarjeta microSD" STR_BACK: "« Atrás" -STR_EXIT: "« SaliR" +STR_EXIT: "« Salir" STR_HOME: "« Inicio" STR_SAVE: "« Guardar" -STR_SELECT: "Seleccionar" +STR_SELECT: "Elegir" STR_TOGGLE: "Cambiar" STR_CONFIRM: "Confirmar" STR_CANCEL: "Cancelar" @@ -235,67 +235,67 @@ STR_DOWNLOAD: "Descargar" STR_RETRY: "Reintentar" STR_YES: "Sí" STR_NO: "No" -STR_STATE_ON: "ENCENDIDO" -STR_STATE_OFF: "APAGADO" +STR_STATE_ON: "Activado" +STR_STATE_OFF: "Desactivado" STR_SET: "Configurar" STR_NOT_SET: "No configurado" -STR_DIR_LEFT: "Izquierda" -STR_DIR_RIGHT: "Derecha" -STR_DIR_UP: "Arriba" -STR_DIR_DOWN: "Abajo" +STR_DIR_LEFT: "Izq." +STR_DIR_RIGHT: "Der." +STR_DIR_UP: "Subir" +STR_DIR_DOWN: "Bajar" STR_CAPS_ON: "MAYÚSCULAS" -STR_CAPS_OFF: "caps" +STR_CAPS_OFF: "minúsculas" STR_OK_BUTTON: "OK" -STR_ON_MARKER: "[ENCENDIDO]" -STR_SLEEP_COVER_FILTER: "Filtro de salva pantalla y protección de la pantalla" +STR_ON_MARKER: "[Activo]" +STR_SLEEP_COVER_FILTER: "Filtro de pantalla de suspensión" STR_FILTER_CONTRAST: "Contraste" -STR_STATUS_BAR_FULL_PERCENT: "Completa con porcentaje" -STR_STATUS_BAR_FULL_BOOK: "Completa con progreso del libro" -STR_STATUS_BAR_BOOK_ONLY: "Solo progreso del libro" -STR_STATUS_BAR_FULL_CHAPTER: "Completa con progreso de capítulos" -STR_UI_THEME: "Estilo de pantalla" +STR_STATUS_BAR_FULL_PERCENT: "Completa con %" +STR_STATUS_BAR_FULL_BOOK: "Completa con progreso lect." +STR_STATUS_BAR_BOOK_ONLY: "Solo progreso" +STR_STATUS_BAR_FULL_CHAPTER: "Completa con progreso cap." +STR_UI_THEME: "Interfaz" STR_THEME_CLASSIC: "Clásico" STR_THEME_LYRA: "Lyra" -STR_THEME_LYRA_EXTENDED: "Lyra Extended" -STR_SUNLIGHT_FADING_FIX: "Corrección de desvastado por sol" +STR_THEME_LYRA_EXTENDED: "Lyra Extendido" +STR_SUNLIGHT_FADING_FIX: "Corrección de desvanecimiento" STR_REMAP_FRONT_BUTTONS: "Reconfigurar botones frontales" -STR_OPDS_BROWSER: "Navegador opds" -STR_COVER_CUSTOM: "Portada + Personalizado" +STR_OPDS_BROWSER: "Navegador OPDS" +STR_COVER_CUSTOM: "Portada + Pers." STR_RECENTS: "Recientes" STR_MENU_RECENT_BOOKS: "Libros recientes" STR_NO_RECENT_BOOKS: "No hay libros recientes" -STR_CALIBRE_DESC: "Utilice las transferencias dispositivos inalámbricos de calibre" -STR_FORGET_AND_REMOVE: "Olvidar la red y eliminar la contraseña guardada?" +STR_CALIBRE_DESC: "Transferir contenido a este dispositivo" +STR_FORGET_AND_REMOVE: "¿Desea olvidar la red y la contraseña guardada?" STR_FORGET_BUTTON: "Olvidar" -STR_CALIBRE_STARTING: "Iniciando calibre..." +STR_CALIBRE_STARTING: "Iniciando Calibre..." STR_CALIBRE_SETUP: "Configuración" STR_CALIBRE_STATUS: "Estado" STR_CLEAR_BUTTON: "Borrar" -STR_DEFAULT_VALUE: "Previo" -STR_REMAP_PROMPT: "Presione un botón frontal para cada función" -STR_UNASSIGNED: "No asignado" +STR_DEFAULT_VALUE: "Predeterminado" +STR_REMAP_PROMPT: "Pulse un botón frontal para cada función" +STR_UNASSIGNED: "Sin asignar" STR_ALREADY_ASSIGNED: "Ya asignado" -STR_REMAP_RESET_HINT: "Botón lateral arriba: Restablecer a la configuración previo" +STR_REMAP_RESET_HINT: "Botón lateral arriba: Restablecer configuración" STR_REMAP_CANCEL_HINT: "Botón lateral abajo: Anular reconfiguración" STR_HW_BACK_LABEL: "Atrás (Primer botón)" STR_HW_CONFIRM_LABEL: "Confirmar (Segundo botón)" -STR_HW_LEFT_LABEL: "Izquierda (Tercer botón)" -STR_HW_RIGHT_LABEL: "Derecha (Cuarto botón)" +STR_HW_LEFT_LABEL: "Izq. (Tercer botón)" +STR_HW_RIGHT_LABEL: "Der. (Cuarto botón)" STR_GO_TO_PERCENT: "Ir a %" STR_GO_HOME_BUTTON: "Volver a inicio" -STR_SYNC_PROGRESS: "Progreso de síncronización" -STR_DELETE_CACHE: "Borrar cache del libro" -STR_CHAPTER_PREFIX: "Capítulo:" +STR_SYNC_PROGRESS: "Sincronizar progreso de lectura" +STR_DELETE_CACHE: "Borrar caché del libro" +STR_CHAPTER_PREFIX: "Cap.:" STR_PAGES_SEPARATOR: " Páginas |" STR_BOOK_PREFIX: "Libro:" STR_KBD_SHIFT: "shift" STR_KBD_SHIFT_CAPS: "SHIFT" STR_KBD_LOCK: "BLOQUEAR" -STR_CALIBRE_URL_HINT: "Para calibre, agregue /opds a su urL" -STR_PERCENT_STEP_HINT: "Izquierda/Derecha: 1% Arriba/Abajo: 10%" -STR_SYNCING_TIME: "Tiempo de síncronización..." -STR_CALC_HASH: "Calculando hash del documento..." -STR_HASH_FAILED: "No se pudo calcular el hash del documento" +STR_CALIBRE_URL_HINT: "Para Calibre, agregue /opds a su URL" +STR_PERCENT_STEP_HINT: "Izq./Der.: 1% | Subir/Bajar: 10%" +STR_SYNCING_TIME: "Tiempo de sincronización..." +STR_CALC_HASH: "Calculando HASH del documento..." +STR_HASH_FAILED: "No se pudo calcular el HASH del documento" STR_FETCH_PROGRESS: "Recuperando progreso remoto..." STR_UPLOAD_PROGRESS: "Subiendo progreso..." STR_NO_CREDENTIALS_MSG: "No se han configurado credenciales" @@ -304,15 +304,15 @@ STR_PROGRESS_FOUND: "¡Progreso encontrado!" STR_REMOTE_LABEL: "Remoto" STR_LOCAL_LABEL: "Local" STR_PAGE_OVERALL_FORMAT: "Página %d, %.2f%% Completada" -STR_PAGE_TOTAL_OVERALL_FORMAT: "Página %d / %d, %.2f% Completada" +STR_PAGE_TOTAL_OVERALL_FORMAT: "Página %d / %d, %.2f%% Completada" STR_DEVICE_FROM_FORMAT: " De: %s" STR_APPLY_REMOTE: "Aplicar progreso remoto" STR_UPLOAD_LOCAL: "Subir progreso local" STR_NO_REMOTE_MSG: "No se encontró progreso remoto" -STR_UPLOAD_PROMPT: "Subir posicion actual?" +STR_UPLOAD_PROMPT: "¿Subir posición actual?" STR_UPLOAD_SUCCESS: "¡Progreso subido!" -STR_SYNC_FAILED_MSG: "Fallo de síncronización" -STR_SECTION_PREFIX: "Seccion" +STR_SYNC_FAILED_MSG: "Fallo de sincronización" +STR_SECTION_PREFIX: "Secc.:" STR_UPLOAD: "Subir" STR_BOOK_S_STYLE: "Estilo del libro" STR_EMBEDDED_STYLE: "Estilo integrado" From 498e087a68ae7c6edba959ff0b0456ae42d824b9 Mon Sep 17 00:00:00 2001 From: DestinySpeaker Date: Sat, 21 Feb 2026 17:55:59 -0800 Subject: [PATCH 14/15] fix: Fixed book title in home screen (#1013) ## Summary * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) * The goal is to fix the title of books in the Home Screen. Before ![IMG_8867](https://github.com/user-attachments/assets/6cc9ca22-b95b-4863-872d-ef427c42f833) After: ![IMG_8868](https://github.com/user-attachments/assets/585031b1-2348-444c-8f32-073fed3b6582) * **What changes are included?** ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- ### 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, Cursor --- lib/Epub/Epub/parsers/ContentOpfParser.cpp | 5 +- src/RecentBooksStore.cpp | 69 ++++++++++++-------- src/components/themes/BaseTheme.cpp | 10 ++- src/components/themes/lyra/LyraTheme.cpp | 74 ++++++++++++++++++++-- 4 files changed, 121 insertions(+), 37 deletions(-) diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index c3a30a3b..1cf7e7e0 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -102,7 +102,10 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name } if (self->state == IN_METADATA && strcmp(name, "dc:title") == 0) { - self->state = IN_BOOK_TITLE; + // Only capture the first dc:title element; subsequent ones are subtitles + if (self->title.empty()) { + self->state = IN_BOOK_TITLE; + } return; } diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index 1306c217..b36afc52 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -85,7 +85,9 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const { LOG_DBG("RBS", "Loading recent book: %s", path.c_str()); - // If epub, try to load the metadata for title/author and cover + // If epub, try to load the metadata for title/author and cover. + // Use buildIfMissing=false to avoid heavy epub loading on boot; getTitle()/getAuthor() may be + // blank until the book is opened, and entries with missing title are omitted from recent list. if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) { Epub epub(path, "/.crosspoint"); epub.load(false, true); @@ -112,40 +114,35 @@ bool RecentBooksStore::loadFromFile() { uint8_t version; serialization::readPod(inputFile, version); - if (version != RECENT_BOOKS_FILE_VERSION) { - if (version == 1 || version == 2) { - // Old version, just read paths - uint8_t count; - serialization::readPod(inputFile, count); - recentBooks.clear(); - recentBooks.reserve(count); - for (uint8_t i = 0; i < count; i++) { - std::string path; - serialization::readString(inputFile, path); + if (version == 1 || version == 2) { + // Old version, just read paths + uint8_t count; + serialization::readPod(inputFile, count); + recentBooks.clear(); + recentBooks.reserve(count); + for (uint8_t i = 0; i < count; i++) { + std::string path; + serialization::readString(inputFile, path); - // load book to get missing data - RecentBook book = getDataFromBook(path); - if (book.title.empty() && book.author.empty() && version == 2) { - // Fall back to loading what we can from the store - std::string title, author; - serialization::readString(inputFile, title); - serialization::readString(inputFile, author); - recentBooks.push_back({path, title, author, ""}); - } else { - recentBooks.push_back(book); - } + // load book to get missing data + RecentBook book = getDataFromBook(path); + if (book.title.empty() && book.author.empty() && version == 2) { + // Fall back to loading what we can from the store + std::string title, author; + serialization::readString(inputFile, title); + serialization::readString(inputFile, author); + recentBooks.push_back({path, title, author, ""}); + } else { + recentBooks.push_back(book); } - } else { - LOG_ERR("RBS", "Deserialization failed: Unknown version %u", version); - inputFile.close(); - return false; } - } else { + } else if (version == 3) { uint8_t count; serialization::readPod(inputFile, count); recentBooks.clear(); recentBooks.reserve(count); + uint8_t omitted = 0; for (uint8_t i = 0; i < count; i++) { std::string path, title, author, coverBmpPath; @@ -153,8 +150,26 @@ bool RecentBooksStore::loadFromFile() { serialization::readString(inputFile, title); serialization::readString(inputFile, author); serialization::readString(inputFile, coverBmpPath); + + // Omit books with missing title (e.g. saved before metadata was available) + if (title.empty()) { + omitted++; + continue; + } + recentBooks.push_back({path, title, author, coverBmpPath}); } + + if (omitted > 0) { + inputFile.close(); + saveToFile(); + LOG_DBG("RBS", "Omitted %u recent book(s) with missing title", omitted); + return true; + } + } else { + LOG_ERR("RBS", "Deserialization failed: Unknown version %u", version); + inputFile.close(); + return false; } inputFile.close(); diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 15f613d3..09203ed8 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -519,7 +519,8 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: // Still have words left, so add ellipsis to last line lines.back().append("..."); - while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { + while (!lines.back().empty() && lines.back().size() > 3 && + renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { // Remove "..." first, then remove one UTF-8 char, then add "..." back lines.back().resize(lines.back().size() - 3); // Remove "..." utf8RemoveLastChar(lines.back()); @@ -540,17 +541,20 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: break; } } + if (i.empty()) continue; // Skip words that couldn't fit even truncated - int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str()); + int newLineWidth = renderer.getTextAdvanceX(UI_12_FONT_ID, currentLine.c_str(), EpdFontFamily::REGULAR); if (newLineWidth > 0) { newLineWidth += spaceWidth; } - newLineWidth += wordWidth; + newLineWidth += renderer.getTextAdvanceX(UI_12_FONT_ID, i.c_str(), EpdFontFamily::REGULAR); if (newLineWidth > maxLineWidth && !currentLine.empty()) { // New line too long, push old line lines.push_back(currentLine); currentLine = i; + } else if (currentLine.empty()) { + currentLine = i; } else { currentLine.append(" ").append(i); } diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 2cde6cc6..6dc11e00 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -3,9 +3,11 @@ #include #include #include +#include #include #include +#include #include "Battery.h" #include "RecentBooksStore.h" @@ -482,13 +484,73 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: hPaddingInSelection, cornerRadius, false, false, true, true, Color::LightGray); } - auto title = renderer.truncatedText(UI_12_FONT_ID, book.title.c_str(), textWidth, EpdFontFamily::BOLD); + // Wrap title to up to 3 lines (word-wrap by advance width) + const std::string& lastBookTitle = book.title; + std::vector words; + words.reserve(8); + std::string::size_type wordStart = 0; + std::string::size_type wordEnd = 0; + // find_first_not_of skips leading/interstitial spaces + while ((wordStart = lastBookTitle.find_first_not_of(' ', wordEnd)) != std::string::npos) { + wordEnd = lastBookTitle.find(' ', wordStart); + if (wordEnd == std::string::npos) wordEnd = lastBookTitle.size(); + words.emplace_back(lastBookTitle.substr(wordStart, wordEnd - wordStart)); + } + const int maxLineWidth = textWidth; + const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID, EpdFontFamily::BOLD); + std::vector titleLines; + std::string currentLine; + for (auto& w : words) { + if (titleLines.size() >= 3) { + titleLines.back().append("..."); + while (!titleLines.back().empty() && titleLines.back().size() > 3 && + renderer.getTextWidth(UI_12_FONT_ID, titleLines.back().c_str(), EpdFontFamily::BOLD) > maxLineWidth) { + titleLines.back().resize(titleLines.back().size() - 3); + utf8RemoveLastChar(titleLines.back()); + titleLines.back().append("..."); + } + break; + } + int wordW = renderer.getTextWidth(UI_12_FONT_ID, w.c_str(), EpdFontFamily::BOLD); + while (wordW > maxLineWidth && !w.empty()) { + utf8RemoveLastChar(w); + std::string withE = w + "..."; + wordW = renderer.getTextWidth(UI_12_FONT_ID, withE.c_str(), EpdFontFamily::BOLD); + if (wordW <= maxLineWidth) { + w = withE; + break; + } + } + if (w.empty()) continue; // Skip words that couldn't fit even truncated + int newW = renderer.getTextAdvanceX(UI_12_FONT_ID, currentLine.c_str(), EpdFontFamily::BOLD); + if (newW > 0) newW += spaceWidth; + newW += renderer.getTextAdvanceX(UI_12_FONT_ID, w.c_str(), EpdFontFamily::BOLD); + if (newW > maxLineWidth && !currentLine.empty()) { + titleLines.push_back(currentLine); + currentLine = w; + } else if (currentLine.empty()) { + currentLine = w; + } else { + currentLine.append(" ").append(w); + } + } + if (!currentLine.empty() && titleLines.size() < 3) titleLines.push_back(currentLine); + auto author = renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), textWidth); - auto bookTitleHeight = renderer.getTextHeight(UI_12_FONT_ID); - renderer.drawText(UI_12_FONT_ID, tileX + hPaddingInSelection + coverWidth + LyraMetrics::values.verticalSpacing, - tileY + tileHeight / 2 - bookTitleHeight, title.c_str(), true, EpdFontFamily::BOLD); - renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection + coverWidth + LyraMetrics::values.verticalSpacing, - tileY + tileHeight / 2 + 5, author.c_str(), true); + const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int titleBlockHeight = titleLineHeight * static_cast(titleLines.size()); + const int authorHeight = book.author.empty() ? 0 : (renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2); + const int totalBlockHeight = titleBlockHeight + authorHeight; + int titleY = tileY + tileHeight / 2 - totalBlockHeight / 2; + const int textX = tileX + hPaddingInSelection + coverWidth + LyraMetrics::values.verticalSpacing; + for (const auto& line : titleLines) { + renderer.drawText(UI_12_FONT_ID, textX, titleY, line.c_str(), true, EpdFontFamily::BOLD); + titleY += titleLineHeight; + } + if (!book.author.empty()) { + titleY += renderer.getLineHeight(UI_10_FONT_ID) / 2; + renderer.drawText(UI_10_FONT_ID, textX, titleY, author.c_str(), true); + } } else { drawEmptyRecents(renderer, rect); } From c3093e3f7188ac2293505204bffc91fee22d41eb Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 22 Feb 2026 03:11:07 +0100 Subject: [PATCH 15/15] fix: Fix hyphenation and rendering of decomposed characters (#1037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * This PR fixes decomposed diacritic handling end-to-end: - Hyphenation: normalize common Latin base+combining sequences to precomposed codepoints before Liang pattern matching, so decomposed words hyphenate correctly - Rendering: correct combining-mark placement logic so non-spacing marks are attached to the preceding base glyph in normal and rotated text rendering paths, with corresponding text-bounds consistency updates. - Hyphenation around non breaking space variants have been fixed (and extended) - Hyphenation of terms that already included of hyphens were fixed to include Liang pattern application (eg "US-Satellitensystem" was *exclusively* broken at the existing hyphen) ## Additional Context * Before 2 * After fix1 fix2 * Note 1: the hyphenation fix is not a 100% bullet proof implementation. It adds composition of *common* base+combining sequences (e.g. O + U+0308 -> Ö) during codepoint collection. A complete solution would require implementing proper Unicode normalization (at least NFC, possibly NFKC in specific cases) before hyphenation and rendering, instead of hand-mapping a few combining marks. That was beyond the scope of this fix. * Note 2: the render fix should be universal and not limited to the constraints outlined above: it properly x-centers the compund glyph over the previous one, and it uses at least 1pt of visual distance in y. Before: Image After: Image * This should resolve the issues described in #998 --- ### 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? _**PARTIALLY**_ --- lib/EpdFont/EpdFont.cpp | 34 ++- lib/Epub/Epub/ParsedText.cpp | 33 ++- .../Epub/hyphenation/HyphenationCommon.cpp | 207 ++++++++++++++++++ lib/Epub/Epub/hyphenation/Hyphenator.cpp | 50 ++++- lib/Epub/Epub/hyphenation/Hyphenator.h | 21 +- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 44 +++- lib/GfxRenderer/GfxRenderer.cpp | 91 +++++++- lib/Utf8/Utf8.h | 8 + 8 files changed, 459 insertions(+), 29 deletions(-) diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index 5b770462..6d777a3e 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -17,6 +17,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star int cursorX = startX; const int cursorY = startY; + int lastBaseX = startX; + int lastBaseAdvance = 0; + int lastBaseTop = 0; + bool hasBaseGlyph = false; + constexpr int MIN_COMBINING_GAP_PX = 1; uint32_t cp; while ((cp = utf8NextCodepoint(reinterpret_cast(&string)))) { const EpdGlyph* glyph = getGlyph(cp); @@ -30,11 +35,30 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star continue; } - *minX = std::min(*minX, cursorX + glyph->left); - *maxX = std::max(*maxX, cursorX + glyph->left + glyph->width); - *minY = std::min(*minY, cursorY + glyph->top - glyph->height); - *maxY = std::max(*maxY, cursorY + glyph->top); - cursorX += glyph->advanceX; + const bool isCombining = utf8IsCombiningMark(cp); + int raiseBy = 0; + if (isCombining && hasBaseGlyph) { + const int currentGap = glyph->top - glyph->height - lastBaseTop; + if (currentGap < MIN_COMBINING_GAP_PX) { + raiseBy = MIN_COMBINING_GAP_PX - currentGap; + } + } + + const int glyphBaseX = (isCombining && hasBaseGlyph) ? (lastBaseX + lastBaseAdvance / 2) : cursorX; + const int glyphBaseY = cursorY - raiseBy; + + *minX = std::min(*minX, glyphBaseX + glyph->left); + *maxX = std::max(*maxX, glyphBaseX + glyph->left + glyph->width); + *minY = std::min(*minY, glyphBaseY + glyph->top - glyph->height); + *maxY = std::max(*maxY, glyphBaseY + glyph->top); + + if (!isCombining) { + lastBaseX = cursorX; + lastBaseAdvance = glyph->advanceX; + lastBaseTop = glyph->top; + hasBaseGlyph = true; + cursorX += glyph->advanceX; + } } } diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 1a0d2c56..867b5515 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -378,20 +378,35 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl 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. + // Continuation flag handling after splitting a word into prefix + remainder. + // + // The prefix keeps the original word's continuation flag so that no-break-space groups + // stay linked. The remainder always gets continues=false because it starts on the next + // line and is not attached to the prefix. + // + // Example: "200 Quadratkilometer" produces tokens: + // [0] "200" continues=false + // [1] " " continues=true + // [2] "Quadratkilometer" continues=true <-- the word being split + // + // After splitting "Quadratkilometer" at "Quadrat-" / "kilometer": + // [0] "200" continues=false + // [1] " " continues=true + // [2] "Quadrat-" continues=true (KEPT — still attached to the no-break group) + // [3] "kilometer" continues=false (NEW — starts fresh on the next line) + // + // This lets the backtracking loop keep the entire prefix group ("200 Quadrat-") on one + // line, while "kilometer" moves to the next line. 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; + // *continuesIt is intentionally left unchanged — the prefix keeps its original attachment. const auto insertContinuesIt = std::next(continuesIt); - wordContinues.insert(insertContinuesIt, originalContinuedToNext); + wordContinues.insert(insertContinuesIt, false); - // Keep the indexed vector in sync if provided + // Keep the indexed vector in sync if provided. if (continuesVec) { - (*continuesVec)[wordIndex] = false; - continuesVec->insert(continuesVec->begin() + wordIndex + 1, originalContinuedToNext); + // (*continuesVec)[wordIndex] stays unchanged — prefix keeps its attachment. + continuesVec->insert(continuesVec->begin() + wordIndex + 1, false); } // Update cached widths to reflect the new prefix/remainder pairing. diff --git a/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp b/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp index 0a6b7a92..15791ae0 100644 --- a/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp +++ b/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp @@ -174,6 +174,213 @@ std::vector collectCodepoints(const std::string& word) { while (*ptr != 0) { const unsigned char* current = ptr; const uint32_t cp = utf8NextCodepoint(&ptr); + // If this is a combining diacritic (e.g., U+0301 = acute) and there's + // a previous base character that can be composed into a single + // precomposed Unicode scalar (Latin-1 / Latin-Extended), do that + // composition here. This provides lightweight NFC-like behavior for + // common Western European diacritics (acute, grave, circumflex, tilde, + // diaeresis, cedilla) without pulling in a full Unicode normalization + // library. + if (!cps.empty()) { + uint32_t prev = cps.back().value; + uint32_t composed = 0; + switch (cp) { + case 0x0300: // grave + switch (prev) { + case 0x0041: + composed = 0x00C0; + break; // A -> À + case 0x0061: + composed = 0x00E0; + break; // a -> à + case 0x0045: + composed = 0x00C8; + break; // E -> È + case 0x0065: + composed = 0x00E8; + break; // e -> è + case 0x0049: + composed = 0x00CC; + break; // I -> Ì + case 0x0069: + composed = 0x00EC; + break; // i -> ì + case 0x004F: + composed = 0x00D2; + break; // O -> Ò + case 0x006F: + composed = 0x00F2; + break; // o -> ò + case 0x0055: + composed = 0x00D9; + break; // U -> Ù + case 0x0075: + composed = 0x00F9; + break; // u -> ù + default: + break; + } + break; + case 0x0301: // acute + switch (prev) { + case 0x0041: + composed = 0x00C1; + break; // A -> Á + case 0x0061: + composed = 0x00E1; + break; // a -> á + case 0x0045: + composed = 0x00C9; + break; // E -> É + case 0x0065: + composed = 0x00E9; + break; // e -> é + case 0x0049: + composed = 0x00CD; + break; // I -> Í + case 0x0069: + composed = 0x00ED; + break; // i -> í + case 0x004F: + composed = 0x00D3; + break; // O -> Ó + case 0x006F: + composed = 0x00F3; + break; // o -> ó + case 0x0055: + composed = 0x00DA; + break; // U -> Ú + case 0x0075: + composed = 0x00FA; + break; // u -> ú + case 0x0059: + composed = 0x00DD; + break; // Y -> Ý + case 0x0079: + composed = 0x00FD; + break; // y -> ý + default: + break; + } + break; + case 0x0302: // circumflex + switch (prev) { + case 0x0041: + composed = 0x00C2; + break; // A -> Â + case 0x0061: + composed = 0x00E2; + break; // a -> â + case 0x0045: + composed = 0x00CA; + break; // E -> Ê + case 0x0065: + composed = 0x00EA; + break; // e -> ê + case 0x0049: + composed = 0x00CE; + break; // I -> Î + case 0x0069: + composed = 0x00EE; + break; // i -> î + case 0x004F: + composed = 0x00D4; + break; // O -> Ô + case 0x006F: + composed = 0x00F4; + break; // o -> ô + case 0x0055: + composed = 0x00DB; + break; // U -> Û + case 0x0075: + composed = 0x00FB; + break; // u -> û + default: + break; + } + break; + case 0x0303: // tilde + switch (prev) { + case 0x0041: + composed = 0x00C3; + break; // A -> Ã + case 0x0061: + composed = 0x00E3; + break; // a -> ã + case 0x004E: + composed = 0x00D1; + break; // N -> Ñ + case 0x006E: + composed = 0x00F1; + break; // n -> ñ + default: + break; + } + break; + case 0x0308: // diaeresis/umlaut + switch (prev) { + case 0x0041: + composed = 0x00C4; + break; // A -> Ä + case 0x0061: + composed = 0x00E4; + break; // a -> ä + case 0x0045: + composed = 0x00CB; + break; // E -> Ë + case 0x0065: + composed = 0x00EB; + break; // e -> ë + case 0x0049: + composed = 0x00CF; + break; // I -> Ï + case 0x0069: + composed = 0x00EF; + break; // i -> ï + case 0x004F: + composed = 0x00D6; + break; // O -> Ö + case 0x006F: + composed = 0x00F6; + break; // o -> ö + case 0x0055: + composed = 0x00DC; + break; // U -> Ü + case 0x0075: + composed = 0x00FC; + break; // u -> ü + case 0x0059: + composed = 0x0178; + break; // Y -> Ÿ + case 0x0079: + composed = 0x00FF; + break; // y -> ÿ + default: + break; + } + break; + case 0x0327: // cedilla + switch (prev) { + case 0x0043: + composed = 0x00C7; + break; // C -> Ç + case 0x0063: + composed = 0x00E7; + break; // c -> ç + default: + break; + } + break; + default: + break; + } + + if (composed != 0) { + cps.back().value = composed; + continue; // skip pushing the combining mark itself + } + } + cps.push_back({cp, static_cast(current - base)}); } diff --git a/lib/Epub/Epub/hyphenation/Hyphenator.cpp b/lib/Epub/Epub/hyphenation/Hyphenator.cpp index e485083f..4d86febe 100644 --- a/lib/Epub/Epub/hyphenation/Hyphenator.cpp +++ b/lib/Epub/Epub/hyphenation/Hyphenator.cpp @@ -1,8 +1,10 @@ #include "Hyphenator.h" +#include #include #include "HyphenationCommon.h" +#include "LanguageHyphenator.h" #include "LanguageRegistry.h" const LanguageHyphenator* Hyphenator::cachedHyphenator_ = nullptr; @@ -32,10 +34,19 @@ size_t byteOffsetForIndex(const std::vector& cps, const size_t in } // Builds a vector of break information from explicit hyphen markers in the given codepoints. +// Only hyphens that appear between two alphabetic characters are considered valid breaks. +// +// Example: "US-Satellitensystems" (cps: U, S, -, S, a, t, ...) +// -> finds '-' at index 2 with alphabetic neighbors 'S' and 'S' +// -> returns one BreakInfo at the byte offset of 'S' (the char after '-'), +// with requiresInsertedHyphen=false because '-' is already visible. +// +// Example: "Satel\u00ADliten" (soft-hyphen between 'l' and 'l') +// -> returns one BreakInfo with requiresInsertedHyphen=true (soft-hyphen +// is invisible and needs a visible '-' when the break is used). std::vector buildExplicitBreakInfos(const std::vector& cps) { std::vector breaks; - // Scan every codepoint looking for explicit/soft hyphen markers that are surrounded by letters. for (size_t i = 1; i + 1 < cps.size(); ++i) { const uint32_t cp = cps[i].value; if (!isExplicitHyphen(cp) || !isAlphabetic(cps[i - 1].value) || !isAlphabetic(cps[i + 1].value)) { @@ -63,6 +74,43 @@ std::vector Hyphenator::breakOffsets(const std::string& w // Explicit hyphen markers (soft or hard) take precedence over language breaks. auto explicitBreakInfos = buildExplicitBreakInfos(cps); if (!explicitBreakInfos.empty()) { + // When a word contains explicit hyphens we also run Liang patterns on each alphabetic + // segment between them. Without this, "US-Satellitensystems" would only offer one split + // point (after "US-"), making it impossible to break mid-"Satellitensystems" even when + // "US-Satelliten-" would fit on the line. + // + // Example: "US-Satellitensystems" + // Segments: ["US", "Satellitensystems"] + // Explicit break: after "US-" -> @3 (no inserted hyphen) + // Pattern breaks on "Satellitensystems" -> @5 Sa|tel (+hyphen) + // @8 Satel|li (+hyphen) + // @10 Satelli|ten (+hyphen) + // @13 Satelliten|sys (+hyphen) + // @16 Satellitensys|tems (+hyphen) + // Result: 6 sorted break points; the line-breaker picks the widest prefix that fits. + if (hyphenator) { + size_t segStart = 0; + for (size_t i = 0; i <= cps.size(); ++i) { + const bool atEnd = (i == cps.size()); + const bool atHyphen = !atEnd && isExplicitHyphen(cps[i].value); + if (atEnd || atHyphen) { + if (i > segStart) { + std::vector segment(cps.begin() + segStart, cps.begin() + i); + auto segIndexes = hyphenator->breakIndexes(segment); + for (const size_t idx : segIndexes) { + const size_t cpIdx = segStart + idx; + if (cpIdx < cps.size()) { + explicitBreakInfos.push_back({cps[cpIdx].byteOffset, true}); + } + } + } + segStart = i + 1; + } + } + // Merge explicit and pattern breaks into ascending byte-offset order. + std::sort(explicitBreakInfos.begin(), explicitBreakInfos.end(), + [](const BreakInfo& a, const BreakInfo& b) { return a.byteOffset < b.byteOffset; }); + } return explicitBreakInfos; } diff --git a/lib/Epub/Epub/hyphenation/Hyphenator.h b/lib/Epub/Epub/hyphenation/Hyphenator.h index ffbe16fa..4447f9cc 100644 --- a/lib/Epub/Epub/hyphenation/Hyphenator.h +++ b/lib/Epub/Epub/hyphenation/Hyphenator.h @@ -9,11 +9,24 @@ class LanguageHyphenator; class Hyphenator { public: struct BreakInfo { - size_t byteOffset; - bool requiresInsertedHyphen; + size_t byteOffset; // Byte position inside the UTF-8 word where a break may occur. + bool requiresInsertedHyphen; // true = a visible '-' must be rendered at the break (pattern/fallback breaks). + // false = the word already contains a hyphen at this position (explicit '-'). }; - // Returns byte offsets where the word may be hyphenated. When includeFallback is true, all positions obeying the - // minimum prefix/suffix constraints are returned even if no language-specific rule matches. + + // Returns byte offsets where the word may be hyphenated. + // + // Break sources (in priority order): + // 1. Explicit hyphens already present in the word (e.g. '-' or soft-hyphen U+00AD). + // When found, language patterns are additionally run on each alphabetic segment + // between hyphens so compound words can break within their parts. + // Example: "US-Satellitensystems" yields breaks after "US-" (no inserted hyphen) + // plus pattern breaks inside "Satellitensystems" (Sa|tel|li|ten|sys|tems). + // 2. Language-specific Liang patterns (e.g. German de_patterns). + // Example: "Quadratkilometer" -> Qua|drat|ki|lo|me|ter. + // 3. Fallback every-N-chars splitting (only when includeFallback is true AND no + // pattern breaks were found). Used as a last resort to prevent a single oversized + // word from overflowing the page width. static std::vector breakOffsets(const std::string& word, bool includeFallback); // Provide a publication-level language hint (e.g. "en", "en-US", "ru") used to select hyphenation rules. diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 4fbba8af..90bf8fee 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -594,28 +594,60 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char continue; } - // Detect U+00A0 (non-breaking space): UTF-8 encoding is 0xC2 0xA0 - // Render a visible space without allowing a line break around it. + // Detect U+00A0 (non-breaking space, UTF-8: 0xC2 0xA0) or + // U+202F (narrow no-break space, UTF-8: 0xE2 0x80 0xAF). + // + // Both are rendered as a visible space but must never allow a line break around them. + // We split the no-break space into its own word token and link the surrounding words + // with continuation flags so the layout engine treats them as an indivisible group. + // + // Example: "200 Quadratkilometer" or "200 Quadratkilometer" + // Input bytes: "200\xC2\xA0Quadratkilometer" (or 0xE2 0x80 0xAF for U+202F) + // Tokens produced: + // [0] "200" continues=false + // [1] " " continues=true (attaches to "200", no gap) + // [2] "Quadratkilometer" continues=true (attaches to " ", no gap) + // + // The continuation flags prevent the line-breaker from inserting a line break + // between "200" and "Quadratkilometer". However, "Quadratkilometer" is now a + // standalone word for hyphenation purposes, so Liang patterns can produce + // "200 Quadrat-" / "kilometer" instead of the unusable "200" / "Quadratkilometer". if (static_cast(s[i]) == 0xC2 && i + 1 < len && static_cast(s[i + 1]) == 0xA0) { - // Flush any pending text so style is applied correctly. if (self->partWordBufferIndex > 0) { self->flushPartWordBuffer(); } - // Add a standalone space that attaches to the previous word. self->partWordBuffer[0] = ' '; self->partWordBuffer[1] = '\0'; self->partWordBufferIndex = 1; self->nextWordContinues = true; // Attach space to previous word (no break). self->flushPartWordBuffer(); - // Ensure the next real word attaches to this space (no break). - self->nextWordContinues = true; + self->nextWordContinues = true; // Next real word attaches to this space (no break). i++; // Skip the second byte (0xA0) continue; } + // U+202F (narrow no-break space) — identical logic to U+00A0 above. + if (static_cast(s[i]) == 0xE2 && i + 2 < len && static_cast(s[i + 1]) == 0x80 && + static_cast(s[i + 2]) == 0xAF) { + if (self->partWordBufferIndex > 0) { + self->flushPartWordBuffer(); + } + + self->partWordBuffer[0] = ' '; + self->partWordBuffer[1] = '\0'; + self->partWordBufferIndex = 1; + self->nextWordContinues = true; + self->flushPartWordBuffer(); + + self->nextWordContinues = true; + + i += 2; // Skip the remaining two bytes (0x80 0xAF) + continue; + } + // Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF const XML_Char FEFF_BYTE_1 = static_cast(0xEF); const XML_Char FEFF_BYTE_2 = static_cast(0xBB); diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index b385bc03..02ce8362 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -157,10 +157,12 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode } } - if constexpr (rotation == TextRotation::Rotated90CW) { - *cursorY -= glyph->advanceX; - } else { - *cursorX += glyph->advanceX; + if (!utf8IsCombiningMark(cp)) { + if constexpr (rotation == TextRotation::Rotated90CW) { + *cursorY -= glyph->advanceX; + } else { + *cursorX += glyph->advanceX; + } } } @@ -212,6 +214,11 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha const EpdFontFamily::Style style) const { int yPos = y + getFontAscenderSize(fontId); int xpos = x; + int lastBaseX = x; + int lastBaseY = yPos; + int lastBaseAdvance = 0; + int lastBaseTop = 0; + bool hasBaseGlyph = false; // cannot draw a NULL / empty string if (text == nullptr || *text == '\0') { @@ -224,9 +231,43 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha return; } const auto& font = fontIt->second; + constexpr int MIN_COMBINING_GAP_PX = 1; uint32_t cp; while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + if (utf8IsCombiningMark(cp) && hasBaseGlyph) { + const EpdGlyph* combiningGlyph = font.getGlyph(cp, style); + if (!combiningGlyph) { + combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style); + } + + int raiseBy = 0; + if (combiningGlyph) { + const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop; + if (currentGap < MIN_COMBINING_GAP_PX) { + raiseBy = MIN_COMBINING_GAP_PX - currentGap; + } + } + + int combiningX = lastBaseX + lastBaseAdvance / 2; + int combiningY = lastBaseY - raiseBy; + renderChar(font, cp, &combiningX, &combiningY, black, style); + continue; + } + + const EpdGlyph* glyph = font.getGlyph(cp, style); + if (!glyph) { + glyph = font.getGlyph(REPLACEMENT_GLYPH, style); + } + + if (!utf8IsCombiningMark(cp)) { + lastBaseX = xpos; + lastBaseY = yPos; + lastBaseAdvance = glyph ? glyph->advanceX : 0; + lastBaseTop = glyph ? glyph->top : 0; + hasBaseGlyph = true; + } + renderChar(font, cp, &xpos, &yPos, black, style); } } @@ -864,6 +905,9 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, const EpdFo int width = 0; const auto& font = fontIt->second; while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + if (utf8IsCombiningMark(cp)) { + continue; + } const EpdGlyph* glyph = font.getGlyph(cp, style); if (!glyph) glyph = font.getGlyph(REPLACEMENT_GLYPH, style); if (glyph) width += glyph->advanceX; @@ -917,9 +961,48 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y int xPos = x; int yPos = y; + int lastBaseX = x; + int lastBaseY = y; + int lastBaseAdvance = 0; + int lastBaseTop = 0; + bool hasBaseGlyph = false; + constexpr int MIN_COMBINING_GAP_PX = 1; uint32_t cp; while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + if (utf8IsCombiningMark(cp) && hasBaseGlyph) { + const EpdGlyph* combiningGlyph = font.getGlyph(cp, style); + if (!combiningGlyph) { + combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style); + } + + int raiseBy = 0; + if (combiningGlyph) { + const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop; + if (currentGap < MIN_COMBINING_GAP_PX) { + raiseBy = MIN_COMBINING_GAP_PX - currentGap; + } + } + + int combiningX = lastBaseX - raiseBy; + int combiningY = lastBaseY - lastBaseAdvance / 2; + renderCharImpl(*this, renderMode, font, cp, &combiningX, &combiningY, black, style); + continue; + } + + const EpdGlyph* glyph = font.getGlyph(cp, style); + if (!glyph) { + glyph = font.getGlyph(REPLACEMENT_GLYPH, style); + } + + if (!utf8IsCombiningMark(cp)) { + lastBaseX = xPos; + lastBaseY = yPos; + lastBaseAdvance = glyph ? glyph->advanceX : 0; + lastBaseTop = glyph ? glyph->top : 0; + hasBaseGlyph = true; + } + renderCharImpl(*this, renderMode, font, cp, &xPos, &yPos, black, style); } } diff --git a/lib/Utf8/Utf8.h b/lib/Utf8/Utf8.h index 23d63a4e..cce7c0d6 100644 --- a/lib/Utf8/Utf8.h +++ b/lib/Utf8/Utf8.h @@ -9,3 +9,11 @@ uint32_t utf8NextCodepoint(const unsigned char** string); size_t utf8RemoveLastChar(std::string& str); // Truncate string by removing N UTF-8 codepoints from the end. void utf8TruncateChars(std::string& str, size_t numChars); + +// Returns true for Unicode combining diacritical marks that should not advance the cursor. +inline bool utf8IsCombiningMark(const uint32_t cp) { + return (cp >= 0x0300 && cp <= 0x036F) // Combining Diacritical Marks + || (cp >= 0x1DC0 && cp <= 0x1DFF) // Combining Diacritical Marks Supplement + || (cp >= 0x20D0 && cp <= 0x20FF) // Combining Diacritical Marks for Symbols + || (cp >= 0xFE20 && cp <= 0xFE2F); // Combining Half Marks +}