diff --git a/.github/workflows/release-fonts.yml b/.github/workflows/release-fonts.yml index 0fcf9874..c95c0927 100644 --- a/.github/workflows/release-fonts.yml +++ b/.github/workflows/release-fonts.yml @@ -40,7 +40,7 @@ jobs: echo "metadata=$(python3 -c 'from cpfont_version import FONTS_MANIFEST_VERSION; print(FONTS_MANIFEST_VERSION)')" >> "$GITHUB_OUTPUT" - name: Build SD card fonts - run: python3 lib/EpdFont/scripts/build-sd-fonts.py --clean + run: python3 lib/EpdFont/scripts/build-sd-fonts.py --clean --verbose -j 1 - name: Flatten output for release assets run: | @@ -83,7 +83,7 @@ jobs: --title "$TITLE" \ --notes "Pre-built \`.cpfont\` font files for CrossPoint Reader. - Download individual files or use **Settings > System > Download Fonts** on the device. + Download individual files or use **Settings > System > Manage Fonts** on the device. See [SD Card Fonts documentation](https://github.com/${{ github.repository }}/blob/main/docs/sd-card-fonts.md) for details." @@ -103,4 +103,4 @@ jobs: This is revision **${{ steps.tags.outputs.revision }}** — see [\`${{ steps.tags.outputs.versioned }}\`](https://github.com/${{ env.FONTS_REPO }}/releases/tag/${{ steps.tags.outputs.versioned }}) for the immutable copy. - Download individual files or use **Settings > System > Download Fonts** on the device." + Download individual files or use **Settings > System > Manage fonts** on the device." diff --git a/README.md b/README.md index c8a0f3de..c870a156 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ This project is **not affiliated with Xteink**; it's built as a community projec Multi-language support: Read EPUBs in various languages, including English, Spanish, French, German, Italian, Portuguese, Russian, Ukrainian, Polish, Swedish, Norwegian, [and more](./USER_GUIDE.md#supported-languages). See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint, including the -[KOReader Sync quick setup](./USER_GUIDE.md#365-koreader-sync-quick-setup). +[KOReader Sync quick setup](./USER_GUIDE.md#367-koreader-sync-quick-setup). For more details about the scope of the project, see the [SCOPE.md](SCOPE.md) document. diff --git a/docs/focus-reading.md b/docs/focus-reading.md new file mode 100644 index 00000000..5063e4b4 --- /dev/null +++ b/docs/focus-reading.md @@ -0,0 +1,33 @@ +# Focus Reading + +Focus Reading is a reading aid that bolds the first portion of each word, guiding your eyes to natural fixation points and helping you read faster with less effort. Some readers — particularly those with ADHD — find it helps them stay engaged with the text and reduces mind-wandering. It is inspired by the Bionic Reading technique. + +Comparison of the same page with and without Focus Reading enabled + +*Left: Focus Reading off. Right: Focus Reading on. Both using Literata.* + +## Enabling Focus Reading + +1. Open **Settings > Reader** +2. Toggle **Focus Reading** on + +Toggling the setting will trigger a re-index of your current book, the same as when changing font settings. Once indexing is complete, page turns proceed as normal. No changes are made to your EPUB files. + +## Examples + +Focus Reading with Noto Serif font + +*Focus Reading with Noto Serif font* + +Focus Reading with Merriweather font + +*Focus Reading with Merriweather font* + +Focus Reading with Atkinson Hyperlegible Next font + +*Focus Reading with Atkinson Hyperlegible Next font* + +## Notes + +- Focus Reading only applies to regular body text. Already-bold text (headings, emphasis) is left unchanged. +- The setting is per-device, not per-book — it applies to all books while enabled. diff --git a/docs/hyphenation-trie-format.md b/docs/hyphenation-trie-format.md index 54c12fa2..ebe54229 100644 --- a/docs/hyphenation-trie-format.md +++ b/docs/hyphenation-trie-format.md @@ -49,5 +49,5 @@ A convenient script `update_hyphenation.sh` is used to update all languages. To use it, run: ```sh -./scripts/update_hypenation.sh +./scripts/update_hyphenation.sh ``` diff --git a/docs/images/focus-reading/focus-reading-atkinson.jpg b/docs/images/focus-reading/focus-reading-atkinson.jpg new file mode 100644 index 00000000..8ec77b10 Binary files /dev/null and b/docs/images/focus-reading/focus-reading-atkinson.jpg differ diff --git a/docs/images/focus-reading/focus-reading-merriweather.jpg b/docs/images/focus-reading/focus-reading-merriweather.jpg new file mode 100644 index 00000000..59d82371 Binary files /dev/null and b/docs/images/focus-reading/focus-reading-merriweather.jpg differ diff --git a/docs/images/focus-reading/focus-reading-notoserif.jpg b/docs/images/focus-reading/focus-reading-notoserif.jpg new file mode 100644 index 00000000..8ffc869d Binary files /dev/null and b/docs/images/focus-reading/focus-reading-notoserif.jpg differ diff --git a/docs/images/focus-reading/focus-reading.jpg b/docs/images/focus-reading/focus-reading.jpg new file mode 100644 index 00000000..97318e7b Binary files /dev/null and b/docs/images/focus-reading/focus-reading.jpg differ diff --git a/docs/sd-card-fonts.md b/docs/sd-card-fonts.md index 39f56e5e..db9b1277 100644 --- a/docs/sd-card-fonts.md +++ b/docs/sd-card-fonts.md @@ -10,7 +10,7 @@ There are three ways to install fonts: ### Option 1: Download from device (recommended) 1. Connect your CrossPoint reader to WiFi -2. Go to **Settings > System > Download Fonts** +2. Go to **Settings > System > Manage Fonts** 3. Browse available font families and tap to download 4. Downloaded fonts appear immediately in **Settings > Reader > Font Family** @@ -24,28 +24,38 @@ There are three ways to install fonts: ### Option 3: Manual SD card copy 1. Download font files from the - [Releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases/tag/sd-fonts) -2. Copy font family folders to `/.crosspoint/fonts/` on your SD card: + [crosspoint-fonts repository](https://github.com/crosspoint-reader/crosspoint-fonts) +2. Copy font family folders to one of two locations on your SD card: + + - `/.fonts/` — hidden directory (preferred; keeps the SD root tidy + when mounted on a desktop) + - `/fonts/` — visible directory (use this if your OS hides dot-files + and you'd rather see the folder in your file manager) + + Both roots are always scanned at boot and the results are merged: a + family installed in `/fonts/` shows up even when `/.fonts/` also + exists, and vice versa. The two roots only collide if the same family + name appears in both — in that case the copy in `/.fonts/` wins and + the duplicate in `/fonts/` is ignored. SD Card Root/ - └── .crosspoint/ - └── fonts/ - ├── Bookerly-SD/ - │ ├── Bookerly-SD_12.cpfont - │ ├── Bookerly-SD_14.cpfont - │ ├── Bookerly-SD_16.cpfont - │ └── Bookerly-SD_18.cpfont + ├── .fonts/ ← Hidden root (preferred) + │ └── Literata/ + │ ├── Literata_12.cpfont + │ ├── Literata_14.cpfont + │ ├── Literata_16.cpfont + │ └── Literata_18.cpfont + └── fonts/ ← Visible root (equally valid) + └── Merriweather/ + ├── Merriweather_12.cpfont └── ... 3. Insert the SD card and power on your CrossPoint reader ## Available Pre-Built Fonts -| Font | Best For | Languages | -|------|----------|-----------| -| Bookerly-SD | General reading | English, Western European | -| NotoSansExtended | Multi-script reading | European, Greek, Cyrillic, Georgian, Armenian, Ethiopic | -| NotoSansCJK | Chinese/Japanese/Korean | CJK + ASCII | +The current list of pre-built fonts is maintained in the +[crosspoint-fonts repository](https://github.com/crosspoint-reader/crosspoint-fonts). ## Converting Custom Fonts diff --git a/lib/EpdFont/SdCardFontManager.cpp b/lib/EpdFont/SdCardFontManager.cpp index 804df07d..a6032336 100644 --- a/lib/EpdFont/SdCardFontManager.cpp +++ b/lib/EpdFont/SdCardFontManager.cpp @@ -28,21 +28,25 @@ int SdCardFontManager::computeFontId(uint32_t contentHash, const char* familyNam return id != 0 ? id : 1; // 0 is reserved as "not found" sentinel } -bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t targetPtSize) { +bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum) { // Unload any previously loaded family first if (!loadedFamilyName_.empty()) { unloadAll(renderer); } - // Pick the single file whose size is closest to targetPtSize. Loading - // only one size bounds resident memory (intervals + kern/ligature tables - // per style) to one file's worth, vs. N_sizes × per-file overhead. - const SdCardFontFileInfo* selected = family.pickClosestSize(targetPtSize); - if (!selected) { + // 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()) { 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()); @@ -66,8 +70,8 @@ bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRender renderer.registerSdCardFont(fontId, font); loaded_.push_back({font, fontId, selected->pointSize}); - LOG_DBG("SDMGR", "Loaded %s size=%u id=%d styles=%u (target=%u)", selected->path.c_str(), selected->pointSize, fontId, - font->styleCount(), targetPtSize); + LOG_DBG("SDMGR", "Loaded %s size=%u id=%d styles=%u (sizeEnum=%u)", selected->path.c_str(), selected->pointSize, + fontId, font->styleCount(), fontSizeEnum); EpdFontFamily fontFamily(font->getEpdFont(0), font->getEpdFont(1), font->getEpdFont(2), font->getEpdFont(3)); renderer.insertFont(fontId, fontFamily); diff --git a/lib/EpdFont/SdCardFontManager.h b/lib/EpdFont/SdCardFontManager.h index def66e8d..aec07472 100644 --- a/lib/EpdFont/SdCardFontManager.h +++ b/lib/EpdFont/SdCardFontManager.h @@ -15,16 +15,12 @@ class SdCardFontManager { SdCardFontManager(const SdCardFontManager&) = delete; SdCardFontManager& operator=(const SdCardFontManager&) = delete; - // Load the single size whose pointSize is closest to targetPtSize. 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. - // - // Closest-pt selection is robust against families that don't ship the - // canonical {12,14,16,18}: a family with only [10,14,18] still resolves - // any reasonable target, where ordinal slot-mapping by SMALL..EXTRA_LARGE - // would mis-select. + // 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. // Returns true on success. - bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t targetPtSize); + bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum); // Unload everything, unregister from renderer. void unloadAll(GfxRenderer& renderer); diff --git a/lib/EpdFont/SdCardFontRegistry.cpp b/lib/EpdFont/SdCardFontRegistry.cpp index af1009b0..2e0f145c 100644 --- a/lib/EpdFont/SdCardFontRegistry.cpp +++ b/lib/EpdFont/SdCardFontRegistry.cpp @@ -4,8 +4,6 @@ #include #include -#include -#include #include // --- SdCardFontFamilyInfo helpers --- @@ -40,21 +38,6 @@ std::vector SdCardFontFamilyInfo::availableSizes() const { return sizes; } -const SdCardFontFileInfo* SdCardFontFamilyInfo::pickClosestSize(uint8_t targetPtSize) const { - const SdCardFontFileInfo* selected = nullptr; - int bestDiff = INT_MAX; - for (const auto& f : files) { - int diff = std::abs(static_cast(f.pointSize) - static_cast(targetPtSize)); - // Strict < ensures the first scan wins on ties; then tie-break by smaller - // pointSize to make the choice independent of filesystem enumeration order. - if (diff < bestDiff || (diff == bestDiff && selected && f.pointSize < selected->pointSize)) { - bestDiff = diff; - selected = &f; - } - } - return selected; -} - // --- SdCardFontRegistry --- bool SdCardFontRegistry::parseFilename(const char* filename, uint8_t& size, uint8_t& style) { diff --git a/lib/EpdFont/SdCardFontRegistry.h b/lib/EpdFont/SdCardFontRegistry.h index aae932d9..f96035ed 100644 --- a/lib/EpdFont/SdCardFontRegistry.h +++ b/lib/EpdFont/SdCardFontRegistry.h @@ -20,15 +20,6 @@ struct SdCardFontFamilyInfo { const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const; bool hasSize(uint8_t size) const; std::vector availableSizes() const; - - // Pick the file whose pointSize is closest to targetPtSize. On ties (equal - // distance) prefers the smaller pointSize so behaviour is deterministic - // across SD card layouts. Returns nullptr when files is empty. - // - // Robust against families that don't ship the canonical {12,14,16,18} set: - // a family with only [10,14,18] resolves target 12 → 10 (or 14 on tie), - // target 16 → 14 or 18, etc., instead of mis-indexing by ordinal slot. - const SdCardFontFileInfo* pickClosestSize(uint8_t targetPtSize) const; }; class SdCardFontRegistry { diff --git a/lib/EpdFont/scripts/build-sd-fonts.py b/lib/EpdFont/scripts/build-sd-fonts.py index 9f29c00d..8f981fbe 100755 --- a/lib/EpdFont/scripts/build-sd-fonts.py +++ b/lib/EpdFont/scripts/build-sd-fonts.py @@ -17,6 +17,12 @@ Usage: # Generate only specific families python3 build-sd-fonts.py --only Literata,IBMPlexMono + + # Stream child process output for debugging + python3 build-sd-fonts.py --verbose + + # Override the per-family timeout (default: 600s) + python3 build-sd-fonts.py --timeout 1200 """ import argparse @@ -25,6 +31,8 @@ import shutil import subprocess import sys import tempfile +import threading +import time import urllib.request from concurrent.futures import ProcessPoolExecutor, as_completed from pathlib import Path @@ -141,7 +149,16 @@ def resolve_font_path(style_spec: dict, family_name: str, style_name: str) -> Pa return resolved -def build_family(family: dict, output_base: Path) -> tuple[str, bool, str]: +def _stream_pipe(pipe, prefix: str, dest: list[str]): + """Read lines from a pipe, print with prefix, and accumulate into dest.""" + for line in pipe: + dest.append(line) + print(f" [{prefix}] {line}", end="", flush=True) + + +def build_family( + family: dict, output_base: Path, verbose: bool = False, timeout: int = 600 +) -> tuple[str, bool, str]: """Build a single font family. Returns (name, success, message).""" name = family["name"] output_dir = output_base / name @@ -185,18 +202,52 @@ def build_family(family: dict, output_base: Path) -> tuple[str, bool, str]: cmd.append("--force-autohint") # Run fontconvert_sdcard.py + start = time.monotonic() try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=600, - ) - if result.returncode != 0: - return name, False, result.stderr.strip() or f"Exit code {result.returncode}" - return name, True, "" - except subprocess.TimeoutExpired: - return name, False, "Timed out after 600s" + if verbose: + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + stdout_lines: list[str] = [] + stderr_lines: list[str] = [] + t_out = threading.Thread( + target=_stream_pipe, args=(proc.stdout, name, stdout_lines) + ) + t_err = threading.Thread( + target=_stream_pipe, args=(proc.stderr, f"{name}/err", stderr_lines) + ) + t_out.start() + t_err.start() + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + elapsed = time.monotonic() - start + return name, False, f"Timed out after {elapsed:.0f}s" + finally: + t_out.join() + t_err.join() + + if proc.returncode != 0: + err = "".join(stderr_lines).strip() + return name, False, err or f"Exit code {proc.returncode}" + return name, True, "" + else: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, + ) + if result.returncode != 0: + return name, False, result.stderr.strip() or f"Exit code {result.returncode}" + return name, True, "" + except subprocess.TimeoutExpired as e: + elapsed = time.monotonic() - start + tail = "" + captured = getattr(e, "stderr", None) or getattr(e, "stdout", None) + if captured: + lines = captured.strip().splitlines() + tail = "\n Last output:\n" + "\n".join(f" | {l}" for l in lines[-20:]) + return name, False, f"Timed out after {elapsed:.0f}s{tail}" except Exception as e: return name, False, str(e) @@ -252,6 +303,14 @@ def main(): help="Max parallel jobs (default: number of families)" ) parser.add_argument("--clean", action="store_true", help="Clean output directory before building") + parser.add_argument( + "--verbose", "-v", action="store_true", + help="Stream child process output in real time (useful for debugging timeouts)" + ) + parser.add_argument( + "--timeout", type=int, default=600, + help="Per-family timeout in seconds (default: 600)" + ) args = parser.parse_args() if args.manifest and not args.base_url: @@ -303,12 +362,14 @@ def main(): # Build phase (parallel) max_workers = args.jobs or len(families) - print(f"\n=== Building {len(families)} families ({max_workers} parallel jobs) ===\n") + verbose = args.verbose + timeout = args.timeout + print(f"\n=== Building {len(families)} families ({max_workers} parallel jobs, timeout {timeout}s) ===\n") failed = [] with ProcessPoolExecutor(max_workers=max_workers) as executor: futures = { - executor.submit(build_family, family, output_base): family["name"] + executor.submit(build_family, family, output_base, verbose, timeout): family["name"] for family in families } for future in as_completed(futures): diff --git a/lib/EpdFont/scripts/fontconvert.py b/lib/EpdFont/scripts/fontconvert.py index 8e7b17a9..a55fab75 100755 --- a/lib/EpdFont/scripts/fontconvert.py +++ b/lib/EpdFont/scripts/fontconvert.py @@ -1,12 +1,10 @@ #!python3 -import freetype import zlib import sys import re import math import argparse from collections import namedtuple -from fontTools.ttLib import TTFont # Force UTF-8 stdout so that `python fontconvert.py … > foo.h` on Windows # (default cp1252) doesn't emit UTF-16 LE / replacement chars in the generated @@ -27,6 +25,9 @@ parser.add_argument("--force-autohint", dest="force_autohint", action="store_tru parser.add_argument("--pnum", dest="pnum", action="store_true", help="Use proportional numerals (pnum OpenType feature) instead of default tabular figures. Reduces visual gaps between digits in running prose.") args = parser.parse_args() +import freetype +from fontTools.ttLib import TTFont + GlyphProps = namedtuple("GlyphProps", ["width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point"]) font_stack = [freetype.Face(f) for f in args.fontstack] diff --git a/lib/EpdFont/scripts/fontconvert_sdcard.py b/lib/EpdFont/scripts/fontconvert_sdcard.py index baf3875f..f0f83c8e 100755 --- a/lib/EpdFont/scripts/fontconvert_sdcard.py +++ b/lib/EpdFont/scripts/fontconvert_sdcard.py @@ -22,7 +22,8 @@ Usage: """ -import freetype +from __future__ import annotations + import struct import sys import os @@ -31,8 +32,6 @@ import math import argparse from collections import namedtuple -from fontTools.ttLib import TTFont - from cpfont_version import CPFONT_VERSION # --- Unicode interval presets --- @@ -252,6 +251,8 @@ def extract_kerning_fonttools(font_path, codepoints, ppem): codepoints. Values are scaled from font design units to integer pixels at ppem. """ + from fontTools.ttLib import TTFont + font = TTFont(font_path) units_per_em = font['head'].unitsPerEm cmap = font.getBestCmap() or {} @@ -402,6 +403,8 @@ def extract_ligatures_fonttools(font_path, codepoints): Returns list of (packed_pair, ligature_codepoint) for the given codepoints. Multi-character ligatures are decomposed into chained pairs. """ + from fontTools.ttLib import TTFont + font = TTFont(font_path) cmap = font.getBestCmap() or {} @@ -514,6 +517,8 @@ def extract_ligatures_fonttools(font_path, codepoints): def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=False): """Rasterize all glyphs for one font style. Returns StyleRasterData.""" + import freetype + style_names = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"} style_label = style_names.get(style_id, str(style_id)) @@ -535,14 +540,16 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F return face return None - # Validate intervals: remove codepoints not present in the font + # Validate intervals: remove codepoints not present in the font. + # Only check glyph existence via get_char_index — do NOT call + # load_glyph here, as that triggers FT_LOAD_RENDER at the target + # DPI and doubles total rasterization time for no benefit. print(f" [{style_label}] Validating intervals against font...", file=sys.stderr) validated_intervals = [] for i_start, i_end in intervals: start = i_start for code_point in range(i_start, i_end + 1): - f = load_glyph(code_point) - if f is None: + if face.get_char_index(code_point) == 0: if start < code_point: validated_intervals.append((start, code_point - 1)) start = code_point + 1 @@ -575,13 +582,18 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F # pitch == width and a top-down layout — that holds in the common # case but breaks on padded or flipped bitmaps and corrupts the # output. Walk by (row, col) using the real pitch instead. + # + # Cache bitmap.buffer in a local — ctypes struct field access + # creates a new Python wrapper object each time, so re-evaluating + # it per pixel is catastrophically slow. pixels4g = [] px = 0 + buf = bitmap.buffer abs_pitch = abs(bitmap.pitch) for y in range(bitmap.rows): row_offset = y * abs_pitch if bitmap.pitch >= 0 else (bitmap.rows - 1 - y) * abs_pitch for x in range(bitmap.width): - v = bitmap.buffer[row_offset + x] + v = buf[row_offset + x] if x % 2 == 0: px = (v >> 4) else: diff --git a/lib/EpdFont/scripts/verify_compression.py b/lib/EpdFont/scripts/verify_compression.py index c4b13e17..c2320c78 100644 --- a/lib/EpdFont/scripts/verify_compression.py +++ b/lib/EpdFont/scripts/verify_compression.py @@ -234,6 +234,9 @@ def main(): if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} ", file=sys.stderr) sys.exit(1) + if sys.argv[1] in ("-h", "--help"): + print(f"Usage: {sys.argv[0]} ") + sys.exit(0) font_dir = sys.argv[1] if not os.path.isdir(font_dir): diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 6f31f5f2..e348270b 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -74,21 +74,170 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style); } +// Checks if a UTF-8 codepoint should be counted as part of a word for Focus Reading +bool isWordCharacter(uint32_t cp) { + // ASCII range (Catches 95%+ of characters immediately) + if (cp < 128) { + // Bitwise trick: (cp | 0x20) converts uppercase ASCII to lowercase. + // This checks for A-Z and a-z mathematically, avoiding memory lookups and + return ((cp | 0x20) >= 'a' && (cp | 0x20) <= 'z') || cp == '\''; + } + + // General Punctuation Block, Currency, Math, Arrows, & Symbols (0x2000 - 0x2BFF) + if (cp >= 0x2000 && cp <= 0x2BFF) { + // Explicitly allow smart quotes, reject all other general punctuation (em-dashes, etc.) + return cp == 0x2018 || cp == 0x2019; + } + + // Latin-1 Punctuation Block (0x00A1 - 0x00BF) + if (cp >= 0x00A1 && cp <= 0x00BF) { + // Allow ordinal indicators and micro sign, reject the rest (¡, ¿, «, », etc.) + return cp == 0x00AA || cp == 0x00B5 || cp == 0x00BA; + } + + // Rejects Two-em dash, Three-em dash, Double oblique hyphen, etc. + if (cp >= 0x2E00 && cp <= 0x2E7F) return false; + + // Rejects Modifier Minus (0x02D7), Small Hyphen (0xFE63), and Fullwidth Hyphen (0xFF0D) + if (cp == 0x02D7 || cp == 0xFE63 || cp == 0xFF0D) return false; + // Assume all other Unicode ranges (accented letters, Cyrillic, Greek, etc.) are valid + + return true; +} + } // namespace void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline, const bool attachToPrevious) { if (word.empty()) return; - words.push_back(std::move(word)); - EpdFontFamily::Style combinedStyle = fontStyle; + EpdFontFamily::Style baseStyle = fontStyle; if (underline) { - combinedStyle = static_cast(combinedStyle | EpdFontFamily::UNDERLINE); + baseStyle = static_cast(baseStyle | EpdFontFamily::UNDERLINE); } - wordStyles.push_back(combinedStyle); - wordContinues.push_back(attachToPrevious); -} + // 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); + return; + } + + // --- FOCUS READING LOGIC BELOW --- + + // Pre-reserve capacity to prevent mid-word heap reallocations. + size_t maxPossibleNewTokens = word.length(); + size_t requiredSize = words.size() + maxPossibleNewTokens; + + if (words.capacity() < requiredSize) { + // Emulate standard geometric growth (doubling) to ensure we don't reallocate on every word. + size_t newCapacity = words.capacity() * 2; + + // Ensure the doubled capacity is actually enough for this specific word + if (newCapacity < requiredSize) { + newCapacity = requiredSize; + } + // Set a sensible minimum starting size so the first few words don't trigger tiny reallocations + if (newCapacity < 16) { + newCapacity = 16; + } + + words.reserve(newCapacity); + wordStyles.reserve(newCapacity); + wordContinues.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) { + if (!isWord) { + // Punctuation and Numbers stay regular + words.emplace_back(segment); + wordStyles.push_back(baseStyle); + wordContinues.push_back(attach); + wordIsFocusSuffix.push_back(false); + } else { + size_t charCount = 0; + const unsigned char* countPtr = reinterpret_cast(segment.data()); + const unsigned char* countEnd = countPtr + segment.length(); + + while (countPtr < countEnd) { + utf8NextCodepoint(&countPtr); + charCount++; + } + + // Target 45% for 1-bold at 4 chars and 3-bold at 7 chars with floor truncation + constexpr size_t FOCUS_READING_PERCENT = 45; + size_t targetBoldChars = (charCount * FOCUS_READING_PERCENT) / 100; + targetBoldChars = std::clamp(targetBoldChars, 1, 9); + + if (targetBoldChars >= charCount) { + // Whole segment is bold - no suffix split needed + words.emplace_back(segment); + wordStyles.push_back(static_cast(baseStyle | EpdFontFamily::BOLD)); + wordContinues.push_back(attach); + wordIsFocusSuffix.push_back(false); + } else { + countPtr = reinterpret_cast(segment.data()); + for (size_t i = 0; i < targetBoldChars; ++i) { + utf8NextCodepoint(&countPtr); + } + size_t splitByteOffset = countPtr - reinterpret_cast(segment.data()); + + // Bold prefix + words.emplace_back(segment.substr(0, splitByteOffset)); + wordStyles.push_back(static_cast(baseStyle | EpdFontFamily::BOLD)); + wordContinues.push_back(attach); + 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); + wordIsFocusSuffix.push_back(true); + } + } + }; + + // Tokenize the string by alternating states (Word vs. Non-Word) + const unsigned char* ptr = reinterpret_cast(word.c_str()); + const unsigned char* end = ptr + word.length(); + + const unsigned char* segmentStart = ptr; + uint32_t firstCp = utf8NextCodepoint(&ptr); // Consume the first char to determine initial state + bool inWordSegment = isWordCharacter(firstCp); + + bool isFirstSegment = true; + + while (ptr < end) { + const unsigned char* currentCpStart = ptr; + uint32_t cp = utf8NextCodepoint(&ptr); + bool isWordChar = isWordCharacter(cp); + + // Whenever the character type flips, slice off the segment we just completed and process it + if (isWordChar != inWordSegment) { + size_t segmentLen = currentCpStart - segmentStart; + std::string_view segment(reinterpret_cast(segmentStart), segmentLen); + + // 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); + + // Setup for the next segment + segmentStart = currentCpStart; + inWordSegment = isWordChar; + isFirstSegment = false; + } + } + + // Process the final remaining segment + size_t segmentLen = end - segmentStart; + std::string_view segment(reinterpret_cast(segmentStart), segmentLen); + processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true); +} // Consumes data to minimize memory usage void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth, const std::function)>& processLine, @@ -153,6 +302,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); + wordIsFocusSuffix.erase(wordIsFocusSuffix.begin(), wordIsFocusSuffix.begin() + consumed); } } @@ -436,6 +586,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl // Insert the remainder word (with matching style and continuation flag) directly after the prefix. words.insert(words.begin() + wordIndex + 1, remainder); wordStyles.insert(wordStyles.begin() + wordIndex + 1, style); + // The hyphen remainder is not a focus suffix - it starts fresh on the next line. + wordIsFocusSuffix.insert(wordIsFocusSuffix.begin() + wordIndex + 1, false); // Continuation flag handling after splitting a word into prefix + remainder. // @@ -500,6 +652,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const renderer.getSpaceAdvance(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]), firstCodepoint(words[lastBreakAt + wordIdx]), wordStyles[lastBreakAt + wordIdx - 1]); } else if (wordIdx > 0 && continuesVec[lastBreakAt + wordIdx]) { + // Non-breaking space tokens (" " with continues=true) are visible, stretchable spaces — + // count them as justifiable gaps so justifyExtra is distributed to them too. + if (words[lastBreakAt + wordIdx] == " ") { + actualGapCount++; + } // Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation) totalNaturalGaps += renderer.getKerning(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]), @@ -541,6 +698,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const advance += renderer.getKerning(fontId, lastCodepoint(words[lastBreakAt + wordIdx]), firstCodepoint(words[lastBreakAt + wordIdx + 1]), wordStyles[lastBreakAt + wordIdx]); + // Non-breaking space tokens are stretchable — expand them during justification like normal spaces. + if (words[lastBreakAt + wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] && + blockStyle.alignment == CssTextAlign::Justify && !isLastLine) { + advance += justifyExtra; + } xpos += advance; } else { int gap = 0; @@ -567,6 +729,63 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const } } - processLine( - std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle)); + // Fast path: when no word on this line was split for focus reading, skip the merge work + // entirely and pass empty boundary/suffixX vectors. TextBlock pays zero per-word RAM cost + // for these annotations when the vectors are empty. + bool lineHasFocusSplit = false; + for (size_t i = 0; i < lineWordCount; i++) { + if (wordIsFocusSuffix[lastBreakAt + i]) { + lineHasFocusSplit = true; + break; + } + } + + if (!lineHasFocusSplit) { + processLine(std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), + std::vector{}, std::vector{}, blockStyle)); + return; + } + + // Slow path: merge focus suffix tokens back into their preceding word entry so each + // original word occupies one TextBlock slot. Splits are recorded as per-word annotations + // applied at render time, cutting the token count significantly when the feature is active. + std::vector outWords; + std::vector outXPos; + std::vector outStyles; + std::vector outBoundaries; + std::vector outSuffixX; + outWords.reserve(lineWordCount); + outXPos.reserve(lineWordCount); + outStyles.reserve(lineWordCount); + outBoundaries.reserve(lineWordCount); + outSuffixX.reserve(lineWordCount); + + for (size_t i = 0; i < lineWordCount; i++) { + if (wordIsFocusSuffix[lastBreakAt + i] && !outWords.empty()) { + // Focus suffix: merge string into the preceding bold-prefix entry. + outWords.back() += lineWords[i]; + } else { + // Normal word: check for a following focus suffix to record the byte boundary. + uint8_t boundary = 0; + uint16_t suffixX = 0; + if (i + 1 < lineWordCount && wordIsFocusSuffix[lastBreakAt + i + 1]) { + boundary = static_cast(std::min(lineWords[i].size(), size_t{255})); + // Suffix x offset = layout-time advance of the bold prefix, already known from xpos table. + suffixX = static_cast(lineXPos[i + 1] - lineXPos[i]); + } + outWords.push_back(std::move(lineWords[i])); + outXPos.push_back(lineXPos[i]); + // For focus entries with a suffix, strip BOLD from the stored style. + // Render re-applies it to the prefix portion only, via the boundary field. + const EpdFontFamily::Style storedStyle = + boundary > 0 ? static_cast(lineWordStyles[i] & ~EpdFontFamily::BOLD) + : lineWordStyles[i]; + outStyles.push_back(storedStyle); + outBoundaries.push_back(boundary); + outSuffixX.push_back(suffixX); + } + } + + processLine(std::make_shared(std::move(outWords), std::move(outXPos), std::move(outStyles), + std::move(outBoundaries), std::move(outSuffixX), blockStyle)); } diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 9d43400e..167d3d26 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -15,10 +15,12 @@ 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 (no space before it) + std::vector wordIsFocusSuffix; // true = token is the regular tail of a focus bold-prefix split BlockStyle blockStyle; bool extraParagraphSpacing; bool hyphenationEnabled; + bool focusReadingEnabled; void applyParagraphIndent(); std::vector computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, @@ -35,8 +37,11 @@ class ParsedText { public: explicit ParsedText(const bool extraParagraphSpacing, const bool hyphenationEnabled = false, - const BlockStyle& blockStyle = BlockStyle()) - : blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {} + const bool focusReadingEnabled = false, const BlockStyle& blockStyle = BlockStyle()) + : blockStyle(blockStyle), + extraParagraphSpacing(extraParagraphSpacing), + hyphenationEnabled(hyphenationEnabled), + focusReadingEnabled(focusReadingEnabled) {} ~ParsedText() = default; void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false); diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 57a92cd8..7cbd37e1 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -13,8 +13,8 @@ namespace { constexpr uint8_t SECTION_FILE_VERSION = 23; 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(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + - sizeof(uint32_t); + sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) + + sizeof(uint32_t) + sizeof(uint32_t); struct PageLutEntry { uint32_t fileOffset; @@ -43,7 +43,8 @@ uint32_t Section::onPageComplete(std::unique_ptr page) { void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, - const bool embeddedStyle, const uint8_t imageRendering) { + const bool embeddedStyle, const uint8_t imageRendering, + const bool focusReadingEnabled) { if (!file) { LOG_DBG("SCT", "File not open for writing header"); return; @@ -51,8 +52,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) + sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) + sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) + - sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(uint32_t) + - sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t), + sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(focusReadingEnabled) + + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t), "Header size mismatch"); serialization::writePod(file, SECTION_FILE_VERSION); serialization::writePod(file, fontId); @@ -64,6 +65,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi serialization::writePod(file, hyphenationEnabled); serialization::writePod(file, embeddedStyle); serialization::writePod(file, imageRendering); + serialization::writePod(file, focusReadingEnabled); serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later) serialization::writePod(file, static_cast(0)); // Placeholder for LUT offset (patched later) serialization::writePod(file, static_cast(0)); // Placeholder for anchor map offset (patched later) @@ -74,7 +76,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, - const uint8_t imageRendering) { + const uint8_t imageRendering, const bool focusReadingEnabled) { if (!Storage.openFileForRead("SCT", filePath, file)) { return false; } @@ -99,6 +101,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con bool fileHyphenationEnabled; bool fileEmbeddedStyle; uint8_t fileImageRendering; + bool fileFocusReadingEnabled; serialization::readPod(file, fileFontId); serialization::readPod(file, fileLineCompression); serialization::readPod(file, fileExtraParagraphSpacing); @@ -108,13 +111,13 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con serialization::readPod(file, fileHyphenationEnabled); serialization::readPod(file, fileEmbeddedStyle); serialization::readPod(file, fileImageRendering); + serialization::readPod(file, fileFocusReadingEnabled); if (fontId != fileFontId || lineCompression != fileLineCompression || extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment || viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight || hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle || - imageRendering != fileImageRendering) { - // Explicit close() required: member variable persists beyond function scope + imageRendering != fileImageRendering || focusReadingEnabled != fileFocusReadingEnabled) { file.close(); LOG_ERR("SCT", "Deserialization failed: Parameters do not match"); clearCache(); @@ -148,7 +151,8 @@ bool Section::clearCache() const { bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, - const uint8_t imageRendering, const std::function& popupFn) { + const uint8_t imageRendering, const bool focusReadingEnabled, + const std::function& popupFn) { const auto localPath = epub->getSpineItem(spineIndex).href; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; @@ -199,7 +203,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c return false; } writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, - viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering); + viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled); std::vector lut = {}; // Derive the content base directory and image cache path prefix for the parser @@ -219,7 +223,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c ChapterHtmlSlimParser visitor( epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, - viewportHeight, hyphenationEnabled, + viewportHeight, hyphenationEnabled, focusReadingEnabled, [this, &lut](std::unique_ptr page, const uint16_t paragraphIndex, const uint16_t listItemIndex) { lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex}); }, diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 9688e276..aea02d34 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -18,7 +18,7 @@ class Section { void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, - bool embeddedStyle, uint8_t imageRendering); + bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled); uint32_t onPageComplete(std::unique_ptr page); public: @@ -33,11 +33,12 @@ class Section { ~Section() = default; bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, - uint8_t imageRendering); + uint8_t imageRendering, bool focusReadingEnabled); bool clearCache() const; bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, - uint8_t imageRendering, const std::function& popupFn = nullptr); + uint8_t imageRendering, bool focusReadingEnabled, + const std::function& popupFn = nullptr); std::unique_ptr loadPageFromSectionFile(); // Look up the page number for an anchor id from the section cache file. diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index e3e35d42..9acd8b03 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -4,18 +4,44 @@ #include #include +#include + void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { - // Validate iterator bounds before rendering - if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) { - LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", (uint32_t)words.size(), - (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size()); + // Focus annotations are optional: empty vectors mean no word in this block has a split. + // When present, they must be sized in lockstep with words[]. + const bool hasFocus = !wordFocusBoundary.empty(); + if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || + (hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) { + LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n", + (uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size(), + (uint32_t)wordFocusBoundary.size(), (uint32_t)wordFocusSuffixX.size()); return; } for (size_t i = 0; i < words.size(); i++) { const int wordX = wordXpos[i] + x; const EpdFontFamily::Style currentStyle = wordStyles[i]; - renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle); + const uint8_t boundary = hasFocus ? wordFocusBoundary[i] : 0; + + if (boundary > 0) { + // Focus split: draw bold prefix, then the regular suffix at a pre-computed x offset. + // The bold prefix is bounded to 9 codepoints by the clamp on targetBoldChars in + // ParsedText::addWord; 9 UTF-8 codepoints occupy at most 9 * 4 = 36 bytes, +1 for null = 37. + // suffixX is computed at cache-creation time to avoid font metric lookups at render time. + static constexpr size_t MAX_FOCUS_PREFIX_BYTES = 9 * 4 + 1; + char boldBuf[40]; + static_assert(sizeof(boldBuf) >= MAX_FOCUS_PREFIX_BYTES, + "boldBuf too small for max focus prefix (9 codepoints * 4 UTF-8 bytes + null)"); + const auto boldStyle = static_cast(currentStyle | EpdFontFamily::BOLD); + const size_t boldLen = std::min({static_cast(boundary), words[i].size(), sizeof(boldBuf) - 1}); + memcpy(boldBuf, words[i].c_str(), boldLen); + boldBuf[boldLen] = '\0'; + renderer.drawText(fontId, wordX, y, boldBuf, true, boldStyle); + const int suffixX = wordX + wordFocusSuffixX[i]; + renderer.drawText(fontId, suffixX, y, words[i].c_str() + boldLen, true, currentStyle); + } else { + renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle); + } if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) { const std::string& w = words[i]; @@ -42,9 +68,15 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } bool TextBlock::serialize(FsFile& file) const { - if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) { - LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", words.size(), - wordXpos.size(), wordStyles.size()); + // Focus annotations are optional; vectors are either empty (no splits in this block) + // or sized in lockstep with words[]. + const bool hasFocus = !wordFocusBoundary.empty(); + if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || + (hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) { + LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n", + static_cast(words.size()), static_cast(wordXpos.size()), + static_cast(wordStyles.size()), static_cast(wordFocusBoundary.size()), + static_cast(wordFocusSuffixX.size())); return false; } @@ -53,6 +85,13 @@ bool TextBlock::serialize(FsFile& file) const { for (const auto& w : words) serialization::writeString(file, w); for (auto x : wordXpos) serialization::writePod(file, x); for (auto s : wordStyles) serialization::writePod(file, s); + // Focus block: 1-byte presence flag, followed by per-word vectors only when present. + // Saves 3 bytes/word when focus reading is disabled or no word on this line was split. + serialization::writePod(file, static_cast(hasFocus ? 1 : 0)); + if (hasFocus) { + for (auto b : wordFocusBoundary) serialization::writePod(file, b); + for (auto sx : wordFocusSuffixX) serialization::writePod(file, sx); + } // Style (alignment + margins/padding/indent) serialization::writePod(file, blockStyle.alignment); @@ -76,6 +115,8 @@ std::unique_ptr TextBlock::deserialize(FsFile& file) { std::vector words; std::vector wordXpos; std::vector wordStyles; + std::vector wordFocusBoundary; + std::vector wordFocusSuffixX; BlockStyle blockStyle; // Word count @@ -94,6 +135,16 @@ std::unique_ptr TextBlock::deserialize(FsFile& file) { for (auto& w : words) serialization::readString(file, w); for (auto& x : wordXpos) serialization::readPod(file, x); for (auto& s : wordStyles) serialization::readPod(file, s); + // Focus block: presence flag, then vectors only if present. Empty vectors when absent + // signal "no splits in this block" to render() (zero per-word RAM cost). + uint8_t hasFocus; + serialization::readPod(file, hasFocus); + if (hasFocus) { + wordFocusBoundary.resize(wc); + wordFocusSuffixX.resize(wc); + for (auto& b : wordFocusBoundary) serialization::readPod(file, b); + for (auto& sx : wordFocusSuffixX) serialization::readPod(file, sx); + } // Style (alignment + margins/padding/indent) serialization::readPod(file, blockStyle.alignment); @@ -109,6 +160,7 @@ std::unique_ptr TextBlock::deserialize(FsFile& file) { serialization::readPod(file, blockStyle.textIndent); serialization::readPod(file, blockStyle.textIndentDefined); - return std::unique_ptr( - new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), blockStyle)); + return std::unique_ptr(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), + std::move(wordFocusBoundary), std::move(wordFocusSuffixX), + blockStyle)); } diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index 85fdd55a..e60b283c 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -15,14 +15,28 @@ class TextBlock final : public Block { std::vector words; std::vector wordXpos; std::vector wordStyles; + // Per-word focus boundary: N > 0 means the first N bytes of words[i] are rendered bold, + // the remainder in the base style. 0 means no split (whole word uses wordStyles[i]). + // N encodes the bold PREFIX length only — bounded to 9 codepoints (≤36 UTF-8 bytes) by + // FOCUS_READING_PERCENT's 1..9 clamp in ParsedText::addWord, so it always fits in uint8_t. + // Vector is empty when no focus splits exist anywhere in the block (zero per-word RAM cost + // when focus reading is disabled, or on lines that happen to contain no splittable words). + std::vector wordFocusBoundary; + // Pre-computed pixel offset from word start to the regular suffix, stored when boundary > 0. + // Eliminates getTextAdvanceX from the render path. 0 when boundary == 0. + // Empty in lockstep with wordFocusBoundary. + std::vector wordFocusSuffixX; BlockStyle blockStyle; public: explicit TextBlock(std::vector words, std::vector word_xpos, - std::vector word_styles, const BlockStyle& blockStyle = BlockStyle()) + std::vector word_styles, std::vector focus_boundary, + std::vector focus_suffix_x, const BlockStyle& blockStyle = BlockStyle()) : words(std::move(words)), wordXpos(std::move(word_xpos)), wordStyles(std::move(word_styles)), + wordFocusBoundary(std::move(focus_boundary)), + wordFocusSuffixX(std::move(focus_suffix_x)), blockStyle(blockStyle) {} ~TextBlock() override = default; void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index c9f89e2e..a25a1dbd 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -141,7 +141,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); pendingAnchorId.clear(); } - currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle)); + currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, focusReadingEnabled, blockStyle)); wordsExtractedInBlock = 0; } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 5802bb94..aeaea7f5 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -47,6 +47,7 @@ class ChapterHtmlSlimParser { uint16_t viewportWidth; uint16_t viewportHeight; bool hyphenationEnabled; + bool focusReadingEnabled; const CssParser* cssParser; bool embeddedStyle; uint8_t imageRendering; @@ -101,6 +102,7 @@ class ChapterHtmlSlimParser { const int fontId, const float lineCompression, const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, + const bool focusReadingEnabled, const std::function, uint16_t, uint16_t)>& completePageFn, const bool embeddedStyle, const std::string& contentBase, const std::string& imageBasePath, const uint8_t imageRendering = 0, @@ -116,6 +118,7 @@ class ChapterHtmlSlimParser { viewportWidth(viewportWidth), viewportHeight(viewportHeight), hyphenationEnabled(hyphenationEnabled), + focusReadingEnabled(focusReadingEnabled), completePageFn(completePageFn), popupFn(popupFn), cssParser(cssParser), diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index dcedb4e2..13283ef3 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -10,6 +10,35 @@ #include "FontCacheManager.h" +namespace { + +const char* resolveVisualText(const char* text, std::string& visualBuffer, int paragraphLevel); + +/** + * Resolves the requested style to the best available style in the given SD card font. + * Falls back gracefully when the font lacks the requested variant. + */ +uint8_t resolveSdCardStyle(const SdCardFont& font, const EpdFontFamily::Style style) { + // Indexed by styleBits (0=REGULAR, 1=BOLD, 2=ITALIC, 3=BOLD_ITALIC) + static const uint8_t kFallbacks[4][4] = { + // REGULAR: REGULAR → BOLD → ITALIC → BOLD_ITALIC + {EpdFontFamily::REGULAR, EpdFontFamily::BOLD, EpdFontFamily::ITALIC, EpdFontFamily::BOLD_ITALIC}, + // BOLD: BOLD → BOLD_ITALIC → REGULAR → ITALIC + {EpdFontFamily::BOLD, EpdFontFamily::BOLD_ITALIC, EpdFontFamily::REGULAR, EpdFontFamily::ITALIC}, + // ITALIC: ITALIC → REGULAR → BOLD → BOLD_ITALIC (REGULAR before BOLD!) + {EpdFontFamily::ITALIC, EpdFontFamily::REGULAR, EpdFontFamily::BOLD, EpdFontFamily::BOLD_ITALIC}, + // BOLD_ITALIC: BOLD_ITALIC → BOLD → ITALIC → REGULAR + {EpdFontFamily::BOLD_ITALIC, EpdFontFamily::BOLD, EpdFontFamily::ITALIC, EpdFontFamily::REGULAR}, + }; + + const uint8_t styleBits = static_cast(style) & 0x03; + for (uint8_t candidate : kFallbacks[styleBits]) { + if (font.hasStyle(candidate)) return candidate; + } + return EpdFontFamily::REGULAR; // no-variant-at-all safety net +} +} // namespace + const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const { if (fontData->groups != nullptr) { auto* fd = fontCacheManager_ ? fontCacheManager_->getDecompressor() : nullptr; @@ -1074,7 +1103,8 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl // Advance table fast-path for SD card fonts during layout auto sdIt = sdCardFonts_.find(fontId); if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) { - return fp4::toPixel(sdIt->second->getAdvance(' ', static_cast(style))); + const uint8_t resolvedStyle = resolveSdCardStyle(*sdIt->second, style); + return fp4::toPixel(sdIt->second->getAdvance(' ', resolvedStyle)); } const auto fontIt = fontMap.find(fontId); @@ -1094,7 +1124,8 @@ int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const // so we return just the space advance without kerning. auto sdIt = sdCardFonts_.find(fontId); if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) { - return fp4::toPixel(sdIt->second->getAdvance(' ', static_cast(style))); + const uint8_t resolvedStyle = resolveSdCardStyle(*sdIt->second, style); + return fp4::toPixel(sdIt->second->getAdvance(' ', resolvedStyle)); } const auto fontIt = fontMap.find(fontId); @@ -1124,7 +1155,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami auto sdIt = sdCardFonts_.find(fontId); if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) { int32_t widthFP = 0; - const uint8_t styleIdx = static_cast(style); + const uint8_t styleIdx = resolveSdCardStyle(*sdIt->second, style); while (uint32_t cp = utf8NextCodepoint(reinterpret_cast(&text))) { widthFP += sdIt->second->getAdvance(cp, styleIdx); } diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index 245ecc10..f854cae8 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -259,6 +259,7 @@ STR_SECTION_PREFIX: "Раздзел" STR_UPLOAD: "Адправіць" STR_BOOK_S_STYLE: "Стыль кнігі" STR_EMBEDDED_STYLE: "Убудаваны стыль" +STR_FOCUS_READING: "Фокуснае чытанне" STR_OPDS_SERVER_URL: "URL OPDS сервера" STR_SCREENSHOT_BUTTON: "Зрабіць здымак экрана" STR_IMAGES: "Выявы" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 377ab05e..40b02ea0 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -287,6 +287,7 @@ STR_SECTION_PREFIX: "Secció " STR_UPLOAD: "Puja" 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_FOOTNOTES: "Notes al peu" STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index a769aa07..379bd690 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -264,6 +264,7 @@ STR_SECTION_PREFIX: "Sekce" STR_UPLOAD: "Nahrát" STR_BOOK_S_STYLE: "Styl knihy" STR_EMBEDDED_STYLE: "Vložený styl" +STR_FOCUS_READING: "Soustředěné čtení" STR_OPDS_SERVER_URL: "URL serveru OPDS" STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky" STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index 91b13025..9a1b979d 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -287,6 +287,7 @@ STR_SECTION_PREFIX: "Afsnit " STR_UPLOAD: "Upload" STR_BOOK_S_STYLE: "Bogens stil" STR_EMBEDDED_STYLE: "Indlejret stil" +STR_FOCUS_READING: "Fokuslæsning" STR_OPDS_SERVER_URL: "OPDS Server URL" STR_FOOTNOTES: "Fodnoter" STR_NO_FOOTNOTES: "Ingen fodnoter på denne side" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 47041116..12593f0a 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -287,6 +287,7 @@ STR_SECTION_PREFIX: "Sectie " STR_UPLOAD: "Uploaden" STR_BOOK_S_STYLE: "Stijl van boek" STR_EMBEDDED_STYLE: "Ingebedde stijl" +STR_FOCUS_READING: "Gefocust lezen" STR_OPDS_SERVER_URL: "OPDS-server URL" STR_FOOTNOTES: "Voetnoten" STR_NO_FOOTNOTES: "Geen voetnoten op deze pagina" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 969af61c..ab9917f1 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -296,6 +296,7 @@ STR_SECTION_PREFIX: "Section " STR_UPLOAD: "Upload" STR_BOOK_S_STYLE: "Book's Style" STR_EMBEDDED_STYLE: "Embedded Style" +STR_FOCUS_READING: "Focus Reading" STR_OPDS_SERVER_URL: "OPDS Server URL" STR_SET_SLEEP_COVER: "Set Cover" STR_FOOTNOTES: "Footnotes" @@ -310,8 +311,8 @@ STR_DELETE_CONFIRM: "Delete this server?" STR_OPDS_SERVERS: "OPDS Servers" STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: " STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)" -STR_DOWNLOAD_FONTS: "Download Fonts" -STR_FONT_DOWNLOAD: "Font Download" +STR_MANAGE_FONTS: "Manage Fonts" +STR_FONT_BROWSER: "Font Browser" STR_LOADING_FONT_LIST: "Loading font list..." STR_NO_FONTS_AVAILABLE: "No fonts available" STR_FONT_INSTALLED: "Font installed!" @@ -322,7 +323,8 @@ STR_SD_CARD_FULL: "Insufficient SD card space" STR_FILES_LABEL: "Files: " STR_SIZE_LABEL: "Size: " STR_REDOWNLOAD: "Re-download" -STR_DOWNLOAD_ALL: "Download / Update All" +STR_DOWNLOAD_ALL: "Download All" +STR_UPDATE_ALL: "Update All" STR_ALL_FONTS_INSTALLED: "All fonts installed!" STR_UPDATE_AVAILABLE: "Update" STR_CRASH_TITLE: "System Crash" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 99a4d974..93672291 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -262,6 +262,7 @@ STR_SECTION_PREFIX: "Osio " STR_UPLOAD: "Lähetä" STR_BOOK_S_STYLE: "Kirjan tyyli" STR_EMBEDDED_STYLE: "Upotettu tyyli" +STR_FOCUS_READING: "Keskittynyt lukeminen" STR_OPDS_SERVER_URL: "OPDS-palvelimen osoite" STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus" STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index fb299ab7..0f43e82b 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -288,6 +288,7 @@ STR_SECTION_PREFIX: "Section " STR_UPLOAD: "Envoyer" STR_BOOK_S_STYLE: "Style du livre" STR_EMBEDDED_STYLE: "Style intégré" +STR_FOCUS_READING: "Lecture focalisée" STR_OPDS_SERVER_URL: "URL serveur OPDS" STR_FOOTNOTES: "Notes de bas de page" STR_NO_FOOTNOTES: "Aucune note sur cette page" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 28312b81..d2f8d0e6 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -289,6 +289,7 @@ STR_SECTION_PREFIX: "Abschnitt" STR_UPLOAD: "Hochladen" STR_BOOK_S_STYLE: "Buch-Stil" STR_EMBEDDED_STYLE: "Eingebetteter Stil" +STR_FOCUS_READING: "Fokus-Lesen" STR_OPDS_SERVER_URL: "OPDS-Server-URL" STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen" STR_FOOTNOTES: "Fußnoten" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 759768aa..070f2a28 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -284,6 +284,7 @@ STR_SECTION_PREFIX: "Szakasz " STR_UPLOAD: "Feltöltés" STR_BOOK_S_STYLE: "Könyv stílusa" STR_EMBEDDED_STYLE: "Beágyazott stílus" +STR_FOCUS_READING: "Fókuszált olvasás" STR_OPDS_SERVER_URL: "OPDS szerver URL" STR_FOOTNOTES: "Lábjegyzetek" STR_NO_FOOTNOTES: "Nincsenek lábjegyzetek ezen az oldalon" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index 3c3bb17b..4a180b4f 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -288,6 +288,7 @@ STR_SECTION_PREFIX: "Sezione " STR_UPLOAD: "Carica" STR_BOOK_S_STYLE: "Stile libro" STR_EMBEDDED_STYLE: "Stile integrato dell'epub" +STR_FOCUS_READING: "Lettura focalizzata" STR_OPDS_SERVER_URL: "Server OPDS" STR_FOOTNOTES: "Note a piè pagina" STR_NO_FOOTNOTES: "Nessuna nota in questa pagina" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index fbee8021..5d9c03e9 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -258,6 +258,7 @@ STR_SECTION_PREFIX: "Бөлім " STR_UPLOAD: "Жүктеп салу" STR_BOOK_S_STYLE: "Кітап стилі" STR_EMBEDDED_STYLE: "Кірістірілген стиль" +STR_FOCUS_READING: "Зейінді оқу" STR_OPDS_SERVER_URL: "OPDS сервері URL" STR_NO_FILES_FOUND: "Файлдар табылмады" STR_IMAGES: "Суреттер" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index bae0764a..d7619a1f 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -284,6 +284,7 @@ STR_SECTION_PREFIX: "Dalis " STR_UPLOAD: "Įkelti" STR_BOOK_S_STYLE: "Knygos stilius" STR_EMBEDDED_STYLE: "Integruotas stilius" +STR_FOCUS_READING: "Sufokusuotas skaitymas" STR_OPDS_SERVER_URL: "OPDS URL" STR_FOOTNOTES: "Išnašos" STR_NO_FOOTNOTES: "Šiame psl. išnašų nėra" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index b85a8719..384dbac8 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -296,6 +296,7 @@ STR_SECTION_PREFIX: "Sekcja " STR_UPLOAD: "Wyślij" STR_BOOK_S_STYLE: "Styl książki" STR_EMBEDDED_STYLE: "Style wbudowane w EPUB" +STR_FOCUS_READING: "Czytanie skupione" STR_OPDS_SERVER_URL: "URL serwera OPDS" STR_SET_SLEEP_COVER: "Ustaw okładkę" STR_FOOTNOTES: "Przypisy" @@ -322,7 +323,8 @@ STR_SD_CARD_FULL: "Za mało pamięci na karcie SD" STR_FILES_LABEL: "Pliki: " STR_SIZE_LABEL: "Rozmiar: " STR_REDOWNLOAD: "Re-download" -STR_DOWNLOAD_ALL: "Pobierz / Uaktualnij wszystkie" +STR_DOWNLOAD_ALL: "Pobierz wszystkie" +STR_UPDATE_ALL: "Uaktualnij wszystkie" STR_ALL_FONTS_INSTALLED: "Wszystkie czcionki zainstalowane!" STR_UPDATE_AVAILABLE: "Uaktualnij" STR_CRASH_TITLE: "Awaria systemu" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index e6249adb..97b447b9 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -264,6 +264,7 @@ STR_SECTION_PREFIX: "Seção" STR_UPLOAD: "Enviar" STR_BOOK_S_STYLE: "Estilo do livro" STR_EMBEDDED_STYLE: "Estilo embutido" +STR_FOCUS_READING: "Leitura focada" STR_OPDS_SERVER_URL: "URL do servidor OPDS" STR_SCREENSHOT_BUTTON: "Capturar tela" STR_TILT_PAGE_TURN: "Virar página por inclinação" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index d4e004db..84d7cac9 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -287,6 +287,7 @@ STR_SECTION_PREFIX: "Secţiune " STR_UPLOAD: "Încărcare" STR_BOOK_S_STYLE: "Stilul cărţii" STR_EMBEDDED_STYLE: "Stil încorporat" +STR_FOCUS_READING: "Lectură concentrată" STR_OPDS_SERVER_URL: "URL server OPDS" STR_FOOTNOTES: "Note de subsol" STR_NO_FOOTNOTES: "Nicio notă de subsol" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 6f00d6c3..005a5eea 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -291,6 +291,7 @@ STR_SECTION_PREFIX: "Раздел " STR_UPLOAD: "Отправить" STR_BOOK_S_STYLE: "Стиль книги" STR_EMBEDDED_STYLE: "Встроенный стиль" +STR_FOCUS_READING: "Фокусное чтение" STR_OPDS_SERVER_URL: "URL OPDS сервера" STR_SCREENSHOT_BUTTON: "Сделать снимок экрана" STR_AUTO_TURN_ENABLED: "Автоперелистывание: " diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 20103772..7e506661 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -284,6 +284,7 @@ STR_SECTION_PREFIX: "Razdelek " STR_UPLOAD: "Naloži" STR_BOOK_S_STYLE: "Slog knjige" STR_EMBEDDED_STYLE: "Vgrajen slog" +STR_FOCUS_READING: "Fokusirano branje" STR_OPDS_SERVER_URL: "URL OPDS strežnika" STR_FOOTNOTES: "Opombe" STR_NO_FOOTNOTES: "Na tej strani ni opomb" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index ea2e56dc..9b4af843 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -288,6 +288,7 @@ STR_SECTION_PREFIX: "Secc.:" STR_UPLOAD: "Subir" 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_FOOTNOTES: "Pie de página" STR_NO_FOOTNOTES: "No hay notas al pie de esta página" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 84ac108e..9a71fd32 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -296,6 +296,7 @@ STR_SECTION_PREFIX: "Sektion" STR_UPLOAD: "Uppladdning" STR_BOOK_S_STYLE: "Bokstil" STR_EMBEDDED_STYLE: "Inbäddad stil" +STR_FOCUS_READING: "Fokusläsning" STR_OPDS_SERVER_URL: "OPDS-serveradress" STR_SET_SLEEP_COVER: "Ställ in omslag" STR_FOOTNOTES: "Fotnoter" @@ -310,8 +311,8 @@ STR_DELETE_CONFIRM: "Vill du ta bort den här servern?" STR_OPDS_SERVERS: "OPDS-servrar" STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: " STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)" -STR_DOWNLOAD_FONTS: "Ladda ner teckensnitt" -STR_FONT_DOWNLOAD: "Nedladdning av teckensnitt" +STR_MANAGE_FONTS: "Hantera teckensnitt" +STR_FONT_BROWSER: "Teckensnittsbläddrare" STR_LOADING_FONT_LIST: "Laddar teckensnittslista..." STR_NO_FONTS_AVAILABLE: "Inga teckensnitt tillgängliga" STR_FONT_INSTALLED: "Teckensnitt installerat!" @@ -322,7 +323,8 @@ STR_SD_CARD_FULL: "Otillräckligt utrymme på SD-kortet" STR_FILES_LABEL: "Filer: " STR_SIZE_LABEL: "Storlek: " STR_REDOWNLOAD: "Ladda ner igen" -STR_DOWNLOAD_ALL: "Ladda ner / Uppdatera alla" +STR_DOWNLOAD_ALL: "Ladda ner alla" +STR_UPDATE_ALL: "Uppdatera alla" STR_ALL_FONTS_INSTALLED: "Alla teckensnitt installerade!" STR_UPDATE_AVAILABLE: "Uppdatering" STR_CRASH_TITLE: "Systemkrasch" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index 066af673..6e0784ce 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -262,6 +262,7 @@ STR_SECTION_PREFIX: "Bölüm " STR_UPLOAD: "Yükle" STR_BOOK_S_STYLE: "Kitabın Stili" STR_EMBEDDED_STYLE: "Gömülü Stil" +STR_FOCUS_READING: "Odaklanmış Okuma" STR_OPDS_SERVER_URL: "OPDS Sunucu Adresi" STR_AUTO_TURN_ENABLED: "Otomatik Çevirme Etkin: " STR_AUTO_TURN_PAGES_PER_MIN: "Otomatik Çevirme (Dakikada Sayfa)" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index dfe0d019..2797f931 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -87,7 +87,7 @@ STR_TIME_TO_SLEEP: "Перехід в режим сну" STR_SHOW_HIDDEN_FILES: "Показати приховані файли" STR_REFRESH_FREQ: "Частота оновлення екрану" STR_KOREADER_SYNC: "Синхронізація KOReader" -STR_CHECK_UPDATES: "Перевірити оновлення" +STR_CHECK_UPDATES: "Перевірити оновлення системи" STR_LANGUAGE: "Мова" STR_CLEAR_READING_CACHE: "Очистити кеш книг" STR_USERNAME: "Ім'я користувача" @@ -171,6 +171,7 @@ STR_NO_UPDATE: "Оновлень немає" STR_UPDATE_FAILED: "Оновлення не вдалося" STR_UPDATE_COMPLETE: "Оновлення завершено" STR_POWER_ON_HINT: "Натисніть і утримуйте кнопку живлення, щоб увімкнути" +STR_RESTARTING_HINT: "Перезавантаження... Якщо пристрій не перезавантажується, утримуйте кнопку живлення кілька секунд." STR_NO_ENTRIES: "Записів не знайдено" STR_DOWNLOADING: "Завантаження..." STR_DOWNLOAD_FAILED: "Завантаження не вдалося" @@ -228,6 +229,9 @@ STR_EXAMPLE_BOOK: "Назва книги" STR_PREVIEW: "Перегляд" STR_TITLE: "Назва" STR_BATTERY: "Акумулятор" +STR_XTC_STATUS_BAR: "XTC Рядок прогресу" +STR_BOTTOM: "Низ" +STR_TOP: "Верх" STR_UI_THEME: "Тема інтерфейсу" STR_THEME_CLASSIC: "Класична" STR_THEME_LYRA: "Lyra" @@ -292,7 +296,9 @@ STR_SECTION_PREFIX: "Розділ " STR_UPLOAD: "Завантажити" STR_BOOK_S_STYLE: "Стиль книги" STR_EMBEDDED_STYLE: "Вбудований стиль" +STR_FOCUS_READING: "Фокусне читання" STR_OPDS_SERVER_URL: "URL сервера OPDS" +STR_SET_SLEEP_COVER: "Як обкл." STR_FOOTNOTES: "Примітки" STR_NO_FOOTNOTES: "На цій сторінці немає приміток" STR_LINK: "[посилання]" @@ -305,6 +311,22 @@ STR_DELETE_CONFIRM: "Видалити цей сервер?" STR_OPDS_SERVERS: "Сервери OPDS" STR_AUTO_TURN_ENABLED: "Автоперегортання увімк: " STR_AUTO_TURN_PAGES_PER_MIN: "Автоперегортання (ст/хв)" +STR_MANAGE_FONTS: "Керування шрифтами" +STR_FONT_BROWSER: "Перегляд шрифтів" +STR_LOADING_FONT_LIST: "Оновлення списку шрифтів..." +STR_NO_FONTS_AVAILABLE: "Шрифти недоступні" +STR_FONT_INSTALLED: "Шрифт встановлено!" +STR_FONT_INSTALL_FAILED: "Не вдалося встановити шрифт" +STR_INSTALLED: "Встановлено" +STR_CONFIRM_DOWNLOAD_PROMPT: "Завантажити?" +STR_SD_CARD_FULL: "Недостатньо місця на SD-карті" +STR_FILES_LABEL: "Файли: " +STR_SIZE_LABEL: "Розмір: " +STR_REDOWNLOAD: "Завантажити повторно" +STR_DOWNLOAD_ALL: "Завантажити все" +STR_UPDATE_ALL: "Оновити все" +STR_ALL_FONTS_INSTALLED: "Всі шрифти встановлено!" +STR_UPDATE_AVAILABLE: "Оновити" STR_CRASH_TITLE: "Збій Системи" STR_CRASH_DESCRIPTION: "Дані про збій збережено в crash_report.txt. Додайте цей файл до вашого звіту про помилку." STR_CRASH_REASON: "Причина збою:" @@ -325,3 +347,16 @@ STR_KB_HINT_SECONDARY_CHAR: "Затисніть ВИБРАТИ для додат STR_KB_HINT_UPPER_SECONDARY: "Затисніть ВИБРАТИ для ВЕЛИКИХ літер / символів" STR_KB_HINT_LOWER_SECONDARY: "Затисніть ВИБРАТИ для малих літер / символів" STR_KB_HINT_URL_SNIPPETS: "Натисніть URL для вибору шаблонів" +STR_SD_FIRMWARE_UPDATE: "Оновлення системи з SD-карти" +STR_SELECT_FIRMWARE_FILE: "Оберіть файл оновлення (.bin)" +STR_NO_BIN_FILES: "Не знайдено .bin файлів" +STR_VALIDATING_FIRMWARE: "Перевірка цілісності файлу..." +STR_INVALID_FIRMWARE: "Невірний файл оновлення системи" +STR_FIRMWARE_TOO_LARGE: "Файл оновлення системи не вміщується в розділ пам'яті" +STR_FIRMWARE_TOO_SMALL: "Файл оновлення системи занадто малий" +STR_FIRMWARE_UPDATE_PROMPT: "Оновити систему?" +STR_FIRMWARE_FILE_OPEN_FAILED: "Неможливо відкрити файл" +STR_FIRMWARE_WRITE_FAILED: "Помилка запису прошивки" +STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не вимикайте пристрій!" +STR_RECOVERY_MODE: "Режим відновлення" +STR_RECOVERY_MODE_HINT: "Помістіть firmware.bin у корінь SD-карти та виберіть його" diff --git a/open-x4-sdk b/open-x4-sdk index 7d86603a..a64a3c29 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit 7d86603ad27709a9a766bb5ad893cfc39e60777e +Subproject commit a64a3c29bebc59b2ccdfe15492cfc4b5e4c26360 diff --git a/scripts/convert_icon.py b/scripts/convert_icon.py index 56ea7d4b..6e20fc8c 100644 --- a/scripts/convert_icon.py +++ b/scripts/convert_icon.py @@ -1,18 +1,21 @@ -import sys import os -from PIL import Image -import cairosvg import io +import sys threshold = 128 +USAGE = 'Usage: python scripts/convert_icon.py input.png|input.svg output_name width height' def svg_to_png_bytes(svg_path, width, height): + import cairosvg + with open(svg_path, 'rb') as f: svg_data = f.read() png_bytes = cairosvg.svg2png(bytestring=svg_data, output_width=width, output_height=height) return png_bytes def load_image(path, width, height): + from PIL import Image + ext = os.path.splitext(path)[1].lower() if ext == '.svg': png_bytes = svg_to_png_bytes(path, width, height) @@ -58,8 +61,11 @@ def image_to_c_array(img, array_name): return c def main(): - if len(sys.argv) < 5: - print('Usage: python convert_image.py input.png output_name width height') + if any(arg in ('-h', '--help') for arg in sys.argv[1:]): + print(USAGE) + sys.exit(0) + if len(sys.argv) != 5: + print(USAGE) sys.exit(1) input_path, output_name, width, height = sys.argv[1:5] array_name = output_name.capitalize() + 'Icon' @@ -77,4 +83,4 @@ def main(): print(f'Wrote {output_path}') if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/scripts/debugging_monitor.py b/scripts/debugging_monitor.py index 30c3533e..fa2b8547 100755 --- a/scripts/debugging_monitor.py +++ b/scripts/debugging_monitor.py @@ -35,6 +35,43 @@ import threading from collections import deque from datetime import datetime +DEFAULT_BAUDRATE = 115200 + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="ESP32 Serial Monitor with Memory Graph - Real-time monitoring, graphing, and command interface" + ) + parser.add_argument( + "port", + nargs="?", + default=None, + help="Serial port (leave empty for autodetection)", + ) + parser.add_argument( + "--baud", + type=int, + default=DEFAULT_BAUDRATE, + help=f"Baud rate (default: {DEFAULT_BAUDRATE})", + ) + parser.add_argument( + "--filter", + type=str, + default="", + help="Only display lines containing this keyword (case-insensitive)", + ) + parser.add_argument( + "--suppress", + type=str, + default="", + help="Suppress lines containing this keyword (case-insensitive)", + ) + return parser + + +if any(arg in ("-h", "--help") for arg in sys.argv[1:]): + build_arg_parser().parse_args() + # Try to import potentially missing packages PACKAGE_MAPPING: dict[str, str] = { "serial": "pyserial", @@ -401,34 +438,7 @@ def main() -> None: - Screenshot capture capability - Graceful shutdown on Ctrl-C or window close """ - parser = argparse.ArgumentParser( - description="ESP32 Serial Monitor with Memory Graph - Real-time monitoring, graphing, and command interface" - ) - default_baudrate = 115200 - parser.add_argument( - "port", - nargs="?", - default=None, - help="Serial port (leave empty for autodetection)", - ) - parser.add_argument( - "--baud", - type=int, - default=default_baudrate, - help=f"Baud rate (default: {default_baudrate})", - ) - parser.add_argument( - "--filter", - type=str, - default="", - help="Only display lines containing this keyword (case-insensitive)", - ) - parser.add_argument( - "--suppress", - type=str, - default="", - help="Suppress lines containing this keyword (case-insensitive)", - ) + parser = build_arg_parser() args = parser.parse_args() port = args.port if port is None: diff --git a/scripts/generate-font-manifest.py b/scripts/generate-font-manifest.py index e962c162..80ef73f8 100755 --- a/scripts/generate-font-manifest.py +++ b/scripts/generate-font-manifest.py @@ -16,6 +16,8 @@ The input directory may be flat (all .cpfont files in one dir) or nested convention _.cpfont. """ +from __future__ import annotations + import argparse import json import os diff --git a/scripts/generate_kerning_ligature_epub.py b/scripts/generate_kerning_ligature_epub.py index ae3c9f66..1ba640ca 100644 --- a/scripts/generate_kerning_ligature_epub.py +++ b/scripts/generate_kerning_ligature_epub.py @@ -137,10 +137,15 @@ Also includes: import io import os +import sys import zipfile import uuid from datetime import datetime +if any(arg in ("-h", "--help") for arg in sys.argv[1:]): + print(__doc__.strip()) + sys.exit(0) + try: from PIL import Image, ImageDraw, ImageFont except ImportError: diff --git a/scripts/generate_test_bmps.py b/scripts/generate_test_bmps.py index 0e0c7894..2fbdd630 100644 --- a/scripts/generate_test_bmps.py +++ b/scripts/generate_test_bmps.py @@ -326,4 +326,7 @@ def main(): if __name__ == '__main__': + if any(arg in ("-h", "--help") for arg in sys.argv[1:]): + print(__doc__.strip()) + sys.exit(0) main() diff --git a/scripts/generate_test_epub.py b/scripts/generate_test_epub.py index e585420c..ae97708a 100644 --- a/scripts/generate_test_epub.py +++ b/scripts/generate_test_epub.py @@ -11,9 +11,14 @@ Creates EPUBs with annotated JPEG and PNG images to verify: """ import os +import sys import zipfile from pathlib import Path +if any(arg in ("-h", "--help") for arg in sys.argv[1:]): + print(__doc__.strip()) + sys.exit(0) + try: from PIL import Image, ImageDraw, ImageFont except ImportError: diff --git a/scripts/update_hypenation.sh b/scripts/update_hyphenation.sh similarity index 100% rename from scripts/update_hypenation.sh rename to scripts/update_hyphenation.sh diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index bd6a8502..0b769005 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -213,6 +213,8 @@ class CrossPointSettings { uint8_t fadingFix = 0; // Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled) uint8_t embeddedStyle = 1; + // Focus Reading - emphasizes the first part of words with bold + uint8_t focusReadingEnabled = 0; // SD card font family name (empty = use built-in fontFamily) char sdFontFamilyName[32] = ""; // Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show) diff --git a/src/SdCardFontSystem.cpp b/src/SdCardFontSystem.cpp index 8e59d6ed..b771bede 100644 --- a/src/SdCardFontSystem.cpp +++ b/src/SdCardFontSystem.cpp @@ -5,15 +5,10 @@ #include "CrossPointSettings.h" -// Map fontSize enum (SMALL=0, MEDIUM=1, LARGE=2, EXTRA_LARGE=3) to the point -// sizes shipped with the built-in fonts. Used to drive closest-pt selection -// in the SD card font registry (see SdCardFontFamilyInfo::pickClosestSize). -static constexpr uint8_t FONT_SIZE_TO_PT[CrossPointSettings::FONT_SIZE_COUNT] = {12, 14, 16, 18}; - -static uint8_t targetPtSizeFromSettings() { +static uint8_t fontSizeEnumFromSettings() { uint8_t e = SETTINGS.fontSize; if (e >= CrossPointSettings::FONT_SIZE_COUNT) e = 1; // default to MEDIUM - return FONT_SIZE_TO_PT[e]; + return e; } void SdCardFontSystem::begin(GfxRenderer& renderer) { @@ -30,7 +25,7 @@ void SdCardFontSystem::begin(GfxRenderer& renderer) { if (SETTINGS.sdFontFamilyName[0] != '\0') { const auto* family = registry_.findFamily(SETTINGS.sdFontFamilyName); if (family) { - if (manager_.loadFamily(*family, renderer, targetPtSizeFromSettings())) { + if (manager_.loadFamily(*family, renderer, fontSizeEnumFromSettings())) { LOG_DBG("SDFS", "Loaded SD card font family: %s", SETTINGS.sdFontFamilyName); } else { LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName); @@ -58,7 +53,7 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { const char* wantedFamily = SETTINGS.sdFontFamilyName; const std::string& currentFamily = manager_.currentFamilyName(); - const uint8_t targetPt = targetPtSizeFromSettings(); + const uint8_t sizeEnum = fontSizeEnumFromSettings(); if (wantedFamily[0] == '\0') { if (!currentFamily.empty()) { @@ -67,8 +62,8 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { return; } - // Reload if family changed OR if the user-selected size now resolves to a - // different on-disk file than what's currently loaded OR if the registry was + // Reload if family changed OR if the user-selected size maps to a + // different file than what's currently loaded OR if the registry was // just rediscovered (file may have been replaced on disk). bool familyMatches = (currentFamily == wantedFamily); if (familyMatches) { @@ -79,11 +74,13 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { SETTINGS.sdFontFamilyName[0] = '\0'; return; } - const auto* best = family->pickClosestSize(targetPt); - const uint8_t bestPt = best ? best->pointSize : 0; - if (!registryWasDirty && bestPt == manager_.currentPointSize()) return; - LOG_DBG("SDFS", "Reloading %s: size %u -> %u (target %u)%s", wantedFamily, manager_.currentPointSize(), bestPt, - targetPt, registryWasDirty ? " [registry dirty]" : ""); + auto sizes = family->availableSizes(); + uint8_t idx = sizeEnum; + if (idx >= sizes.size()) idx = sizes.size() - 1; + uint8_t wantedPt = sizes.empty() ? 0 : sizes[idx]; + 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]" : ""); } if (!currentFamily.empty()) { @@ -92,7 +89,7 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { const auto* family = registry_.findFamily(wantedFamily); if (family) { - if (manager_.loadFamily(*family, renderer, targetPt)) { + if (manager_.loadFamily(*family, renderer, sizeEnum)) { LOG_DBG("SDFS", "Loaded SD font family: %s", wantedFamily); } else { LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily); diff --git a/src/SettingsList.h b/src/SettingsList.h index 9af7101e..d8a118c3 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -145,6 +145,8 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist "paragraphAlignment", StrId::STR_CAT_READER), SettingInfo::Toggle(StrId::STR_EMBEDDED_STYLE, &CrossPointSettings::embeddedStyle, "embeddedStyle", StrId::STR_CAT_READER), + SettingInfo::Toggle(StrId::STR_FOCUS_READING, &CrossPointSettings::focusReadingEnabled, "focusReadingEnabled", + StrId::STR_CAT_READER), SettingInfo::Toggle(StrId::STR_HYPHENATION, &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled", StrId::STR_CAT_READER), SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation, diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 3de6d69d..7732762b 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -609,7 +609,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering)) { + SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) { LOG_DBG("ERS", "Cache not found, building..."); GUI.drawPopup(renderer, tr(STR_INDEXING)); @@ -619,7 +619,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering, popupFn)) { + SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn)) { LOG_ERR("ERS", "Failed to persist page data to SD"); section.reset(); showPendingSyncSaveError(); @@ -750,7 +750,7 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering)) { + SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) { return; } @@ -758,7 +758,7 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering)) { + SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) { LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex); } } diff --git a/src/activities/settings/FontDownloadActivity.cpp b/src/activities/settings/FontDownloadActivity.cpp index ce18ce67..0acba062 100644 --- a/src/activities/settings/FontDownloadActivity.cpp +++ b/src/activities/settings/FontDownloadActivity.cpp @@ -11,6 +11,7 @@ #include "MappedInputManager.h" #include "SdCardFontGlobals.h" #include "activities/network/WifiSelectionActivity.h" +#include "activities/util/ConfirmationActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "network/HttpDownloader.h" @@ -106,6 +107,7 @@ bool FontDownloadActivity::fetchAndParseManifest() { baseUrl_ = doc["baseUrl"] | ""; families_.clear(); + fontInstaller_.refreshRegistry(); JsonArray familiesArr = doc["families"].as(); families_.reserve(familiesArr.size()); @@ -171,7 +173,7 @@ bool FontDownloadActivity::fetchAndParseManifest() { void FontDownloadActivity::downloadAll() { for (size_t i = 0; i < families_.size(); i++) { - if (families_[i].installed && !families_[i].hasUpdate) continue; + if (families_[i].installed) continue; downloadFamily(families_[i]); if (state_ == ERROR) return; } @@ -182,10 +184,59 @@ void FontDownloadActivity::downloadAll() { } } -size_t FontDownloadActivity::totalUninstalledSize() const { +void FontDownloadActivity::updateAll() { + for (size_t i = 0; i < families_.size(); i++) { + if (!families_[i].hasUpdate) continue; + downloadFamily(families_[i]); + if (state_ == ERROR) return; + } + + { + RenderLock lock(*this); + state_ = COMPLETE; + } +} + +bool FontDownloadActivity::showDownloadAllRow() const { + for (const auto& f : families_) { + if (!f.installed) return true; + } + return false; +} + +bool FontDownloadActivity::showUpdateAllRow() const { + for (const auto& f : families_) { + if (f.hasUpdate) return true; + } + return false; +} + +int FontDownloadActivity::specialRowCount() const { + return (showDownloadAllRow() ? 1 : 0) + (showUpdateAllRow() ? 1 : 0); +} + +bool FontDownloadActivity::isDownloadAllRow(int index) const { return showDownloadAllRow() && index == 0; } + +bool FontDownloadActivity::isUpdateAllRow(int index) const { + return showUpdateAllRow() && index == (showDownloadAllRow() ? 1 : 0); +} + +int FontDownloadActivity::listItemCount() const { + return families_.empty() ? 0 : static_cast(families_.size()) + specialRowCount(); +} + +size_t FontDownloadActivity::totalDownloadSize() const { size_t total = 0; for (const auto& f : families_) { - if (!f.installed || f.hasUpdate) total += f.totalSize; + if (!f.installed) total += f.totalSize; + } + return total; +} + +size_t FontDownloadActivity::totalUpdateSize() const { + size_t total = 0; + for (const auto& f : families_) { + if (f.hasUpdate) total += f.totalSize; } return total; } @@ -297,6 +348,7 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { fontInstaller_.refreshRegistry(); family.installed = true; + family.hasUpdate = false; { RenderLock lock(*this); @@ -304,6 +356,47 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { } } +void FontDownloadActivity::promptDeleteSelectedFamily() { + const int pendingDeleteFamilyIndex = familyIndexFromList(selectedIndex_); + if (pendingDeleteFamilyIndex < 0 || pendingDeleteFamilyIndex >= static_cast(families_.size())) { + return; + } + + std::string heading = tr(STR_DELETE); + const auto& family = families_[pendingDeleteFamilyIndex]; + std::string body = family.name; + startActivityForResult(std::make_unique(renderer, mappedInput, heading, body), + [this](const ActivityResult& result) { onDeleteConfirmationResult(result); }); +} + +void FontDownloadActivity::onDeleteConfirmationResult(const ActivityResult& result) { + if (result.isCancelled) { + requestUpdate(); + return; + } + + auto& family = families_[familyIndexFromList(selectedIndex_)]; + + if (fontInstaller_.deleteFamily(family.name.c_str()) != FontInstaller::Error::OK) { + RenderLock lock(*this); + state_ = ERROR; + errorMessage_ = "Failed to delete font"; + } else { + fontInstaller_.refreshRegistry(); + family.installed = false; + family.hasUpdate = false; + } + + requestUpdate(); +} + +bool FontDownloadActivity::isSelectedFamilyDeletable() const { + if (isDownloadAllRow(selectedIndex_) || isUpdateAllRow(selectedIndex_)) return false; + if (selectedIndex_ < specialRowCount() || selectedIndex_ >= listItemCount()) return false; + const auto& family = families_[familyIndexFromList(selectedIndex_)]; + return family.installed && !family.hasUpdate; +} + // --- Input handling --- void FontDownloadActivity::loop() { @@ -338,12 +431,17 @@ void FontDownloadActivity::loop() { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (!families_.empty()) { - if (isDownloadAllSelected()) { + if (isDownloadAllRow(selectedIndex_)) { downloadAll(); + } else if (isUpdateAllRow(selectedIndex_)) { + updateAll(); } else { - const auto& family = families_[familyIndexFromList(selectedIndex_)]; + auto& family = families_[familyIndexFromList(selectedIndex_)]; if (!family.installed || family.hasUpdate) { - downloadFamily(families_[familyIndexFromList(selectedIndex_)]); + downloadFamily(family); + } else { + promptDeleteSelectedFamily(); + return; } } requestUpdateAndWait(); @@ -403,7 +501,7 @@ void FontDownloadActivity::render(RenderLock&&) { renderer.clearScreen(); - GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_DOWNLOAD)); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_BROWSER)); const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID); const auto contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; @@ -422,18 +520,21 @@ void FontDownloadActivity::render(RenderLock&&) { Rect{0, contentTop, pageWidth, pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing}, listItemCount(), selectedIndex_, [this](int index) -> std::string { - if (index == 0) { - return std::string(tr(STR_DOWNLOAD_ALL)) + " (" + formatSize(totalUninstalledSize()) + ")"; + if (isDownloadAllRow(index)) { + return std::string(tr(STR_DOWNLOAD_ALL)) + " (" + formatSize(totalDownloadSize()) + ")"; + } + if (isUpdateAllRow(index)) { + return std::string(tr(STR_UPDATE_ALL)) + " (" + formatSize(totalUpdateSize()) + ")"; } return families_[familyIndexFromList(index)].name; }, [this](int index) -> std::string { - if (index == 0) return ""; + if (isDownloadAllRow(index) || isUpdateAllRow(index)) return ""; return families_[familyIndexFromList(index)].description; }, nullptr, [this](int index) -> std::string { - if (index == 0) return ""; + if (isDownloadAllRow(index) || isUpdateAllRow(index)) return ""; const auto& f = families_[familyIndexFromList(index)]; if (f.hasUpdate) return tr(STR_UPDATE_AVAILABLE); if (f.installed) return tr(STR_INSTALLED); @@ -441,12 +542,16 @@ void FontDownloadActivity::render(RenderLock&&) { }, true, [this](int index) -> bool { - if (index == 0) return false; + if (isDownloadAllRow(index) || isUpdateAllRow(index)) return false; const auto& f = families_[familyIndexFromList(index)]; return f.installed && !f.hasUpdate; }); - const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DOWNLOAD), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), + isSelectedFamilyDeletable() ? tr(STR_DELETE) + : isUpdateAllRow(selectedIndex_) ? tr(STR_UPDATE) + : tr(STR_DOWNLOAD), + tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } } else if (state_ == DOWNLOADING) { @@ -466,10 +571,6 @@ void FontDownloadActivity::render(RenderLock&&) { renderer, Rect{metrics.contentSidePadding, barY, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight}, static_cast(progress * 100), 100); - - int percentY = barY + metrics.progressBarHeight + metrics.verticalSpacing; - renderer.drawCenteredText(UI_10_FONT_ID, percentY, - (std::to_string(static_cast(progress * 100)) + "%").c_str()); } else if (state_ == COMPLETE) { renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_FONT_INSTALLED), true, EpdFontFamily::BOLD); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); diff --git a/src/activities/settings/FontDownloadActivity.h b/src/activities/settings/FontDownloadActivity.h index 16d52fa9..7f76e7c6 100644 --- a/src/activities/settings/FontDownloadActivity.h +++ b/src/activities/settings/FontDownloadActivity.h @@ -84,10 +84,19 @@ class FontDownloadActivity : public Activity { bool fetchAndParseManifest(); void downloadFamily(ManifestFamily& family); void downloadAll(); + void updateAll(); static bool computeFileCrc32(const char* path, uint32_t& outCrc); - bool isDownloadAllSelected() const { return selectedIndex_ == 0 && !families_.empty(); } - int familyIndexFromList(int listIndex) const { return listIndex - 1; } - int listItemCount() const { return families_.empty() ? 0 : static_cast(families_.size()) + 1; } - size_t totalUninstalledSize() const; + bool showDownloadAllRow() const; + bool showUpdateAllRow() const; + int specialRowCount() const; + bool isDownloadAllRow(int index) const; + bool isUpdateAllRow(int index) const; + bool isSelectedFamilyDeletable() const; + void promptDeleteSelectedFamily(); + void onDeleteConfirmationResult(const ActivityResult& result); + int familyIndexFromList(int listIndex) const { return listIndex - specialRowCount(); } + int listItemCount() const; + size_t totalDownloadSize() const; + size_t totalUpdateSize() const; static std::string formatSize(size_t bytes); }; diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index 7bf1a444..db39830e 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -116,8 +116,8 @@ void OtaUpdateActivity::render(RenderLock&&) { static_cast(updaterProgress * 100), 100); y += metrics.progressBarHeight + metrics.verticalSpacing; - renderer.drawCenteredText(UI_10_FONT_ID, y, - (std::to_string(static_cast(updaterProgress * 100)) + "%").c_str()); + // Percent label is drawn by BaseTheme::drawProgressBar; this slot is left intentionally empty + // so the bytes line below stays at the same Y it was at when the activity drew its own percent. y += height + metrics.verticalSpacing; renderer.drawCenteredText( UI_10_FONT_ID, y, diff --git a/src/activities/settings/SdFirmwareUpdateActivity.cpp b/src/activities/settings/SdFirmwareUpdateActivity.cpp index 7e73a7f9..b0ca1f63 100644 --- a/src/activities/settings/SdFirmwareUpdateActivity.cpp +++ b/src/activities/settings/SdFirmwareUpdateActivity.cpp @@ -230,7 +230,8 @@ void SdFirmwareUpdateActivity::render(RenderLock&&) { Rect{metrics.contentSidePadding, y, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight}, static_cast(pct), 100); y += metrics.progressBarHeight + metrics.verticalSpacing; - renderer.drawCenteredText(UI_10_FONT_ID, y, (std::to_string(pct) + "%").c_str()); + // Percent label is drawn by BaseTheme::drawProgressBar; this slot is left intentionally empty + // so the do-not-power-off line below stays at the same Y as before. y += lineHeight + metrics.verticalSpacing; renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF)); } else if (state == State::SUCCESS) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 9239ff91..adf99acd 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -57,9 +57,9 @@ void SettingsActivity::rebuildSettingsLists() { systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates)); systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate)); systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language)); - // Insert "Download Fonts" right after the font family setting so users discover it naturally + // Insert "Manage Fonts" right after the font family setting so users discover it naturally readerSettings.insert(readerSettings.begin() + 1, - SettingInfo::Action(StrId::STR_DOWNLOAD_FONTS, SettingAction::DownloadFonts)); + SettingInfo::Action(StrId::STR_MANAGE_FONTS, SettingAction::DownloadFonts)); readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar)); // Update currentSettings pointer and count for the active category diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index 4a29086b..0387bc55 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -97,7 +97,12 @@ void BmpViewerActivity::onEnter() { } // 4. Prepare Rendering - const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), "", ""); + bool hasPrevious = (siblingImages.size() > 1 && currentImageIndex > 0); + bool hasNext = (siblingImages.size() > 1 && currentImageIndex != -1 && + currentImageIndex < static_cast(siblingImages.size()) - 1); + + const auto labels = + mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), (hasPrevious ? "<" : ""), (hasNext ? ">" : "")); GUI.fillPopupProgress(renderer, popupRect, 50); @@ -185,7 +190,8 @@ void BmpViewerActivity::loop() { return; } - if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + if (mappedInput.wasReleased(MappedInputManager::Button::Left) || + mappedInput.wasReleased(MappedInputManager::Button::Up)) { if (siblingImages.size() > 1 && currentImageIndex > 0) { currentImageIndex--; std::string dirPath = FsHelpers::extractFolderPath(filePath); @@ -196,7 +202,8 @@ void BmpViewerActivity::loop() { return; } - if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + if (mappedInput.wasReleased(MappedInputManager::Button::Right) || + mappedInput.wasReleased(MappedInputManager::Button::Down)) { if (siblingImages.size() > 1 && currentImageIndex != -1 && currentImageIndex < static_cast(siblingImages.size()) - 1) { currentImageIndex++; diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.cpp b/src/components/themes/roundedraff/RoundedRaffTheme.cpp index 3e0ede09..3d5ea90c 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.cpp +++ b/src/components/themes/roundedraff/RoundedRaffTheme.cpp @@ -5,7 +5,6 @@ #include #include -#include #include #include @@ -44,18 +43,6 @@ void drawScrollBar(const GfxRenderer& renderer, Rect rect, int itemCount, int pa renderer.fillRect(barX, thumbY, barW, thumbH); } -std::string sanitizeButtonLabel(std::string label) { - // Remove common directional prefixes/symbols (e.g. "<< Home", unsupported icon glyphs). - while (!label.empty() && !std::isalnum(static_cast(label[0]))) { - label.erase(0, 1); - } - // Trim any extra left spaces. - while (!label.empty() && label[0] == ' ') { - label.erase(0, 1); - } - return label; -} - } // namespace int coverWidth = 0; @@ -412,11 +399,11 @@ void RoundedRaffTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const bool backDisabled = (btn1 == nullptr || btn1[0] == '\0'); const int leftGroupX = sidePadding; const int rightGroupX = leftGroupX + groupWidth + groupGap; - const std::string backLabel = backDisabled ? "" : sanitizeButtonLabel(std::string(btn1)); + const std::string backLabel = backDisabled ? "" : std::string(btn1); // Callers should provide the button labels. If a label is not specified, it should render empty. - const std::string selectText = (btn2 && btn2[0] != '\0') ? sanitizeButtonLabel(std::string(btn2)) : ""; - const std::string upText = (btn3 && btn3[0] != '\0') ? sanitizeButtonLabel(std::string(btn3)) : ""; - const std::string downText = (btn4 && btn4[0] != '\0') ? sanitizeButtonLabel(std::string(btn4)) : ""; + const std::string selectText = (btn2 && btn2[0] != '\0') ? std::string(btn2) : ""; + const std::string upText = (btn3 && btn3[0] != '\0') ? std::string(btn3) : ""; + const std::string downText = (btn4 && btn4[0] != '\0') ? std::string(btn4) : ""; // Ensure button hints always "win" visually even if other elements accidentally render into this area. renderer.fillRect(leftGroupX, hintY, groupWidth, hintHeight, false); diff --git a/test/hyphenation_eval/resources/generate_hyphenation_test_data.py b/test/hyphenation_eval/resources/generate_hyphenation_test_data.py index ec38ee67..288336e1 100644 --- a/test/hyphenation_eval/resources/generate_hyphenation_test_data.py +++ b/test/hyphenation_eval/resources/generate_hyphenation_test_data.py @@ -16,7 +16,6 @@ Requirements: import argparse import re from collections import Counter -import pyphen from pathlib import Path import zipfile @@ -75,6 +74,8 @@ def generate_hyphenation_data( min_prefix: Minimum characters allowed before the first hyphen (default: 2) min_suffix: Minimum characters allowed after the last hyphen (default: 2) """ + import pyphen + print(f"Reading from: {input_file}") # Read the input file