diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e2fd673b..037b6504 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -241,6 +241,10 @@ The Settings screen allows you to configure the device's behavior. There are a f - "Chapter Skip" (default) - Long-pressing skips to next/previous chapter - "Page Scroll" - Long-pressing scrolls a page up/down +- **Long-press Menu**: Selects the function bound to holding the menu button (Confirm) while reading an EPUB. **Cycles through the available functions** each time the setting is selected — additional functions may be added in future releases, so this is not a binary on/off toggle. A short press of Confirm always opens the reader menu as normal: + - "Bookmark" (default) - Hold Confirm (~0.4 second) to drop a bookmark at the current page. + - "KOSync" - Hold Confirm (~1 second) to launch KOReader sync directly. + - "Disabled" - Long-press is ignored; only short-press opens the reader menu. - **Short Power Button Click**: Controls the effect of a short click of the power button: @@ -528,6 +532,7 @@ When reading an EPUB that contains footnotes, you can navigate to the footnote t * **Return to Home:** Press the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen. * **Return to Browse Files:** Press and hold the **Back** button to close the book and return to the **[Browse Files](#33-browse-files-screen)** screen. * **Reader Menu:** Press **Confirm** to open the **[Reader Menu](#5-reader-menu)**, which includes chapter navigation, reading options, and more. +* **Long-press Confirm (configurable):** Holding **Confirm** runs the function chosen by the **Long-press Menu** setting in **[Controls Settings](#363-controls)** — "Bookmark" (default) drops a bookmark, "KOSync" launches KOReader Sync, "Disabled" does nothing. A short press always opens the Reader Menu. ### Supported Languages @@ -574,9 +579,9 @@ Accessible by selecting **Chapters** from the Reader Menu. Bookmarks can be created to quickly save and restore your place in a book. -To create a bookmark, hold **Confirm** for 1 second while inside a book. A popup will appear letting you know a bookmark was created. The popup message will automatically disappear in a couple of seconds. +To create a bookmark, hold **Confirm** for about half a second while inside a book. A popup will appear letting you know a bookmark was created. The popup message will automatically disappear in a couple of seconds. -To open bookmarks, press **Confirm** while inside a book. Then navigate to the **Bookmarks** menu. Bookmarks can be opened by navigating to them and pressing **Confirm**, which will redirect you to that place in the book. You can delete bookmarks by holding **Confirm** for 1 second, and then pressing **Confirm** again to confirm deletion, or **Back** to cancel. +To open bookmarks, press **Confirm** while inside a book. Then navigate to the **Bookmarks** menu. Bookmarks can be opened by navigating to them and pressing **Confirm**, which will redirect you to that place in the book. You can delete bookmarks by holding **Confirm** for about 0.7 seconds, and then pressing **Confirm** again to confirm deletion, or **Back** to cancel. Bookmarks are stored in the `.crosspoint/bookmarks` folder in the JSON format. diff --git a/docs/images/wifi/webserver_upload_dragdrop.png b/docs/images/wifi/webserver_upload_dragdrop.png new file mode 100644 index 00000000..5ab12d1a Binary files /dev/null and b/docs/images/wifi/webserver_upload_dragdrop.png differ diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index 599554b7..6055e845 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -101,6 +101,9 @@ static uint8_t lookupKernClass(const EpdKernClassEntry* entries, const uint16_t } int8_t EpdFont::getKerning(const uint32_t leftCp, const uint32_t rightCp) const { + if (utf8IsCjkBreakable(leftCp) || utf8IsCjkBreakable(rightCp)) { + return 0; + } if (!data->kernMatrix) { return 0; } diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 1c48f278..1609ab3a 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -115,6 +115,9 @@ void SdCardFont::freeStyleAll(PerStyle& s) { freeStyleMiniData(s); delete[] s.fullIntervals; s.fullIntervals = nullptr; + delete[] s.bmpIntervals; + s.bmpIntervals = nullptr; + s.intervalsAreBmp16 = false; freeStyleKernLigatureData(s); s.present = false; } @@ -516,59 +519,94 @@ bool SdCardFont::load(const char* path) { styleCount_ = styleCount; contentHash_ = hash; - // Load full intervals into RAM for each present style + // Load full intervals into RAM for each present style. BMP-only fonts with + // fewer than 65536 glyphs use a compact 6-byte interval table instead of the + // on-disk 12-byte table; large sparse CJK subsets otherwise keep tens of KB + // of always-resident heap just for lookup metadata. for (uint8_t i = 0; i < MAX_STYLES; i++) { auto& s = styles_[i]; if (!s.present) continue; - s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount]; - if (!s.fullIntervals) { - LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i); - freeAll(); - return false; - } - if (!file.seekSet(s.intervalsFileOffset)) { LOG_ERR("SDCF", "Failed to seek to intervals for style %u", i); freeAll(); return false; } - size_t intervalsBytes = s.header.intervalCount * sizeof(EpdUnicodeInterval); - if (file.read(reinterpret_cast(s.fullIntervals), intervalsBytes) != static_cast(intervalsBytes)) { - LOG_ERR("SDCF", "Failed to read intervals for style %u", i); - freeAll(); - return false; - } // Validate interval contents before any later code (findGlobalGlyphIndex, // glyph reads) trusts them. A malformed file could otherwise drive // out-of-range glyph indices into bogus on-disk reads. - { - uint32_t expectedOffset = 0; - uint32_t prevLast = 0; + bool canUseBmp16 = s.header.glyphCount <= UINT16_MAX; + uint32_t expectedOffset = 0; + uint32_t prevLast = 0; + EpdUnicodeInterval iv{}; + for (uint32_t j = 0; j < s.header.intervalCount; ++j) { + if (file.read(reinterpret_cast(&iv), sizeof(iv)) != sizeof(iv)) { + LOG_ERR("SDCF", "Failed to read interval %u for style %u", j, i); + freeAll(); + return false; + } + if (iv.first > iv.last) { + LOG_ERR("SDCF", "Style %u: invalid interval %u (first 0x%lX > last 0x%lX)", i, j, + static_cast(iv.first), static_cast(iv.last)); + file.close(); + freeAll(); + return false; + } + const uint32_t span = iv.last - iv.first + 1; + const bool overlapsPrev = (j > 0 && iv.first <= prevLast); + const bool spanTooBig = (span > s.header.glyphCount); + const bool offsetMismatch = (iv.offset != expectedOffset); + const bool offsetOverruns = (iv.offset > s.header.glyphCount - span); + if (overlapsPrev || spanTooBig || offsetMismatch || offsetOverruns) { + LOG_ERR("SDCF", "Style %u: invalid interval layout at %u (overlap=%d span=%u offMis=%d offOver=%d)", i, j, + overlapsPrev, span, offsetMismatch, offsetOverruns); + file.close(); + freeAll(); + return false; + } + if (iv.first > UINT16_MAX || iv.last > UINT16_MAX || iv.offset > UINT16_MAX) { + canUseBmp16 = false; + } + expectedOffset += span; + prevLast = iv.last; + } + + if (!file.seekSet(s.intervalsFileOffset)) { + LOG_ERR("SDCF", "Failed to seek back to intervals for style %u", i); + freeAll(); + return false; + } + + if (canUseBmp16) { + s.bmpIntervals = new (std::nothrow) PerStyle::BmpInterval16[s.header.intervalCount]; + if (!s.bmpIntervals) { + LOG_ERR("SDCF", "Failed to allocate compact intervals for style %u", i); + freeAll(); + return false; + } for (uint32_t j = 0; j < s.header.intervalCount; ++j) { - const auto& iv = s.fullIntervals[j]; - if (iv.first > iv.last) { - LOG_ERR("SDCF", "Style %u: invalid interval %u (first 0x%lX > last 0x%lX)", i, j, - static_cast(iv.first), static_cast(iv.last)); - file.close(); + if (file.read(reinterpret_cast(&iv), sizeof(iv)) != sizeof(iv)) { + LOG_ERR("SDCF", "Failed to read compact interval %u for style %u", j, i); freeAll(); return false; } - const uint32_t span = iv.last - iv.first + 1; - const bool overlapsPrev = (j > 0 && iv.first <= prevLast); - const bool spanTooBig = (span > s.header.glyphCount); - const bool offsetMismatch = (iv.offset != expectedOffset); - const bool offsetOverruns = (iv.offset > s.header.glyphCount - span); - if (overlapsPrev || spanTooBig || offsetMismatch || offsetOverruns) { - LOG_ERR("SDCF", "Style %u: invalid interval layout at %u (overlap=%d span=%u offMis=%d offOver=%d)", i, j, - overlapsPrev, span, offsetMismatch, offsetOverruns); - file.close(); - freeAll(); - return false; - } - expectedOffset += span; - prevLast = iv.last; + s.bmpIntervals[j] = {static_cast(iv.first), static_cast(iv.last), + static_cast(iv.offset)}; + } + s.intervalsAreBmp16 = true; + } else { + s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount]; + if (!s.fullIntervals) { + LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i); + freeAll(); + return false; + } + size_t intervalsBytes = s.header.intervalCount * sizeof(EpdUnicodeInterval); + if (file.read(reinterpret_cast(s.fullIntervals), intervalsBytes) != static_cast(intervalsBytes)) { + LOG_ERR("SDCF", "Failed to read intervals for style %u", i); + freeAll(); + return false; } } @@ -603,13 +641,15 @@ int32_t SdCardFont::findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) int right = static_cast(s.header.intervalCount) - 1; while (left <= right) { int mid = left + (right - left) / 2; - const auto& interval = s.fullIntervals[mid]; - if (codepoint < interval.first) { + const uint32_t first = s.intervalsAreBmp16 ? s.bmpIntervals[mid].first : s.fullIntervals[mid].first; + const uint32_t last = s.intervalsAreBmp16 ? s.bmpIntervals[mid].last : s.fullIntervals[mid].last; + if (codepoint < first) { right = mid - 1; - } else if (codepoint > interval.last) { + } else if (codepoint > last) { left = mid + 1; } else { - return static_cast(interval.offset + (codepoint - interval.first)); + const uint32_t offset = s.intervalsAreBmp16 ? s.bmpIntervals[mid].offset : s.fullIntervals[mid].offset; + return static_cast(offset + (codepoint - first)); } } return -1; @@ -1257,7 +1297,7 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { if (!self->loaded_ || styleIdx >= MAX_STYLES || !self->styles_[styleIdx].present) return nullptr; const auto& s = self->styles_[styleIdx]; - if (!s.fullIntervals) return nullptr; + if (!s.fullIntervals && !s.bmpIntervals) return nullptr; // Check overflow cache first (matching both codepoint and style) for (uint32_t i = 0; i < self->overflowCount_; i++) { diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index d4d680ed..443fee68 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -58,9 +58,9 @@ class SdCardFont { // Returns true if advance table is populated for at least one style. bool hasAdvanceTable() const; - // Free mini data for all styles, restore stub EpdFontData. - // Also clears the temporary advance table (built per layout pass) but - // preserves the persistent advance cache (reused across passes). + // Free mini data for all styles and restore stub EpdFontData. + // Preserves the persistent advance cache so repeated layout passes can reuse + // previously fetched metrics. void clearCache(); // Drop the persistent advance cache. Call when unloading the SD font or @@ -140,6 +140,14 @@ class SdCardFont { // Full intervals loaded from file (kept in RAM for codepoint lookup) EpdUnicodeInterval* fullIntervals = nullptr; + struct BmpInterval16 { + uint16_t first; + uint16_t last; + uint16_t offset; + } __attribute__((packed)); + static_assert(sizeof(BmpInterval16) == 6, "BmpInterval16 must remain compact"); + BmpInterval16* bmpIntervals = nullptr; + bool intervalsAreBmp16 = false; // Persistent kern-class + ligature tables (lazy-loaded on first prewarm). // The full kern MATRIX is NOT resident — on Literata-class fonts a single diff --git a/lib/EpdFont/SdCardFontManager.cpp b/lib/EpdFont/SdCardFontManager.cpp index a6032336..2d221386 100644 --- a/lib/EpdFont/SdCardFontManager.cpp +++ b/lib/EpdFont/SdCardFontManager.cpp @@ -34,19 +34,15 @@ bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRender unloadAll(renderer); } - // Select by ordinal position: sort available sizes, then map the font size - // enum (SMALL=0 .. EXTRA_LARGE=3) to the corresponding slot. When the - // family has fewer sizes than 4, clamp to the last available size. - auto sizes = family.availableSizes(); - if (sizes.empty()) { + // Select the physical point size closest to the built-in reader sizes. Some + // CJK font packs only ship larger sizes, so ordinal selection can make + // MEDIUM load 18pt+ and produce oversized pages on small devices. + const SdCardFontFileInfo* selected = family.findClosestReaderSize(fontSizeEnum); + if (!selected) { LOG_ERR("SDMGR", "Family %s has no files to load", family.name.c_str()); return false; } - uint8_t idx = fontSizeEnum; - if (idx >= sizes.size()) idx = sizes.size() - 1; - const SdCardFontFileInfo* selected = family.findFile(sizes[idx]); - auto* font = new (std::nothrow) SdCardFont(); if (!font) { LOG_ERR("SDMGR", "Failed to allocate SdCardFont for %s", selected->path.c_str()); diff --git a/lib/EpdFont/SdCardFontManager.h b/lib/EpdFont/SdCardFontManager.h index aec07472..f9d2b3f2 100644 --- a/lib/EpdFont/SdCardFontManager.h +++ b/lib/EpdFont/SdCardFontManager.h @@ -15,10 +15,10 @@ class SdCardFontManager { SdCardFontManager(const SdCardFontManager&) = delete; SdCardFontManager& operator=(const SdCardFontManager&) = delete; - // Load the font file matching fontSizeEnum (SMALL=0 .. EXTRA_LARGE=3) by - // ordinal position in the family's sorted size list. Only one .cpfont file - // is loaded; other sizes remain on disk. This keeps resident interval + - // kern/ligature tables to one size's worth of memory. + // Load the font file whose physical point size is closest to the reader + // fontSizeEnum (SMALL=12, MEDIUM=14, LARGE=16, EXTRA_LARGE=18). Only one + // .cpfont file is loaded; other sizes remain on disk. This keeps resident + // interval + kern/ligature tables to one size's worth of memory. // Returns true on success. bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum); @@ -32,7 +32,7 @@ class SdCardFontManager { // Get name of currently loaded family (empty if none). const std::string& currentFamilyName() const { return loadedFamilyName_; }; - // Point size that was actually loaded (closest match to targetPtSize). + // Point size that was actually loaded. // 0 if nothing loaded. uint8_t currentPointSize() const { return loadedPointSize_; }; diff --git a/lib/EpdFont/SdCardFontRegistry.cpp b/lib/EpdFont/SdCardFontRegistry.cpp index fe01c62e..07966a85 100644 --- a/lib/EpdFont/SdCardFontRegistry.cpp +++ b/lib/EpdFont/SdCardFontRegistry.cpp @@ -15,6 +15,40 @@ const SdCardFontFileInfo* SdCardFontFamilyInfo::findFile(uint8_t size, uint8_t s return nullptr; } +const SdCardFontFileInfo* SdCardFontFamilyInfo::findClosestReaderSize(const uint8_t fontSizeEnum, + const uint8_t style) const { + if (files.empty()) return nullptr; + + uint8_t target = 14; + switch (fontSizeEnum) { + case 0: + target = 12; + break; + case 2: + target = 16; + break; + case 3: + target = 18; + break; + case 1: + default: + target = 14; + break; + } + + const SdCardFontFileInfo* best = nullptr; + uint8_t bestDelta = 255; + for (const auto& f : files) { + if (f.style != style) continue; + const uint8_t delta = f.pointSize > target ? f.pointSize - target : target - f.pointSize; + if (!best || delta < bestDelta || (delta == bestDelta && f.pointSize < best->pointSize)) { + best = &f; + bestDelta = delta; + } + } + return best; +} + bool SdCardFontFamilyInfo::hasSize(uint8_t size) const { for (const auto& f : files) { if (f.pointSize == size) return true; diff --git a/lib/EpdFont/SdCardFontRegistry.h b/lib/EpdFont/SdCardFontRegistry.h index f96035ed..57220f4d 100644 --- a/lib/EpdFont/SdCardFontRegistry.h +++ b/lib/EpdFont/SdCardFontRegistry.h @@ -18,6 +18,7 @@ struct SdCardFontFamilyInfo { std::vector files; const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const; + const SdCardFontFileInfo* findClosestReaderSize(uint8_t fontSizeEnum, uint8_t style = 0) const; bool hasSize(uint8_t size) const; std::vector availableSizes() const; }; diff --git a/lib/EpdFont/scripts/fontconvert.py b/lib/EpdFont/scripts/fontconvert.py index a55fab75..26df3b8a 100755 --- a/lib/EpdFont/scripts/fontconvert.py +++ b/lib/EpdFont/scripts/fontconvert.py @@ -248,7 +248,7 @@ unmerged_intervals = sorted(intervals + add_ints) intervals = [] unvalidated_intervals = [] for i_start, i_end in unmerged_intervals: - if len(unvalidated_intervals) > 0 and i_start + 1 <= unvalidated_intervals[-1][1]: + if len(unvalidated_intervals) > 0 and i_start <= unvalidated_intervals[-1][1] + 1: unvalidated_intervals[-1] = (unvalidated_intervals[-1][0], max(unvalidated_intervals[-1][1], i_end)) continue unvalidated_intervals.append((i_start, i_end)) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 47d11708..5f5dcc8a 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "Epub/parsers/ContainerParser.h" @@ -73,8 +74,9 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, const return false; } - // Grab data from opfParser into epub - bookMetadata.title = opfParser.title; + // Grab data from opfParser into epub. Normalize titles to NFC so NFD (combining + // mark) text renders correctly — the device fonts have no mark positioning. + bookMetadata.title = utf8ComposeNfc(opfParser.title); bookMetadata.author = opfParser.author; bookMetadata.language = opfParser.language; bookMetadata.coverItemHref = opfParser.coverItemHref; diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp index a71ab5cb..f3089b68 100644 --- a/lib/Epub/Epub/BookMetadataCache.cpp +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -9,7 +10,7 @@ #include "FsHelpers.h" namespace { -constexpr uint8_t BOOK_CACHE_VERSION = 7; +constexpr uint8_t BOOK_CACHE_VERSION = 8; // v8: TOC/book titles stored NFC-composed constexpr char bookBinFile[] = "/book.bin"; constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; @@ -403,7 +404,9 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri } } - const TocEntry entry(title, href, anchor, level, spineIndex); + // Compose the title to NFC at index time so the cache stores precomposed glyphs; + // device fonts have no combining-mark positioning, so NFD titles render broken. + const TocEntry entry(utf8ComposeNfc(title), href, anchor, level, spineIndex); writeTocEntry(tocFile, entry); tocCount++; } diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index b66b36ee..e57d1743 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -24,6 +24,7 @@ constexpr size_t RTL_PARAGRAPH_PROBE_WORDS = 3; // Per-word: scan enough chars to see through leading neutrals (quotes, numbers) // before giving up. 64 is a hedge for pathological cases like long numeric tokens. constexpr int RTL_PER_WORD_PROBE_DEPTH = 64; +constexpr size_t MIN_JUSTIFY_GAPS = 1; // Byte-level pre-check: Hebrew UTF-8 lead bytes 0xD6-0xD7, Arabic/Syriac 0xD8-0xDB. bool mayContainRtlBytes(const char* str) { @@ -57,6 +58,134 @@ uint32_t lastCodepoint(const std::string& word) { bool containsSoftHyphen(const std::string& word) { return word.find(SOFT_HYPHEN_UTF8) != std::string::npos; } +bool isNoBreakBeforeCjkPunctuation(const uint32_t cp) { + switch (cp) { + case '.': + case ',': + case ':': + case ';': + case '!': + case '?': + case ')': + case ']': + case '}': + case 0x00BB: // » + case 0x2019: // ’ + case 0x201D: // ” + case 0x3001: // 、 + case 0x3002: // 。 + case 0x3009: // 〉 + case 0x300B: // 》 + case 0x300D: // 」 + case 0x300F: // 』 + case 0x3011: // 】 + case 0x3015: // 〕 + case 0x3017: // 〗 + case 0x3019: // 〙 + case 0x301B: // 〛 + case 0xFF01: // ! + case 0xFF09: // ) + case 0xFF0C: // , + case 0xFF0E: // . + case 0xFF1A: // : + case 0xFF1B: // ; + case 0xFF1F: // ? + case 0xFF3D: // ] + case 0xFF5D: // } + return true; + default: + return false; + } +} + +bool isNoBreakAfterCjkPunctuation(const uint32_t cp) { + switch (cp) { + case '(': + case '[': + case '{': + case 0x00AB: // « + case 0x2018: // ‘ + case 0x201C: // “ + case 0x3008: // 〈 + case 0x300A: // 《 + case 0x300C: // 「 + case 0x300E: // 『 + case 0x3010: // 【 + case 0x3014: // 〔 + case 0x3016: // 〖 + case 0x3018: // 〘 + case 0x301A: // 〚 + case 0xFF08: // ( + case 0xFF3B: // [ + case 0xFF5B: // { + return true; + default: + return false; + } +} + +bool containsCjkBreakableCodepoint(const std::string& text) { + const auto* ptr = reinterpret_cast(text.c_str()); + while (*ptr) { + const uint32_t cp = utf8NextCodepoint(&ptr); + if (utf8IsCjkBreakable(cp)) { + return true; + } + } + return false; +} + +bool hasCjkBreakOpportunityBetween(const uint32_t leftCp, const uint32_t rightCp) { + if (!utf8IsCjkBreakable(leftCp) && !utf8IsCjkBreakable(rightCp)) return false; + if (isNoBreakAfterCjkPunctuation(leftCp) || isNoBreakBeforeCjkPunctuation(rightCp)) return false; + if (utf8IsCombiningMark(rightCp)) return false; + return true; +} + +std::vector cjkCharacterBreakByteOffsets(const std::string& text) { + struct CodepointBoundary { + uint32_t cp; + size_t endOffset; + }; + + std::vector codepoints; + codepoints.reserve(text.size()); + bool hasCjkBreakable = false; + + const auto* ptr = reinterpret_cast(text.c_str()); + const auto* const start = ptr; + while (*ptr) { + const uint32_t cp = utf8NextCodepoint(&ptr); + if (cp == 0) break; + if (utf8IsCjkBreakable(cp)) { + hasCjkBreakable = true; + } + codepoints.push_back({cp, static_cast(ptr - start)}); + } + + if (!hasCjkBreakable || codepoints.size() < 2) return {}; + + std::vector allowedOffsets; + allowedOffsets.reserve(codepoints.size() - 1); + for (size_t i = 0; i + 1 < codepoints.size(); ++i) { + const uint32_t current = codepoints[i].cp; + const uint32_t next = codepoints[i + 1].cp; + if (!hasCjkBreakOpportunityBetween(current, next)) continue; + allowedOffsets.push_back(codepoints[i].endOffset); + } + return allowedOffsets; +} + +int computeJustifyExtra(const int spareSpace, const size_t gapCount) { + if (gapCount < MIN_JUSTIFY_GAPS || spareSpace <= 0) return 0; + // Distribute the spare space evenly across gaps. Do NOT bail out to 0 when the + // per-gap stretch is large: a sparse line (few words on a wide page) legitimately + // needs big gaps to reach the margin. Returning 0 there disables justification for + // that line, leaving it right-aligned (RTL) / left-aligned (LTR) — the mismatched + // alignment bug. Match the un-capped behavior of the old code. + return spareSpace / static_cast(gapCount); +} + // Removes every soft hyphen in-place so rendered glyphs match measured widths. void stripSoftHyphensInPlace(std::string& word) { size_t pos = 0; @@ -125,6 +254,14 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool attachToPrevious) { if (word.empty()) return; + // The device fonts carry no combining-mark positioning, so EPUB text stored in NFD + // (a base letter followed by separate combining accents -- common for Vietnamese, + // and used for many EPUB

chapter headings) renders with the marks detached or + // misplaced. Compose to NFC here, the single funnel every word passes through, so a + // precomposed glyph is used instead. This runs once per word at layout time (the + // result is cached in the section file) and is a cheap no-op for mark-free text. + word = utf8ComposeNfc(word); + EpdFontFamily::Style baseStyle = fontStyle; if (underline) { baseStyle = static_cast(baseStyle | EpdFontFamily::UNDERLINE); @@ -132,12 +269,54 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool wordStartsRtl = !hasRtlWord && mayContainRtlBytes(word.c_str()) && BidiUtils::startsWithRtl(word.c_str(), RTL_PER_WORD_PROBE_DEPTH); + const auto pushToken = [&](std::string token, const bool continues, const bool noSpaceBefore, + const bool isFocusSuffix) { + words.push_back(std::move(token)); + wordStyles.push_back(baseStyle); + wordContinues.push_back(continues); + wordNoSpaceBefore.push_back(noSpaceBefore); + wordIsFocusSuffix.push_back(isFocusSuffix); + }; + + bool effectiveAttachToPrevious = attachToPrevious; + bool effectiveNoSpaceBefore = false; + if (attachToPrevious && !words.empty() && + hasCjkBreakOpportunityBetween(lastCodepoint(words.back()), firstCodepoint(word))) { + effectiveAttachToPrevious = false; + effectiveNoSpaceBefore = true; + } + + if (auto breakOffsets = cjkCharacterBreakByteOffsets(word); !breakOffsets.empty()) { + bool firstToken = true; + size_t tokenStart = 0; + for (const size_t breakOffset : breakOffsets) { + if (breakOffset <= tokenStart || breakOffset > word.size()) continue; + pushToken(word.substr(tokenStart, breakOffset - tokenStart), firstToken ? effectiveAttachToPrevious : false, + firstToken ? effectiveNoSpaceBefore : true, false); + firstToken = false; + tokenStart = breakOffset; + } + if (tokenStart < word.size()) { + pushToken(word.substr(tokenStart), firstToken ? effectiveAttachToPrevious : false, + firstToken ? effectiveNoSpaceBefore : true, false); + } + if (wordStartsRtl) { + hasRtlWord = true; + } + return; + } + + if (containsCjkBreakableCodepoint(word)) { + pushToken(std::move(word), effectiveAttachToPrevious, effectiveNoSpaceBefore, false); + if (wordStartsRtl) { + hasRtlWord = true; + } + return; + } + // Already-bold text should stay fully bold; focus splitting would make its suffix regular later. if (!this->focusReadingEnabled || (baseStyle & EpdFontFamily::BOLD) != 0) { - words.push_back(std::move(word)); - wordStyles.push_back(baseStyle); - wordContinues.push_back(attachToPrevious); - wordIsFocusSuffix.push_back(false); + pushToken(std::move(word), effectiveAttachToPrevious, effectiveNoSpaceBefore, false); if (wordStartsRtl) { hasRtlWord = true; } @@ -166,17 +345,19 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, words.reserve(newCapacity); wordStyles.reserve(newCapacity); wordContinues.reserve(newCapacity); + wordNoSpaceBefore.reserve(newCapacity); wordIsFocusSuffix.reserve(newCapacity); } // Lambda helper to process and push individual sub-segments of the string // Use std::string_view to avoid heap allocations when slicing - auto processSegment = [&](std::string_view segment, bool isWord, bool attach) { + auto processSegment = [&](std::string_view segment, bool isWord, bool attach, bool noSpaceBefore) { if (!isWord) { // Punctuation and Numbers stay regular words.emplace_back(segment); wordStyles.push_back(baseStyle); wordContinues.push_back(attach); + wordNoSpaceBefore.push_back(noSpaceBefore); wordIsFocusSuffix.push_back(false); } else { size_t charCount = 0; @@ -198,6 +379,7 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, words.emplace_back(segment); wordStyles.push_back(static_cast(baseStyle | EpdFontFamily::BOLD)); wordContinues.push_back(attach); + wordNoSpaceBefore.push_back(noSpaceBefore); wordIsFocusSuffix.push_back(false); } else { countPtr = reinterpret_cast(segment.data()); @@ -210,12 +392,14 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, words.emplace_back(segment.substr(0, splitByteOffset)); wordStyles.push_back(static_cast(baseStyle | EpdFontFamily::BOLD)); wordContinues.push_back(attach); + wordNoSpaceBefore.push_back(noSpaceBefore); wordIsFocusSuffix.push_back(false); // Regular suffix - marked so extractLine can merge it back into single TextBlock entry words.emplace_back(segment.substr(splitByteOffset)); wordStyles.push_back(baseStyle); wordContinues.push_back(true); + wordNoSpaceBefore.push_back(false); wordIsFocusSuffix.push_back(true); } } @@ -243,7 +427,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, // Only the very first segment inherits the original attachToPrevious flag. // Every subsequent segment MUST attach=true so it glues seamlessly to the prefix. - processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true); + processSegment(segment, inWordSegment, isFirstSegment ? effectiveAttachToPrevious : true, + isFirstSegment ? effectiveNoSpaceBefore : false); // Setup for the next segment segmentStart = currentCpStart; @@ -255,7 +440,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, // Process the final remaining segment size_t segmentLen = end - segmentStart; std::string_view segment(reinterpret_cast(segmentStart), segmentLen); - processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true); + processSegment(segment, inWordSegment, isFirstSegment ? effectiveAttachToPrevious : true, + isFirstSegment ? effectiveNoSpaceBefore : false); if (wordStartsRtl) { hasRtlWord = true; } @@ -324,14 +510,16 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo std::vector lineBreakIndices; if (hyphenationEnabled) { // Use greedy layout that can split words mid-loop when a hyphenated prefix fits. - lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues); + lineBreakIndices = + computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore); } else { - lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues); + lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore); } const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1; for (size_t i = 0; i < lineCount; ++i) { - extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId); + extractLine(i, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore, lineBreakIndices, processLine, renderer, + fontId); } // Remove consumed words so size() reflects only remaining words @@ -340,6 +528,7 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo words.erase(words.begin(), words.begin() + consumed); wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed); wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed); + wordNoSpaceBefore.erase(wordNoSpaceBefore.begin(), wordNoSpaceBefore.begin() + consumed); wordIsFocusSuffix.erase(wordIsFocusSuffix.begin(), wordIsFocusSuffix.begin() + consumed); } } @@ -356,7 +545,8 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere } std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth, - std::vector& wordWidths, std::vector& continuesVec) { + std::vector& wordWidths, std::vector& continuesVec, + std::vector& noSpaceBeforeVec) { if (words.empty()) { return {}; } @@ -395,7 +585,9 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c for (size_t j = i; j < totalWordCount; ++j) { // Add space before word j, unless it's the first word on the line or a continuation int gap = 0; - if (j > static_cast(i) && !continuesVec[j]) { + if (j > static_cast(i) && noSpaceBeforeVec[j]) { + gap = 0; + } else if (j > static_cast(i) && !continuesVec[j]) { gap = renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]); } else if (j > static_cast(i) && continuesVec[j]) { @@ -470,7 +662,8 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c // Builds break indices while opportunistically splitting the word that would overflow the current line. std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth, std::vector& wordWidths, - std::vector& continuesVec) { + std::vector& continuesVec, + std::vector& noSpaceBeforeVec) { const int firstLineIndent = resolveFirstLineIndent(true, renderer, fontId); std::vector lineBreakIndices; @@ -488,7 +681,9 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r while (currentIndex < wordWidths.size()) { const bool isFirstWord = currentIndex == lineStart; int spacing = 0; - if (!isFirstWord && !continuesVec[currentIndex]) { + if (!isFirstWord && noSpaceBeforeVec[currentIndex]) { + spacing = 0; + } else if (!isFirstWord && !continuesVec[currentIndex]) { spacing = renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]), firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]); } else if (!isFirstWord && continuesVec[currentIndex]) { @@ -618,6 +813,7 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl // line, while "kilometer" moves to the next line. // wordContinues[wordIndex] is intentionally left unchanged — the prefix keeps its original attachment. wordContinues.insert(wordContinues.begin() + wordIndex + 1, false); + wordNoSpaceBefore.insert(wordNoSpaceBefore.begin() + wordIndex + 1, false); // Update cached widths to reflect the new prefix/remainder pairing. wordWidths[wordIndex] = static_cast(chosenWidth); @@ -627,7 +823,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl } void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const std::vector& wordWidths, - const std::vector& continuesVec, const std::vector& lineBreakIndices, + const std::vector& continuesVec, const std::vector& noSpaceBeforeVec, + const std::vector& lineBreakIndices, const std::function)>& processLine, const GfxRenderer& renderer, const int fontId) { const size_t lineBreak = lineBreakIndices[breakIndex]; @@ -660,7 +857,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) { lineWordWidthSum += wordWidths[lastBreakAt + wordIdx]; // Count gaps: each word after the first creates a gap, unless it's a continuation - if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) { + if (wordIdx > 0 && noSpaceBeforeVec[lastBreakAt + wordIdx]) { + // Unicode break opportunity with no inserted Latin-style space. It is still + // a stretchable gap for justified CJK/Korean text. + actualGapCount++; + } else if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) { actualGapCount++; totalNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx - 1]), firstCodepoint(lineWords[wordIdx]), lineWordStyles[wordIdx - 1]); @@ -689,8 +890,8 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const // For justified text, compute per-gap extra to distribute remaining space evenly const int spareSpace = effectivePageWidth - lineWordWidthSum - totalNaturalGaps; - const int justifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine && actualGapCount >= 1) - ? spareSpace / static_cast(actualGapCount) + const int justifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine) + ? computeJustifyExtra(spareSpace, actualGapCount) : 0; // BiDi processing: reorder words with UAX#9 in full-line context. @@ -709,11 +910,13 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const reorderedStylesScratch.clear(); reorderedWidthsScratch.clear(); reorderedContinuesScratch.clear(); + reorderedNoSpaceBeforeScratch.clear(); reorderedFocusSuffixScratch.clear(); reorderedWordsScratch.reserve(visualOrderScratch.size()); reorderedStylesScratch.reserve(visualOrderScratch.size()); reorderedWidthsScratch.reserve(visualOrderScratch.size()); reorderedContinuesScratch.reserve(visualOrderScratch.size()); + reorderedNoSpaceBeforeScratch.reserve(visualOrderScratch.size()); reorderedFocusSuffixScratch.reserve(visualOrderScratch.size()); for (size_t i = 0; i < visualOrderScratch.size(); ++i) { @@ -740,6 +943,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const } } reorderedContinuesScratch.push_back(continues); + reorderedNoSpaceBeforeScratch.push_back(!continues && noSpaceBeforeVec[lastBreakAt + src]); } int reorderedWordWidthSum = 0; @@ -747,7 +951,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int reorderedNaturalGaps = 0; for (size_t wordIdx = 0; wordIdx < reorderedWidthsScratch.size(); wordIdx++) { reorderedWordWidthSum += reorderedWidthsScratch[wordIdx]; - if (wordIdx > 0 && !reorderedContinuesScratch[wordIdx]) { + if (wordIdx > 0 && reorderedNoSpaceBeforeScratch[wordIdx]) { + // Unicode break opportunity with no inserted Latin-style space. It is still + // a stretchable gap for justified CJK/Korean text. + reorderedGapCount++; + } else if (wordIdx > 0 && !reorderedContinuesScratch[wordIdx]) { reorderedGapCount++; reorderedNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]), firstCodepoint(reorderedWordsScratch[wordIdx]), @@ -763,10 +971,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const } const int reorderedSpare = effectivePageWidth - reorderedWordWidthSum - reorderedNaturalGaps; - const int reorderedJustifyExtra = - (effectiveAlignment == CssTextAlign::Justify && !isLastLine && reorderedGapCount >= 1) - ? reorderedSpare / static_cast(reorderedGapCount) - : 0; + const int reorderedJustifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine) + ? computeJustifyExtra(reorderedSpare, reorderedGapCount) + : 0; const int justifyContribution = (effectiveAlignment == CssTextAlign::Justify && !isLastLine) ? reorderedJustifyExtra * static_cast(reorderedGapCount) @@ -805,9 +1012,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const } xpos += advance; } else if (wordIdx + 1 < reorderedWidthsScratch.size()) { - int gap = renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]), - firstCodepoint(reorderedWordsScratch[wordIdx + 1]), - reorderedStylesScratch[wordIdx]); + const bool nextNoSpace = reorderedNoSpaceBeforeScratch[wordIdx + 1]; + int gap = nextNoSpace ? 0 + : renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]), + firstCodepoint(reorderedWordsScratch[wordIdx + 1]), + reorderedStylesScratch[wordIdx]); if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) { gap += reorderedJustifyExtra; } @@ -846,11 +1055,15 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const xpos -= advance; } else { int gap = 0; + bool nextNoSpace = false; if (wordIdx + 1 < lineWordCount) { - gap = renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]), - firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); + nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1]; + gap = nextNoSpace + ? 0 + : renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]), + firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); } - if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) { + if (wordIdx + 1 < lineWordCount && effectiveAlignment == CssTextAlign::Justify && !isLastLine) { gap += justifyExtra; } xpos -= gap; @@ -880,11 +1093,15 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const xpos += advance; } else { int gap = 0; + bool nextNoSpace = false; if (wordIdx + 1 < lineWordCount) { - gap = renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]), - firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); + nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1]; + gap = nextNoSpace + ? 0 + : renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]), + firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); } - if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) { + if (wordIdx + 1 < lineWordCount && effectiveAlignment == CssTextAlign::Justify && !isLastLine) { gap += justifyExtra; } xpos += wordWidths[lastBreakAt + wordIdx] + gap; diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 9c3af7cb..81fbe69b 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -15,7 +15,8 @@ class GfxRenderer; class ParsedText { std::vector words; std::vector wordStyles; - std::vector wordContinues; // true = word attaches to previous (no space before it) + std::vector wordContinues; // true = word attaches to previous with no break + std::vector wordNoSpaceBefore; // true = may break before token, but no synthetic space when joined std::vector wordIsFocusSuffix; // true = token is the regular tail of a focus bold-prefix split BlockStyle blockStyle; bool extraParagraphSpacing; @@ -27,18 +28,22 @@ class ParsedText { std::vector reorderedStylesScratch; std::vector reorderedWidthsScratch; std::vector reorderedContinuesScratch; + std::vector reorderedNoSpaceBeforeScratch; std::vector reorderedFocusSuffixScratch; std::vector visualOrderScratch; int resolveFirstLineIndent(bool isFirstLine, const GfxRenderer& renderer, int fontId) const; std::vector computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, - std::vector& wordWidths, std::vector& continuesVec); + std::vector& wordWidths, std::vector& continuesVec, + std::vector& noSpaceBeforeVec); std::vector computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, - std::vector& wordWidths, std::vector& continuesVec); + std::vector& wordWidths, std::vector& continuesVec, + std::vector& noSpaceBeforeVec); bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId, std::vector& wordWidths, bool allowFallbackBreaks); void extractLine(size_t breakIndex, int pageWidth, const std::vector& wordWidths, - const std::vector& continuesVec, const std::vector& lineBreakIndices, + const std::vector& continuesVec, const std::vector& noSpaceBeforeVec, + const std::vector& lineBreakIndices, const std::function)>& processLine, const GfxRenderer& renderer, int fontId); std::vector calculateWordWidths(const GfxRenderer& renderer, int fontId); diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index c13da526..db249481 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -10,7 +10,8 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -constexpr uint8_t SECTION_FILE_VERSION = 26; +// v27: words NFC-composed at layout time; bump invalidates NFD section caches. +constexpr uint8_t SECTION_FILE_VERSION = 27; constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) + diff --git a/lib/Epub/Epub/htmlEntities.cpp b/lib/Epub/Epub/htmlEntities.cpp index 2fd35b8a..a798d0e2 100644 --- a/lib/Epub/Epub/htmlEntities.cpp +++ b/lib/Epub/Epub/htmlEntities.cpp @@ -13,54 +13,57 @@ struct EntityPair { // Sorted lexicographically by key to allow binary search. static constexpr EntityPair ENTITY_LOOKUP[] = { - {"Æ", "Æ"}, {"Á", "Á"}, {"Â", "Â"}, {"À", "À"}, {"Α", "Α"}, - {"Å", "Å"}, {"Ã", "Ã"}, {"Ä", "Ä"}, {"Β", "Β"}, {"Ç", "Ç"}, - {"Χ", "Χ"}, {"‡", "‡"}, {"Δ", "Δ"}, {"Ð", "Ð"}, {"É", "É"}, - {"Ê", "Ê"}, {"È", "È"}, {"Ε", "Ε"}, {"Η", "Η"}, {"Ë", "Ë"}, - {"Γ", "Γ"}, {"Í", "Í"}, {"Î", "Î"}, {"Ì", "Ì"}, {"Ι", "Ι"}, - {"Ï", "Ï"}, {"Κ", "Κ"}, {"Λ", "Λ"}, {"Μ", "Μ"}, {"Ñ", "Ñ"}, - {"Ν", "Ν"}, {"Œ", "Œ"}, {"Ó", "Ó"}, {"Ô", "Ô"}, {"Ò", "Ò"}, - {"Ω", "Ω"}, {"Ο", "Ο"}, {"Ø", "Ø"}, {"Õ", "Õ"}, {"Ö", "Ö"}, - {"Φ", "Φ"}, {"Π", "Π"}, {"″", "″"}, {"Ψ", "Ψ"}, {"Ρ", "Ρ"}, - {"Š", "Š"}, {"Σ", "Σ"}, {"Þ", "Þ"}, {"Τ", "Τ"}, {"Θ", "Θ"}, - {"Ú", "Ú"}, {"Û", "Û"}, {"Ù", "Ù"}, {"Υ", "Υ"}, {"Ü", "Ü"}, - {"Ξ", "Ξ"}, {"Ý", "Ý"}, {"Ÿ", "Ÿ"}, {"Ζ", "Ζ"}, {"á", "á"}, - {"â", "â"}, {"´", "´"}, {"æ", "æ"}, {"à", "à"}, {"α", "α"}, - {"&", "&"}, {"∧", "∧"}, {"∠", "∠"}, {"å", "å"}, {"≈", "≈"}, - {"ã", "ã"}, {"ä", "ä"}, {"„", "„"}, {"β", "β"}, {"¦", "¦"}, - {"•", "•"}, {"∩", "∩"}, {"ç", "ç"}, {"¸", "¸"}, {"¢", "¢"}, - {"χ", "χ"}, {"ˆ", "ˆ"}, {"♣", "♣"}, {"≅", "≅"}, {"©", "©"}, - {"↵", "↵"}, {"∪", "∪"}, {"¤", "¤"}, {"†", "†"}, {"↓", "↓"}, - {"°", "°"}, {"δ", "δ"}, {"♦", "♦"}, {"÷", "÷"}, {"é", "é"}, - {"ê", "ê"}, {"è", "è"}, {"∅", "∅"}, {" ", " "}, {" ", " "}, - {"ε", "ε"}, {"≡", "≡"}, {"η", "η"}, {"ð", "ð"}, {"ë", "ë"}, - {"€", "€"}, {"∃", "∃"}, {"ƒ", "ƒ"}, {"∀", "∀"}, {"½", "½"}, - {"¼", "¼"}, {"¾", "¾"}, {"⁄", "⁄"}, {"γ", "γ"}, {"≥", "≥"}, - {">", ">"}, {"↔", "↔"}, {"♥", "♥"}, {"…", "…"}, {"í", "í"}, - {"î", "î"}, {"¡", "¡"}, {"ì", "ì"}, {"∞", "∞"}, {"∫", "∫"}, - {"ι", "ι"}, {"¿", "¿"}, {"∈", "∈"}, {"ï", "ï"}, {"κ", "κ"}, - {"λ", "λ"}, {"«", "«"}, {"←", "←"}, {"⌈", "⌈"}, {"“", "\u201C"}, - {"≤", "≤"}, {"⌊", "⌊"}, {"∗", "∗"}, {"◊", "◊"}, {"‎", "\u200E"}, - {"‹", "‹"}, {"‘", "\u2018"}, {"<", "<"}, {"¯", "¯"}, {"—", "—"}, - {"µ", "µ"}, {"−", "−"}, {"μ", "μ"}, {"∇", "∇"}, {" ", "\xC2\xA0"}, - {"–", "–"}, {"≠", "≠"}, {"∋", "∋"}, {"¬", "¬"}, {"∉", "∉"}, - {"⊄", "⊄"}, {"ñ", "ñ"}, {"ν", "ν"}, {"ó", "ó"}, {"ô", "ô"}, - {"œ", "œ"}, {"ò", "ò"}, {"‾", "‾"}, {"ω", "ω"}, {"ο", "ο"}, - {"⊕", "⊕"}, {"∨", "∨"}, {"ª", "ª"}, {"º", "º"}, {"ø", "ø"}, - {"õ", "õ"}, {"⊗", "⊗"}, {"ö", "ö"}, {"¶", "¶"}, {"∂", "∂"}, - {"‰", "‰"}, {"⊥", "⊥"}, {"φ", "φ"}, {"π", "π"}, {"ϖ", "ϖ"}, - {"±", "±"}, {"£", "£"}, {"′", "′"}, {"∏", "∏"}, {"∝", "∝"}, - {"ψ", "ψ"}, {""", "\""}, {"√", "√"}, {"»", "»"}, {"→", "→"}, - {"⌉", "⌉"}, {"”", "\u201D"}, {"®", "®"}, {"⌋", "⌋"}, {"ρ", "ρ"}, - {"‏", "\u200F"}, {"›", "›"}, {"’", "\u2019"}, {"‚", "‚"}, {"š", "š"}, - {"⋅", "⋅"}, {"§", "§"}, {"­", "\xC2\xAD"}, {"σ", "σ"}, {"ς", "ς"}, - {"∼", "∼"}, {"♠", "♠"}, {"⊂", "⊂"}, {"⊆", "⊆"}, {"∑", "∑"}, - {"¹", "¹"}, {"²", "²"}, {"³", "³"}, {"⊃", "⊃"}, {"⊇", "⊇"}, - {"ß", "ß"}, {"τ", "τ"}, {"∴", "∴"}, {"θ", "θ"}, {"ϑ", "ϑ"}, - {" ", " "}, {"þ", "þ"}, {"˜", "˜"}, {"×", "×"}, {"™", "™"}, - {"ú", "ú"}, {"↑", "↑"}, {"û", "û"}, {"ù", "ù"}, {"¨", "¨"}, - {"ϒ", "ϒ"}, {"υ", "υ"}, {"ü", "ü"}, {"ξ", "ξ"}, {"ý", "ý"}, - {"¥", "¥"}, {"ÿ", "ÿ"}, {"ζ", "ζ"}, {"‍", "\u200D"}, {"‌", "\u200C"}, + {"Æ", "Æ"}, {"Á", "Á"}, {"Â", "Â"}, {"À", "À"}, {"Α", "Α"}, + {"Å", "Å"}, {"Ã", "Ã"}, {"Ä", "Ä"}, {"Β", "Β"}, {"Ç", "Ç"}, + {"Χ", "Χ"}, {"‡", "‡"}, {"Δ", "Δ"}, {"Ð", "Ð"}, {"É", "É"}, + {"Ê", "Ê"}, {"È", "È"}, {"Ε", "Ε"}, {"Η", "Η"}, {"Ë", "Ë"}, + {"Γ", "Γ"}, {"Í", "Í"}, {"Î", "Î"}, {"Ì", "Ì"}, {"Ι", "Ι"}, + {"Ï", "Ï"}, {"Κ", "Κ"}, {"Λ", "Λ"}, {"Μ", "Μ"}, {"Ñ", "Ñ"}, + {"Ν", "Ν"}, {"Œ", "Œ"}, {"Ó", "Ó"}, {"Ô", "Ô"}, {"Ò", "Ò"}, + {"Ω", "Ω"}, {"Ο", "Ο"}, {"Ø", "Ø"}, {"Õ", "Õ"}, {"Ö", "Ö"}, + {"Φ", "Φ"}, {"Π", "Π"}, {"″", "″"}, {"Ψ", "Ψ"}, {"Ρ", "Ρ"}, + {"Š", "Š"}, {"Σ", "Σ"}, {"Þ", "Þ"}, {"Τ", "Τ"}, {"Θ", "Θ"}, + {"Ú", "Ú"}, {"Û", "Û"}, {"Ù", "Ù"}, {"Υ", "Υ"}, {"Ü", "Ü"}, + {"Ξ", "Ξ"}, {"Ý", "Ý"}, {"Ÿ", "Ÿ"}, {"Ζ", "Ζ"}, {"á", "á"}, + {"â", "â"}, {"´", "´"}, {"æ", "æ"}, {"à", "à"}, {"ℵ", "ℵ"}, + {"α", "α"}, {"&", "&"}, {"∧", "∧"}, {"∠", "∠"}, {"å", "å"}, + {"≈", "≈"}, {"ã", "ã"}, {"ä", "ä"}, {"„", "„"}, {"β", "β"}, + {"¦", "¦"}, {"•", "•"}, {"∩", "∩"}, {"ç", "ç"}, {"¸", "¸"}, + {"¢", "¢"}, {"χ", "χ"}, {"ˆ", "ˆ"}, {"♣", "♣"}, {"≅", "≅"}, + {"©", "©"}, {"↵", "↵"}, {"∪", "∪"}, {"¤", "¤"}, {"⇓", "⇓"}, + {"†", "†"}, {"↓", "↓"}, {"°", "°"}, {"δ", "δ"}, {"♦", "♦"}, + {"÷", "÷"}, {"é", "é"}, {"ê", "ê"}, {"è", "è"}, {"∅", "∅"}, + {" ", " "}, {" ", " "}, {"ε", "ε"}, {"≡", "≡"}, {"η", "η"}, + {"ð", "ð"}, {"ë", "ë"}, {"€", "€"}, {"∃", "∃"}, {"ƒ", "ƒ"}, + {"∀", "∀"}, {"½", "½"}, {"¼", "¼"}, {"¾", "¾"}, {"⁄", "⁄"}, + {"γ", "γ"}, {"≥", "≥"}, {">", ">"}, {"⇔", "⇔"}, {"↔", "↔"}, + {"♥", "♥"}, {"…", "…"}, {"í", "í"}, {"î", "î"}, {"¡", "¡"}, + {"ì", "ì"}, {"ℑ", "ℑ"}, {"∞", "∞"}, {"∫", "∫"}, {"ι", "ι"}, + {"¿", "¿"}, {"∈", "∈"}, {"ï", "ï"}, {"κ", "κ"}, {"⇐", "⇐"}, + {"λ", "λ"}, {"⟨", "〈"}, {"«", "«"}, {"←", "←"}, {"⌈", "⌈"}, + {"“", "\u201C"}, {"≤", "≤"}, {"⌊", "⌊"}, {"∗", "∗"}, {"◊", "◊"}, + {"‎", "\u200E"}, {"‹", "‹"}, {"‘", "\u2018"}, {"<", "<"}, {"¯", "¯"}, + {"—", "—"}, {"µ", "µ"}, {"·", "·"}, {"−", "−"}, {"μ", "μ"}, + {"∇", "∇"}, {" ", "\xC2\xA0"}, {"–", "–"}, {"≠", "≠"}, {"∋", "∋"}, + {"¬", "¬"}, {"∉", "∉"}, {"⊄", "⊄"}, {"ñ", "ñ"}, {"ν", "ν"}, + {"ó", "ó"}, {"ô", "ô"}, {"œ", "œ"}, {"ò", "ò"}, {"‾", "‾"}, + {"ω", "ω"}, {"ο", "ο"}, {"⊕", "⊕"}, {"∨", "∨"}, {"ª", "ª"}, + {"º", "º"}, {"ø", "ø"}, {"õ", "õ"}, {"⊗", "⊗"}, {"ö", "ö"}, + {"¶", "¶"}, {"∂", "∂"}, {"‰", "‰"}, {"⊥", "⊥"}, {"φ", "φ"}, + {"π", "π"}, {"ϖ", "ϖ"}, {"±", "±"}, {"£", "£"}, {"′", "′"}, + {"∏", "∏"}, {"∝", "∝"}, {"ψ", "ψ"}, {""", "\""}, {"⇒", "⇒"}, + {"√", "√"}, {"⟩", "〉"}, {"»", "»"}, {"→", "→"}, {"⌉", "⌉"}, + {"”", "\u201D"}, {"ℜ", "\u211C"}, {"®", "®"}, {"⌋", "⌋"}, {"ρ", "ρ"}, + {"‏", "\u200F"}, {"›", "›"}, {"’", "\u2019"}, {"‚", "‚"}, {"š", "š"}, + {"⋅", "⋅"}, {"§", "§"}, {"­", "\xC2\xAD"}, {"σ", "σ"}, {"ς", "ς"}, + {"∼", "∼"}, {"♠", "♠"}, {"⊂", "⊂"}, {"⊆", "⊆"}, {"∑", "∑"}, + {"¹", "¹"}, {"²", "²"}, {"³", "³"}, {"⊃", "⊃"}, {"⊇", "⊇"}, + {"ß", "ß"}, {"τ", "τ"}, {"∴", "∴"}, {"θ", "θ"}, {"ϑ", "ϑ"}, + {" ", " "}, {"þ", "þ"}, {"˜", "˜"}, {"×", "×"}, {"™", "™"}, + {"⇑", "⇑"}, {"ú", "ú"}, {"↑", "↑"}, {"û", "û"}, {"ù", "ù"}, + {"¨", "¨"}, {"ϒ", "ϒ"}, {"υ", "υ"}, {"ü", "ü"}, {"℘", "℘"}, + {"ξ", "ξ"}, {"ý", "ý"}, {"¥", "¥"}, {"ÿ", "ÿ"}, {"ζ", "ζ"}, + {"‍", "\u200D"}, {"‌", "\u200C"}, }; // Verify the table is sorted at compile time. diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index badd4c43..0c9feb3e 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1502,8 +1502,18 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami int32_t widthFP = 0; const bool isSupSub = (style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0; const uint8_t styleIdx = resolveSdCardStyle(*sdIt->second, style); + const auto fontIt = fontMap.find(fontId); + if (fontIt == fontMap.end()) { + LOG_ERR("GFX", "Font %d not found", fontId); + return 0; + } + const auto& font = fontIt->second; while (uint32_t cp = utf8NextCodepoint(reinterpret_cast(&text))) { int32_t advFP = sdIt->second->getAdvance(cp, styleIdx); + if (advFP == 0 && !utf8IsCombiningMark(cp)) { + const EpdGlyph* glyph = font.getGlyph(cp, style); + advFP = glyph ? glyph->advanceX : 0; + } widthFP += isSupSub ? (advFP + 1) / 2 : advFP; } return fp4::toPixel(widthFP); diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index 2172f81b..f9bcd6c2 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -71,6 +71,7 @@ STR_SIDE_BTN_LAYOUT: "Бакавыя кнопкі" STR_TOUCH_READER_CONTROLS: "Сэнсарнае кіраванне чытаннем" STR_FRONT_BTN_FOLLOW_ORIENTATION: "Арыентаваць пярэднія кнопкі" STR_LONG_PRESS_SKIP: "Доўгае націсканне - змена раздзела" +STR_FONT_PREVIEW_TEXT: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай" STR_FONT_FAMILY: "Шрыфт чытання" STR_FONT_SIZE: "Памер шрыфту інтэрфейсу" STR_LINE_SPACING: "Міжрадковы інтэрвал" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index b883df04..71b98fc2 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó" STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat" STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítols" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Canvi d'orientació" +STR_LONG_PRESS_MENU: "Funció de pulsació llarga" STR_FONT_FAMILY: "Tipus de lletra" STR_FONT_SIZE: "Mida de la lletra (UI)" STR_LINE_SPACING: "Interlineat del lector" @@ -139,6 +140,8 @@ STR_INVERTED: "Invertit" STR_LANDSCAPE_CCW: "Horitzontal antihorari" STR_PREV_NEXT: "Anterior/Següent" STR_NEXT_PREV: "Següent/Anterior" +STR_KOSYNC: "KOSync" +STR_BOOKMARK_OPTION: "Punt de llibre" STR_DISABLED: "Desactivats" STR_NOTO_SERIF: "Noto Serif" STR_NOTO_SANS: "Noto Sans" @@ -295,6 +298,7 @@ STR_BOOK_S_STYLE: "Estil del llibre" STR_EMBEDDED_STYLE: "Estil incrustat" STR_FOCUS_READING: "Lectura enfocada" STR_OPDS_SERVER_URL: "URL del servidor OPDS" +STR_PWR_BTN_FOOTNOTE_BACK: "Retorn ràpid des de les notes al peu" STR_FOOTNOTES: "Notes al peu" STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina" STR_LINK: "[enllaç]" @@ -339,6 +343,7 @@ STR_MANAGE_FONTS: "Gestiona els tipus de lletra" STR_FONT_BROWSER: "Navegador de tipus de lletra" STR_LOADING_FONT_LIST: "S'està carregant la llista de tipus de lletra..." STR_NO_FONTS_AVAILABLE: "No hi ha tipus de lletra disponibles" +STR_FONT_PREVIEW_TEXT: "Jove xef, porti whisky amb quinze glaçons d'hidrogen, coi!" STR_FONT_INSTALLED: "Tipus de lletra instal·lat!" STR_FONT_INSTALL_FAILED: "Ha fallat la instal·lació del tipus de lletra" STR_INSTALLED: "Instal·lat" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index f2fb4997..5eb6ece9 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -74,6 +74,7 @@ STR_LONG_PRESS_BEHAVIOR: "Chování při dlouhém stisknutí tlačítka" STR_LONG_PRESS_BEHAVIOR_OFF: "VYP" STR_LONG_PRESS_BEHAVIOR_SKIP: "Přeskočení kapitoly" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Změna orientace" +STR_FONT_PREVIEW_TEXT: "Příliš žluťoučký kůň úpěl ďábelské ódy" STR_FONT_FAMILY: "Rodina písem čtečky" STR_FONT_SIZE: "Velikost písma rozhraní" STR_LINE_SPACING: "Řádkování čtečky" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index dce46b36..a5cd0cac 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportamiento al mantener pulsado el botón" STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivado" STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Cambio de orientación" +STR_FONT_PREVIEW_TEXT: "Høj bly gom vandt fræk sexquiz på wc" STR_FONT_FAMILY: "Læser skrifttype" STR_FONT_SIZE: "Læser skriftstørrelse" STR_LINE_SPACING: "Linjeafstand" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 9d20a144..01bee864 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior" STR_LONG_PRESS_BEHAVIOR_OFF: "OFF" STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change" +STR_FONT_PREVIEW_TEXT: "Pa's wijze lynx bezag vroom het fikse aquaduct" STR_FONT_FAMILY: "Lettertype lezer" STR_FONT_SIZE: "Lettergrootte lezer" STR_LINE_SPACING: "Regelafstand lezer" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 2d55c6bd..db677716 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -79,6 +79,8 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior" STR_LONG_PRESS_BEHAVIOR_OFF: "OFF" STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change" +STR_LONG_PRESS_MENU: "Long-press Menu" +STR_FONT_PREVIEW_TEXT: "The quick brown fox jumps over the lazy dog" STR_FONT_FAMILY: "Reader Font Family" STR_FONT_SIZE: "Reader Font Size" STR_LINE_SPACING: "Reader Line Spacing" @@ -141,6 +143,8 @@ STR_INVERTED: "Inverted" STR_LANDSCAPE_CCW: "Landscape CCW" STR_PREV_NEXT: "Prev/Next" STR_NEXT_PREV: "Next/Prev" +STR_KOSYNC: "KOSync" +STR_BOOKMARK_OPTION: "Bookmark" STR_DISABLED: "Disabled" STR_NOTO_SERIF: "Noto Serif" STR_NOTO_SANS: "Noto Sans" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index cf12887d..ae6f6802 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -74,6 +74,7 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior" STR_LONG_PRESS_BEHAVIOR_OFF: "OFF" STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change" +STR_FONT_PREVIEW_TEXT: "Törkylempijävongahdus" STR_FONT_FAMILY: "Lukijan fonttiperhe" STR_FONT_SIZE: "Käyttöliittymän fonttikoko" STR_LINE_SPACING: "Lukijan riviväli" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 7875b411..9428d343 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportement lors d'un appui long" STR_LONG_PRESS_BEHAVIOR_OFF: "Désactivé" STR_LONG_PRESS_BEHAVIOR_SKIP: "Saut de chapitre" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Changement d'orientation" +STR_FONT_PREVIEW_TEXT: "Portez ce vieux whisky au juge blond qui fume" STR_FONT_FAMILY: "Police de caractères du lecteur" STR_FONT_SIZE: "Taille police lecteur" STR_LINE_SPACING: "Interligne" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 0e6d1740..dcef83c5 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -74,6 +74,7 @@ STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck" STR_LONG_PRESS_BEHAVIOR_OFF: "AUS" STR_LONG_PRESS_BEHAVIOR_SKIP: "Kapitel überspringen" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ausrichtung ändern" +STR_FONT_PREVIEW_TEXT: "Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich" STR_FONT_FAMILY: "Lese-Schriftfamilie" STR_FONT_SIZE: "Schriftgröße" STR_LINE_SPACING: "Lese-Zeilenabstand" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index a68ebb88..25cae26c 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "פעולת לחיצה ארוכה" STR_LONG_PRESS_BEHAVIOR_OFF: "כבוי" STR_LONG_PRESS_BEHAVIOR_SKIP: "דלג פרק" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "שנה כיוון מסך" +STR_FONT_PREVIEW_TEXT: "דג סקרן שט בים מאוכזב ולפתע מצא חברה" STR_FONT_FAMILY: "גופן הקריאה" STR_FONT_SIZE: "גודל גופן" STR_LINE_SPACING: "מרווח בין שורות" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 573da146..99020feb 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -75,6 +75,7 @@ STR_SIDE_BTN_LAYOUT: "Oldalsó gomb elrendezés (olvasó)" STR_TOUCH_READER_CONTROLS: "Érintőképernyős vezérlés (olvasó)" STR_FRONT_BTN_FOLLOW_ORIENTATION: "Elülső gombok tájolása" STR_LONG_PRESS_SKIP: "Hosszú nyomás - fejezet ugrás" +STR_FONT_PREVIEW_TEXT: "Egy hűtlen vejét fülöncsípő, dühös mexikói úr Wesselényinél mázol Quitóban" STR_FONT_FAMILY: "Olvasó betűkészlet" STR_FONT_SIZE: "Olvasó betűméret" STR_LINE_SPACING: "Olvasó sorköz" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index 87cfa1c9..ead2f5b9 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -78,6 +78,8 @@ STR_LONG_PRESS_BEHAVIOR: "Press. lunga pul. laterali" STR_LONG_PRESS_BEHAVIOR_OFF: "OFF" STR_LONG_PRESS_BEHAVIOR_SKIP: "Salta capitolo" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientamento" +STR_LONG_PRESS_MENU: "Menu press. lunga" +STR_FONT_PREVIEW_TEXT: "Pranzo d'acqua fa volti sghembi" STR_FONT_FAMILY: "Font lettore" STR_FONT_SIZE: "Dimensione font" STR_LINE_SPACING: "Interlinea lettore" @@ -376,4 +378,7 @@ STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi non connesso" STR_HOLD_CONFIRM_TO_DELETE: "Tieni premuto Conferma per cancellare" STR_NEXT_FIELD: "Succ." STR_CURRENT_TIME: "Ora attuale: " -STR_DISABLED: "Disattivato" \ No newline at end of file +STR_DISABLED: "Disattivato" +STR_BOOKMARK_OPTION: "Segnalibro" +STR_KOSYNC: "KOSync" +STR_PWR_BTN_FOOTNOTE_BACK: "Rientro rapido dalle note" \ No newline at end of file diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 37a62479..3e62d1b4 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -70,6 +70,7 @@ STR_SIDE_BTN_LAYOUT: "Бүйірлік түймелер орналасуы (оқ STR_TOUCH_READER_CONTROLS: "Сенсорлық басқару (оқырман)" STR_FRONT_BTN_FOLLOW_ORIENTATION: "Алдыңғы түймелерді бағдарлау" STR_LONG_PRESS_SKIP: "Ұзақ басу арқылы тарау өткізу" +STR_FONT_PREVIEW_TEXT: "Канагаттандырылмагандыктарыныздан" STR_FONT_FAMILY: "Оқырман қаріп тобы" STR_FONT_SIZE: "Интерфейс қаріп өлшемі" STR_LINE_SPACING: "Оқырман жол аралығы" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 7514a97a..7817f01b 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -75,6 +75,7 @@ STR_SIDE_BTN_LAYOUT: "Šoniniai mygtukai" STR_TOUCH_READER_CONTROLS: "Liečiamasis valdymas (skaityklė)" STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuoti priekinius mygtukus" STR_LONG_PRESS_SKIP: "Praleisti skyrių (ilgai)" +STR_FONT_PREVIEW_TEXT: "Įlinkdama fechtuotojo špaga sublykčiojusi pragręžė apvalų arbūzą" STR_FONT_FAMILY: "Šriftas" STR_FONT_SIZE: "Šrifto dydis" STR_LINE_SPACING: "Tarpai tarp eilučių" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 17bc20fb..1ca57094 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Funkcja długiego przyciśnięcia" STR_LONG_PRESS_BEHAVIOR_OFF: "Wył." STR_LONG_PRESS_BEHAVIOR_SKIP: "Przeskocz rozdział" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientacja ekranu" +STR_FONT_PREVIEW_TEXT: "Pchnąć w tę łódź jeża lub ośm skrzyń fig" STR_FONT_FAMILY: "Czcionka" STR_FONT_SIZE: "Rozmiar czcionki" STR_LINE_SPACING: "Odstępy między wierszami" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 524e7505..45511b7d 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -74,6 +74,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportamento do botão de premir e segurar" STR_LONG_PRESS_BEHAVIOR_OFF: "Desligado" STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação" +STR_FONT_PREVIEW_TEXT: "Vejo galã sexy pôr quinze kiwis à força em baú achatado" STR_FONT_FAMILY: "Fonte do leitor" STR_FONT_SIZE: "Tam. fonte UI" STR_LINE_SPACING: "Espaçamento entre linhas" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index c3c19ebe..4a742c6b 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportament buton apăsat lung" STR_LONG_PRESS_BEHAVIOR_OFF: "Dezactivat" STR_LONG_PRESS_BEHAVIOR_SKIP: "Sărire capitol" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Schimbă orientarea" +STR_FONT_PREVIEW_TEXT: "Încă vând gem, whisky bej și tequila roz, preț fix" STR_FONT_FAMILY: "Familie font lectură" STR_FONT_SIZE: "Dimensiune font" STR_LINE_SPACING: "Spaţiere între rânduri" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index c0401034..fc7d2126 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -79,6 +79,8 @@ STR_LONG_PRESS_BEHAVIOR: "Долгое нажатие" STR_LONG_PRESS_BEHAVIOR_OFF: "Ничего" STR_LONG_PRESS_BEHAVIOR_SKIP: "Пропуск главы" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Изменить ориентацию" +STR_LONG_PRESS_MENU: "Долгое нажатие меню" +STR_FONT_PREVIEW_TEXT: "Съешь ещё этих мягких французских булок, да выпей же чаю" STR_FONT_FAMILY: "Шрифт чтения" STR_FONT_SIZE: "Размер шрифта интерфейса" STR_LINE_SPACING: "Межстрочный интервал" @@ -142,7 +144,9 @@ STR_INVERTED: "Инверсия" STR_LANDSCAPE_CCW: "Ландшафт (CCW)" STR_PREV_NEXT: "Назад/Вперёд" STR_NEXT_PREV: "Вперёд/Назад" -STR_DISABLED: "Выключены" +STR_KOSYNC: "KOSync" +STR_BOOKMARK_OPTION: "Закладка" +STR_DISABLED: "Выключено" STR_NOTO_SERIF: "Noto Serif" STR_NOTO_SANS: "Noto Sans" STR_SMALL: "Маленький" diff --git a/lib/I18n/translations/slovak.yaml b/lib/I18n/translations/slovak.yaml index b4d36506..32b6bb00 100644 --- a/lib/I18n/translations/slovak.yaml +++ b/lib/I18n/translations/slovak.yaml @@ -79,6 +79,7 @@ STR_LONG_PRESS_BEHAVIOR: "Správanie pri dlhom stlačení tlačidla" STR_LONG_PRESS_BEHAVIOR_OFF: "VYP" STR_LONG_PRESS_BEHAVIOR_SKIP: "Preskočiť kapitolu" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Zmeniť orientáciu" +STR_FONT_PREVIEW_TEXT: "Vypätá dcéra grófa Maxwella s IQ nižším ako kôň núti čeľaď hrýzť hŕbu jabĺk" STR_FONT_FAMILY: "Rodina písiem čítačky" STR_FONT_SIZE: "Veľkosť písma rozhrania" STR_LINE_SPACING: "Riadkovanie čítačky" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 0f877bf9..ce603a17 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -75,6 +75,7 @@ STR_SIDE_BTN_LAYOUT: "Razpored stranskih gumbov" STR_TOUCH_READER_CONTROLS: "Dotikalni nadzor (bralnik)" STR_FRONT_BTN_FOLLOW_ORIENTATION: "Usmeri sprednje gumbe" STR_LONG_PRESS_SKIP: "Dolgi pritisk za preskok poglavja" +STR_FONT_PREVIEW_TEXT: "V kožuščku hudobnega fanta stopiclja mizar" STR_FONT_FAMILY: "Pisava bralnika" STR_FONT_SIZE: "Velikost pisave" STR_LINE_SPACING: "Razmik med vrsticami" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 077f73d9..99af1103 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -78,6 +78,8 @@ STR_LONG_PRESS_BEHAVIOR: "Al mantener pulsado un botón" STR_LONG_PRESS_BEHAVIOR_OFF: "No hacer nada" STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Cambiar orient." +STR_LONG_PRESS_MENU: "Función de pulsación larga" +STR_FONT_PREVIEW_TEXT: "Benjamín pidió una bebida de kiwi y fresa. Noé, sin vergüenza, la más exquisita champaña del menú" STR_FONT_FAMILY: "Tipografía" STR_FONT_SIZE: "Tamaño" STR_LINE_SPACING: "Interlineado" @@ -140,6 +142,8 @@ STR_INVERTED: "Invertido" STR_LANDSCAPE_CCW: "Horizontal (antihorario)" STR_PREV_NEXT: "Ant./Sig." STR_NEXT_PREV: "Sig./Ant." +STR_KOSYNC: "KOSync" +STR_BOOKMARK_OPTION: "Marcador" STR_DISABLED: "Desactivados" STR_NOTO_SERIF: "Noto Serif" STR_NOTO_SANS: "Noto Sans" @@ -319,8 +323,9 @@ STR_BOOK_S_STYLE: "Estilo del libro" STR_EMBEDDED_STYLE: "Estilo integrado" STR_FOCUS_READING: "Lectura enfocada" STR_OPDS_SERVER_URL: "URL del servidor OPDS" +STR_PWR_BTN_FOOTNOTE_BACK: "Retorno rápido desde las notas al pie" STR_SET_SLEEP_COVER: "Pant. sus." -STR_FOOTNOTES: "Pie de página" +STR_FOOTNOTES: "Notas al pie" STR_NO_FOOTNOTES: "No hay notas al pie de esta página" STR_LINK: "[enlace]" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min." diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 83279d1e..d4b1dc01 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -79,6 +79,7 @@ STR_LONG_PRESS_BEHAVIOR: "Beteende vid lång knapptryckning" STR_LONG_PRESS_BEHAVIOR_OFF: "AV" STR_LONG_PRESS_BEHAVIOR_SKIP: "Hoppa över kapitel" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ändra orientering" +STR_FONT_PREVIEW_TEXT: "Flygande bäckasiner söka hwila på mjuka tuvor" STR_FONT_FAMILY: "Eboksläsarens typsnittsfamilj" STR_FONT_SIZE: "Eboksläsarens typsnittsstorlek" STR_LINE_SPACING: "Eboksläsarens linjemellanrum" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index ec8fb162..8ceed3ee 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -73,6 +73,7 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior" STR_LONG_PRESS_BEHAVIOR_OFF: "OFF" STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change" +STR_FONT_PREVIEW_TEXT: "Pijamalı hasta yağız şoföre çabucak güvendi" STR_FONT_FAMILY: "Okuyucu Yazı Tipi Ailesi" STR_FONT_SIZE: "Arayüz Yazı Boyutu" STR_LINE_SPACING: "Okuyucu Satır Aralığı" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index f56eb2a6..83f982fb 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -61,6 +61,7 @@ STR_CAT_READER: "Читач" STR_CAT_CONTROLS: "Кнопки" STR_CAT_SYSTEM: "Система" STR_SLEEP_SCREEN: "Екран у режимі сну" +STR_QUICK_RESUME_TIMEOUT: "Швидке поверн. після таймауту" STR_SLEEP_COVER_MODE: "Режим заповнення" STR_HIDE_BATTERY: "Приховати % батареї" STR_EXTRA_SPACING: "Додатковий інтервал між абзацами" @@ -78,6 +79,7 @@ STR_LONG_PRESS_BEHAVIOR: "Поведінка при довгому настик STR_LONG_PRESS_BEHAVIOR_OFF: "Немає" STR_LONG_PRESS_BEHAVIOR_SKIP: "Наступ. розділ (утримув.)" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Зміна орієнтації екрану" +STR_FONT_PREVIEW_TEXT: "Єхидна, ґава, їжак ще й шиплячі плазуни бігцем форсують Янцзи" STR_FONT_FAMILY: "Шрифт" STR_FONT_SIZE: "Розмір шрифту" STR_LINE_SPACING: "Міжрядковий інтервал" @@ -86,8 +88,8 @@ STR_PARA_ALIGNMENT: "Вирівнювання тексту" STR_HYPHENATION: "Перенесення слів" STR_TIME_TO_SLEEP: "Перехід в режим сну" STR_SHOW_HIDDEN_FILES: "Показати приховані файли" -STR_REMOVE_READ_FROM_RECENTS: "Очищати прочитані книги зі списку останніх" -STR_MOVE_FINISHED_TO_READ: "Переміщати прочитані книги до теки Read" +STR_REMOVE_READ_FROM_RECENTS: "Приховувати прочитані книги" +STR_MOVE_FINISHED_TO_READ: "Переміщати прочит. в теку Read" STR_REFRESH_FREQ: "Частота оновлення екрану" STR_KOREADER_SYNC: "Синхронізація KOReader" STR_CHECK_UPDATES: "Перевірити оновлення системи" @@ -140,6 +142,7 @@ STR_INVERTED: "Перевернутий" STR_LANDSCAPE_CCW: "Альбом. проти год." STR_PREV_NEXT: "Попер/Наст" STR_NEXT_PREV: "Наст/Попер" +STR_DISABLED: "Вимкнуто" STR_NOTO_SERIF: "Noto Serif" STR_NOTO_SANS: "Noto Sans" STR_SMALL: "Малий" @@ -174,6 +177,8 @@ STR_DOWNLOADING: "Завантаження..." STR_DOWNLOAD_FAILED: "Завантаження не вдалося" STR_ERROR_MSG: "Помилка:" STR_UNNAMED: "Без назви" +STR_HOLD_CONFIRM_TO_DELETE: "Утрим. Підтвердити, щоб Видалити" +STR_BOOKMARK_INSTRUCTIONS: "Утрим. Підтвердити в книзі, щоб створ. закладку" STR_NO_SERVER_URL: "URL сервера не налаштовано" STR_FETCH_FEED_FAILED: "Не вдалося отримати стрічку" STR_PARSE_FEED_FAILED: "Не вдалося розпарсити стрічку" @@ -229,18 +234,34 @@ STR_BATTERY: "Акумулятор" STR_XTC_STATUS_BAR: "XTC Рядок прогресу" STR_BOTTOM: "Низ" STR_TOP: "Верх" +STR_CLOCK: "Годинник" +STR_CLOCK_UTC_OFFSET: "Часовий пояс" +STR_CLOCK_FORMAT: "Формат Годинника" +STR_CLOCK_FORMAT_24H: "24-години" +STR_CLOCK_FORMAT_12H: "12-годин" +STR_CURRENT_TIME: "Поточний час:" +STR_NEXT_FIELD: "наступний" +STR_CLOCK_SYNC: "Синхр. Годинник" +STR_CLOCK_SYNC_NOW: "Синхр. годин. зараз" +STR_CLOCK_SYNCING: "Синхр. через NTP..." +STR_CLOCK_SYNC_OK: "Успішна синхр." +STR_CLOCK_SYNC_FAIL: "Невдала синхр. " +STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi не під'єднано" +STR_CLOCK_SYNC_NO_WIFI_HINT: "Під'єднайтесь спершу до Wi-Fi, тоді спробуйте знову." +STR_CLOCK_SYNCED: "Годинник синхр." STR_UI_THEME: "Тема інтерфейсу" STR_THEME_CLASSIC: "Класична" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Виправлення вицвітання на сонці" -STR_QUICK_RESUME_TIMEOUT: "Швидке продовження після таймауту" STR_REMAP_FRONT_BUTTONS: "Налаштувати передні кнопки" +STR_BOOKMARKS: "Закладки" +STR_BOOKMARK_ADDED: "Закладку додано" STR_OPDS_BROWSER: "Браузер OPDS" STR_SEARCH: "Пошук" STR_COVER_CUSTOM: "Обкл. + власне" -STR_QUICK_RESUME: "Швидке продовження" +STR_QUICK_RESUME: "Швидке поверн." STR_MENU_RECENT_BOOKS: "Останні книги" STR_REMOVE_FROM_RECENTS: "Видалити з останніх книг?" STR_NO_RECENT_BOOKS: "Немає останніх книг" @@ -266,6 +287,7 @@ STR_GO_HOME_BUTTON: "На головну" STR_SYNC_PROGRESS: "Прогрес синхронізації" STR_DELETE_CACHE: "Видалити кеш книги" STR_DELETE: "Видалити" +STR_CONFIRM_DELETE_BOOKMARK: "Видалити цю закладку?" STR_DISPLAY_QR: "Показати сторінку як QR-код" STR_CHAPTER_PREFIX: "Розділ: " STR_PAGES_SEPARATOR: " сторінок | " @@ -298,14 +320,15 @@ STR_BOOK_S_STYLE: "Стиль книги" STR_EMBEDDED_STYLE: "Вбудований стиль" STR_FOCUS_READING: "Фокусне читання" STR_OPDS_SERVER_URL: "URL сервера OPDS" +STR_PWR_BTN_FOOTNOTE_BACK: "Швидке повернення з приміток" STR_SET_SLEEP_COVER: "Як обкл." STR_FOOTNOTES: "Примітки" STR_NO_FOOTNOTES: "На цій сторінці немає приміток" STR_LINK: "[посилання]" +STR_SCREENSHOT_BUTTON: "Знімок екрана" STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв" STR_SLEEP_NEVER: "Ніколи" STR_SLEEP_TIMER_STEP_HINT: "Вліво/Вправо: 1 хв Вгору/Вниз: 5 хв" -STR_SCREENSHOT_BUTTON: "Знімок екрана" STR_ADD_SERVER: "Додати сервер" STR_SERVER_NAME: "Назва сервера" STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index 670486bb..200ef19e 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -79,6 +79,8 @@ STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó" STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat" STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítols" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Canvi d'orientació" +STR_LONG_PRESS_MENU: "Funció de pulsació llarga" +STR_FONT_PREVIEW_TEXT: "Jove xef, porti whisky amb quinze glaçons d'hidrogen, coi!" STR_FONT_FAMILY: "Família de fonts" STR_FONT_SIZE: "Grandària de la lletra (UI)" STR_LINE_SPACING: "Interlineat del lector" @@ -143,6 +145,8 @@ STR_INVERTED: "Invertit" STR_LANDSCAPE_CCW: "Horitzontal antihorari" STR_PREV_NEXT: "Anterior/Següent" STR_NEXT_PREV: "Següent/Anterior" +STR_KOSYNC: "KOSync" +STR_BOOKMARK_OPTION: "Punt de llibre" STR_DISABLED: "Desactivats" STR_NOTO_SERIF: "Noto Serif" STR_NOTO_SANS: "Noto Sans" @@ -298,6 +302,7 @@ STR_BOOK_S_STYLE: "Estil del llibre" STR_EMBEDDED_STYLE: "Estil incrustat" STR_FOCUS_READING: "Lectura enfocada" STR_OPDS_SERVER_URL: "URL del servidor OPDS" +STR_PWR_BTN_FOOTNOTE_BACK: "Retorn ràpid des de les notes al peu" STR_FOOTNOTES: "Notes al peu" STR_NO_FOOTNOTES: "No hi ha notes al peu en esta pàgina" STR_LINK: "[enllaç]" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index a7b93611..956cd64a 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -79,6 +79,7 @@ STR_LONG_PRESS_BEHAVIOR: "Hành vi nhấn giữ nút" STR_LONG_PRESS_BEHAVIOR_OFF: "TẮT" STR_LONG_PRESS_BEHAVIOR_SKIP: "Nhảy chương" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Đổi hướng" +STR_FONT_PREVIEW_TEXT: "Trường quê em do bố của em xây kĩ nên sạch và đẹp lắm" STR_FONT_FAMILY: "Phông chữ trình đọc" STR_FONT_SIZE: "Cỡ chữ trình đọc" STR_LINE_SPACING: "Giãn dòng trình đọc" diff --git a/lib/KOReaderSync/ProgressMapper.cpp b/lib/KOReaderSync/ProgressMapper.cpp index 146d5abe..2f1ab97f 100644 --- a/lib/KOReaderSync/ProgressMapper.cpp +++ b/lib/KOReaderSync/ProgressMapper.cpp @@ -30,8 +30,7 @@ int parseIndex(const std::string& xpath, const char* prefix, bool last = false) int parseCharOffset(const std::string& xpath) { const size_t textPos = xpath.rfind("text()"); - if (textPos == std::string::npos) return 0; - const size_t dotPos = xpath.find('.', textPos); + const size_t dotPos = (textPos != std::string::npos) ? xpath.find('.', textPos) : xpath.rfind('.'); if (dotPos == std::string::npos || dotPos + 1 >= xpath.size()) return 0; int val = 0; for (size_t i = dotPos + 1; i < xpath.size(); i++) { @@ -104,7 +103,13 @@ bool isChapterStartXPath(const std::string& xpath) { if (dotPos == std::string::npos || dotPos <= bodyContentStart || dotPos + 1 >= xpath.size()) { return false; } - if (xpath.find('/', bodyContentStart) != std::string::npos) { + size_t terminalEnd = dotPos; + static constexpr char kTextNode[] = "/text()"; + const size_t textNodePos = xpath.rfind(kTextNode, dotPos); + if (textNodePos != std::string::npos && textNodePos >= bodyContentStart) { + terminalEnd = textNodePos; + } + if (xpath.find('/', bodyContentStart) < terminalEnd) { return false; } @@ -122,7 +127,7 @@ struct XPathStep { static constexpr int MAX_XPATH_DEPTH = 16; -// Parse the XPath segment between /body/DocFragment[N]/body/ and text()[N].offset +// Parse the XPath segment between /body/DocFragment[N]/body/ and the terminal position // into an ordered sequence of steps. Returns step count, 0 on failure. // Example input: "/body/DocFragment[1]/body/div[1]/ul/li[4]/text()[1].51" // Fills steps with: {div,1}, {ul,1}, {li,4} @@ -136,13 +141,20 @@ int parseXPathSteps(const std::string& xpath, XPathStep steps[MAX_XPATH_DEPTH]) if (xpath.compare(afterBracket + 1, strlen(kBody), kBody) != 0) return 0; size_t pos = afterBracket + 1 + strlen(kBody); - const size_t textPos = xpath.rfind("/text()"); - if (textPos == std::string::npos || textPos <= pos) return 0; + size_t stepsEnd = xpath.rfind("/text()"); + if (stepsEnd == std::string::npos) { + stepsEnd = xpath.rfind('.'); + if (stepsEnd == std::string::npos || stepsEnd <= pos || stepsEnd + 1 >= xpath.size()) return 0; + for (size_t i = stepsEnd + 1; i < xpath.size(); i++) { + if (xpath[i] < '0' || xpath[i] > '9') return 0; + } + } + if (stepsEnd <= pos) return 0; int count = 0; - while (pos < textPos && count < MAX_XPATH_DEPTH) { + while (pos < stepsEnd && count < MAX_XPATH_DEPTH) { const size_t slash = xpath.find('/', pos); - const size_t segEnd = (slash < textPos) ? slash : textPos; + const size_t segEnd = (slash < stepsEnd) ? slash : stepsEnd; XPathStep& step = steps[count]; const size_t bracket = xpath.find('[', pos); @@ -166,7 +178,7 @@ int parseXPathSteps(const std::string& xpath, XPathStep steps[MAX_XPATH_DEPTH]) } count++; - pos = (slash < textPos) ? slash + 1 : textPos; + pos = (slash < stepsEnd) ? slash + 1 : stepsEnd; } return count; } @@ -223,10 +235,148 @@ class ParagraphStreamer final : public Print { char capturedAnchorId[MAX_ANCHOR_ID] = {}; int capturedAnchorIdLen = 0; bool capturingAnchorTag = false; - enum IdScanState { ID_SCAN, ID_I, ID_D, ID_EQ, ID_IN_VALUE_D, ID_IN_VALUE_S } idState = ID_SCAN; + enum AnchorAttrState { + ATTR_FIND_NAME, + ATTR_READ_NAME, + ATTR_AFTER_NAME, + ATTR_BEFORE_VALUE, + ATTR_CAPTURE_D, + ATTR_CAPTURE_S + } attrState = ATTR_FIND_NAME; + uint8_t attrNameLen = 0; + bool currentAttrIsId = false; bool inAttrQuote = false; // true while inside a quoted attribute value (prevents '/' from being treated as self-close) char attrQuoteChar = 0; + uint8_t nonVisibleDepth = 0; + + bool isNonVisibleTag() const { + return strcasecmp(tagName, "head") == 0 || strcasecmp(tagName, "style") == 0 || + strcasecmp(tagName, "script") == 0 || strcasecmp(tagName, "title") == 0; + } + + static bool isAttrWhitespace(uint8_t c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; } + + static bool isAttrNameChar(uint8_t c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || + c == ':' || c == '.'; + } + + void resetAnchorAttrScan() { + attrState = ATTR_FIND_NAME; + attrNameLen = 0; + currentAttrIsId = false; + } + + void finishCapturedAnchorId() { + capturedAnchorId[capturedAnchorIdLen] = '\0'; + capturingAnchorTag = false; + resetAnchorAttrScan(); + } + + void beginAnchorIdScan() { + capturingAnchorTag = true; + resetAnchorAttrScan(); + } + + void endAnchorIdScan() { + if (capturingAnchorTag) { + capturedAnchorIdLen = 0; + } + capturingAnchorTag = false; + resetAnchorAttrScan(); + } + + void appendCapturedAnchorId(uint8_t c) { + if (capturedAnchorIdLen + 1 < MAX_ANCHOR_ID) { + capturedAnchorId[capturedAnchorIdLen++] = c; + } + } + + void scanAnchorAttribute(uint8_t c) { + switch (attrState) { + case ATTR_FIND_NAME: + if (isAttrNameChar(c)) { + attrState = ATTR_READ_NAME; + attrNameLen = 1; + currentAttrIsId = c == 'i'; + } + break; + case ATTR_READ_NAME: + if (isAttrNameChar(c)) { + if (attrNameLen == 1) { + currentAttrIsId = currentAttrIsId && c == 'd'; + } else { + currentAttrIsId = false; + } + attrNameLen++; + } else { + currentAttrIsId = currentAttrIsId && attrNameLen == 2; + if (isAttrWhitespace(c)) { + attrState = ATTR_AFTER_NAME; + } else if (c == '=') { + attrState = ATTR_BEFORE_VALUE; + } else { + resetAnchorAttrScan(); + } + } + break; + case ATTR_AFTER_NAME: + if (isAttrWhitespace(c)) { + break; + } + if (c == '=') { + attrState = ATTR_BEFORE_VALUE; + } else if (isAttrNameChar(c)) { + attrState = ATTR_READ_NAME; + attrNameLen = 1; + currentAttrIsId = c == 'i'; + } else { + resetAnchorAttrScan(); + } + break; + case ATTR_BEFORE_VALUE: + if (isAttrWhitespace(c)) { + break; + } + if (currentAttrIsId && c == '"') { + capturedAnchorIdLen = 0; + attrState = ATTR_CAPTURE_D; + } else if (currentAttrIsId && c == '\'') { + capturedAnchorIdLen = 0; + attrState = ATTR_CAPTURE_S; + } else if (c == '"') { + attrState = ATTR_CAPTURE_D; + } else if (c == '\'') { + attrState = ATTR_CAPTURE_S; + } else { + resetAnchorAttrScan(); + } + break; + case ATTR_CAPTURE_D: + if (c == '"') { + if (currentAttrIsId) { + finishCapturedAnchorId(); + } else { + resetAnchorAttrScan(); + } + } else if (currentAttrIsId) { + appendCapturedAnchorId(c); + } + break; + case ATTR_CAPTURE_S: + if (c == '\'') { + if (currentAttrIsId) { + finishCapturedAnchorId(); + } else { + resetAnchorAttrScan(); + } + } else if (currentAttrIsId) { + appendCapturedAnchorId(c); + } + break; + } + } void onVisibleCodepoint() { totalVisChars++; @@ -286,15 +436,19 @@ class ParagraphStreamer final : public Print { void onOpenTag() { htmlDepth++; + if (nonVisibleDepth > 0 || isNonVisibleTag()) { + nonVisibleDepth++; + return; + } + if (stepCount == 0) { if (strcasecmp(tagName, "p") == 0) onLegacyP(); return; } - // Capture inside the fully-matched element even after target char is found + // Capture a child inside the fully-matched element even after target char is found. if (revPFound && matchedDepth == stepCount && capturedAnchorIdLen == 0 && strcasecmp(tagName, "a") == 0) { - capturingAnchorTag = true; - idState = ID_SCAN; + beginAnchorIdScan(); } if (revDone) return; @@ -315,6 +469,7 @@ class ParagraphStreamer final : public Print { stepEnteredAtDepth[matchedDepth] = htmlDepth; matchedDepth++; if (matchedDepth == stepCount) { + beginAnchorIdScan(); paragraphAtMatch = pCount; liCountAtMatch = liCount; revPFound = true; @@ -332,6 +487,12 @@ class ParagraphStreamer final : public Print { } void onCloseTag() { + if (nonVisibleDepth > 0) { + nonVisibleDepth--; + if (htmlDepth > 0) htmlDepth--; + return; + } + // Legacy mode: each direct child element closing advances the text node index. if (stepCount == 0 && revPFound && !revDone && paragraphHtmlDepth >= 0 && htmlDepth == paragraphHtmlDepth + 1) { currentTextNode++; @@ -419,42 +580,12 @@ class ParagraphStreamer final : public Print { attrQuoteChar = 0; } if (capturingAnchorTag) { - switch (idState) { - case ID_SCAN: - idState = (c == 'i' || c == 'I') ? ID_I : ID_SCAN; - break; - case ID_I: - idState = (c == 'd' || c == 'D') ? ID_D : ID_SCAN; - break; - case ID_D: - idState = (c == '=') ? ID_EQ : ID_SCAN; - break; - case ID_EQ: - if (c == '"') - idState = ID_IN_VALUE_D; - else if (c == '\'') - idState = ID_IN_VALUE_S; - break; - case ID_IN_VALUE_D: - if (c == '"') { - capturedAnchorId[capturedAnchorIdLen] = '\0'; - capturingAnchorTag = false; - } else if (capturedAnchorIdLen + 1 < MAX_ANCHOR_ID) - capturedAnchorId[capturedAnchorIdLen++] = c; - break; - case ID_IN_VALUE_S: - if (c == '\'') { - capturedAnchorId[capturedAnchorIdLen] = '\0'; - capturingAnchorTag = false; - } else if (capturedAnchorIdLen + 1 < MAX_ANCHOR_ID) - capturedAnchorId[capturedAnchorIdLen++] = c; - break; - } + scanAnchorAttribute(c); } // Only treat '/' as self-closing when outside a quoted attribute value. if (c == '/' && !inAttrQuote) { + endAnchorIdScan(); onCloseTag(); - capturingAnchorTag = false; } break; } @@ -512,10 +643,13 @@ class ParagraphStreamer final : public Print { tagNameLen = 0; tagIsClose = false; capturingAnchorTag = false; - idState = ID_SCAN; + resetAnchorAttrScan(); inAttrQuote = false; attrQuoteChar = 0; } else if (c == '>') { + if (tagState == TAG_ATTRS) { + endAnchorIdScan(); + } globalInTag = false; inAttrQuote = false; if (tagState == TAG_IN_NAME && tagNameLen > 0) { @@ -529,6 +663,9 @@ class ParagraphStreamer final : public Print { tagState = TAG_IDLE; } else if (globalInTag) { processByteInTag(c); + } else if (nonVisibleDepth > 0) { + // Ignore head/style/script/title text. KOReader XPaths are body-relative, and CSS text + // should not contribute to intra-spine progress. } else { if (c == '&') { globalInEntity = true; @@ -762,4 +899,4 @@ std::string ProgressMapper::generateXPath(const std::shared_ptr& epub, int const int p = s.paragraphCount(); return (p > 0) ? base + "/p[" + std::to_string(p) + "]" : base; -} \ No newline at end of file +} diff --git a/lib/Utf8/Utf8.cpp b/lib/Utf8/Utf8.cpp index b59a14d4..340c4510 100644 --- a/lib/Utf8/Utf8.cpp +++ b/lib/Utf8/Utf8.cpp @@ -1,5 +1,73 @@ #include "Utf8.h" +#include "Utf8ComposeTable.h" + +namespace { +// Look up the canonical composition of (base + combining mark), or 0 if none. +uint32_t utf8ComposePair(const uint32_t base, const uint32_t mark) { + if (base > 0xFFFF || mark > 0xFFFF) return 0; + int lo = 0; + int hi = kUtf8ComposeTableSize - 1; + while (lo <= hi) { + const int mid = (lo + hi) / 2; + const Utf8ComposeEntry& e = kUtf8ComposeTable[mid]; + if (e.base < base || (e.base == base && e.mark < mark)) { + lo = mid + 1; + } else if (e.base > base || (e.base == base && e.mark > mark)) { + hi = mid - 1; + } else { + return e.composed; + } + } + return 0; +} +} // namespace + +std::string utf8ComposeNfc(const std::string& in) { + // Fast path: NFC composition can only change text that contains a combining + // diacritical mark U+0300-036F (UTF-8 lead byte 0xCC or 0xCD). Plain ASCII and + // already-precomposed (NFC) text -- the vast majority of words -- have none, so + // return them untouched without walking codepoints or allocating. A 0xCD that is + // actually a non-combining codepoint just falls through to the full pass below. + bool maybeHasMarks = false; + for (const unsigned char c : in) { + if (c == 0xCC || c == 0xCD) { + maybeHasMarks = true; + break; + } + } + if (!maybeHasMarks) return in; + + std::string out; + out.reserve(in.size()); + const unsigned char* p = reinterpret_cast(in.c_str()); + uint32_t base = 0; + bool haveBase = false; + while (*p) { + const uint32_t cp = utf8NextCodepoint(&p); + if (cp == 0) break; + if (utf8IsCombiningMark(cp)) { + const uint32_t composed = haveBase ? utf8ComposePair(base, cp) : 0; + if (composed) { + base = composed; // keep accumulating further marks onto the composed char + continue; + } + // No composition: flush the pending base, then emit the mark unchanged. + if (haveBase) { + utf8AppendCodepoint(base, out); + haveBase = false; + } + utf8AppendCodepoint(cp, out); + } else { + if (haveBase) utf8AppendCodepoint(base, out); + base = cp; + haveBase = true; + } + } + if (haveBase) utf8AppendCodepoint(base, out); + return out; +} + int utf8CodepointLen(const unsigned char c) { if (c < 0x80) return 1; // 0xxxxxxx if ((c >> 5) == 0x6) return 2; // 110xxxxx diff --git a/lib/Utf8/Utf8.h b/lib/Utf8/Utf8.h index e7238f85..64c04808 100644 --- a/lib/Utf8/Utf8.h +++ b/lib/Utf8/Utf8.h @@ -12,6 +12,12 @@ 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); +// Canonical composition (NFC) for the Latin / Vietnamese range: precomposes a +// base letter followed by combining diacritical mark(s) into a single codepoint. +// Needed because the device fonts have no combining-mark positioning, so text +// stored in NFD (e.g. some EPUB chapter titles) otherwise renders broken. +std::string utf8ComposeNfc(const std::string& in); + // Truncate a raw char buffer to the last complete UTF-8 codepoint boundary. // Returns the new length (<= len). If the buffer ends mid-sequence, the // incomplete trailing bytes are excluded. @@ -21,12 +27,15 @@ int utf8SafeTruncateBuffer(const char* buf, int len); // Covers CJK Unified Ideographs, Hiragana, Katakana, Hangul Syllables, CJK punctuation, // and fullwidth forms — the ranges where word boundaries are implicit per character. inline bool utf8IsCjkBreakable(const uint32_t cp) { - return (cp >= 0x3000 && cp <= 0x303F) // CJK Symbols and Punctuation + return (cp >= 0x1100 && cp <= 0x11FF) // Hangul Jamo + || (cp >= 0x3000 && cp <= 0x303F) // CJK Symbols and Punctuation || (cp >= 0x3040 && cp <= 0x309F) // Hiragana || (cp >= 0x30A0 && cp <= 0x30FF) // Katakana + || (cp >= 0x3130 && cp <= 0x318F) // Hangul Compatibility Jamo || (cp >= 0x3400 && cp <= 0x4DBF) // CJK Extension A || (cp >= 0x4E00 && cp <= 0x9FFF) // CJK Unified Ideographs || (cp >= 0xAC00 && cp <= 0xD7AF) // Hangul Syllables + || (cp >= 0xD7B0 && cp <= 0xD7FF) // Hangul Jamo Extended-B || (cp >= 0xF900 && cp <= 0xFAFF) // CJK Compatibility Ideographs || (cp >= 0xFE30 && cp <= 0xFE4F) // CJK Compatibility Forms || (cp >= 0xFF01 && cp <= 0xFF60) // Fullwidth Latin / Punctuation diff --git a/lib/Utf8/Utf8ComposeTable.h b/lib/Utf8/Utf8ComposeTable.h new file mode 100644 index 00000000..4b7ee00e --- /dev/null +++ b/lib/Utf8/Utf8ComposeTable.h @@ -0,0 +1,229 @@ +// Auto-generated canonical composition (NFC) table for the Latin / Vietnamese +// range (combining marks U+0300-U+036F). Generated from Python unicodedata. +// Used by utf8ComposeNfc() to precompose decomposed (NFD) text so the device +// fonts (which lack combining-mark positioning) render it correctly. +#pragma once +#include + +struct Utf8ComposeEntry { + uint16_t base; + uint16_t mark; + uint16_t composed; +}; + +// Sorted by (base, mark) for binary search. +static constexpr Utf8ComposeEntry kUtf8ComposeTable[] = { + {0x0041, 0x0300, 0x00C0}, {0x0041, 0x0301, 0x00C1}, {0x0041, 0x0302, 0x00C2}, {0x0041, 0x0303, 0x00C3}, + {0x0041, 0x0304, 0x0100}, {0x0041, 0x0306, 0x0102}, {0x0041, 0x0307, 0x0226}, {0x0041, 0x0308, 0x00C4}, + {0x0041, 0x0309, 0x1EA2}, {0x0041, 0x030A, 0x00C5}, {0x0041, 0x030C, 0x01CD}, {0x0041, 0x030F, 0x0200}, + {0x0041, 0x0311, 0x0202}, {0x0041, 0x0323, 0x1EA0}, {0x0041, 0x0325, 0x1E00}, {0x0041, 0x0328, 0x0104}, + {0x0041, 0x0340, 0x00C0}, {0x0041, 0x0341, 0x00C1}, {0x0042, 0x0307, 0x1E02}, {0x0042, 0x0323, 0x1E04}, + {0x0042, 0x0331, 0x1E06}, {0x0043, 0x0301, 0x0106}, {0x0043, 0x0302, 0x0108}, {0x0043, 0x0307, 0x010A}, + {0x0043, 0x030C, 0x010C}, {0x0043, 0x0327, 0x00C7}, {0x0043, 0x0341, 0x0106}, {0x0044, 0x0307, 0x1E0A}, + {0x0044, 0x030C, 0x010E}, {0x0044, 0x0323, 0x1E0C}, {0x0044, 0x0327, 0x1E10}, {0x0044, 0x032D, 0x1E12}, + {0x0044, 0x0331, 0x1E0E}, {0x0045, 0x0300, 0x00C8}, {0x0045, 0x0301, 0x00C9}, {0x0045, 0x0302, 0x00CA}, + {0x0045, 0x0303, 0x1EBC}, {0x0045, 0x0304, 0x0112}, {0x0045, 0x0306, 0x0114}, {0x0045, 0x0307, 0x0116}, + {0x0045, 0x0308, 0x00CB}, {0x0045, 0x0309, 0x1EBA}, {0x0045, 0x030C, 0x011A}, {0x0045, 0x030F, 0x0204}, + {0x0045, 0x0311, 0x0206}, {0x0045, 0x0323, 0x1EB8}, {0x0045, 0x0327, 0x0228}, {0x0045, 0x0328, 0x0118}, + {0x0045, 0x032D, 0x1E18}, {0x0045, 0x0330, 0x1E1A}, {0x0045, 0x0340, 0x00C8}, {0x0045, 0x0341, 0x00C9}, + {0x0046, 0x0307, 0x1E1E}, {0x0047, 0x0301, 0x01F4}, {0x0047, 0x0302, 0x011C}, {0x0047, 0x0304, 0x1E20}, + {0x0047, 0x0306, 0x011E}, {0x0047, 0x0307, 0x0120}, {0x0047, 0x030C, 0x01E6}, {0x0047, 0x0327, 0x0122}, + {0x0047, 0x0341, 0x01F4}, {0x0048, 0x0302, 0x0124}, {0x0048, 0x0307, 0x1E22}, {0x0048, 0x0308, 0x1E26}, + {0x0048, 0x030C, 0x021E}, {0x0048, 0x0323, 0x1E24}, {0x0048, 0x0327, 0x1E28}, {0x0048, 0x032E, 0x1E2A}, + {0x0049, 0x0300, 0x00CC}, {0x0049, 0x0301, 0x00CD}, {0x0049, 0x0302, 0x00CE}, {0x0049, 0x0303, 0x0128}, + {0x0049, 0x0304, 0x012A}, {0x0049, 0x0306, 0x012C}, {0x0049, 0x0307, 0x0130}, {0x0049, 0x0308, 0x00CF}, + {0x0049, 0x0309, 0x1EC8}, {0x0049, 0x030C, 0x01CF}, {0x0049, 0x030F, 0x0208}, {0x0049, 0x0311, 0x020A}, + {0x0049, 0x0323, 0x1ECA}, {0x0049, 0x0328, 0x012E}, {0x0049, 0x0330, 0x1E2C}, {0x0049, 0x0340, 0x00CC}, + {0x0049, 0x0341, 0x00CD}, {0x0049, 0x0344, 0x1E2E}, {0x004A, 0x0302, 0x0134}, {0x004B, 0x0301, 0x1E30}, + {0x004B, 0x030C, 0x01E8}, {0x004B, 0x0323, 0x1E32}, {0x004B, 0x0327, 0x0136}, {0x004B, 0x0331, 0x1E34}, + {0x004B, 0x0341, 0x1E30}, {0x004C, 0x0301, 0x0139}, {0x004C, 0x030C, 0x013D}, {0x004C, 0x0323, 0x1E36}, + {0x004C, 0x0327, 0x013B}, {0x004C, 0x032D, 0x1E3C}, {0x004C, 0x0331, 0x1E3A}, {0x004C, 0x0341, 0x0139}, + {0x004D, 0x0301, 0x1E3E}, {0x004D, 0x0307, 0x1E40}, {0x004D, 0x0323, 0x1E42}, {0x004D, 0x0341, 0x1E3E}, + {0x004E, 0x0300, 0x01F8}, {0x004E, 0x0301, 0x0143}, {0x004E, 0x0303, 0x00D1}, {0x004E, 0x0307, 0x1E44}, + {0x004E, 0x030C, 0x0147}, {0x004E, 0x0323, 0x1E46}, {0x004E, 0x0327, 0x0145}, {0x004E, 0x032D, 0x1E4A}, + {0x004E, 0x0331, 0x1E48}, {0x004E, 0x0340, 0x01F8}, {0x004E, 0x0341, 0x0143}, {0x004F, 0x0300, 0x00D2}, + {0x004F, 0x0301, 0x00D3}, {0x004F, 0x0302, 0x00D4}, {0x004F, 0x0303, 0x00D5}, {0x004F, 0x0304, 0x014C}, + {0x004F, 0x0306, 0x014E}, {0x004F, 0x0307, 0x022E}, {0x004F, 0x0308, 0x00D6}, {0x004F, 0x0309, 0x1ECE}, + {0x004F, 0x030B, 0x0150}, {0x004F, 0x030C, 0x01D1}, {0x004F, 0x030F, 0x020C}, {0x004F, 0x0311, 0x020E}, + {0x004F, 0x031B, 0x01A0}, {0x004F, 0x0323, 0x1ECC}, {0x004F, 0x0328, 0x01EA}, {0x004F, 0x0340, 0x00D2}, + {0x004F, 0x0341, 0x00D3}, {0x0050, 0x0301, 0x1E54}, {0x0050, 0x0307, 0x1E56}, {0x0050, 0x0341, 0x1E54}, + {0x0052, 0x0301, 0x0154}, {0x0052, 0x0307, 0x1E58}, {0x0052, 0x030C, 0x0158}, {0x0052, 0x030F, 0x0210}, + {0x0052, 0x0311, 0x0212}, {0x0052, 0x0323, 0x1E5A}, {0x0052, 0x0327, 0x0156}, {0x0052, 0x0331, 0x1E5E}, + {0x0052, 0x0341, 0x0154}, {0x0053, 0x0301, 0x015A}, {0x0053, 0x0302, 0x015C}, {0x0053, 0x0307, 0x1E60}, + {0x0053, 0x030C, 0x0160}, {0x0053, 0x0323, 0x1E62}, {0x0053, 0x0326, 0x0218}, {0x0053, 0x0327, 0x015E}, + {0x0053, 0x0341, 0x015A}, {0x0054, 0x0307, 0x1E6A}, {0x0054, 0x030C, 0x0164}, {0x0054, 0x0323, 0x1E6C}, + {0x0054, 0x0326, 0x021A}, {0x0054, 0x0327, 0x0162}, {0x0054, 0x032D, 0x1E70}, {0x0054, 0x0331, 0x1E6E}, + {0x0055, 0x0300, 0x00D9}, {0x0055, 0x0301, 0x00DA}, {0x0055, 0x0302, 0x00DB}, {0x0055, 0x0303, 0x0168}, + {0x0055, 0x0304, 0x016A}, {0x0055, 0x0306, 0x016C}, {0x0055, 0x0308, 0x00DC}, {0x0055, 0x0309, 0x1EE6}, + {0x0055, 0x030A, 0x016E}, {0x0055, 0x030B, 0x0170}, {0x0055, 0x030C, 0x01D3}, {0x0055, 0x030F, 0x0214}, + {0x0055, 0x0311, 0x0216}, {0x0055, 0x031B, 0x01AF}, {0x0055, 0x0323, 0x1EE4}, {0x0055, 0x0324, 0x1E72}, + {0x0055, 0x0328, 0x0172}, {0x0055, 0x032D, 0x1E76}, {0x0055, 0x0330, 0x1E74}, {0x0055, 0x0340, 0x00D9}, + {0x0055, 0x0341, 0x00DA}, {0x0055, 0x0344, 0x01D7}, {0x0056, 0x0303, 0x1E7C}, {0x0056, 0x0323, 0x1E7E}, + {0x0057, 0x0300, 0x1E80}, {0x0057, 0x0301, 0x1E82}, {0x0057, 0x0302, 0x0174}, {0x0057, 0x0307, 0x1E86}, + {0x0057, 0x0308, 0x1E84}, {0x0057, 0x0323, 0x1E88}, {0x0057, 0x0340, 0x1E80}, {0x0057, 0x0341, 0x1E82}, + {0x0058, 0x0307, 0x1E8A}, {0x0058, 0x0308, 0x1E8C}, {0x0059, 0x0300, 0x1EF2}, {0x0059, 0x0301, 0x00DD}, + {0x0059, 0x0302, 0x0176}, {0x0059, 0x0303, 0x1EF8}, {0x0059, 0x0304, 0x0232}, {0x0059, 0x0307, 0x1E8E}, + {0x0059, 0x0308, 0x0178}, {0x0059, 0x0309, 0x1EF6}, {0x0059, 0x0323, 0x1EF4}, {0x0059, 0x0340, 0x1EF2}, + {0x0059, 0x0341, 0x00DD}, {0x005A, 0x0301, 0x0179}, {0x005A, 0x0302, 0x1E90}, {0x005A, 0x0307, 0x017B}, + {0x005A, 0x030C, 0x017D}, {0x005A, 0x0323, 0x1E92}, {0x005A, 0x0331, 0x1E94}, {0x005A, 0x0341, 0x0179}, + {0x0061, 0x0300, 0x00E0}, {0x0061, 0x0301, 0x00E1}, {0x0061, 0x0302, 0x00E2}, {0x0061, 0x0303, 0x00E3}, + {0x0061, 0x0304, 0x0101}, {0x0061, 0x0306, 0x0103}, {0x0061, 0x0307, 0x0227}, {0x0061, 0x0308, 0x00E4}, + {0x0061, 0x0309, 0x1EA3}, {0x0061, 0x030A, 0x00E5}, {0x0061, 0x030C, 0x01CE}, {0x0061, 0x030F, 0x0201}, + {0x0061, 0x0311, 0x0203}, {0x0061, 0x0323, 0x1EA1}, {0x0061, 0x0325, 0x1E01}, {0x0061, 0x0328, 0x0105}, + {0x0061, 0x0340, 0x00E0}, {0x0061, 0x0341, 0x00E1}, {0x0062, 0x0307, 0x1E03}, {0x0062, 0x0323, 0x1E05}, + {0x0062, 0x0331, 0x1E07}, {0x0063, 0x0301, 0x0107}, {0x0063, 0x0302, 0x0109}, {0x0063, 0x0307, 0x010B}, + {0x0063, 0x030C, 0x010D}, {0x0063, 0x0327, 0x00E7}, {0x0063, 0x0341, 0x0107}, {0x0064, 0x0307, 0x1E0B}, + {0x0064, 0x030C, 0x010F}, {0x0064, 0x0323, 0x1E0D}, {0x0064, 0x0327, 0x1E11}, {0x0064, 0x032D, 0x1E13}, + {0x0064, 0x0331, 0x1E0F}, {0x0065, 0x0300, 0x00E8}, {0x0065, 0x0301, 0x00E9}, {0x0065, 0x0302, 0x00EA}, + {0x0065, 0x0303, 0x1EBD}, {0x0065, 0x0304, 0x0113}, {0x0065, 0x0306, 0x0115}, {0x0065, 0x0307, 0x0117}, + {0x0065, 0x0308, 0x00EB}, {0x0065, 0x0309, 0x1EBB}, {0x0065, 0x030C, 0x011B}, {0x0065, 0x030F, 0x0205}, + {0x0065, 0x0311, 0x0207}, {0x0065, 0x0323, 0x1EB9}, {0x0065, 0x0327, 0x0229}, {0x0065, 0x0328, 0x0119}, + {0x0065, 0x032D, 0x1E19}, {0x0065, 0x0330, 0x1E1B}, {0x0065, 0x0340, 0x00E8}, {0x0065, 0x0341, 0x00E9}, + {0x0066, 0x0307, 0x1E1F}, {0x0067, 0x0301, 0x01F5}, {0x0067, 0x0302, 0x011D}, {0x0067, 0x0304, 0x1E21}, + {0x0067, 0x0306, 0x011F}, {0x0067, 0x0307, 0x0121}, {0x0067, 0x030C, 0x01E7}, {0x0067, 0x0327, 0x0123}, + {0x0067, 0x0341, 0x01F5}, {0x0068, 0x0302, 0x0125}, {0x0068, 0x0307, 0x1E23}, {0x0068, 0x0308, 0x1E27}, + {0x0068, 0x030C, 0x021F}, {0x0068, 0x0323, 0x1E25}, {0x0068, 0x0327, 0x1E29}, {0x0068, 0x032E, 0x1E2B}, + {0x0068, 0x0331, 0x1E96}, {0x0069, 0x0300, 0x00EC}, {0x0069, 0x0301, 0x00ED}, {0x0069, 0x0302, 0x00EE}, + {0x0069, 0x0303, 0x0129}, {0x0069, 0x0304, 0x012B}, {0x0069, 0x0306, 0x012D}, {0x0069, 0x0308, 0x00EF}, + {0x0069, 0x0309, 0x1EC9}, {0x0069, 0x030C, 0x01D0}, {0x0069, 0x030F, 0x0209}, {0x0069, 0x0311, 0x020B}, + {0x0069, 0x0323, 0x1ECB}, {0x0069, 0x0328, 0x012F}, {0x0069, 0x0330, 0x1E2D}, {0x0069, 0x0340, 0x00EC}, + {0x0069, 0x0341, 0x00ED}, {0x0069, 0x0344, 0x1E2F}, {0x006A, 0x0302, 0x0135}, {0x006A, 0x030C, 0x01F0}, + {0x006B, 0x0301, 0x1E31}, {0x006B, 0x030C, 0x01E9}, {0x006B, 0x0323, 0x1E33}, {0x006B, 0x0327, 0x0137}, + {0x006B, 0x0331, 0x1E35}, {0x006B, 0x0341, 0x1E31}, {0x006C, 0x0301, 0x013A}, {0x006C, 0x030C, 0x013E}, + {0x006C, 0x0323, 0x1E37}, {0x006C, 0x0327, 0x013C}, {0x006C, 0x032D, 0x1E3D}, {0x006C, 0x0331, 0x1E3B}, + {0x006C, 0x0341, 0x013A}, {0x006D, 0x0301, 0x1E3F}, {0x006D, 0x0307, 0x1E41}, {0x006D, 0x0323, 0x1E43}, + {0x006D, 0x0341, 0x1E3F}, {0x006E, 0x0300, 0x01F9}, {0x006E, 0x0301, 0x0144}, {0x006E, 0x0303, 0x00F1}, + {0x006E, 0x0307, 0x1E45}, {0x006E, 0x030C, 0x0148}, {0x006E, 0x0323, 0x1E47}, {0x006E, 0x0327, 0x0146}, + {0x006E, 0x032D, 0x1E4B}, {0x006E, 0x0331, 0x1E49}, {0x006E, 0x0340, 0x01F9}, {0x006E, 0x0341, 0x0144}, + {0x006F, 0x0300, 0x00F2}, {0x006F, 0x0301, 0x00F3}, {0x006F, 0x0302, 0x00F4}, {0x006F, 0x0303, 0x00F5}, + {0x006F, 0x0304, 0x014D}, {0x006F, 0x0306, 0x014F}, {0x006F, 0x0307, 0x022F}, {0x006F, 0x0308, 0x00F6}, + {0x006F, 0x0309, 0x1ECF}, {0x006F, 0x030B, 0x0151}, {0x006F, 0x030C, 0x01D2}, {0x006F, 0x030F, 0x020D}, + {0x006F, 0x0311, 0x020F}, {0x006F, 0x031B, 0x01A1}, {0x006F, 0x0323, 0x1ECD}, {0x006F, 0x0328, 0x01EB}, + {0x006F, 0x0340, 0x00F2}, {0x006F, 0x0341, 0x00F3}, {0x0070, 0x0301, 0x1E55}, {0x0070, 0x0307, 0x1E57}, + {0x0070, 0x0341, 0x1E55}, {0x0072, 0x0301, 0x0155}, {0x0072, 0x0307, 0x1E59}, {0x0072, 0x030C, 0x0159}, + {0x0072, 0x030F, 0x0211}, {0x0072, 0x0311, 0x0213}, {0x0072, 0x0323, 0x1E5B}, {0x0072, 0x0327, 0x0157}, + {0x0072, 0x0331, 0x1E5F}, {0x0072, 0x0341, 0x0155}, {0x0073, 0x0301, 0x015B}, {0x0073, 0x0302, 0x015D}, + {0x0073, 0x0307, 0x1E61}, {0x0073, 0x030C, 0x0161}, {0x0073, 0x0323, 0x1E63}, {0x0073, 0x0326, 0x0219}, + {0x0073, 0x0327, 0x015F}, {0x0073, 0x0341, 0x015B}, {0x0074, 0x0307, 0x1E6B}, {0x0074, 0x0308, 0x1E97}, + {0x0074, 0x030C, 0x0165}, {0x0074, 0x0323, 0x1E6D}, {0x0074, 0x0326, 0x021B}, {0x0074, 0x0327, 0x0163}, + {0x0074, 0x032D, 0x1E71}, {0x0074, 0x0331, 0x1E6F}, {0x0075, 0x0300, 0x00F9}, {0x0075, 0x0301, 0x00FA}, + {0x0075, 0x0302, 0x00FB}, {0x0075, 0x0303, 0x0169}, {0x0075, 0x0304, 0x016B}, {0x0075, 0x0306, 0x016D}, + {0x0075, 0x0308, 0x00FC}, {0x0075, 0x0309, 0x1EE7}, {0x0075, 0x030A, 0x016F}, {0x0075, 0x030B, 0x0171}, + {0x0075, 0x030C, 0x01D4}, {0x0075, 0x030F, 0x0215}, {0x0075, 0x0311, 0x0217}, {0x0075, 0x031B, 0x01B0}, + {0x0075, 0x0323, 0x1EE5}, {0x0075, 0x0324, 0x1E73}, {0x0075, 0x0328, 0x0173}, {0x0075, 0x032D, 0x1E77}, + {0x0075, 0x0330, 0x1E75}, {0x0075, 0x0340, 0x00F9}, {0x0075, 0x0341, 0x00FA}, {0x0075, 0x0344, 0x01D8}, + {0x0076, 0x0303, 0x1E7D}, {0x0076, 0x0323, 0x1E7F}, {0x0077, 0x0300, 0x1E81}, {0x0077, 0x0301, 0x1E83}, + {0x0077, 0x0302, 0x0175}, {0x0077, 0x0307, 0x1E87}, {0x0077, 0x0308, 0x1E85}, {0x0077, 0x030A, 0x1E98}, + {0x0077, 0x0323, 0x1E89}, {0x0077, 0x0340, 0x1E81}, {0x0077, 0x0341, 0x1E83}, {0x0078, 0x0307, 0x1E8B}, + {0x0078, 0x0308, 0x1E8D}, {0x0079, 0x0300, 0x1EF3}, {0x0079, 0x0301, 0x00FD}, {0x0079, 0x0302, 0x0177}, + {0x0079, 0x0303, 0x1EF9}, {0x0079, 0x0304, 0x0233}, {0x0079, 0x0307, 0x1E8F}, {0x0079, 0x0308, 0x00FF}, + {0x0079, 0x0309, 0x1EF7}, {0x0079, 0x030A, 0x1E99}, {0x0079, 0x0323, 0x1EF5}, {0x0079, 0x0340, 0x1EF3}, + {0x0079, 0x0341, 0x00FD}, {0x007A, 0x0301, 0x017A}, {0x007A, 0x0302, 0x1E91}, {0x007A, 0x0307, 0x017C}, + {0x007A, 0x030C, 0x017E}, {0x007A, 0x0323, 0x1E93}, {0x007A, 0x0331, 0x1E95}, {0x007A, 0x0341, 0x017A}, + {0x00A8, 0x0300, 0x1FED}, {0x00A8, 0x0301, 0x0385}, {0x00A8, 0x0340, 0x1FED}, {0x00A8, 0x0341, 0x0385}, + {0x00A8, 0x0342, 0x1FC1}, {0x00C2, 0x0300, 0x1EA6}, {0x00C2, 0x0301, 0x1EA4}, {0x00C2, 0x0303, 0x1EAA}, + {0x00C2, 0x0309, 0x1EA8}, {0x00C2, 0x0323, 0x1EAC}, {0x00C2, 0x0340, 0x1EA6}, {0x00C2, 0x0341, 0x1EA4}, + {0x00C4, 0x0304, 0x01DE}, {0x00C5, 0x0301, 0x01FA}, {0x00C5, 0x0341, 0x01FA}, {0x00C6, 0x0301, 0x01FC}, + {0x00C6, 0x0304, 0x01E2}, {0x00C6, 0x0341, 0x01FC}, {0x00C7, 0x0301, 0x1E08}, {0x00C7, 0x0341, 0x1E08}, + {0x00CA, 0x0300, 0x1EC0}, {0x00CA, 0x0301, 0x1EBE}, {0x00CA, 0x0303, 0x1EC4}, {0x00CA, 0x0309, 0x1EC2}, + {0x00CA, 0x0323, 0x1EC6}, {0x00CA, 0x0340, 0x1EC0}, {0x00CA, 0x0341, 0x1EBE}, {0x00CF, 0x0301, 0x1E2E}, + {0x00CF, 0x0341, 0x1E2E}, {0x00D2, 0x031B, 0x1EDC}, {0x00D3, 0x031B, 0x1EDA}, {0x00D4, 0x0300, 0x1ED2}, + {0x00D4, 0x0301, 0x1ED0}, {0x00D4, 0x0303, 0x1ED6}, {0x00D4, 0x0309, 0x1ED4}, {0x00D4, 0x0323, 0x1ED8}, + {0x00D4, 0x0340, 0x1ED2}, {0x00D4, 0x0341, 0x1ED0}, {0x00D5, 0x0301, 0x1E4C}, {0x00D5, 0x0304, 0x022C}, + {0x00D5, 0x0308, 0x1E4E}, {0x00D5, 0x031B, 0x1EE0}, {0x00D5, 0x0341, 0x1E4C}, {0x00D6, 0x0304, 0x022A}, + {0x00D8, 0x0301, 0x01FE}, {0x00D8, 0x0341, 0x01FE}, {0x00D9, 0x031B, 0x1EEA}, {0x00DA, 0x031B, 0x1EE8}, + {0x00DC, 0x0300, 0x01DB}, {0x00DC, 0x0301, 0x01D7}, {0x00DC, 0x0304, 0x01D5}, {0x00DC, 0x030C, 0x01D9}, + {0x00DC, 0x0340, 0x01DB}, {0x00DC, 0x0341, 0x01D7}, {0x00E2, 0x0300, 0x1EA7}, {0x00E2, 0x0301, 0x1EA5}, + {0x00E2, 0x0303, 0x1EAB}, {0x00E2, 0x0309, 0x1EA9}, {0x00E2, 0x0323, 0x1EAD}, {0x00E2, 0x0340, 0x1EA7}, + {0x00E2, 0x0341, 0x1EA5}, {0x00E4, 0x0304, 0x01DF}, {0x00E5, 0x0301, 0x01FB}, {0x00E5, 0x0341, 0x01FB}, + {0x00E6, 0x0301, 0x01FD}, {0x00E6, 0x0304, 0x01E3}, {0x00E6, 0x0341, 0x01FD}, {0x00E7, 0x0301, 0x1E09}, + {0x00E7, 0x0341, 0x1E09}, {0x00EA, 0x0300, 0x1EC1}, {0x00EA, 0x0301, 0x1EBF}, {0x00EA, 0x0303, 0x1EC5}, + {0x00EA, 0x0309, 0x1EC3}, {0x00EA, 0x0323, 0x1EC7}, {0x00EA, 0x0340, 0x1EC1}, {0x00EA, 0x0341, 0x1EBF}, + {0x00EF, 0x0301, 0x1E2F}, {0x00EF, 0x0341, 0x1E2F}, {0x00F2, 0x031B, 0x1EDD}, {0x00F3, 0x031B, 0x1EDB}, + {0x00F4, 0x0300, 0x1ED3}, {0x00F4, 0x0301, 0x1ED1}, {0x00F4, 0x0303, 0x1ED7}, {0x00F4, 0x0309, 0x1ED5}, + {0x00F4, 0x0323, 0x1ED9}, {0x00F4, 0x0340, 0x1ED3}, {0x00F4, 0x0341, 0x1ED1}, {0x00F5, 0x0301, 0x1E4D}, + {0x00F5, 0x0304, 0x022D}, {0x00F5, 0x0308, 0x1E4F}, {0x00F5, 0x031B, 0x1EE1}, {0x00F5, 0x0341, 0x1E4D}, + {0x00F6, 0x0304, 0x022B}, {0x00F8, 0x0301, 0x01FF}, {0x00F8, 0x0341, 0x01FF}, {0x00F9, 0x031B, 0x1EEB}, + {0x00FA, 0x031B, 0x1EE9}, {0x00FC, 0x0300, 0x01DC}, {0x00FC, 0x0301, 0x01D8}, {0x00FC, 0x0304, 0x01D6}, + {0x00FC, 0x030C, 0x01DA}, {0x00FC, 0x0340, 0x01DC}, {0x00FC, 0x0341, 0x01D8}, {0x0102, 0x0300, 0x1EB0}, + {0x0102, 0x0301, 0x1EAE}, {0x0102, 0x0303, 0x1EB4}, {0x0102, 0x0309, 0x1EB2}, {0x0102, 0x0323, 0x1EB6}, + {0x0102, 0x0340, 0x1EB0}, {0x0102, 0x0341, 0x1EAE}, {0x0103, 0x0300, 0x1EB1}, {0x0103, 0x0301, 0x1EAF}, + {0x0103, 0x0303, 0x1EB5}, {0x0103, 0x0309, 0x1EB3}, {0x0103, 0x0323, 0x1EB7}, {0x0103, 0x0340, 0x1EB1}, + {0x0103, 0x0341, 0x1EAF}, {0x0106, 0x0327, 0x1E08}, {0x0107, 0x0327, 0x1E09}, {0x0112, 0x0300, 0x1E14}, + {0x0112, 0x0301, 0x1E16}, {0x0112, 0x0340, 0x1E14}, {0x0112, 0x0341, 0x1E16}, {0x0113, 0x0300, 0x1E15}, + {0x0113, 0x0301, 0x1E17}, {0x0113, 0x0340, 0x1E15}, {0x0113, 0x0341, 0x1E17}, {0x0114, 0x0327, 0x1E1C}, + {0x0115, 0x0327, 0x1E1D}, {0x014C, 0x0300, 0x1E50}, {0x014C, 0x0301, 0x1E52}, {0x014C, 0x0328, 0x01EC}, + {0x014C, 0x0340, 0x1E50}, {0x014C, 0x0341, 0x1E52}, {0x014D, 0x0300, 0x1E51}, {0x014D, 0x0301, 0x1E53}, + {0x014D, 0x0328, 0x01ED}, {0x014D, 0x0340, 0x1E51}, {0x014D, 0x0341, 0x1E53}, {0x015A, 0x0307, 0x1E64}, + {0x015B, 0x0307, 0x1E65}, {0x0160, 0x0307, 0x1E66}, {0x0161, 0x0307, 0x1E67}, {0x0168, 0x0301, 0x1E78}, + {0x0168, 0x031B, 0x1EEE}, {0x0168, 0x0341, 0x1E78}, {0x0169, 0x0301, 0x1E79}, {0x0169, 0x031B, 0x1EEF}, + {0x0169, 0x0341, 0x1E79}, {0x016A, 0x0308, 0x1E7A}, {0x016B, 0x0308, 0x1E7B}, {0x017F, 0x0307, 0x1E9B}, + {0x01A0, 0x0300, 0x1EDC}, {0x01A0, 0x0301, 0x1EDA}, {0x01A0, 0x0303, 0x1EE0}, {0x01A0, 0x0309, 0x1EDE}, + {0x01A0, 0x0323, 0x1EE2}, {0x01A0, 0x0340, 0x1EDC}, {0x01A0, 0x0341, 0x1EDA}, {0x01A1, 0x0300, 0x1EDD}, + {0x01A1, 0x0301, 0x1EDB}, {0x01A1, 0x0303, 0x1EE1}, {0x01A1, 0x0309, 0x1EDF}, {0x01A1, 0x0323, 0x1EE3}, + {0x01A1, 0x0340, 0x1EDD}, {0x01A1, 0x0341, 0x1EDB}, {0x01AF, 0x0300, 0x1EEA}, {0x01AF, 0x0301, 0x1EE8}, + {0x01AF, 0x0303, 0x1EEE}, {0x01AF, 0x0309, 0x1EEC}, {0x01AF, 0x0323, 0x1EF0}, {0x01AF, 0x0340, 0x1EEA}, + {0x01AF, 0x0341, 0x1EE8}, {0x01B0, 0x0300, 0x1EEB}, {0x01B0, 0x0301, 0x1EE9}, {0x01B0, 0x0303, 0x1EEF}, + {0x01B0, 0x0309, 0x1EED}, {0x01B0, 0x0323, 0x1EF1}, {0x01B0, 0x0340, 0x1EEB}, {0x01B0, 0x0341, 0x1EE9}, + {0x01B7, 0x030C, 0x01EE}, {0x01EA, 0x0304, 0x01EC}, {0x01EB, 0x0304, 0x01ED}, {0x0226, 0x0304, 0x01E0}, + {0x0227, 0x0304, 0x01E1}, {0x0228, 0x0306, 0x1E1C}, {0x0229, 0x0306, 0x1E1D}, {0x022E, 0x0304, 0x0230}, + {0x022F, 0x0304, 0x0231}, {0x0292, 0x030C, 0x01EF}, {0x0391, 0x0300, 0x1FBA}, {0x0391, 0x0301, 0x0386}, + {0x0391, 0x0304, 0x1FB9}, {0x0391, 0x0306, 0x1FB8}, {0x0391, 0x0313, 0x1F08}, {0x0391, 0x0314, 0x1F09}, + {0x0391, 0x0340, 0x1FBA}, {0x0391, 0x0341, 0x0386}, {0x0391, 0x0343, 0x1F08}, {0x0391, 0x0345, 0x1FBC}, + {0x0395, 0x0300, 0x1FC8}, {0x0395, 0x0301, 0x0388}, {0x0395, 0x0313, 0x1F18}, {0x0395, 0x0314, 0x1F19}, + {0x0395, 0x0340, 0x1FC8}, {0x0395, 0x0341, 0x0388}, {0x0395, 0x0343, 0x1F18}, {0x0397, 0x0300, 0x1FCA}, + {0x0397, 0x0301, 0x0389}, {0x0397, 0x0313, 0x1F28}, {0x0397, 0x0314, 0x1F29}, {0x0397, 0x0340, 0x1FCA}, + {0x0397, 0x0341, 0x0389}, {0x0397, 0x0343, 0x1F28}, {0x0397, 0x0345, 0x1FCC}, {0x0399, 0x0300, 0x1FDA}, + {0x0399, 0x0301, 0x038A}, {0x0399, 0x0304, 0x1FD9}, {0x0399, 0x0306, 0x1FD8}, {0x0399, 0x0308, 0x03AA}, + {0x0399, 0x0313, 0x1F38}, {0x0399, 0x0314, 0x1F39}, {0x0399, 0x0340, 0x1FDA}, {0x0399, 0x0341, 0x038A}, + {0x0399, 0x0343, 0x1F38}, {0x039F, 0x0300, 0x1FF8}, {0x039F, 0x0301, 0x038C}, {0x039F, 0x0313, 0x1F48}, + {0x039F, 0x0314, 0x1F49}, {0x039F, 0x0340, 0x1FF8}, {0x039F, 0x0341, 0x038C}, {0x039F, 0x0343, 0x1F48}, + {0x03A1, 0x0314, 0x1FEC}, {0x03A5, 0x0300, 0x1FEA}, {0x03A5, 0x0301, 0x038E}, {0x03A5, 0x0304, 0x1FE9}, + {0x03A5, 0x0306, 0x1FE8}, {0x03A5, 0x0308, 0x03AB}, {0x03A5, 0x0314, 0x1F59}, {0x03A5, 0x0340, 0x1FEA}, + {0x03A5, 0x0341, 0x038E}, {0x03A9, 0x0300, 0x1FFA}, {0x03A9, 0x0301, 0x038F}, {0x03A9, 0x0313, 0x1F68}, + {0x03A9, 0x0314, 0x1F69}, {0x03A9, 0x0340, 0x1FFA}, {0x03A9, 0x0341, 0x038F}, {0x03A9, 0x0343, 0x1F68}, + {0x03A9, 0x0345, 0x1FFC}, {0x03AC, 0x0345, 0x1FB4}, {0x03AE, 0x0345, 0x1FC4}, {0x03B1, 0x0300, 0x1F70}, + {0x03B1, 0x0301, 0x03AC}, {0x03B1, 0x0304, 0x1FB1}, {0x03B1, 0x0306, 0x1FB0}, {0x03B1, 0x0313, 0x1F00}, + {0x03B1, 0x0314, 0x1F01}, {0x03B1, 0x0340, 0x1F70}, {0x03B1, 0x0341, 0x03AC}, {0x03B1, 0x0342, 0x1FB6}, + {0x03B1, 0x0343, 0x1F00}, {0x03B1, 0x0345, 0x1FB3}, {0x03B5, 0x0300, 0x1F72}, {0x03B5, 0x0301, 0x03AD}, + {0x03B5, 0x0313, 0x1F10}, {0x03B5, 0x0314, 0x1F11}, {0x03B5, 0x0340, 0x1F72}, {0x03B5, 0x0341, 0x03AD}, + {0x03B5, 0x0343, 0x1F10}, {0x03B7, 0x0300, 0x1F74}, {0x03B7, 0x0301, 0x03AE}, {0x03B7, 0x0313, 0x1F20}, + {0x03B7, 0x0314, 0x1F21}, {0x03B7, 0x0340, 0x1F74}, {0x03B7, 0x0341, 0x03AE}, {0x03B7, 0x0342, 0x1FC6}, + {0x03B7, 0x0343, 0x1F20}, {0x03B7, 0x0345, 0x1FC3}, {0x03B9, 0x0300, 0x1F76}, {0x03B9, 0x0301, 0x03AF}, + {0x03B9, 0x0304, 0x1FD1}, {0x03B9, 0x0306, 0x1FD0}, {0x03B9, 0x0308, 0x03CA}, {0x03B9, 0x0313, 0x1F30}, + {0x03B9, 0x0314, 0x1F31}, {0x03B9, 0x0340, 0x1F76}, {0x03B9, 0x0341, 0x03AF}, {0x03B9, 0x0342, 0x1FD6}, + {0x03B9, 0x0343, 0x1F30}, {0x03B9, 0x0344, 0x0390}, {0x03BF, 0x0300, 0x1F78}, {0x03BF, 0x0301, 0x03CC}, + {0x03BF, 0x0313, 0x1F40}, {0x03BF, 0x0314, 0x1F41}, {0x03BF, 0x0340, 0x1F78}, {0x03BF, 0x0341, 0x03CC}, + {0x03BF, 0x0343, 0x1F40}, {0x03C1, 0x0313, 0x1FE4}, {0x03C1, 0x0314, 0x1FE5}, {0x03C1, 0x0343, 0x1FE4}, + {0x03C5, 0x0300, 0x1F7A}, {0x03C5, 0x0301, 0x03CD}, {0x03C5, 0x0304, 0x1FE1}, {0x03C5, 0x0306, 0x1FE0}, + {0x03C5, 0x0308, 0x03CB}, {0x03C5, 0x0313, 0x1F50}, {0x03C5, 0x0314, 0x1F51}, {0x03C5, 0x0340, 0x1F7A}, + {0x03C5, 0x0341, 0x03CD}, {0x03C5, 0x0342, 0x1FE6}, {0x03C5, 0x0343, 0x1F50}, {0x03C5, 0x0344, 0x03B0}, + {0x03C9, 0x0300, 0x1F7C}, {0x03C9, 0x0301, 0x03CE}, {0x03C9, 0x0313, 0x1F60}, {0x03C9, 0x0314, 0x1F61}, + {0x03C9, 0x0340, 0x1F7C}, {0x03C9, 0x0341, 0x03CE}, {0x03C9, 0x0342, 0x1FF6}, {0x03C9, 0x0343, 0x1F60}, + {0x03C9, 0x0345, 0x1FF3}, {0x03CA, 0x0300, 0x1FD2}, {0x03CA, 0x0301, 0x0390}, {0x03CA, 0x0340, 0x1FD2}, + {0x03CA, 0x0341, 0x0390}, {0x03CA, 0x0342, 0x1FD7}, {0x03CB, 0x0300, 0x1FE2}, {0x03CB, 0x0301, 0x03B0}, + {0x03CB, 0x0340, 0x1FE2}, {0x03CB, 0x0341, 0x03B0}, {0x03CB, 0x0342, 0x1FE7}, {0x03CE, 0x0345, 0x1FF4}, + {0x03D2, 0x0301, 0x03D3}, {0x03D2, 0x0308, 0x03D4}, {0x03D2, 0x0341, 0x03D3}, {0x0406, 0x0308, 0x0407}, + {0x0410, 0x0306, 0x04D0}, {0x0410, 0x0308, 0x04D2}, {0x0413, 0x0301, 0x0403}, {0x0413, 0x0341, 0x0403}, + {0x0415, 0x0300, 0x0400}, {0x0415, 0x0306, 0x04D6}, {0x0415, 0x0308, 0x0401}, {0x0415, 0x0340, 0x0400}, + {0x0416, 0x0306, 0x04C1}, {0x0416, 0x0308, 0x04DC}, {0x0417, 0x0308, 0x04DE}, {0x0418, 0x0300, 0x040D}, + {0x0418, 0x0304, 0x04E2}, {0x0418, 0x0306, 0x0419}, {0x0418, 0x0308, 0x04E4}, {0x0418, 0x0340, 0x040D}, + {0x041A, 0x0301, 0x040C}, {0x041A, 0x0341, 0x040C}, {0x041E, 0x0308, 0x04E6}, {0x0423, 0x0304, 0x04EE}, + {0x0423, 0x0306, 0x040E}, {0x0423, 0x0308, 0x04F0}, {0x0423, 0x030B, 0x04F2}, {0x0427, 0x0308, 0x04F4}, + {0x042B, 0x0308, 0x04F8}, {0x042D, 0x0308, 0x04EC}, {0x0430, 0x0306, 0x04D1}, {0x0430, 0x0308, 0x04D3}, + {0x0433, 0x0301, 0x0453}, {0x0433, 0x0341, 0x0453}, {0x0435, 0x0300, 0x0450}, {0x0435, 0x0306, 0x04D7}, + {0x0435, 0x0308, 0x0451}, {0x0435, 0x0340, 0x0450}, {0x0436, 0x0306, 0x04C2}, {0x0436, 0x0308, 0x04DD}, + {0x0437, 0x0308, 0x04DF}, {0x0438, 0x0300, 0x045D}, {0x0438, 0x0304, 0x04E3}, {0x0438, 0x0306, 0x0439}, + {0x0438, 0x0308, 0x04E5}, {0x0438, 0x0340, 0x045D}, {0x043A, 0x0301, 0x045C}, {0x043A, 0x0341, 0x045C}, + {0x043E, 0x0308, 0x04E7}, {0x0443, 0x0304, 0x04EF}, {0x0443, 0x0306, 0x045E}, {0x0443, 0x0308, 0x04F1}, + {0x0443, 0x030B, 0x04F3}, {0x0447, 0x0308, 0x04F5}, {0x044B, 0x0308, 0x04F9}, {0x044D, 0x0308, 0x04ED}, + {0x0456, 0x0308, 0x0457}, {0x0474, 0x030F, 0x0476}, {0x0475, 0x030F, 0x0477}, {0x04D8, 0x0308, 0x04DA}, + {0x04D9, 0x0308, 0x04DB}, {0x04E8, 0x0308, 0x04EA}, {0x04E9, 0x0308, 0x04EB}, {0x1E36, 0x0304, 0x1E38}, + {0x1E37, 0x0304, 0x1E39}, {0x1E5A, 0x0304, 0x1E5C}, {0x1E5B, 0x0304, 0x1E5D}, {0x1E60, 0x0323, 0x1E68}, + {0x1E61, 0x0323, 0x1E69}, {0x1E62, 0x0307, 0x1E68}, {0x1E63, 0x0307, 0x1E69}, {0x1EA0, 0x0302, 0x1EAC}, + {0x1EA0, 0x0306, 0x1EB6}, {0x1EA1, 0x0302, 0x1EAD}, {0x1EA1, 0x0306, 0x1EB7}, {0x1EB8, 0x0302, 0x1EC6}, + {0x1EB9, 0x0302, 0x1EC7}, {0x1ECC, 0x0302, 0x1ED8}, {0x1ECC, 0x031B, 0x1EE2}, {0x1ECD, 0x0302, 0x1ED9}, + {0x1ECD, 0x031B, 0x1EE3}, {0x1ECE, 0x031B, 0x1EDE}, {0x1ECF, 0x031B, 0x1EDF}, {0x1EE4, 0x031B, 0x1EF0}, + {0x1EE5, 0x031B, 0x1EF1}, {0x1EE6, 0x031B, 0x1EEC}, {0x1EE7, 0x031B, 0x1EED}, +}; +static constexpr int kUtf8ComposeTableSize = sizeof(kUtf8ComposeTable) / sizeof(kUtf8ComposeTable[0]); diff --git a/lib/ZipFile/ZipFile.cpp b/lib/ZipFile/ZipFile.cpp index 1c3964a1..711c05c7 100644 --- a/lib/ZipFile/ZipFile.cpp +++ b/lib/ZipFile/ZipFile.cpp @@ -397,37 +397,34 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo // Continue out of block with data set } else if (fileStat.method == ZIP_METHOD_DEFLATED) { - // Read out deflated content from file - const auto deflatedData = static_cast(malloc(deflatedDataSize)); - if (deflatedData == nullptr) { - LOG_ERR("ZIP", "Failed to allocate memory for decompression buffer"); + auto* fileReadBuffer = static_cast(malloc(1024)); + if (!fileReadBuffer) { + LOG_ERR("ZIP", "Failed to allocate memory for zip file read buffer"); free(data); return nullptr; } - const size_t dataRead = file.read(deflatedData, deflatedDataSize); + ZipInflateCtx ctx; + ctx.file = &file; + ctx.fileRemaining = deflatedDataSize; + ctx.readBuf = fileReadBuffer; + ctx.readBufSize = 1024; - if (dataRead != deflatedDataSize) { - LOG_ERR("ZIP", "Failed to read data, expected %d got %d", deflatedDataSize, dataRead); - free(deflatedData); + if (!ctx.reader.init(true)) { + LOG_ERR("ZIP", "Failed to init inflate reader"); + free(fileReadBuffer); free(data); return nullptr; } + ctx.reader.setReadCallback(zipReadCallback); - bool success = false; - { - InflateReader r; - r.init(false); - r.setSource(deflatedData, deflatedDataSize); - success = r.read(data, inflatedDataSize); - } - free(deflatedData); - - if (!success) { + if (!ctx.reader.read(data, inflatedDataSize)) { LOG_ERR("ZIP", "Failed to inflate file"); + free(fileReadBuffer); free(data); return nullptr; } + free(fileReadBuffer); // Continue out of block with data set } else { diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 3fe88304..888529c1 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -136,6 +136,17 @@ class CrossPointSettings { // Short power button press actions enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, FORCE_REFRESH = 3, FOOTNOTES = 4, SHORT_PWRBTN_COUNT }; + // Long-press Confirm action while reading an EPUB. The setting cycles through these values. + // Persisted in settings.json by index: any new function (e.g. dictionary, bookmark) MUST use a + // value >= 2 and be appended at the END of the enumValues array in SettingsList.h, otherwise the + // stored indices shift and existing saves are silently misinterpreted. + enum LONG_PRESS_MENU_FUNCTION { + LP_MENU_KOSYNC = 0, + LP_MENU_DISABLED = 1, + LP_MENU_BOOKMARK = 2, + LONG_PRESS_MENU_FUNCTION_COUNT + }; + // Hide battery percentage enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT }; @@ -226,6 +237,9 @@ class CrossPointSettings { uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press page turn button behavior uint8_t longPressButtonBehavior = OFF; + // Long-press Confirm function in EPUB reader (cycles through LONG_PRESS_MENU_FUNCTION values). + // Defaults to Bookmark to preserve the upstream long-press-Confirm-adds-bookmark behavior. + uint8_t longPressMenuFunction = LP_MENU_BOOKMARK; // UI Theme uint8_t uiTheme = LYRA; // Sunlight fading compensation diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index f658efa1..ce64ee0d 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -8,6 +8,15 @@ #include "CrossPointSettings.h" #include "components/TouchRegistry.h" +bool MappedInputManager::isNavDirectionSwapped() const { + // Key the swap on the orientation the screen is *actually* rendered at, not the persisted reader + // setting. The reader (and its modal menus) render rotated, so navigation/labels flip there; the + // home and settings UI render in portrait, so they never flip even when a rotated reader is configured. + const auto orientation = renderer.getOrientation(); + return SETTINGS.frontButtonFollowOrientation && + (orientation == GfxRenderer::PortraitInverted || orientation == GfxRenderer::LandscapeCounterClockwise); +} + bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const { const auto sideLayout = SETTINGS.sideButtonLayout; @@ -55,6 +64,15 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint default: return false; } + case Button::NavNext: + // Logical "next item" navigation: side Down + front Right, with the control axis flipped in + // INVERTED / LANDSCAPE_CCW (frontButtonFollowOrientation) so it matches the rotated hint labels. + return isNavDirectionSwapped() ? (mapButton(Button::Up, fn) || mapButton(Button::Left, fn)) + : (mapButton(Button::Down, fn) || mapButton(Button::Right, fn)); + case Button::NavPrevious: + // Logical "previous item" navigation: side Up + front Left, axis-flipped in the same orientations. + return isNavDirectionSwapped() ? (mapButton(Button::Down, fn) || mapButton(Button::Right, fn)) + : (mapButton(Button::Up, fn) || mapButton(Button::Left, fn)); } return false; @@ -255,9 +273,7 @@ unsigned long MappedInputManager::getHeldTime() const { MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const { // Swap previous/next labels to match the page turn direction swap in INVERTED and LANDSCAPE_CCW. - const bool swapLabels = - SETTINGS.frontButtonFollowOrientation && (SETTINGS.orientation == CrossPointSettings::INVERTED || - SETTINGS.orientation == CrossPointSettings::LANDSCAPE_CCW); + const bool swapLabels = isNavDirectionSwapped(); const char* leftLabel = swapLabels ? next : previous; const char* rightLabel = swapLabels ? previous : next; diff --git a/src/MappedInputManager.h b/src/MappedInputManager.h index c40d5f45..71b71bb8 100644 --- a/src/MappedInputManager.h +++ b/src/MappedInputManager.h @@ -6,7 +6,7 @@ class GfxRenderer; class MappedInputManager { public: - enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward }; + enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious }; enum class SwipeDir { None, Left, Right, Up, Down }; struct Labels { @@ -16,7 +16,7 @@ class MappedInputManager { const char* btn4; }; - MappedInputManager(HalGPIO& gpio, GfxRenderer& renderer) : gpio(gpio), renderer(renderer) {} + MappedInputManager(HalGPIO& gpio, const GfxRenderer& renderer) : gpio(gpio), renderer(renderer) {} void update() const { gpio.update(); } bool wasPressed(Button button) const; @@ -56,9 +56,20 @@ class MappedInputManager { // Returns the raw front button index that was pressed this frame (or -1 if none). int getPressedFrontButton() const; + // True when the control axis is flipped relative to the physical buttons: the user opted into + // orientation-following front buttons AND the screen is *currently rendered* rotated (INVERTED / + // LANDSCAPE_CCW). Keyed on the live renderer orientation rather than the persisted reader setting, + // so portrait UI (home, settings) never swaps while the reader and its menus do. + [[nodiscard]] bool isNavDirectionSwapped() const; + private: HalGPIO& gpio; - GfxRenderer& renderer; + // Logical-to-physical button mapping depends on what the user is actually looking at: when the + // screen is rendered rotated, the directional buttons must flip to match. The renderer is the only + // authority on the *live* orientation (the reader rotates it and restores portrait on exit), so we + // read it here instead of CrossPointSettings.orientation, which is just the persisted reader + // preference and stays "rotated" even while portrait UI like home/settings is on screen. + const GfxRenderer& renderer; bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const; void rememberTouchHeldTime() const; diff --git a/src/SdCardFontSystem.cpp b/src/SdCardFontSystem.cpp index b771bede..79aa1226 100644 --- a/src/SdCardFontSystem.cpp +++ b/src/SdCardFontSystem.cpp @@ -5,12 +5,16 @@ #include "CrossPointSettings.h" +namespace { + static uint8_t fontSizeEnumFromSettings() { uint8_t e = SETTINGS.fontSize; if (e >= CrossPointSettings::FONT_SIZE_COUNT) e = 1; // default to MEDIUM return e; } +} // namespace + void SdCardFontSystem::begin(GfxRenderer& renderer) { registry_.discover(); @@ -74,10 +78,8 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { SETTINGS.sdFontFamilyName[0] = '\0'; return; } - auto sizes = family->availableSizes(); - uint8_t idx = sizeEnum; - if (idx >= sizes.size()) idx = sizes.size() - 1; - uint8_t wantedPt = sizes.empty() ? 0 : sizes[idx]; + const auto* selected = family->findClosestReaderSize(sizeEnum); + const uint8_t wantedPt = selected ? selected->pointSize : 0; if (!registryWasDirty && wantedPt == manager_.currentPointSize()) return; LOG_DBG("SDFS", "Reloading %s: size %u -> %u (enum %u)%s", wantedFamily, manager_.currentPointSize(), wantedPt, sizeEnum, registryWasDirty ? " [registry dirty]" : ""); diff --git a/src/SettingsList.h b/src/SettingsList.h index 90bf41c7..b61534ef 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -173,12 +173,16 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist {StrId::STR_LONG_PRESS_BEHAVIOR_OFF, StrId::STR_LONG_PRESS_BEHAVIOR_SKIP, StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION}, "longPressButtonBehavior", StrId::STR_CAT_CONTROLS), + SettingInfo::Enum(StrId::STR_LONG_PRESS_MENU, &CrossPointSettings::longPressMenuFunction, + {StrId::STR_KOSYNC, StrId::STR_DISABLED, StrId::STR_BOOKMARK_OPTION}, "longPressMenuFunction", + StrId::STR_CAT_CONTROLS), SettingInfo::Enum( StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn, {StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH, StrId::STR_FOOTNOTES}, "shortPwrBtn", StrId::STR_CAT_CONTROLS), SettingInfo::Toggle(StrId::STR_PWR_BTN_FOOTNOTE_BACK, &CrossPointSettings::pwrBtnFootnoteBack, "pwrBtnFootnoteBack", StrId::STR_CAT_CONTROLS), + // --- System --- SettingInfo::Value( StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeoutMinutes, diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 20519e8c..175f1d80 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -1,5 +1,6 @@ #include "ActivityManager.h" +#include #include #include diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index b3cca7ec..a382cb85 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -260,7 +260,9 @@ void EpubReaderActivity::loop() { // top-left Back corner is consumed by wasReleased(Back) above, never reaching here. const auto touch = ReaderUtils::detectTouchPageTurn(renderer); - // Enter reader menu activity (Confirm release, or a center touch-and-hold). + // Enter reader menu activity on short-press Confirm release, or a center touch-and-hold. A + // long-press that fired a bound function (bookmark or KOReader sync) sets ignoreNextConfirmRelease + // so the release following the hold does not also open the menu. if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(touch)) { if (ignoreNextConfirmRelease) { ignoreNextConfirmRelease = false; @@ -288,14 +290,32 @@ void EpubReaderActivity::loop() { } } - if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && - mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS) { - if (!showBookmarkMessage) { - addBookmark(); - showBookmarkMessage = true; - ignoreNextConfirmRelease = true; // Prevent accidental menu open after adding bookmark - bookmarkMessageTime = millis(); - requestUpdate(); + // Long-press Confirm runs the user-selected function (SETTINGS.longPressMenuFunction). + if (mappedInput.isPressed(MappedInputManager::Button::Confirm)) { + switch (SETTINGS.longPressMenuFunction) { + case CrossPointSettings::LP_MENU_BOOKMARK: + // Hold ~0.4s drops a bookmark at the current page. + if (mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS && !showBookmarkMessage) { + addBookmark(); + showBookmarkMessage = true; + ignoreNextConfirmRelease = true; // Prevent accidental menu open after adding bookmark + bookmarkMessageTime = millis(); + requestUpdate(); + } + break; + case CrossPointSettings::LP_MENU_KOSYNC: + // Hold ~1s launches KOReader sync. If sync can't run (no credentials stored), fall + // through so the normal Confirm-release still opens the reader menu. + if (mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { + if (launchKOReaderSync()) { + ignoreNextConfirmRelease = true; // sync launched or error shown; suppress menu open + return; + } + } + break; + case CrossPointSettings::LP_MENU_DISABLED: + default: + break; } } @@ -585,50 +605,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction break; } case EpubReaderMenuActivity::MenuAction::SYNC: { - if (KOREADER_STORE.hasCredentials()) { - const int currentPage = section ? section->currentPage : nextPageNumber; - const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount; - std::optional paragraphIndex; - if (section && currentPage >= 0 && currentPage < section->pageCount) { - const uint16_t paragraphPage = - currentPage > 0 ? static_cast(currentPage - 1) : static_cast(currentPage); - if (const auto pIdx = section->getParagraphIndexForPage(paragraphPage)) { - paragraphIndex = *pIdx; - } - } - - // Pre-compute local KO position and chapter name while Epub is still in RAM. - CrossPointPosition localPos = getCurrentPosition(); - SavedProgressPosition localKoPos = ProgressMapper::toSavedProgress(epub, localPos); - const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex); - std::string localChapterName = (tocIdx >= 0) ? epub->getTocItem(tocIdx).title : ""; - const std::string savedEpubPath = epub->getPath(); - - // Persist current position so the reader resumes at the right page on return. - // goToReader() depends on this file, so abort the sync if the write fails. - if (!saveProgress(currentSpineIndex, currentPage, totalPages)) { - LOG_ERR("KOSync", "Aborting sync because current progress could not be saved"); - pendingSyncSaveError = true; - requestUpdate(); - return; - } - - // Release Epub and Section to free ~65KB RAM for the TLS handshake. - LOG_DBG("KOSync", "Releasing epub for sync (heap before: %u)", (unsigned)ESP.getFreeHeap()); - { - RenderLock lock(*this); - if (section) { - nextPageNumber = section->currentPage; - } - section.reset(); - epub.reset(); - } - LOG_DBG("KOSync", "Epub released (heap after: %u)", (unsigned)ESP.getFreeHeap()); - - activityManager.replaceActivity(std::make_unique( - renderer, mappedInput, savedEpubPath, currentSpineIndex, currentPage, totalPages, std::move(localKoPos), - std::move(localChapterName), paragraphIndex)); - } + launchKOReaderSync(); break; } case EpubReaderMenuActivity::MenuAction::BOOKMARKS: { @@ -640,6 +617,54 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } } +bool EpubReaderActivity::launchKOReaderSync() { + if (!KOREADER_STORE.hasCredentials()) return false; // no-op: nothing to launch + + const int currentPage = section ? section->currentPage : nextPageNumber; + const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount; + std::optional paragraphIndex; + if (section && currentPage >= 0 && currentPage < section->pageCount) { + const uint16_t paragraphPage = + currentPage > 0 ? static_cast(currentPage - 1) : static_cast(currentPage); + if (const auto pIdx = section->getParagraphIndexForPage(paragraphPage)) { + paragraphIndex = *pIdx; + } + } + + // Pre-compute local KO position and chapter name while Epub is still in RAM. + CrossPointPosition localPos = getCurrentPosition(); + SavedProgressPosition localKoPos = ProgressMapper::toSavedProgress(epub, localPos); + const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex); + std::string localChapterName = (tocIdx >= 0) ? epub->getTocItem(tocIdx).title : ""; + const std::string savedEpubPath = epub->getPath(); + + // Persist current position so the reader resumes at the right page on return. + // goToReader() depends on this file, so abort the sync if the write fails. + if (!saveProgress(currentSpineIndex, currentPage, totalPages)) { + LOG_ERR("KOSync", "Aborting sync because current progress could not be saved"); + pendingSyncSaveError = true; + requestUpdate(); + return true; // acted: surfaced a save error to the user + } + + // Release Epub and Section to free ~65KB RAM for the TLS handshake. + LOG_DBG("KOSync", "Releasing epub for sync (heap before: %u)", (unsigned)ESP.getFreeHeap()); + { + RenderLock lock(*this); + if (section) { + nextPageNumber = section->currentPage; + } + section.reset(); + epub.reset(); + } + LOG_DBG("KOSync", "Epub released (heap after: %u)", (unsigned)ESP.getFreeHeap()); + + activityManager.replaceActivity(std::make_unique( + renderer, mappedInput, savedEpubPath, currentSpineIndex, currentPage, totalPages, std::move(localKoPos), + std::move(localChapterName), paragraphIndex)); + return true; // acted: launched the sync activity +} + void EpubReaderActivity::applyOrientation(const uint8_t orientation) { // No-op if the selected orientation matches current settings. if (SETTINGS.orientation == orientation) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 38aecd26..d1b46225 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -60,6 +60,9 @@ class EpubReaderActivity final : public Activity { // Jump to a percentage of the book (0-100), mapping it to spine and page. void jumpToPercent(int percent); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); + // Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op + // because no KOReader credentials are stored. + bool launchKOReaderSync(); void applyOrientation(uint8_t orientation); void toggleAutoPageTurn(uint8_t selectedPageTurnOption); void pageTurn(bool isForwardTurn); diff --git a/src/activities/reader/EpubReaderBookmarksActivity.cpp b/src/activities/reader/EpubReaderBookmarksActivity.cpp index 0ac165ac..7ac318d9 100644 --- a/src/activities/reader/EpubReaderBookmarksActivity.cpp +++ b/src/activities/reader/EpubReaderBookmarksActivity.cpp @@ -234,7 +234,7 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) { const auto backLabel = confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_CANCEL) : tr(STR_BACK); const auto confirmLabel = - bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_OPEN)) : ""; + bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_SELECT)) : ""; const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); diff --git a/src/activities/reader/EpubReaderUtils.h b/src/activities/reader/EpubReaderUtils.h index 7dc0fb63..e9ac05b8 100644 --- a/src/activities/reader/EpubReaderUtils.h +++ b/src/activities/reader/EpubReaderUtils.h @@ -1,23 +1,19 @@ #pragma once #include -#include #include +#include "ProgressFile.h" + namespace EpubReaderUtils { // Persists reader progress for an EPUB to its cache directory. Returns true on success. -inline bool saveProgress(Epub& epub, int spineIndex, int pageNumber, int pageCount) { +inline bool saveProgress(const Epub& epub, int spineIndex, int pageNumber, int pageCount) { if (spineIndex < 0 || spineIndex > 0xFFFF || pageNumber < 0 || pageNumber > 0xFFFF || pageCount < 0 || pageCount > 0xFFFF) { LOG_ERR("ERS", "Progress values out of range: spine=%d page=%d count=%d", spineIndex, pageNumber, pageCount); return false; } - HalFile f; - if (!Storage.openFileForWrite("ERS", epub.getCachePath() + "/progress.bin", f)) { - LOG_ERR("ERS", "Could not open progress file for write!"); - return false; - } uint8_t data[6]; data[0] = spineIndex & 0xFF; data[1] = (spineIndex >> 8) & 0xFF; @@ -25,9 +21,7 @@ inline bool saveProgress(Epub& epub, int spineIndex, int pageNumber, int pageCou data[3] = (pageNumber >> 8) & 0xFF; data[4] = pageCount & 0xFF; data[5] = (pageCount >> 8) & 0xFF; - const size_t written = f.write(data, sizeof(data)); - if (written != sizeof(data)) { - LOG_ERR("ERS", "Short write saving progress: %u/%u bytes", (unsigned)written, (unsigned)sizeof(data)); + if (!ProgressFile::writeAtomic(epub.getCachePath(), data, sizeof(data))) { return false; } LOG_DBG("ERS", "Progress saved: spine=%d page=%d", spineIndex, pageNumber); diff --git a/src/activities/reader/ProgressFile.h b/src/activities/reader/ProgressFile.h new file mode 100644 index 00000000..5238a157 --- /dev/null +++ b/src/activities/reader/ProgressFile.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace ProgressFile { + +// Writes `len` bytes of reader progress to `/progress.bin` without +// ever leaving the canonical file half-written. +// +// The bytes go to a temporary `progress.bin.tmp` first; only once that is fully +// written and closed is it renamed over progress.bin. An interrupted write +// (power loss or a crash mid-SPI) therefore damages only the throwaway temp file. +// Previously a truncate-in-place write that was cut short left progress.bin with +// a broken FAT cluster chain that the firmware could neither rewrite nor clear, +// stranding the book on an old page (issue #2275). +// +// This is crash-safe, not metadata-atomic: on FAT the replace is remove + rename, +// two separate directory operations, so a crash between them can leave neither +// file -- which simply reads as "no saved progress" on next launch, never a +// corrupt or unclearable file. The point is that progress.bin is never torn. +// +// Note: this prevents corruption on a healthy card going forward. It cannot +// repair an already-corrupted progress.bin -- removing the stale file may itself +// fail at the FAT level, in which case recovery still requires fsck on a host. +// +// Returns true only if the new progress.bin is fully in place. +inline bool writeAtomic(const std::string& cachePath, const uint8_t* data, size_t len) { + const std::string finalPath = cachePath + "/progress.bin"; + const std::string tmpPath = cachePath + "/progress.bin.tmp"; + + { + HalFile f; + if (!Storage.openFileForWrite("PRG", tmpPath, f)) { + LOG_ERR("PRG", "Could not open temp progress file for write: %s", tmpPath.c_str()); + return false; + } + const size_t written = f.write(data, len); + if (written != len) { + LOG_ERR("PRG", "Short write saving progress to %s: %u/%u bytes", tmpPath.c_str(), (unsigned)written, + (unsigned)len); + return false; + } + f.flush(); + // f (the temp file) is closed at scope exit (DESTRUCTOR_CLOSES_FILE=1) before + // the rename below -- SdFat must not rename a path that still has an open FsFile. + } + + // SdFat's rename does not overwrite an existing destination, so drop the old + // canonical file first. The brief window where neither file exists reads as + // "no saved progress" on next launch -- never a corrupt, unclearable file. + Storage.remove(finalPath.c_str()); + if (!Storage.rename(tmpPath.c_str(), finalPath.c_str())) { + LOG_ERR("PRG", "Failed to rename temp progress into place: %s", finalPath.c_str()); + return false; + } + return true; +} + +} // namespace ProgressFile diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 6894ee70..e4c040df 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "CrossPointSettings.h" #include "Epub.h" @@ -29,7 +30,11 @@ std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { return nullptr; } - auto epub = std::unique_ptr(new Epub(path, "/.crosspoint")); + auto epub = makeUniqueNoThrow(path, "/.crosspoint"); + if (!epub) { + LOG_ERR("READER", "Failed to allocate EPUB object"); + return nullptr; + } if (epub->load(true, SETTINGS.embeddedStyle == 0)) { return epub; } @@ -44,7 +49,11 @@ std::unique_ptr ReaderActivity::loadXtc(const std::string& path) { return nullptr; } - auto xtc = std::unique_ptr(new Xtc(path, "/.crosspoint")); + auto xtc = makeUniqueNoThrow(path, "/.crosspoint"); + if (!xtc) { + LOG_ERR("READER", "Failed to allocate XTC object"); + return nullptr; + } if (xtc->load()) { return xtc; } @@ -59,7 +68,11 @@ std::unique_ptr ReaderActivity::loadTxt(const std::string& path) { return nullptr; } - auto txt = std::unique_ptr(new Txt(path, "/.crosspoint")); + auto txt = makeUniqueNoThrow(path, "/.crosspoint"); + if (!txt) { + LOG_ERR("READER", "Failed to allocate TXT object"); + return nullptr; + } if (txt->load()) { return txt; } diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 243dd0f6..6efcbb62 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -47,9 +47,7 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) { const bool usePress = SETTINGS.longPressButtonBehavior == SETTINGS.OFF; const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward(); const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack(); - const bool swapFront = - SETTINGS.frontButtonFollowOrientation && (SETTINGS.orientation == CrossPointSettings::INVERTED || - SETTINGS.orientation == CrossPointSettings::LANDSCAPE_CCW); + const bool swapFront = input.isNavDirectionSwapped(); const auto prevButton = swapFront ? MappedInputManager::Button::Right : MappedInputManager::Button::Left; const auto nextButton = swapFront ? MappedInputManager::Button::Left : MappedInputManager::Button::Right; const bool prev = diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 7f2e4b71..ab0953f3 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -11,6 +11,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "ProgressFile.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "components/UITheme.h" @@ -426,14 +427,13 @@ void TxtReaderActivity::renderStatusBar() const { } void TxtReaderActivity::saveProgress() const { - HalFile f; - if (Storage.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) { - uint8_t data[4]; - data[0] = currentPage & 0xFF; - data[1] = (currentPage >> 8) & 0xFF; - data[2] = 0; - data[3] = 0; - f.write(data, 4); + uint8_t data[4]; + data[0] = currentPage & 0xFF; + data[1] = (currentPage >> 8) & 0xFF; + data[2] = 0; + data[3] = 0; + if (!ProgressFile::writeAtomic(txt->getCachePath(), data, sizeof(data))) { + LOG_ERR("TRS", "Failed to save progress: page %d", currentPage); } } diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index d76e7484..3db1f39c 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -17,6 +17,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "ProgressFile.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "XtcReaderChapterSelectionActivity.h" @@ -381,15 +382,13 @@ void XtcReaderActivity::renderPage() { } void XtcReaderActivity::saveProgress() const { - HalFile f; - if (Storage.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) { - uint8_t data[4]; - data[0] = currentPage & 0xFF; - data[1] = (currentPage >> 8) & 0xFF; - data[2] = (currentPage >> 16) & 0xFF; - data[3] = (currentPage >> 24) & 0xFF; - f.write(data, 4); - f.close(); + uint8_t data[4]; + data[0] = currentPage & 0xFF; + data[1] = (currentPage >> 8) & 0xFF; + data[2] = (currentPage >> 16) & 0xFF; + data[3] = (currentPage >> 24) & 0xFF; + if (!ProgressFile::writeAtomic(xtc->getCachePath(), data, sizeof(data))) { + LOG_ERR("XTR", "Failed to save progress: page %lu", currentPage); } } diff --git a/src/activities/settings/ClockSyncActivity.cpp b/src/activities/settings/ClockSyncActivity.cpp index 1f75192f..620d45bb 100644 --- a/src/activities/settings/ClockSyncActivity.cpp +++ b/src/activities/settings/ClockSyncActivity.cpp @@ -10,6 +10,8 @@ #include "CrossPointSettings.h" #include "MappedInputManager.h" +#include "SilentRestart.h" +#include "activities/network/WifiSelectionActivity.h" #include "components/UITheme.h" #include "fontIds.h" @@ -17,14 +19,46 @@ void ClockSyncActivity::onEnter() { Activity::onEnter(); state = SYNCING; syncedTime[0] = '\0'; + + if (WiFi.status() == WL_CONNECTED) { + requestUpdate(); + return; + } + + shouldTearDownWifiOnExit = true; + launchWifiSelection(); +} + +void ClockSyncActivity::onExit() { + Activity::onExit(); + + if (shouldTearDownWifiOnExit && WiFi.getMode() != WIFI_MODE_NULL) { + WiFi.disconnect(false); + delay(30); + silentRestart(); + } +} + +void ClockSyncActivity::launchWifiSelection() { + LOG_INF("CLK", "Manual sync requested without WiFi, launching WiFi selection"); + startActivityForResult(std::make_unique(renderer, mappedInput), + [this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); }); +} + +void ClockSyncActivity::onWifiSelectionComplete(const bool connected) { + if (!connected) { + LOG_INF("CLK", "WiFi selection cancelled before manual clock sync"); + finish(); + return; + } + + state = SYNCING; requestUpdate(); } -void ClockSyncActivity::onExit() { Activity::onExit(); } - void ClockSyncActivity::runSync() { if (WiFi.status() != WL_CONNECTED) { - LOG_INF("CLK", "Manual sync requested but WiFi is not connected"); + LOG_INF("CLK", "Manual sync requested but WiFi is not connected after selection"); state = NO_WIFI; requestUpdate(); return; @@ -59,8 +93,7 @@ void ClockSyncActivity::loop() { return; } - if (mappedInput.wasPressed(MappedInputManager::Button::Back) || - mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { finish(); return; } @@ -107,7 +140,7 @@ void ClockSyncActivity::render(RenderLock&&) { } if (state != SYNCING) { - const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_OK_BUTTON), "", ""); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } diff --git a/src/activities/settings/ClockSyncActivity.h b/src/activities/settings/ClockSyncActivity.h index 887ee2e5..06ec956e 100644 --- a/src/activities/settings/ClockSyncActivity.h +++ b/src/activities/settings/ClockSyncActivity.h @@ -3,7 +3,8 @@ #include "activities/Activity.h" // Manual NTP resync action. Runs a forced sync (bypassing the once-per-device debounce), -// reports success/failure, then waits for Back. Requires WiFi to already be connected. +// reports success/failure, then waits for Back. If WiFi is not connected yet, it reuses the +// normal WiFi selection flow first. class ClockSyncActivity final : public Activity { public: explicit ClockSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) @@ -19,6 +20,9 @@ class ClockSyncActivity final : public Activity { enum State { SYNCING, SUCCESS, NO_WIFI, FAILED }; State state = SYNCING; char syncedTime[16] = {0}; + bool shouldTearDownWifiOnExit = false; void runSync(); + void launchWifiSelection(); + void onWifiSelectionComplete(bool connected); }; diff --git a/src/activities/settings/FontSelectionActivity.cpp b/src/activities/settings/FontSelectionActivity.cpp index dbdcb52e..306bc2c1 100644 --- a/src/activities/settings/FontSelectionActivity.cpp +++ b/src/activities/settings/FontSelectionActivity.cpp @@ -1,13 +1,36 @@ #include "FontSelectionActivity.h" +#include #include #include +#include +#include +#include + #include "CrossPointSettings.h" #include "MappedInputManager.h" +#include "SdCardFontSystem.h" #include "components/UITheme.h" #include "fontIds.h" +namespace { +constexpr const char* ELLIPSIS_UTF8 = "\xe2\x80\xa6"; + +int findCurrentFontIndex(const SdCardFontRegistry* registry, const char* sdFontFamilyName, uint8_t fontFamily) { + if (sdFontFamilyName[0] != '\0' && registry) { + const auto& families = registry->getFamilies(); + for (int i = 0; i < static_cast(families.size()); i++) { + if (families[i].name == sdFontFamilyName) { + return CrossPointSettings::BUILTIN_FONT_COUNT + i; + } + } + } + + return fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? fontFamily : 0; +} +} // namespace + FontSelectionActivity::FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const SdCardFontRegistry* registry) : Activity("FontSelect", renderer, mappedInput), registry_(registry) {} @@ -15,12 +38,22 @@ FontSelectionActivity::FontSelectionActivity(GfxRenderer& renderer, MappedInputM void FontSelectionActivity::onEnter() { Activity::onEnter(); - // Build combined font list: built-in + SD card fonts + // Get metrics and calculate layout dimensions + metrics_ = UITheme::getInstance().getMetrics(); + afterHeader = metrics_.topPadding + metrics_.headerHeight + metrics_.verticalSpacing; + bottomReserved = metrics_.buttonHintsHeight + metrics_.verticalSpacing; + usableHeight = renderer.getScreenHeight() - afterHeader - bottomReserved; + previewHeight = usableHeight * metrics_.previewHeightPercent / 100; + + originalFontFamily_ = SETTINGS.fontFamily; + strncpy(originalSdFontFamilyName_, SETTINGS.sdFontFamilyName, sizeof(originalSdFontFamilyName_) - 1); + originalSdFontFamilyName_[sizeof(originalSdFontFamilyName_) - 1] = '\0'; + fonts_.clear(); fonts_.reserve(CrossPointSettings::BUILTIN_FONT_COUNT + (registry_ ? registry_->getFamilyCount() : 0)); - fonts_.push_back({I18N.get(StrId::STR_NOTO_SERIF), true, 0}); - fonts_.push_back({I18N.get(StrId::STR_NOTO_SANS), true, 1}); + fonts_.push_back({I18N.get(StrId::STR_NOTO_SERIF), true, static_cast(CrossPointSettings::NOTOSERIF)}); + fonts_.push_back({I18N.get(StrId::STR_NOTO_SANS), true, static_cast(CrossPointSettings::NOTOSANS)}); if (registry_) { const auto& families = registry_->getFamilies(); @@ -29,19 +62,8 @@ void FontSelectionActivity::onEnter() { } } - // Find current selection - selectedIndex_ = 0; - if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) { - const auto& families = registry_->getFamilies(); - for (int i = 0; i < static_cast(families.size()); i++) { - if (families[i].name == SETTINGS.sdFontFamilyName) { - selectedIndex_ = CrossPointSettings::BUILTIN_FONT_COUNT + i; - break; - } - } - } else { - selectedIndex_ = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0; - } + selectedIndex_ = findCurrentFontIndex(registry_, SETTINGS.sdFontFamilyName, SETTINGS.fontFamily); + previewFontIndex_ = selectedIndex_; requestUpdate(); } @@ -50,12 +72,42 @@ void FontSelectionActivity::onExit() { Activity::onExit(); } void FontSelectionActivity::loop() { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + SETTINGS.fontFamily = originalFontFamily_; + strncpy(SETTINGS.sdFontFamilyName, originalSdFontFamilyName_, sizeof(SETTINGS.sdFontFamilyName) - 1); + SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0'; + sdFontSystem.ensureLoaded(renderer); finish(); return; } const int listSize = static_cast(fonts_.size()); - const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false); + const int pageItems = + UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, previewHeight + metrics_.verticalSpacing); + + // First select of a font loads it into the preview pane; confirming the already-previewed + // font commits it. Shared by hardware Confirm and touch taps so both honour the preview step. + const auto confirmSelection = [this]() { + if (selectedIndex_ == previewFontIndex_) { + handleSelection(); + return; + } + previewFontIndex_ = selectedIndex_; + const auto& font = fonts_[selectedIndex_]; + if (font.isBuiltin) { + SETTINGS.fontFamily = font.settingIndex; + SETTINGS.sdFontFamilyName[0] = '\0'; + } else if (registry_) { + const int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT; + const auto& families = registry_->getFamilies(); + if (sdIdx < static_cast(families.size())) { + strncpy(SETTINGS.sdFontFamilyName, families[sdIdx].name.c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1); + SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0'; + sdFontSystem.ensureLoaded(renderer); + } + } + requestUpdate(); + }; + // Vertical swipe page-scrolls the list (touch nav without the side buttons). if (mappedInput.wasListScroll(selectedIndex_, listSize, pageItems)) { requestUpdate(); @@ -71,12 +123,12 @@ void FontSelectionActivity::loop() { int tappedId = -1; if (mappedInput.wasItemTapped(tappedId) && tappedId >= 0) { selectedIndex_ = tappedId; - handleSelection(); + confirmSelection(); return; } if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - handleSelection(); + confirmSelection(); return; } @@ -107,7 +159,7 @@ void FontSelectionActivity::handleSelection() { SETTINGS.fontFamily = font.settingIndex; SETTINGS.sdFontFamilyName[0] = '\0'; } else if (registry_) { - int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT; + const int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT; const auto& families = registry_->getFamilies(); if (sdIdx < static_cast(families.size())) { strncpy(SETTINGS.sdFontFamilyName, families[sdIdx].name.c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1); @@ -117,39 +169,81 @@ void FontSelectionActivity::handleSelection() { finish(); } +void FontSelectionActivity::renderPreviewPane(int top, int height, int fontId, const char* fontName) const { + const int left = metrics_.previewPadding; + const int width = renderer.getScreenWidth() - (metrics_.previewPadding * 2); + if (width <= 0 || height <= 0) return; + + const int labelFontId = UI_10_FONT_ID; + const int labelH = renderer.getTextHeight(labelFontId); + const int labelGap = 4; + const int labelReserved = labelH + labelGap + metrics_.previewPadding; + + char labelBuf[128]; + snprintf(labelBuf, sizeof(labelBuf), "%s \"%s\"", tr(STR_PREVIEW), fontName ? fontName : ""); + const int labelY = top + height - metrics_.previewPadding - labelH; + renderer.drawText(labelFontId, left, labelY, labelBuf); + + if (fontId == 0) return; + + const int lineH = renderer.getTextHeight(fontId); + if (lineH <= 0) return; + + const int innerHeight = height - metrics_.previewPadding - labelReserved; + const int maxLines = std::max(1, innerHeight / (lineH + 2)); + + const char* previewText = I18N.get(StrId::STR_FONT_PREVIEW_TEXT); + if (auto* fcm = renderer.getFontCacheManager()) { + char prewarmBuf[256]; + snprintf(prewarmBuf, sizeof(prewarmBuf), "%s %s", previewText, ELLIPSIS_UTF8); + fcm->prewarmCache(fontId, prewarmBuf, 0x01); + } + + const auto lines = renderer.wrappedText(fontId, previewText, width, maxLines); + + int y = top + metrics_.previewPadding; + const int textBottomLimit = top + height - labelReserved; + for (const auto& line : lines) { + if (y + lineH > textBottomLimit) break; + renderer.drawText(fontId, left, y, line.c_str()); + y += lineH + 2; + } +} + void FontSelectionActivity::render(RenderLock&&) { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - const auto& metrics = UITheme::getInstance().getMetrics(); - GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_FAMILY)); + GUI.drawHeader(renderer, Rect{0, metrics_.topPadding, pageWidth, metrics_.headerHeight}, tr(STR_FONT_FAMILY)); - const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; - const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing; + const int previewTop = afterHeader; + const int listTop = previewTop + previewHeight + metrics_.verticalSpacing; + const int listHeight = usableHeight - previewHeight - metrics_.verticalSpacing; - // Determine which font index is currently active (to mark as "Selected") - int currentFontIndex = 0; - if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) { - const auto& families = registry_->getFamilies(); - for (int i = 0; i < static_cast(families.size()); i++) { - if (families[i].name == SETTINGS.sdFontFamilyName) { - currentFontIndex = CrossPointSettings::BUILTIN_FONT_COUNT + i; - break; - } - } - } else { - currentFontIndex = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0; - } + const int previewFontId = SETTINGS.getReaderFontId(); + const char* previewFontName = (previewFontIndex_ >= 0 && previewFontIndex_ < static_cast(fonts_.size())) + ? fonts_[previewFontIndex_].name.c_str() + : nullptr; + renderPreviewPane(previewTop, previewHeight, previewFontId, previewFontName); + renderer.drawLine(0, listTop - metrics_.verticalSpacing / 2, pageWidth, listTop - metrics_.verticalSpacing / 2); + + const int currentFontIndex = findCurrentFontIndex(registry_, originalSdFontFamilyName_, originalFontFamily_); GUI.drawList( - renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast(fonts_.size()), selectedIndex_, + renderer, Rect{0, listTop, pageWidth, listHeight}, static_cast(fonts_.size()), selectedIndex_, [this](int index) { return fonts_[index].name; }, nullptr, nullptr, - [this, currentFontIndex](int index) -> std::string { return index == currentFontIndex ? tr(STR_SELECTED) : ""; }, + [this, currentFontIndex](int index) -> std::string { + if (index == previewFontIndex_ && index != currentFontIndex) return tr(STR_PREVIEW); + if (index == currentFontIndex) return tr(STR_SELECTED); + return ""; + }, true); - const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + const bool onPreviewed = selectedIndex_ == previewFontIndex_; + const char* confirmLabel = onPreviewed ? tr(STR_SELECT) : tr(STR_PREVIEW); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); diff --git a/src/activities/settings/FontSelectionActivity.h b/src/activities/settings/FontSelectionActivity.h index 9094c802..d3bfbbd2 100644 --- a/src/activities/settings/FontSelectionActivity.h +++ b/src/activities/settings/FontSelectionActivity.h @@ -2,10 +2,12 @@ #include +#include #include #include #include "activities/Activity.h" +#include "components/themes/BaseTheme.h" #include "util/ButtonNavigator.h" class FontSelectionActivity final : public Activity { @@ -20,15 +22,26 @@ class FontSelectionActivity final : public Activity { private: void handleSelection(); + int getFontIdForPreview(int index) const; + void renderPreviewPane(int top, int height, int fontId, const char* fontName) const; struct FontEntry { std::string name; bool isBuiltin; - uint8_t settingIndex; // index used by valueSetter + uint8_t settingIndex; }; const SdCardFontRegistry* registry_; ButtonNavigator buttonNavigator_; std::vector fonts_; int selectedIndex_ = 0; + int previewFontIndex_ = 0; + uint8_t originalFontFamily_ = 0; + char originalSdFontFamilyName_[32] = {}; + + ThemeMetrics metrics_ = {}; + int afterHeader = 0; + int bottomReserved = 0; + int usableHeight = 0; + int previewHeight = 0; }; diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index 29b3a79f..78fd9cde 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -100,7 +100,9 @@ bool KeyboardEntryActivity::handleKeyPress() { case SpecialKeyType::Shift: delPressCount = 0; hintVisible = false; - if (urlMode || inputType == InputType::Url) return true; + // Shift is meaningless in the URL-snippet panel and the symbol layout, but + // must work for the URL letter layout so uppercase letters can be entered (#2178). + if (urlMode) return true; if (symMode) return true; shiftState = (shiftState + 1) % 2; return true; @@ -700,8 +702,8 @@ void KeyboardEntryActivity::render(RenderLock&&) { const char* label; }; const BottomKeyInfo bottomKeys[BOTTOM_KEY_COUNT] = { - {(symMode || urlMode || inputType == InputType::Url) ? KeyboardKeyType::Disabled : KeyboardKeyType::Shift, - (symMode || urlMode || inputType == InputType::Url) ? shiftString[0] : shiftString[shiftState]}, + {(symMode || urlMode) ? KeyboardKeyType::Disabled : KeyboardKeyType::Shift, + (symMode || urlMode) ? shiftString[0] : shiftString[shiftState]}, {KeyboardKeyType::Mode, urlMode ? "abc" : (symMode ? "abc" : "#@!")}, {inputType == InputType::Url ? KeyboardKeyType::Mode : KeyboardKeyType::Space, inputType == InputType::Url ? "URL" : nullptr}, diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 89c2cf26..2b87a0f4 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -39,6 +39,9 @@ struct ThemeMetrics { int headerHeight; int verticalSpacing; + int previewPadding; + int previewHeightPercent; + int contentSidePadding; int listRowHeight; int listWithSubtitleRowHeight; @@ -123,6 +126,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .batteryBarHeight = 20, .headerHeight = 45, .verticalSpacing = 10, + .previewPadding = 12, + .previewHeightPercent = 30, .contentSidePadding = 20, .listRowHeight = 30, .listWithSubtitleRowHeight = 50, diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index 8607b9de..cfeab625 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -12,6 +12,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .batteryBarHeight = 40, .headerHeight = 84, .verticalSpacing = 16, + .previewPadding = 12, + .previewHeightPercent = 30, .contentSidePadding = 20, .listRowHeight = 40, .listWithSubtitleRowHeight = 60, diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.h b/src/components/themes/roundedraff/RoundedRaffTheme.h index 5de7f138..75f3a509 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.h +++ b/src/components/themes/roundedraff/RoundedRaffTheme.h @@ -11,6 +11,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .batteryBarHeight = 20, .headerHeight = 45, .verticalSpacing = 10, + .previewPadding = 12, + .previewHeightPercent = 30, .contentSidePadding = 20, .listRowHeight = 42, .listWithSubtitleRowHeight = 69, diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 04a5b557..bec1ba33 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -291,6 +291,27 @@ .upload-form input[type="file"].has-files::file-selector-button { background-color: #95a5a6; } + .drop-zone { + border: 2px dashed var(--border-color); + border-radius: 6px; + padding: 14px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background-color 0.15s; + } + .drop-zone.dragover { + border-color: var(--accent-color); + background-color: var(--accent-color-10); + } + .drop-zone-hint { + margin: 8px 0 0; + font-size: 0.85em; + color: var(--label-color); + pointer-events: none; + } + .drop-zone.dragover .drop-zone-hint { + color: var(--accent-color); + } .upload-btn { background-color: #27ae60; color: white; @@ -1526,7 +1547,10 @@

📤 Upload file

Select a file to upload to

- +
+ +

⬇ Drop files here — or click to browse

+