From 78625afe7612abee1d8adbdb87fc36c5f3b0621e Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Mon, 4 May 2026 22:11:40 -0500 Subject: [PATCH 01/21] chore: Added RAM to firmware_size_history.py script (#1830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Added RAM delta output to firmware_size_history.py output, e.g.: ``` % scripts/firmware_size_history.py --commits adcd796 8e18472 [info] Will restore to 'sd-card-fonts' when finished. [info] Stashing uncommitted changes... [info] Building 2 commits... [1/2] adcd7961c9 feat: self-heal from transient WiFi loss, add dBm indicator during WebServerActivity (#1780) Building (env: default)... Flash: 5,751,209, RAM: 97,996 bytes [2/2] 8e184724d1 Merge branch 'master' into sd-card-fonts Building (env: default)... Flash: 5,762,395, RAM: 97,988 bytes [info] Restoring 'sd-card-fonts'... [info] Restoring stashed changes... Commit Flash Delta RAM Delta Title ────────── ─────────── ─────── ─────────── ─────── ──────────────────────────────────────── adcd7961c9 5,751,209 97,996 feat: self-heal from transient WiFi loss, add dBm indicator during WebServerActivity (#1780) 8e184724d1 5,762,395 +11,186 97,988 -8 Merge branch 'master' into sd-card-fonts ``` --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**YES**_ --- scripts/firmware_size_history.py | 87 +++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 30 deletions(-) diff --git a/scripts/firmware_size_history.py b/scripts/firmware_size_history.py index c910d6ac..388e1b3f 100755 --- a/scripts/firmware_size_history.py +++ b/scripts/firmware_size_history.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Build firmware at selected commits and report flash usage. +Build firmware at selected commits and report flash and RAM usage. Two modes (mutually exclusive, one required): @@ -33,6 +33,9 @@ import re import subprocess import sys +RAM_RE = re.compile( + r"RAM:.*?(\d+)\s+bytes\s+from\s+(\d+)\s+bytes" +) FLASH_RE = re.compile( r"Flash:.*?(\d+)\s+bytes\s+from\s+(\d+)\s+bytes" ) @@ -93,9 +96,9 @@ def build_firmware(env): return result.returncode, result.stdout + "\n" + result.stderr -def parse_flash_used(output): - """Extract used-bytes integer from PlatformIO output, or None.""" - m = FLASH_RE.search(output) +def parse_size_line(regex, output): + """Extract used-bytes integer matching *regex* from PlatformIO output, or None.""" + m = regex.search(output) if m: return int(m.group(1)) return None @@ -111,10 +114,10 @@ def write_csv(out, rows, fieldnames): def format_table(rows): """Print rows as an aligned human-readable table to stdout.""" COL_COMMIT = 10 - COL_FLASH = 11 + COL_SIZE = 11 COL_DELTA = 7 - def fmt_flash(val): + def fmt_size(val): if val == "FAILED": return "FAILED" return f"{val:,}" @@ -126,25 +129,33 @@ def format_table(rows): header = ( f"{'Commit':<{COL_COMMIT}} " - f"{'Flash':>{COL_FLASH}} " + f"{'Flash':>{COL_SIZE}} " + f"{'Delta':>{COL_DELTA}} " + f"{'RAM':>{COL_SIZE}} " f"{'Delta':>{COL_DELTA}} " f"Title" ) sep = ( f"{BOX_CHAR * COL_COMMIT} " - f"{BOX_CHAR * COL_FLASH} " + f"{BOX_CHAR * COL_SIZE} " + f"{BOX_CHAR * COL_DELTA} " + f"{BOX_CHAR * COL_SIZE} " f"{BOX_CHAR * COL_DELTA} " f"{BOX_CHAR * 40}" ) print(header) print(sep) for row in rows: - flash_str = fmt_flash(row["flash_bytes"]) - delta_str = fmt_delta(row["delta"]) + flash_str = fmt_size(row["flash_bytes"]) + flash_d = fmt_delta(row["flash_delta"]) + ram_str = fmt_size(row["ram_bytes"]) + ram_d = fmt_delta(row["ram_delta"]) print( f"{row['commit']:<{COL_COMMIT}} " - f"{flash_str:>{COL_FLASH}} " - f"{delta_str:>{COL_DELTA}} " + f"{flash_str:>{COL_SIZE}} " + f"{flash_d:>{COL_DELTA}} " + f"{ram_str:>{COL_SIZE}} " + f"{ram_d:>{COL_DELTA}} " f"{row['title']}" ) @@ -173,7 +184,7 @@ def build_commits_from_list(refs): def main(): parser = argparse.ArgumentParser( - description="Measure firmware flash size across git commits.", + description="Measure firmware flash and RAM size across git commits.", epilog=( "Range mode walks every commit between START and END (one branch). " "List mode builds specific refs that may come from different branches." @@ -233,19 +244,22 @@ def main(): print(f" Building (env: {args.env})...", file=sys.stderr) rc, output = build_firmware(args.env) - if rc != 0: + build_failed = rc != 0 + if build_failed: print(f" BUILD FAILED (exit {rc}) -- skipping", file=sys.stderr) - results.append((sha, title, None)) + results.append((sha, title, None, None, True)) continue - used = parse_flash_used(output) - if used is None: + flash_used = parse_size_line(FLASH_RE, output) + ram_used = parse_size_line(RAM_RE, output) + if flash_used is None: print(" Could not parse flash size from output -- skipping", file=sys.stderr) - results.append((sha, title, None)) + results.append((sha, title, None, None, True)) continue - print(f" Flash used: {used:,} bytes", file=sys.stderr) - results.append((sha, title, used)) + ram_str = f", RAM: {ram_used:,}" if ram_used is not None else "" + print(f" Flash: {flash_used:,}{ram_str} bytes", file=sys.stderr) + results.append((sha, title, flash_used, ram_used, False)) except KeyboardInterrupt: print("\n[info] Interrupted -- writing partial results.", file=sys.stderr) @@ -258,22 +272,35 @@ def main(): # Build result rows with deltas rows = [] - prev_size = None - for sha, title, used in results: - if used is not None and prev_size is not None: - delta = used - prev_size + prev_flash = None + prev_ram = None + for sha, title, flash_used, ram_used, build_failed in results: + flash_delta = "" + ram_delta = "" + if flash_used is not None and prev_flash is not None: + flash_delta = flash_used - prev_flash + if ram_used is not None and prev_ram is not None: + ram_delta = ram_used - prev_ram + if build_failed: + flash_bytes = "FAILED" + ram_bytes = "FAILED" else: - delta = "" + flash_bytes = flash_used if flash_used is not None else "N/A" + ram_bytes = ram_used if ram_used is not None else "N/A" rows.append({ "commit": sha[:10], "title": title, - "flash_bytes": used if used is not None else "FAILED", - "delta": delta, + "flash_bytes": flash_bytes, + "flash_delta": flash_delta, + "ram_bytes": ram_bytes, + "ram_delta": ram_delta, }) - if used is not None: - prev_size = used + if flash_used is not None: + prev_flash = flash_used + if ram_used is not None: + prev_ram = ram_used - fieldnames = ["commit", "title", "flash_bytes", "delta"] + fieldnames = ["commit", "title", "flash_bytes", "flash_delta", "ram_bytes", "ram_delta"] if args.csv is not None: if args.csv == "-": From 40af42683edf7b304952fb6f64912dd35240a1bf Mon Sep 17 00:00:00 2001 From: KymAndriy <32357131+KymAndriy@users.noreply.github.com> Date: Tue, 5 May 2026 12:13:59 +0300 Subject: [PATCH 02/21] refactor: change ukrainian translation to adaptation and add missing lines (#1828) Co-authored-by: KymAndriy --- docs/translators.md | 1 + lib/I18n/translations/ukrainian.yaml | 55 +++++++++++++++++++++------- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/docs/translators.md b/docs/translators.md index 50a37031..250ba596 100644 --- a/docs/translators.md +++ b/docs/translators.md @@ -53,6 +53,7 @@ If you'd like to add your name to this list, please open a PR adding yourself an ## Ukrainian - [mirus-ua](https://github.com/mirus-ua) +- [KymAndriy](https://github.com/KymAndriy) ## Belarusian - [Dexif](https://github.com/dexif) diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 845a0aad..c556aef3 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -26,7 +26,7 @@ STR_LOADING: "Завантаження..." STR_LOADING_POPUP: "Завантаження" STR_WIFI_NETWORKS: "Мережі WiFi" STR_NO_NETWORKS: "Мереж не знайдено" -STR_NETWORKS_FOUND: "мереж %zu " +STR_NETWORKS_FOUND: "мереж %zu" STR_SCANNING: "Сканування..." STR_CONNECTING: "Підключення..." STR_CONNECTED: "Підключено!" @@ -84,27 +84,27 @@ STR_SCREEN_MARGIN: "Відступи від країв" STR_PARA_ALIGNMENT: "Вирівнювання тексту" STR_HYPHENATION: "Перенесення слів" STR_TIME_TO_SLEEP: "Перехід в режим сну" +STR_SHOW_HIDDEN_FILES: "Показати приховані файли" STR_REFRESH_FREQ: "Частота оновлення екрану" STR_KOREADER_SYNC: "Синхронізація KOReader" STR_CHECK_UPDATES: "Перевірити оновлення" STR_LANGUAGE: "Мова" -STR_SHOW_HIDDEN_FILES: "Показати приховані файли" STR_CLEAR_READING_CACHE: "Очистити кеш книг" STR_USERNAME: "Ім'я користувача" STR_PASSWORD: "Пароль" -STR_SYNC_SERVER_URL: "URL сервера синхронізації" -STR_DOCUMENT_MATCHING: "Зіставлення документів" +STR_SYNC_SERVER_URL: "URL для синхронізації" +STR_DOCUMENT_MATCHING: "Порівняння документів" STR_AUTHENTICATE: "Автентифікувати" STR_KOREADER_USERNAME: "Ім'я користувача KOReader" STR_KOREADER_PASSWORD: "Пароль KOReader" STR_FILENAME: "Ім'я файлу" -STR_BINARY: "Двійковий" -STR_SET_CREDENTIALS_FIRST: "Спочатку встановіть облікові дані" +STR_BINARY: "Побайтово" +STR_SET_CREDENTIALS_FIRST: "Спочатку вкажіть облікові дані" STR_WIFI_CONN_FAILED: "Помилка підключення WiFi" STR_AUTHENTICATING: "Автентифікація..." STR_AUTH_SUCCESS: "Успішно автентифіковано!" STR_KOREADER_AUTH: "Автентифікація KOReader" -STR_SYNC_READY: "Синхронізація KOReader готова до використання" +STR_SYNC_READY: "Синхронізація KOReader активована" STR_AUTH_FAILED: "Помилка автентифікації" STR_DONE: "Готово" STR_CLEAR_CACHE_WARNING_1: "Це очистить усі кешовані дані книг." @@ -179,6 +179,8 @@ STR_UNNAMED: "Без назви" STR_NO_SERVER_URL: "URL сервера не налаштовано" STR_FETCH_FEED_FAILED: "Не вдалося отримати стрічку" STR_PARSE_FEED_FAILED: "Не вдалося розпарсити стрічку" +STR_NEXT_PAGE: "Наступна с.»" +STR_PREV_PAGE: "« Попередня с." STR_NETWORK_PREFIX: "Мережа: " STR_IP_ADDRESS_PREFIX: "IP адреса: " STR_ERROR_GENERAL_FAILURE: "Помилка: Загальна помилка" @@ -198,14 +200,14 @@ STR_OPEN: "Відкрити" STR_DOWNLOAD: "Завант." STR_RETRY: "Повтор." STR_YES: "Так" +STR_NO: "Ні" STR_SHOW: "Показати" STR_HIDE: "Сховати" -STR_NO: "Ні" STR_STATE_ON: "УВІМК" STR_STATE_OFF: "ВИМК" STR_NOT_SET: "Не встановлено" -STR_DIR_LEFT: "Ліво" -STR_DIR_RIGHT: "Право" +STR_DIR_LEFT: "Вліво" +STR_DIR_RIGHT: "Вправо" STR_DIR_UP: "Вгору" STR_DIR_DOWN: "Вниз" STR_OK_BUTTON: "OK" @@ -229,10 +231,12 @@ STR_BATTERY: "Акумулятор" STR_UI_THEME: "Тема інтерфейсу" STR_THEME_CLASSIC: "Класична" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Виправлення вицвітання на сонці" STR_REMAP_FRONT_BUTTONS: "Налаштувати передні кнопки" STR_OPDS_BROWSER: "Браузер OPDS" +STR_SEARCH: "Пошук" STR_COVER_CUSTOM: "Обкл. + власне" STR_MENU_RECENT_BOOKS: "Останні книги" STR_NO_RECENT_BOOKS: "Немає останніх книг" @@ -251,8 +255,8 @@ STR_REMAP_RESET_HINT: "Бічна кнопка Вгору: Скинути до STR_REMAP_CANCEL_HINT: "Бічна кнопка Вниз: Скасувати налаштування" STR_HW_BACK_LABEL: "Назад (1-ша кнопка)" STR_HW_CONFIRM_LABEL: "Підтвердити (2-га кнопка)" -STR_HW_LEFT_LABEL: "Ліво (3-тя кнопка)" -STR_HW_RIGHT_LABEL: "Право (4-та кнопка)" +STR_HW_LEFT_LABEL: "Вліво (3-тя кнопка)" +STR_HW_RIGHT_LABEL: "Вправо (4-та кнопка)" STR_GO_TO_PERCENT: "Перейти до %" STR_GO_HOME_BUTTON: "На головну" STR_SYNC_PROGRESS: "Прогрес синхронізації" @@ -263,7 +267,7 @@ STR_CHAPTER_PREFIX: "Розділ: " STR_PAGES_SEPARATOR: " сторінок | " STR_BOOK_PREFIX: "Книга: " STR_CALIBRE_URL_HINT: "Для Calibre додайте /opds до вашої URL" -STR_PERCENT_STEP_HINT: "Ліво/Право: 1% Вгору/Вниз: 10%" +STR_PERCENT_STEP_HINT: "Вліво/Вправо: 1% Вгору/Вниз: 10%" STR_SYNCING_TIME: "Синхронізація часу..." STR_CALC_HASH: "Обчислення хешу документа..." STR_HASH_FAILED: "Не вдалося обчислити хеш документа" @@ -292,6 +296,31 @@ STR_FOOTNOTES: "Примітки" STR_NO_FOOTNOTES: "На цій сторінці немає приміток" STR_LINK: "[посилання]" STR_SCREENSHOT_BUTTON: "Знімок екрана" +STR_ADD_SERVER: "Додати сервер" +STR_SERVER_NAME: "Назва сервера" +STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS" +STR_DELETE_SERVER: "Видалити сервер" +STR_DELETE_CONFIRM: "Видалити цей сервер?" +STR_OPDS_SERVERS: "Сервери OPDS" STR_AUTO_TURN_ENABLED: "Автоперегортання увімк: " STR_AUTO_TURN_PAGES_PER_MIN: "Автоперегортання (ст/хв)" +STR_CRASH_TITLE: "Збій Системи" +STR_CRASH_DESCRIPTION: "Дані про збій збережено в crash_report.txt. Додайте цей файл до вашого звіту про помилку." +STR_CRASH_REASON: "Причина збою:" +STR_CRASH_NO_REASON: "(Причину не вказано)" STR_TILT_PAGE_TURN: "Перегортання нахилом" +STR_KB_HINT_MOVE_CURSOR: "Натисніть ВЛІВО / ВПРАВО для переміщення курсору" +STR_KB_HINT_RETURN_CURSOR: "Натисніть ВЛІВО для повернення до курсору" +STR_KB_HINT_HIDE_PASSWORD: "Затисніть ВПРАВО, натисніть [***], щоб приховати пароль" +STR_KB_HINT_SHOW_PASSWORD: "Затисніть ВПРАВО, натисніть [abc], щоб показати пароль" +STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Натисніть [***], щоб приховати пароль" +STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Натисніть [abc], щоб показати пароль" +STR_KB_HINT_EDIT_ENTRY: "Затисніть ВГОРУ для редагування" +STR_KB_TIPS: "Поради:" +STR_KB_HINT_RETURN_KEYBOARD: "Натисніть ВНИЗ, щоб повернутися до клавіатури" +STR_KB_HINT_EXIT_URL_MODE: "Натисніть [ABC] для виходу з режиму URL" +STR_KB_HINT_CLEAR_TEXT: "Затисніть <--, щоб видалити весь текст" +STR_KB_HINT_SECONDARY_CHAR: "Затисніть ВИБРАТИ для додаткових символів" +STR_KB_HINT_UPPER_SECONDARY: "Затисніть ВИБРАТИ для ВЕЛИКИХ літер / символів" +STR_KB_HINT_LOWER_SECONDARY: "Затисніть ВИБРАТИ для малих літер / символів" +STR_KB_HINT_URL_SNIPPETS: "Натисніть URL для вибору шаблонів" From f44722a0f2de5cf87c16b223f84cc4a1cfcf2cd9 Mon Sep 17 00:00:00 2001 From: Stefan Blixten Karlsson Date: Tue, 5 May 2026 11:14:48 +0200 Subject: [PATCH 03/21] fix: swedish translation (#1829) --- lib/I18n/translations/swedish.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index fb934a28..73444fad 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -171,6 +171,7 @@ STR_NO_UPDATE: "Ingen uppdatering tillgänglig" STR_UPDATE_FAILED: "Uppdatering misslyckades" STR_UPDATE_COMPLETE: "Uppdatering färdig" STR_POWER_ON_HINT: "Tryck och håll strömknappen för att sätta på igen" +STR_RESTARTING_HINT: "Startar om... Om enheten inte startar om, håll ner strömknappen i några sekunder." STR_NO_ENTRIES: "Inga poster funna" STR_DOWNLOADING: "Laddar ner…" STR_DOWNLOAD_FAILED: "Nedladdning misslyckades" @@ -324,3 +325,16 @@ STR_KB_HINT_SECONDARY_CHAR: "Håll VÄLJ för sekundärt tecken" STR_KB_HINT_UPPER_SECONDARY: "Håll VÄLJ för VERSALER eller sekundärt tecken" STR_KB_HINT_LOWER_SECONDARY: "Håll VÄLJ för gemener eller sekundärt tecken" STR_KB_HINT_URL_SNIPPETS: "Tryck på URL för URL-fragment" +STR_SD_FIRMWARE_UPDATE: "Uppdatering av firmware från SD-kort" +STR_SELECT_FIRMWARE_FILE: "Välj firmwarefil (.bin)" +STR_NO_BIN_FILES: "Inga .bin-filer hittades" +STR_VALIDATING_FIRMWARE: "Validerar firmware..." +STR_INVALID_FIRMWARE: "Ogiltig firmwarefil" +STR_FIRMWARE_TOO_LARGE: "Firmware för stor för partitionen" +STR_FIRMWARE_TOO_SMALL: "Firmwarefilen är för liten" +STR_FIRMWARE_UPDATE_PROMPT: "Uppdatera firmware?" +STR_FIRMWARE_FILE_OPEN_FAILED: "Kan inte öppna filen" +STR_FIRMWARE_WRITE_FAILED: "Skrivning till firmware misslyckades" +STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Stäng inte av!" +STR_RECOVERY_MODE: "Återställningsläge" +STR_RECOVERY_MODE_HINT: "Placera firmware.bin i SD-kortroten och välj den" From efa4f71a68f6f1885e18a08194f668343ee9afc1 Mon Sep 17 00:00:00 2001 From: Eliz Date: Tue, 5 May 2026 10:23:20 +0100 Subject: [PATCH 04/21] feat: Set sleep cover from BMP viewer (#1104) --- lib/FsHelpers/FsHelpers.cpp | 53 +++++++++ lib/FsHelpers/FsHelpers.h | 3 + lib/I18n/translations/english.yaml | 1 + src/activities/boot_sleep/SleepActivity.cpp | 51 ++++++--- src/activities/home/FileBrowserActivity.cpp | 55 +--------- src/activities/util/BmpViewerActivity.cpp | 116 +++++++++++++++++++- src/activities/util/BmpViewerActivity.h | 5 + 7 files changed, 210 insertions(+), 74 deletions(-) diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp index 352e2c26..891190fd 100644 --- a/lib/FsHelpers/FsHelpers.cpp +++ b/lib/FsHelpers/FsHelpers.cpp @@ -1,5 +1,6 @@ #include "FsHelpers.h" +#include #include #include #include @@ -42,6 +43,58 @@ std::string normalisePath(const std::string& path) { return result; } +void sortFileList(std::vector& strs) { + std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { + // Directories first + bool isDir1 = str1.back() == '/'; + bool isDir2 = str2.back() == '/'; + if (isDir1 != isDir2) return isDir1; + + // Start naive natural sort + const char* s1 = str1.c_str(); + const char* s2 = str2.c_str(); + + // Iterate while both strings have characters + while (*s1 && *s2) { + // Check if both are at the start of a number + if (isdigit(*s1) && isdigit(*s2)) { + // Skip leading zeros and track them + const char* start1 = s1; + const char* start2 = s2; + while (*s1 == '0') s1++; + while (*s2 == '0') s2++; + + // Count digits to compare lengths first + int len1 = 0, len2 = 0; + while (isdigit(s1[len1])) len1++; + while (isdigit(s2[len2])) len2++; + + // Different length so return smaller integer value + if (len1 != len2) return len1 < len2; + + // Same length so compare digit by digit + for (int i = 0; i < len1; i++) { + if (s1[i] != s2[i]) return s1[i] < s2[i]; + } + + // Numbers equal so advance pointers + s1 += len1; + s2 += len2; + } else { + // Regular case-insensitive character comparison + char c1 = tolower(*s1); + char c2 = tolower(*s2); + if (c1 != c2) return c1 < c2; + s1++; + s2++; + } + } + + // One string is prefix of other + return *s1 == '\0' && *s2 != '\0'; + }); +} + bool checkFileExtension(std::string_view fileName, const char* extension) { const size_t extLen = strlen(extension); if (fileName.length() < extLen) { diff --git a/lib/FsHelpers/FsHelpers.h b/lib/FsHelpers/FsHelpers.h index b70a3dc8..56c2c987 100644 --- a/lib/FsHelpers/FsHelpers.h +++ b/lib/FsHelpers/FsHelpers.h @@ -3,11 +3,14 @@ #include #include +#include namespace FsHelpers { std::string normalisePath(const std::string& path); +void sortFileList(std::vector& strs); + /** * Check if the given filename ends with the specified extension (case-insensitive). */ diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index eb14a242..b6623899 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -293,6 +293,7 @@ STR_UPLOAD: "Upload" STR_BOOK_S_STYLE: "Book's Style" STR_EMBEDDED_STYLE: "Embedded Style" STR_OPDS_SERVER_URL: "OPDS Server URL" +STR_SET_SLEEP_COVER: "Set Cover" STR_FOOTNOTES: "Footnotes" STR_NO_FOOTNOTES: "No footnotes on this page" STR_LINK: "[link]" diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 06efdef7..19f443b0 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -49,6 +49,23 @@ void SleepActivity::renderCustomSleepScreen() const { // Check if we have a /.sleep (preferred) or /sleep directory const char* sleepDir = nullptr; auto dir = Storage.open("/.sleep"); + + // Look for sleep.bmp on the root of the sd card to determine if we should + // render a custom sleep screen instead of the default. + // This takes priority over the /sleep folder. + FsFile file; + if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) { + Bitmap bitmap(file, true); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + LOG_DBG("SLP", "Loading: /sleep.bmp"); + renderBitmapSleepScreen(bitmap); + file.close(); + if (dir) dir.close(); + return; + } + file.close(); + } + if (dir && dir.isDirectory()) { sleepDir = "/.sleep"; } else { @@ -62,26 +79,31 @@ void SleepActivity::renderCustomSleepScreen() const { std::vector files; char name[500]; // collect all valid BMP files - for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { - if (file.isDirectory()) { + for (auto dirFile = dir.openNextFile(); dirFile; dirFile = dir.openNextFile()) { + if (dirFile.isDirectory()) { + dirFile.close(); continue; } - file.getName(name, sizeof(name)); + dirFile.getName(name, sizeof(name)); auto filename = std::string(name); if (filename[0] == '.') { + dirFile.close(); continue; } if (!FsHelpers::hasBmpExtension(filename)) { LOG_DBG("SLP", "Skipping non-.bmp file name: %s", name); + dirFile.close(); continue; } - Bitmap bitmap(file); + Bitmap bitmap(dirFile); if (bitmap.parseHeaders() != BmpReaderError::Ok) { LOG_DBG("SLP", "Skipping invalid BMP file: %s", name); + dirFile.close(); continue; } files.emplace_back(filename); + dirFile.close(); } const auto numFiles = files.size(); if (numFiles > 0) { @@ -97,29 +119,22 @@ void SleepActivity::renderCustomSleepScreen() const { APP_STATE.pushRecentSleep(randomFileIndex); APP_STATE.saveToFile(); const auto filename = std::string(sleepDir) + "/" + files[randomFileIndex]; - FsFile file; - if (Storage.openFileForRead("SLP", filename, file)) { + FsFile randFile; + if (Storage.openFileForRead("SLP", filename, randFile)) { LOG_DBG("SLP", "Randomly loading: %s/%s", sleepDir, files[randomFileIndex].c_str()); delay(100); - Bitmap bitmap(file, true); + Bitmap bitmap(randFile, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { renderBitmapSleepScreen(bitmap); + randFile.close(); + dir.close(); return; } + randFile.close(); } } } - // Look for sleep.bmp on the root of the sd card to determine if we should - // render a custom sleep screen instead of the default. - FsFile file; - if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) { - Bitmap bitmap(file, true); - if (bitmap.parseHeaders() == BmpReaderError::Ok) { - LOG_DBG("SLP", "Loading: /sleep.bmp"); - renderBitmapSleepScreen(bitmap); - return; - } - } + if (dir) dir.close(); renderDefaultSleepScreen(); } diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index c54e320c..3ea626e2 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -18,58 +18,6 @@ namespace { constexpr unsigned long GO_HOME_MS = 1000; } // namespace -void sortFileList(std::vector& strs) { - std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { - // Directories first - bool isDir1 = str1.back() == '/'; - bool isDir2 = str2.back() == '/'; - if (isDir1 != isDir2) return isDir1; - - // Start naive natural sort - const char* s1 = str1.c_str(); - const char* s2 = str2.c_str(); - - // Iterate while both strings have characters - while (*s1 && *s2) { - // Check if both are at the start of a number - if (isdigit(*s1) && isdigit(*s2)) { - // Skip leading zeros and track them - const char* start1 = s1; - const char* start2 = s2; - while (*s1 == '0') s1++; - while (*s2 == '0') s2++; - - // Count digits to compare lengths first - int len1 = 0, len2 = 0; - while (isdigit(s1[len1])) len1++; - while (isdigit(s2[len2])) len2++; - - // Different length so return smaller integer value - if (len1 != len2) return len1 < len2; - - // Same length so compare digit by digit - for (int i = 0; i < len1; i++) { - if (s1[i] != s2[i]) return s1[i] < s2[i]; - } - - // Numbers equal so advance pointers - s1 += len1; - s2 += len2; - } else { - // Regular case-insensitive character comparison - char c1 = tolower(*s1); - char c2 = tolower(*s2); - if (c1 != c2) return c1 < c2; - s1++; - s2++; - } - } - - // One string is prefix of other - return *s1 == '\0' && *s2 != '\0'; - }); -} - void FileBrowserActivity::loadFiles() { files.clear(); @@ -103,7 +51,8 @@ void FileBrowserActivity::loadFiles() { } } } - sortFileList(files); + root.close(); + FsHelpers::sortFileList(files); } void FileBrowserActivity::onEnter() { diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index 37fa8fe1..4a29086b 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -1,19 +1,67 @@ #include "BmpViewerActivity.h" #include +#include #include #include #include +#include + +#include "CrossPointSettings.h" #include "components/UITheme.h" #include "fontIds.h" BmpViewerActivity::BmpViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path) : Activity("BmpViewer", renderer, mappedInput), filePath(std::move(path)) {} +void BmpViewerActivity::loadSiblingImages() { + siblingImages.clear(); + currentImageIndex = -1; + + if (filePath.empty()) return; + + std::string dirPath = FsHelpers::extractFolderPath(filePath); + size_t lastSlash = filePath.find_last_of('/'); + std::string fileName = (lastSlash != std::string::npos) ? filePath.substr(lastSlash + 1) : filePath; + + auto dir = Storage.open(dirPath.c_str()); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return; + } + + char name[500]; + for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { + if (!file.isDirectory()) { + file.getName(name, sizeof(name)); + if (name[0] != '.') { + std::string fname(name); + if (fname.length() >= 4 && fname.substr(fname.length() - 4) == ".bmp") { + siblingImages.push_back(fname); + } + } + } + file.close(); + } + dir.close(); + + FsHelpers::sortFileList(siblingImages); + + for (size_t i = 0; i < siblingImages.size(); ++i) { + if (siblingImages[i] == fileName) { + currentImageIndex = static_cast(i); + break; + } + } +} + void BmpViewerActivity::onEnter() { Activity::onEnter(); - // Removed the redundant initial renderer.clearScreen() + + if (siblingImages.empty() && !filePath.empty()) { + loadSiblingImages(); + } FsFile file; @@ -49,7 +97,8 @@ void BmpViewerActivity::onEnter() { } // 4. Prepare Rendering - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), "", ""); + GUI.fillPopupProgress(renderer, popupRect, 50); renderer.clearScreen(); @@ -61,7 +110,7 @@ void BmpViewerActivity::onEnter() { GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); // Single pass for non-grayscale images - renderer.displayBuffer(HalDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); } else { // Handle file parsing error @@ -89,6 +138,39 @@ void BmpViewerActivity::onExit() { renderer.displayBuffer(HalDisplay::HALF_REFRESH); } +void BmpViewerActivity::doSetSleepCover() { + GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + + bool success = false; + FsFile inFile, outFile; + if (Storage.openFileForRead("BMP", filePath, inFile)) { + if (Storage.openFileForWrite("BMP", "/sleep.bmp", outFile)) { + char buffer[2048]; + int bytesRead; + success = true; + while ((bytesRead = inFile.read(buffer, sizeof(buffer))) > 0) { + if (outFile.write(buffer, bytesRead) != bytesRead) { + success = false; + break; + } + } + outFile.close(); + } + inFile.close(); + } + + if (success) { + SETTINGS.sleepScreen = CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM; + SETTINGS.saveToFile(); + GUI.drawPopup(renderer, tr(STR_DONE)); + } else { + GUI.drawPopup(renderer, tr(STR_FAILED_LOWER)); + } + + delay(1000); + onEnter(); +} + void BmpViewerActivity::loop() { // Keep CPU awake/polling so 1st click works Activity::loop(); @@ -97,4 +179,32 @@ void BmpViewerActivity::loop() { activityManager.goToFileBrowser(filePath); return; } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + doSetSleepCover(); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + if (siblingImages.size() > 1 && currentImageIndex > 0) { + currentImageIndex--; + std::string dirPath = FsHelpers::extractFolderPath(filePath); + if (dirPath.back() != '/') dirPath += "/"; + filePath = dirPath + siblingImages[currentImageIndex]; + onEnter(); + } + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + if (siblingImages.size() > 1 && currentImageIndex != -1 && + currentImageIndex < static_cast(siblingImages.size()) - 1) { + currentImageIndex++; + std::string dirPath = FsHelpers::extractFolderPath(filePath); + if (dirPath.back() != '/') dirPath += "/"; + filePath = dirPath + siblingImages[currentImageIndex]; + onEnter(); + } + return; + } } \ No newline at end of file diff --git a/src/activities/util/BmpViewerActivity.h b/src/activities/util/BmpViewerActivity.h index feac448e..f805d375 100644 --- a/src/activities/util/BmpViewerActivity.h +++ b/src/activities/util/BmpViewerActivity.h @@ -15,5 +15,10 @@ class BmpViewerActivity final : public Activity { void loop() override; private: + void loadSiblingImages(); + void doSetSleepCover(); + std::string filePath; + std::vector siblingImages; + int currentImageIndex = -1; }; \ No newline at end of file From 3e7d63dab9cb3de0c10a9fccfc95b4a3b9bd820d Mon Sep 17 00:00:00 2001 From: Pietro Campagnano Date: Tue, 5 May 2026 15:48:31 +0200 Subject: [PATCH 05/21] style: align action buttons vertically with page title (#1795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * **What is the goal of this PR?** Fix the vertical misalignment between the "File Manager" title and the action buttons (Upload / New Folder / Delete Selected). * **What changes are included?** - `h2`: `margin-top: 0` → `margin: 0` — removes the default browser `margin-bottom` that was inflating the height of the header-left flex container, pushing the calculated flex center below the visual midpoint of the title text - `.page-header-left`: `align-items: baseline` → `align-items: center` — ensures the title and breadcrumbs are center-aligned with each other and with the buttons ### Before Screenshot From 2026-04-30 22-29-28 ### After Screenshot From 2026-04-30 22-48-49 ## Additional Context Small CSS-only change, no JavaScript or HTML structure touched. --- ### AI Usage Did you use AI tools to help write this code? _**YES**_ Co-authored-by: Claude Sonnet 4.6 --- src/network/html/FilesPage.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 288a6a07..f6b94248 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -44,7 +44,7 @@ } h2 { color: var(--title-color); - margin-top: 0; + margin: 0; } .card { background: var(--card-bg); @@ -63,7 +63,7 @@ } .page-header-left { display: flex; - align-items: baseline; + align-items: center; gap: 12px; flex-wrap: wrap; } From 939014d996a488c4de7470e3c4f02a68849a566f Mon Sep 17 00:00:00 2001 From: WuTofu <5987870+WuTofu@users.noreply.github.com> Date: Wed, 6 May 2026 21:57:37 +0800 Subject: [PATCH 06/21] fix: incorrect y-axis scale factor in jpeg nearest-neighbor downscaler (#1807) ## Summary * **What is the goal of this PR?** Fixes a bug in the jpeg nearest-neighbor downscaler where source image rows containing visible content may be cropped. * **What changes are included?** Split the single `fineScaleFP`/`invScaleFP` scale pair in `JpegToFramebufferConverter.cpp` into separate X (`fineScaleFPX`/`invScaleFPX`) and Y (`fineScaleFPY`/`invScaleFPY`) pairs ## Additional Context The one case that I encountered in my epub: img_6_0 The image generated from pxc cache with the latest commit: img_6_0_master pxc With this fix, the bottom outline of each character is still there: img_6_0_fixed pxc --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**PARTIALLY**_ --- .../converters/JpegToFramebufferConverter.cpp | 60 ++++++++++++------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp index 4cf55ae3..b0863bb5 100644 --- a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -32,9 +32,15 @@ struct JpegContext { int dstWidth{0}; int dstHeight{0}; - // Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU) - int32_t fineScaleFP{1 << 16}; // src -> dst mapping - int32_t invScaleFP{1 << 16}; // dst -> src mapping + // Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU). + // X and Y axes use separate scale factors: the aspect ratio of the output (dstWidth/dstHeight) + // may differ from the source (srcWidth/srcHeight) due to integer rounding of displayHeight. + // Using a single (X-based) scale for both axes causes the wrong srcRow to be skipped + // during nearest-neighbor downscaling, potentially losing critical image content. + int32_t fineScaleFPX{1 << 16}; // X: src -> dst column mapping + int32_t invScaleFPX{1 << 16}; // X: dst -> src column mapping + int32_t fineScaleFPY{1 << 16}; // Y: src -> dst row mapping + int32_t invScaleFPY{1 << 16}; // Y: dst -> src row mapping PixelCache cache; bool caching{false}; @@ -125,8 +131,10 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { const bool useDithering = ctx->config->useDithering; const bool caching = ctx->caching; - const int32_t fineScaleFP = ctx->fineScaleFP; - const int32_t invScaleFP = ctx->invScaleFP; + const int32_t fineScaleFPX = ctx->fineScaleFPX; + const int32_t invScaleFPX = ctx->invScaleFPX; + const int32_t fineScaleFPY = ctx->fineScaleFPY; + const int32_t invScaleFPY = ctx->invScaleFPY; GfxRenderer& renderer = *ctx->renderer; const int cfgX = ctx->config->x; const int cfgY = ctx->config->y; @@ -137,10 +145,10 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { const int srcYEnd = blockY + blockH; const int srcXEnd = blockX + validW; - int dstYStart = (int)((int64_t)blockY * fineScaleFP >> FP_SHIFT); - int dstYEnd = (srcYEnd >= ctx->scaledSrcHeight) ? ctx->dstHeight : (int)((int64_t)srcYEnd * fineScaleFP >> FP_SHIFT); - int dstXStart = (int)((int64_t)blockX * fineScaleFP >> FP_SHIFT); - int dstXEnd = (srcXEnd >= ctx->scaledSrcWidth) ? ctx->dstWidth : (int)((int64_t)srcXEnd * fineScaleFP >> FP_SHIFT); + int dstYStart = (int)((int64_t)blockY * fineScaleFPY >> FP_SHIFT); + int dstYEnd = (srcYEnd >= ctx->scaledSrcHeight) ? ctx->dstHeight : (int)((int64_t)srcYEnd * fineScaleFPY >> FP_SHIFT); + int dstXStart = (int)((int64_t)blockX * fineScaleFPX >> FP_SHIFT); + int dstXEnd = (srcXEnd >= ctx->scaledSrcWidth) ? ctx->dstWidth : (int)((int64_t)srcXEnd * fineScaleFPX >> FP_SHIFT); // Pre-clamp destination ranges to screen bounds (eliminates per-pixel screen checks) int clampYMax = ctx->dstHeight; @@ -165,7 +173,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { } // === 1:1 fast path: no scaling math === - if (fineScaleFP == FP_ONE) { + if (fineScaleFPX == FP_ONE && fineScaleFPY == FP_ONE) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; pw.beginRow(outY); @@ -191,11 +199,11 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { // === Bilinear interpolation (upscale: fineScale > 1.0) === // Smooths block boundaries that would otherwise create visible banding // on progressive JPEG DC-only decode (1/8 resolution upscaled to target). - if (fineScaleFP > FP_ONE) { + if (fineScaleFPX > FP_ONE && fineScaleFPY > FP_ONE) { // Pre-compute safe X range where lx0 and lx0+1 are both in [0, validW-1]. // Only the left/right edge pixels (typically 0-2 and 1-8 respectively) need clamping. - int safeXStart = (int)(((int64_t)blockX * fineScaleFP + FP_MASK) >> FP_SHIFT); - int safeXEnd = (int)((int64_t)(blockX + validW - 1) * fineScaleFP >> FP_SHIFT); + int safeXStart = (int)(((int64_t)blockX * fineScaleFPX + FP_MASK) >> FP_SHIFT); + int safeXEnd = (int)((int64_t)(blockX + validW - 1) * fineScaleFPX >> FP_SHIFT); if (safeXStart < dstXStart) safeXStart = dstXStart; if (safeXEnd > dstXEnd) safeXEnd = dstXEnd; if (safeXStart > safeXEnd) safeXEnd = safeXStart; @@ -204,7 +212,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { const int outY = cfgY + dstY; pw.beginRow(outY); if (caching) cw.beginRow(outY, ctx->config->y); - const int32_t srcFyFP = dstY * invScaleFP; + const int32_t srcFyFP = dstY * invScaleFPY; const int32_t fy = srcFyFP & FP_MASK; const int32_t fyInv = FP_ONE - fy; int ly0 = (srcFyFP >> FP_SHIFT) - blockY; @@ -219,7 +227,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { // Left edge (with X boundary clamping) for (int dstX = dstXStart; dstX < safeXStart; dstX++) { const int outX = cfgX + dstX; - const int32_t srcFxFP = dstX * invScaleFP; + const int32_t srcFxFP = dstX * invScaleFPX; const int32_t fx = srcFxFP & FP_MASK; const int32_t fxInv = FP_ONE - fx; int lx0 = (srcFxFP >> FP_SHIFT) - blockX; @@ -247,7 +255,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { // Interior (no X boundary checks — lx0 and lx0+1 guaranteed in bounds) for (int dstX = safeXStart; dstX < safeXEnd; dstX++) { const int outX = cfgX + dstX; - const int32_t srcFxFP = dstX * invScaleFP; + const int32_t srcFxFP = dstX * invScaleFPX; const int32_t fx = srcFxFP & FP_MASK; const int32_t fxInv = FP_ONE - fx; const int lx0 = (srcFxFP >> FP_SHIFT) - blockX; @@ -270,7 +278,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { // Right edge (with X boundary clamping) for (int dstX = safeXEnd; dstX < dstXEnd; dstX++) { const int outX = cfgX + dstX; - const int32_t srcFxFP = dstX * invScaleFP; + const int32_t srcFxFP = dstX * invScaleFPX; const int32_t fx = srcFxFP & FP_MASK; const int32_t fxInv = FP_ONE - fx; int lx0 = (srcFxFP >> FP_SHIFT) - blockX; @@ -301,7 +309,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { const int outY = cfgY + dstY; pw.beginRow(outY); if (caching) cw.beginRow(outY, ctx->config->y); - const int32_t srcFyFP = dstY * invScaleFP; + const int32_t srcFyFP = dstY * invScaleFPY; int ly = (srcFyFP >> FP_SHIFT) - blockY; if (ly < 0) ly = 0; if (ly >= blockH) ly = blockH - 1; @@ -309,7 +317,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { for (int dstX = dstXStart; dstX < dstXEnd; dstX++) { const int outX = cfgX + dstX; - const int32_t srcFxFP = dstX * invScaleFP; + const int32_t srcFxFP = dstX * invScaleFPX; int lx = (srcFxFP >> FP_SHIFT) - blockX; if (lx < 0) lx = 0; if (lx >= validW) lx = validW - 1; @@ -442,12 +450,22 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat jpegScaleDenom = chooseJpegScale(targetScale, jpegScaleOption); } + if (destWidth <= 0 || destHeight <= 0) { + LOG_ERR("JPG", "Degenerate output dimensions %dx%d for %s, skipping render", destWidth, destHeight, + imagePath.c_str()); + jpeg->close(); + delete jpeg; + return false; + } + ctx.scaledSrcWidth = (srcWidth + jpegScaleDenom - 1) / jpegScaleDenom; ctx.scaledSrcHeight = (srcHeight + jpegScaleDenom - 1) / jpegScaleDenom; ctx.dstWidth = destWidth; ctx.dstHeight = destHeight; - ctx.fineScaleFP = (int32_t)((int64_t)destWidth * FP_ONE / ctx.scaledSrcWidth); - ctx.invScaleFP = (int32_t)((int64_t)ctx.scaledSrcWidth * FP_ONE / destWidth); + ctx.fineScaleFPX = (int32_t)((int64_t)destWidth * FP_ONE / ctx.scaledSrcWidth); + ctx.invScaleFPX = (int32_t)((int64_t)ctx.scaledSrcWidth * FP_ONE / destWidth); + ctx.fineScaleFPY = (int32_t)((int64_t)destHeight * FP_ONE / ctx.scaledSrcHeight); + ctx.invScaleFPY = (int32_t)((int64_t)ctx.scaledSrcHeight * FP_ONE / destHeight); LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f, jpegScale 1/%d, fineScale %.2f)%s", srcWidth, srcHeight, destWidth, destHeight, targetScale, jpegScaleDenom, (float)destWidth / ctx.scaledSrcWidth, From 4ea938b70907d8f22d41769aee4564b7ddb1f547 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Wed, 6 May 2026 09:56:35 -0500 Subject: [PATCH 07/21] chore: Update SDK to fork in CrossPoint org (#1836) ## Summary Update the Open X4 SDK to point to the CrossPoint organization fork. This allows us to take SDK changes without being blocked on the upstream repository. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index b0d8e240..80308f05 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "open-x4-sdk"] path = open-x4-sdk - url = https://github.com/open-x4-epaper/community-sdk.git + url = https://github.com/crosspoint-reader/community-sdk.git From cfe3a948a079c6ed1f94a12e3c538ba162b315c4 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Wed, 6 May 2026 09:57:06 -0500 Subject: [PATCH 08/21] refactor: Simplify XtcReaderActivity with detectPageTurn (#1837) ## Summary Simplify duplicated code in `XtcReaderActivity` to use `ReaderUtils::detectPageTurn`. This implementation is now shared with `EpubReaderActivity` and `TxtReaderActivity`. Also deduplicated the chapter skip time constant. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**PARTIALLY**_ --- src/activities/reader/EpubReaderActivity.cpp | 5 +- src/activities/reader/ReaderUtils.h | 1 + src/activities/reader/TxtReaderActivity.cpp | 2 +- src/activities/reader/XtcReaderActivity.cpp | 53 ++++---------------- src/components/UITheme.cpp | 4 -- 5 files changed, 13 insertions(+), 52 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index ea6ad2cc..1426544b 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -30,7 +30,6 @@ namespace { // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() -constexpr unsigned long skipChapterMs = 700; // pages per minute, first item is 1 to prevent division by zero if accessed constexpr int PAGE_TURN_RATES[] = {1, 1, 3, 6, 12}; @@ -185,7 +184,7 @@ void EpubReaderActivity::loop() { return; } - auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput); + const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput); if (!prevTriggered && !nextTriggered) { return; } @@ -203,7 +202,7 @@ void EpubReaderActivity::loop() { return; } - const bool longPress = !fromTilt && mappedInput.getHeldTime() > skipChapterMs; + const bool longPress = !fromTilt && mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS; // Don't skip chapter after screenshot if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) { diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 13480696..1de6174b 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -10,6 +10,7 @@ namespace ReaderUtils { constexpr unsigned long GO_HOME_MS = 1000; +constexpr unsigned long SKIP_HOLD_MS = 700; inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) { switch (orientation) { diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 30886e83..b00665e7 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -71,7 +71,7 @@ void TxtReaderActivity::loop() { return; } - auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput); + const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput); if (!prevTriggered && !nextTriggered) { return; } diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 02043265..8e904fb3 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -10,22 +10,17 @@ #include #include #include -#include #include #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "ReaderUtils.h" #include "RecentBooksStore.h" #include "XtcReaderChapterSelectionActivity.h" #include "components/UITheme.h" #include "fontIds.h" -namespace { -constexpr unsigned long skipPageMs = 700; -constexpr unsigned long goHomeMs = 1000; -} // namespace - void XtcReaderActivity::onEnter() { Activity::onEnter(); @@ -70,34 +65,19 @@ void XtcReaderActivity::loop() { } // Long press BACK (1s+) goes to file selection - if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { + if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { activityManager.goToFileBrowser(xtc ? xtc->getPath() : ""); return; } // Short press BACK goes directly to home - if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back) && + mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) { onGoHome(); return; } - // When long-press chapter skip is disabled, turn pages on press instead of release. - const bool usePressForPageTurn = SETTINGS.longPressButtonBehavior == SETTINGS.OFF; - const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward(); - const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack(); - const bool prevTriggered = - tiltPrev || (usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) || - mappedInput.wasPressed(MappedInputManager::Button::Left)) - : (mappedInput.wasReleased(MappedInputManager::Button::PageBack) || - mappedInput.wasReleased(MappedInputManager::Button::Left))); - const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && - mappedInput.wasReleased(MappedInputManager::Button::Power); - const bool nextTriggered = - tiltNext || (usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) || - powerPageTurn || mappedInput.wasPressed(MappedInputManager::Button::Right)) - : (mappedInput.wasReleased(MappedInputManager::Button::PageForward) || - powerPageTurn || mappedInput.wasReleased(MappedInputManager::Button::Right))); - + const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput); if (!prevTriggered && !nextTriggered) { return; } @@ -113,9 +93,8 @@ void XtcReaderActivity::loop() { return; } - const bool fromTilt = tiltPrev || tiltNext; - const bool skipPages = - !fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP && mappedInput.getHeldTime() > skipPageMs; + const bool skipPages = !fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP && + mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS; const int skipAmount = skipPages ? 10 : 1; if (prevTriggered) { @@ -242,14 +221,7 @@ void XtcReaderActivity::renderPage() { } } - // Display BW with conditional refresh based on pagesUntilFullRefresh - if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); - } else { - renderer.displayBuffer(); - pagesUntilFullRefresh--; - } + ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); // Pass 2: LSB buffer - mark DARK gray only (XTH value 1) // In LUT: 0 bit = apply gray effect, 1 bit = untouched @@ -321,14 +293,7 @@ void XtcReaderActivity::renderPage() { // XTC pages already have status bar pre-rendered, no need to add our own - // Display with appropriate refresh - if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); - } else { - renderer.displayBuffer(); - pagesUntilFullRefresh--; - } + ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth); } diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 7e3a5875..012ad26b 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -13,10 +13,6 @@ #include "components/themes/lyra/LyraTheme.h" #include "components/themes/roundedraff/RoundedRaffTheme.h" -namespace { -constexpr int SKIP_PAGE_MS = 700; -} // namespace - UITheme UITheme::instance; UITheme::UITheme() { From 395e68ea2b684f39f6a4dcb683a417b36af38c74 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Wed, 6 May 2026 09:57:22 -0500 Subject: [PATCH 09/21] refactor: Simplify isReaderActivity bookkeeping (#1838) ## Summary Before, any sub-activity of a reader activity needed to override `isReaderActivity` to maintain correct bookkeeping through `ActivityManager::isReaderActivity`. We could easily miss this in any new sub-activities. Instead, simplify so each reader activity correctly reports and then `ActivityManager` checks for any reader activity in its stack. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- src/activities/ActivityManager.cpp | 8 +++++++- .../reader/EpubReaderChapterSelectionActivity.h | 1 - src/activities/reader/EpubReaderFootnotesActivity.h | 1 - src/activities/reader/EpubReaderMenuActivity.h | 1 - .../reader/EpubReaderPercentSelectionActivity.h | 1 - src/activities/reader/KOReaderSyncActivity.h | 1 - src/activities/reader/QrDisplayActivity.h | 1 - src/activities/reader/XtcReaderChapterSelectionActivity.h | 1 - 8 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index e1d5dd21..96c3c7d0 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -2,6 +2,8 @@ #include +#include + #include "OpdsServerStore.h" #include "boot_sleep/BootActivity.h" #include "boot_sleep/SleepActivity.h" @@ -230,7 +232,11 @@ void ActivityManager::popActivity() { bool ActivityManager::preventAutoSleep() const { return currentActivity && currentActivity->preventAutoSleep(); } -bool ActivityManager::isReaderActivity() const { return currentActivity && currentActivity->isReaderActivity(); } +bool ActivityManager::isReaderActivity() const { + return std::any_of(stackActivities.begin(), stackActivities.end(), + [](const auto& activity) { return activity->isReaderActivity(); }) || + (currentActivity && currentActivity->isReaderActivity()); +} bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); } diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.h b/src/activities/reader/EpubReaderChapterSelectionActivity.h index 216eadf4..20b53aa4 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.h +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.h @@ -32,5 +32,4 @@ class EpubReaderChapterSelectionActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; - bool isReaderActivity() const override { return true; } }; diff --git a/src/activities/reader/EpubReaderFootnotesActivity.h b/src/activities/reader/EpubReaderFootnotesActivity.h index 85fe692d..7336d038 100644 --- a/src/activities/reader/EpubReaderFootnotesActivity.h +++ b/src/activities/reader/EpubReaderFootnotesActivity.h @@ -19,7 +19,6 @@ class EpubReaderFootnotesActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; - bool isReaderActivity() const override { return true; } private: const std::vector& footnotes; diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 3937d62c..9ddba93d 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -32,7 +32,6 @@ class EpubReaderMenuActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; - bool isReaderActivity() const override { return true; } private: struct MenuItem { diff --git a/src/activities/reader/EpubReaderPercentSelectionActivity.h b/src/activities/reader/EpubReaderPercentSelectionActivity.h index ad68fc32..8cba8664 100644 --- a/src/activities/reader/EpubReaderPercentSelectionActivity.h +++ b/src/activities/reader/EpubReaderPercentSelectionActivity.h @@ -15,7 +15,6 @@ class EpubReaderPercentSelectionActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; - bool isReaderActivity() const override { return true; } private: // Current percent value (0-100) shown on the slider. diff --git a/src/activities/reader/KOReaderSyncActivity.h b/src/activities/reader/KOReaderSyncActivity.h index 29010699..ec4e1fde 100644 --- a/src/activities/reader/KOReaderSyncActivity.h +++ b/src/activities/reader/KOReaderSyncActivity.h @@ -41,7 +41,6 @@ class KOReaderSyncActivity final : public Activity { void loop() override; void render(RenderLock&&) override; bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; } - bool isReaderActivity() const override { return true; } private: enum State { diff --git a/src/activities/reader/QrDisplayActivity.h b/src/activities/reader/QrDisplayActivity.h index d6ff236a..3cfdb6b3 100644 --- a/src/activities/reader/QrDisplayActivity.h +++ b/src/activities/reader/QrDisplayActivity.h @@ -14,7 +14,6 @@ class QrDisplayActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; - bool isReaderActivity() const override { return true; } private: std::string textPayload; diff --git a/src/activities/reader/XtcReaderChapterSelectionActivity.h b/src/activities/reader/XtcReaderChapterSelectionActivity.h index 75d44215..42040ad5 100644 --- a/src/activities/reader/XtcReaderChapterSelectionActivity.h +++ b/src/activities/reader/XtcReaderChapterSelectionActivity.h @@ -23,5 +23,4 @@ class XtcReaderChapterSelectionActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; - bool isReaderActivity() const override { return true; } }; From 11bc36ebb5045e29ebd24874cb52d34a5513c206 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Thu, 7 May 2026 01:03:51 -0500 Subject: [PATCH 10/21] refactor: Use fixed-size integers for BookMetadataCache data (#1844) --- lib/Epub/Epub/BookMetadataCache.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/BookMetadataCache.h b/lib/Epub/Epub/BookMetadataCache.h index 7f45090a..2ff123d2 100644 --- a/lib/Epub/Epub/BookMetadataCache.h +++ b/lib/Epub/Epub/BookMetadataCache.h @@ -18,11 +18,11 @@ class BookMetadataCache { struct SpineEntry { std::string href; - size_t cumulativeSize; + uint32_t cumulativeSize; int16_t tocIndex; SpineEntry() : cumulativeSize(0), tocIndex(-1) {} - SpineEntry(std::string href, const size_t cumulativeSize, const int16_t tocIndex) + SpineEntry(std::string href, const uint32_t cumulativeSize, const int16_t tocIndex) : href(std::move(href)), cumulativeSize(cumulativeSize), tocIndex(tocIndex) {} }; @@ -44,7 +44,7 @@ class BookMetadataCache { private: std::string cachePath; - size_t lutOffset; + uint32_t lutOffset; uint16_t spineCount; uint16_t tocCount; bool loaded; From dadce519c42e6b0333f705534fe0672ac2cb888c Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Thu, 7 May 2026 09:07:57 +0300 Subject: [PATCH 11/21] fix: display empty lines in txt reader (#1841) --- src/activities/reader/TxtReaderActivity.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index b00665e7..6d95245d 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -19,7 +19,7 @@ namespace { constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading // Cache file magic and version constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" -constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes +constexpr uint8_t CACHE_VERSION = 3; // Increment when cache format changes } // namespace void TxtReaderActivity::onEnter() { @@ -223,8 +223,14 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector // Track position within this source line (in bytes from pos) size_t lineBytePos = 0; - // Word wrap if needed - while (!line.empty() && static_cast(outLines.size()) < linesPerPage) { + // Emit at least one visual line for each source line (including blank lines), + // then continue with wrapping when needed. + do { + if (line.empty()) { + outLines.emplace_back(); + break; + } + int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str()); if (lineWidth <= viewportWidth) { @@ -264,7 +270,7 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector } lineBytePos += skipChars; line = line.substr(skipChars); - } + } while (!line.empty() && static_cast(outLines.size()) < linesPerPage); // Determine how much of the source buffer we consumed if (line.empty()) { From c15b5b9bc5fafd8241f7c3689fbc9494327f37e5 Mon Sep 17 00:00:00 2001 From: pablohc Date: Thu, 7 May 2026 14:10:17 +0200 Subject: [PATCH 12/21] fix: short-press power action triggered after screenshot combo release (#1853) When Power+Down is released with a slight stagger (Down before Power), the Power release event leaked through to the activity loop, triggering short-press Power actions like clip selection. Add a screenshotComboActive flag that suppresses all input processing until Power is fully released after a screenshot combo. ## Summary * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) * **What changes are included?** ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**YES**_ --- src/main.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index 8258a57c..80602529 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -381,7 +381,9 @@ void loop() { } static bool screenshotButtonsReleased = true; + static bool screenshotComboActive = false; if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.isPressed(HalGPIO::BTN_DOWN)) { + screenshotComboActive = true; if (screenshotButtonsReleased) { screenshotButtonsReleased = false; { @@ -390,8 +392,16 @@ void loop() { } } return; - } else { + } + if (screenshotComboActive) { + if (gpio.isPressed(HalGPIO::BTN_POWER)) return; + if (gpio.wasReleased(HalGPIO::BTN_POWER)) { + screenshotButtonsReleased = true; + screenshotComboActive = false; + return; + } screenshotButtonsReleased = true; + screenshotComboActive = false; } const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs(); From 83f0cee565c498243066bca6ea39da57ff62474a Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Thu, 7 May 2026 08:17:51 -0500 Subject: [PATCH 13/21] fix: Roundraff theme home menu offset with no recent books (#1845) ## Summary With the Roundraff theme selected and no recent books, the home screen menu shows a "Continue Reading" option and menu handling is offset by one ("Continue Reading" actually does "Browse Files", "File Transfer" actually does "Settings", etc.) Simple fix here is to omit the "Continue Reading" menu item when there are no recent books. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- src/activities/home/HomeActivity.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index ff07b4f0..7bb0a340 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -235,7 +235,7 @@ void HomeActivity::render(RenderLock&&) { menuIcons.insert(menuIcons.begin() + 2, Library); } - if (metrics.homeContinueReadingInMenu) { + if (metrics.homeContinueReadingInMenu && !recentBooks.empty()) { // Insert Continue Reading at the top if enabled in theme menuItems.insert(menuItems.begin(), tr(STR_CONTINUE_READING)); menuIcons.insert(menuIcons.begin(), Book); From e841c841946370aba8c278994b218aa2135dc93c Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Thu, 7 May 2026 16:57:33 -0500 Subject: [PATCH 14/21] refactor: Deduplicate Roundraff battery drawing (#1847) ## Summary Deduplicated battery drawing code for Roundraff theme with other themes. Now `BaseTheme` provides non-virtual `drawBatteryLeft` and `drawBatteryRight` methods, which use a shared static `drawBatteryOutline` implementation, and defer to a virtual `fillBatteryIcon` implementation. In Classic and Roundraff `fillBatteryIcon` uses a solid fill, while Lyra and Lyra Extended use a segmented fill. The charging indicator inside the battery was missing for Roundraff, but is now consistent with other themes because of the shared drawing implementation. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**PARTIALLY**_ --- src/components/themes/BaseTheme.cpp | 77 +++++++++---------- src/components/themes/BaseTheme.h | 18 ++--- src/components/themes/lyra/LyraTheme.cpp | 67 ++++------------ src/components/themes/lyra/LyraTheme.h | 4 +- .../themes/roundedraff/RoundedRaffTheme.cpp | 61 +++------------ 5 files changed, 72 insertions(+), 155 deletions(-) diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 49f5d2a7..1d173aaa 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -20,37 +20,6 @@ constexpr int homeMenuMargin = 20; constexpr int homeMarginTop = 30; constexpr int subtitleY = 738; -// Helper: draw battery icon at given position -void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) { - // Draw battery outline (shared code) - BaseTheme::drawBatteryOutline(renderer, x, y, battWidth, rectHeight); - - const bool charging = gpio.isUsbConnected(); - - // The +1 is to round up, so that we always fill at least one pixel - const int maxFillWidth = battWidth - 5; - const int fillHeight = rectHeight - 4; - if (maxFillWidth <= 0 || fillHeight <= 0) { - return; - } - int filledWidth = percentage * maxFillWidth / 100 + 1; - if (filledWidth > maxFillWidth) { - filledWidth = maxFillWidth; - } - - // When charging, ensure minimum fill so lightning bolt is fully visible - constexpr int minFillForBolt = 8; - if (charging && filledWidth < minFillForBolt) { - filledWidth = std::min(minFillForBolt, maxFillWidth); - } - - renderer.fillRect(x + 2, y + 2, filledWidth, fillHeight); - - // Draw lightning bolt when charging (white/inverted on black fill for visibility) - if (charging) { - BaseTheme::drawBatteryLightningBolt(renderer, x + 4, y + 2); - } -} } // namespace void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) { @@ -79,6 +48,33 @@ void BaseTheme::drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false); } +void BaseTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const { + const bool charging = gpio.isUsbConnected(); + + const int maxFillWidth = rect.width - 5; + const int fillHeight = rect.height - 4; + if (maxFillWidth <= 0 || fillHeight <= 0) { + return; + } + // +1 to round up so we always fill at least one pixel + int filledWidth = percentage * maxFillWidth / 100 + 1; + if (filledWidth > maxFillWidth) { + filledWidth = maxFillWidth; + } + + // When charging, ensure minimum fill so lightning bolt is fully visible + constexpr int minFillForBolt = 8; + if (charging && filledWidth < minFillForBolt) { + filledWidth = std::min(minFillForBolt, maxFillWidth); + } + + renderer.fillRect(rect.x + 2, rect.y + 2, filledWidth, fillHeight); + + if (charging) { + drawBatteryLightningBolt(renderer, rect.x + 4, rect.y + 2); + } +} + void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { // Left aligned: icon on left, percentage on right (reader mode) const uint16_t percentage = powerManager.getBatteryPercentage(); @@ -86,11 +82,12 @@ void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo if (showPercentage) { const auto percentageText = std::to_string(percentage) + "%"; - renderer.drawText(SMALL_FONT_ID, rect.x + BaseTheme::batteryPercentSpacing + BaseMetrics::values.batteryWidth, - rect.y, percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + rect.width, rect.y, percentageText.c_str()); } - drawBatteryIcon(renderer, rect.x, y, BaseMetrics::values.batteryWidth, rect.height, percentage); + const Rect iconRect{rect.x, y, rect.width, rect.height}; + drawBatteryOutline(renderer, rect.x, y, rect.width, rect.height); + fillBatteryIcon(renderer, iconRect, percentage); } void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { @@ -102,16 +99,12 @@ void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const b if (showPercentage) { const auto percentageText = std::to_string(percentage) + "%"; const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); - // Clear the area where we're going to draw the text to prevent ghosting - const auto textHeight = renderer.getTextHeight(SMALL_FONT_ID); - renderer.fillRect(rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, textWidth, textHeight, false); - // Draw text to the left of the icon - renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, - percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str()); } - // Icon is already at correct position from rect.x - drawBatteryIcon(renderer, rect.x, y, BaseMetrics::values.batteryWidth, rect.height, percentage); + const Rect iconRect{rect.x, y, rect.width, rect.height}; + drawBatteryOutline(renderer, rect.x, y, rect.width, rect.height); + fillBatteryIcon(renderer, iconRect, percentage); } void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const size_t current, diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index e24f8074..daf60a12 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -125,11 +125,12 @@ class BaseTheme { virtual ~BaseTheme() = default; // Component drawing methods - virtual void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) const; - virtual void drawBatteryLeft(const GfxRenderer& renderer, Rect rect, - bool showPercentage = true) const; // Left aligned (reader mode) - virtual void drawBatteryRight(const GfxRenderer& renderer, Rect rect, - bool showPercentage = true) const; // Right aligned (UI headers) + void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) const; + void drawBatteryLeft(const GfxRenderer& renderer, Rect rect, + bool showPercentage = true) const; // Left aligned (reader mode) + void drawBatteryRight(const GfxRenderer& renderer, Rect rect, + bool showPercentage = true) const; // Right aligned (UI headers) + virtual void fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const; virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const; virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const; @@ -153,10 +154,9 @@ class BaseTheme { const std::function& rowIcon) const; virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const; virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const; - virtual void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, - const int pageCount, std::string title, const int paddingBottom = 0, - const int textYOffset = 0) const; - virtual void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const; + void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount, + std::string title, const int paddingBottom = 0, const int textYOffset = 0) const; + void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const; virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false, int contentStartX = 0, int contentWidth = 0) const; virtual void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected, diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 994b047e..a1f687fd 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -41,30 +41,6 @@ constexpr int listIconSize = 24; constexpr int mainMenuColumns = 2; int coverWidth = 0; -void drawLyraBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, - uint16_t percentage) { - BaseTheme::drawBatteryOutline(renderer, x, y, battWidth, rectHeight); - - const bool charging = gpio.isUsbConnected(); - - if (charging) { - // Draw solid fill when charging so lightning bolt is visible - renderer.fillRect(x + 2, y + 2, battWidth - 5, rectHeight - 4); - BaseTheme::drawBatteryLightningBolt(renderer, x + 4, y + 2); - } else { - // Draw bars when not charging - if (percentage > 10) { - renderer.fillRect(x + 2, y + 2, 3, rectHeight - 4); - } - if (percentage > 40) { - renderer.fillRect(x + 6, y + 2, 3, rectHeight - 4); - } - if (percentage > 70) { - renderer.fillRect(x + 10, y + 2, 3, rectHeight - 4); - } - } -} - const uint8_t* iconForName(UIIcon icon, int size) { if (size == 24) { switch (icon) { @@ -107,35 +83,24 @@ const uint8_t* iconForName(UIIcon icon, int size) { } } // namespace -void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { - // Left aligned: icon on left, percentage on right (reader mode) - const uint16_t percentage = powerManager.getBatteryPercentage(); +void LyraTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const { + const bool charging = gpio.isUsbConnected(); - if (showPercentage) { - const auto percentageText = std::to_string(percentage) + "%"; - renderer.drawText(SMALL_FONT_ID, rect.x + BaseTheme::batteryPercentSpacing + LyraMetrics::values.batteryWidth, - rect.y, percentageText.c_str()); + if (charging) { + // Solid fill when charging so lightning bolt is visible + renderer.fillRect(rect.x + 2, rect.y + 2, rect.width - 5, rect.height - 4); + drawBatteryLightningBolt(renderer, rect.x + 4, rect.y + 2); + } else { + if (percentage > 10) { + renderer.fillRect(rect.x + 2, rect.y + 2, 3, rect.height - 4); + } + if (percentage > 40) { + renderer.fillRect(rect.x + 6, rect.y + 2, 3, rect.height - 4); + } + if (percentage > 70) { + renderer.fillRect(rect.x + 10, rect.y + 2, 3, rect.height - 4); + } } - - drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage); -} - -void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { - // Right aligned: percentage on left, icon on right (UI headers) - const uint16_t percentage = powerManager.getBatteryPercentage(); - - if (showPercentage) { - const auto percentageText = std::to_string(percentage) + "%"; - const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); - // Clear the area where we're going to draw the text to prevent ghosting - const auto textHeight = renderer.getTextHeight(SMALL_FONT_ID); - renderer.fillRect(rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, textWidth, textHeight, false); - // Draw text to the left of the icon - renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, - percentageText.c_str()); - } - - drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage); } void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const { diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index eec76b10..60ef3989 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -49,9 +49,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, class LyraTheme : public BaseTheme { public: // Component drawing methods - // void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) override; - void drawBatteryLeft(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const override; - void drawBatteryRight(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const override; + void fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const override; void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const override; void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, const char* rightLabel = nullptr) const override; diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.cpp b/src/components/themes/roundedraff/RoundedRaffTheme.cpp index 0307f3e4..29bb913e 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.cpp +++ b/src/components/themes/roundedraff/RoundedRaffTheme.cpp @@ -1,7 +1,6 @@ #include "RoundedRaffTheme.h" #include -#include #include #include @@ -22,7 +21,6 @@ constexpr int kBottomRadius = 15; constexpr int kRowRadius = 20; constexpr int kInteractiveInsetX = 20; constexpr int kSelectableRowGap = 6; -constexpr int batteryPercentSpacing = 4; constexpr int kTitleFontId = UI_12_FONT_ID; // Requested main title size: 12px constexpr int kSubtitleFontId = SMALL_FONT_ID; // Requested subtitle size: 8px constexpr int kGuideFontId = SMALL_FONT_ID; // Closest available to requested 6px @@ -46,42 +44,6 @@ void drawScrollBar(const GfxRenderer& renderer, Rect rect, int itemCount, int pa renderer.fillRect(barX, thumbY, barW, thumbH); } -void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) { - // Top line - renderer.drawLine(x + 1, y, x + battWidth - 3, y); - // Bottom line - renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1); - // Left line - renderer.drawLine(x, y + 1, x, y + rectHeight - 2); - // Battery end - renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2); - renderer.drawPixel(x + battWidth - 1, y + 3); - renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4); - renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5); - - // The +1 is to round up, so that we always fill at least one pixel. - int filledWidth = percentage * (battWidth - 5) / 100 + 1; - if (filledWidth > battWidth - 5) { - filledWidth = battWidth - 5; // Ensure we don't overflow. - } - - renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4); -} - -void drawBatteryRightStable(const GfxRenderer& renderer, Rect iconRect, uint16_t percentage, bool showPercentage) { - // Match BaseTheme::drawBatteryRight layout, but use a stable percentage value for this render. - const int iconY = iconRect.y + 6; - - if (showPercentage) { - const auto percentageText = std::to_string(percentage) + "%"; - const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); - renderer.drawText(SMALL_FONT_ID, iconRect.x - textWidth - batteryPercentSpacing, iconRect.y, - percentageText.c_str()); - } - - drawBatteryIcon(renderer, iconRect.x, iconY, RoundedRaffMetrics::values.batteryWidth, iconRect.height, percentage); -} - 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]))) { @@ -110,28 +72,27 @@ void RoundedRaffTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const const bool showBatteryPercentage = SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; - const uint16_t percentage = powerManager.getBatteryPercentage(); const int batteryIconX = rect.x + rect.width - sidePadding - RoundedRaffMetrics::values.batteryWidth; + + // Reserve space for the widest possible percentage text to avoid title/battery overlap int batteryGroupLeftX = batteryIconX; if (showBatteryPercentage) { - const auto percentageText = std::to_string(percentage) + "%"; - batteryGroupLeftX -= renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()) + batteryPercentSpacing; - - // Clear a fixed-width area for the battery percentage to avoid ghosting when digit count changes (e.g. 100% -> - // 99%). + // Clear a fixed-width area for the battery percentage to avoid ghosting when digit count changes (e.g. 100% -> 99%) const int maxTextWidth = renderer.getTextWidth(SMALL_FONT_ID, "100%"); + batteryGroupLeftX -= maxTextWidth + batteryPercentSpacing; + const int clearW = maxTextWidth + batteryPercentSpacing + RoundedRaffMetrics::values.batteryWidth; const int clearH = std::max(renderer.getTextHeight(SMALL_FONT_ID), RoundedRaffMetrics::values.batteryHeight + 8); renderer.fillRect(batteryIconX - maxTextWidth - batteryPercentSpacing, rect.y + 14, clearW, clearH, false); } - const int maxTextWidth = std::max(0, batteryGroupLeftX - 20 - titleX); - auto headerTitle = renderer.truncatedText(kTitleFontId, title, maxTextWidth, EpdFontFamily::BOLD); + const int maxTitleWidth = std::max(0, batteryGroupLeftX - 20 - titleX); + auto headerTitle = renderer.truncatedText(kTitleFontId, title, maxTitleWidth, EpdFontFamily::BOLD); renderer.drawText(kTitleFontId, titleX, titleY, headerTitle.c_str(), true, EpdFontFamily::BOLD); - drawBatteryRightStable(renderer, - Rect{batteryIconX, rect.y + 14, RoundedRaffMetrics::values.batteryWidth, - RoundedRaffMetrics::values.batteryHeight}, - percentage, showBatteryPercentage); + drawBatteryRight(renderer, + Rect{batteryIconX, rect.y + 14, RoundedRaffMetrics::values.batteryWidth, + RoundedRaffMetrics::values.batteryHeight}, + showBatteryPercentage); } void RoundedRaffTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, From 1e2f6e2d674bc38b343f8cf28d8762fa36fb6e88 Mon Sep 17 00:00:00 2001 From: WuTofu <5987870+WuTofu@users.noreply.github.com> Date: Fri, 8 May 2026 06:32:30 +0800 Subject: [PATCH 15/21] feat: enhance long press action to delete both files and directories (#1803) ## Summary * **What is the goal of this PR?** Enhance file browser long-press behavior so the delete action now works for both files and directories. * **What changes are included?** Updated `FileBrowserActivity.cpp` to support for directory deletion on long-press --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- src/activities/home/FileBrowserActivity.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 3ea626e2..870e93b6 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -139,17 +139,20 @@ void FileBrowserActivity::loop() { return; } - if (mode == Mode::Books && mappedInput.getHeldTime() >= GO_HOME_MS && !isDirectory) { - // --- LONG PRESS ACTION: DELETE FILE --- + if (mode == Mode::Books && mappedInput.getHeldTime() >= GO_HOME_MS) { + // --- LONG PRESS ACTION: DELETE FILE OR DIRECTORY --- std::string cleanBasePath = basepath; if (cleanBasePath.back() != '/') cleanBasePath += "/"; const std::string fullPath = cleanBasePath + entry; - auto handler = [this, fullPath](const ActivityResult& res) { + auto handler = [this, fullPath, isDirectory](const ActivityResult& res) { if (!res.isCancelled) { LOG_DBG("FileBrowser", "Attempting to delete: %s", fullPath.c_str()); - clearFileMetadata(fullPath); - if (Storage.remove(fullPath.c_str())) { + if (!isDirectory) { + clearFileMetadata(fullPath); + } + const bool deleted = isDirectory ? Storage.removeDir(fullPath.c_str()) : Storage.remove(fullPath.c_str()); + if (deleted) { LOG_DBG("FileBrowser", "Deleted successfully"); loadFiles(); if (files.empty()) { @@ -161,7 +164,7 @@ void FileBrowserActivity::loop() { requestUpdate(true); } else { - LOG_ERR("FileBrowser", "Failed to delete file: %s", fullPath.c_str()); + LOG_ERR("FileBrowser", "Failed to delete: %s", fullPath.c_str()); } } else { LOG_DBG("FileBrowser", "Delete cancelled by user"); From e8d7153d7fad86a584f85c4eafe6e00de9d170e7 Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Fri, 8 May 2026 07:29:48 +0500 Subject: [PATCH 16/21] feat: edit wifi networks in webui (#1743) ## Summary **What is the goal of this PR?** Add Wi-Fi network management to the Web UI settings page, similar to existing OPDS server management, so users can view, add, edit, and delete saved Wi-Fi credentials from the browser. Closes https://github.com/crosspoint-reader/crosspoint-reader/issues/1544 and https://github.com/crosspoint-reader/crosspoint-reader/discussions/607 **What changes are included?** - Added Wi-Fi API endpoints: - GET /api/wifi Listing saved networks (without exposing plaintext passwords) - POST /api/wifi Creating/updating networks - POST /api/wifi/delete Deleting networks by index - Added a new Wi-Fi Networks management section web UI settings page image --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< YES >**_ --------- Co-authored-by: Copilot --- USER_GUIDE.md | 22 ++++- src/network/CrossPointWebServer.cpp | 140 ++++++++++++++++++++++++++++ src/network/CrossPointWebServer.h | 5 + src/network/html/SettingsPage.html | 115 +++++++++++++++++++++++ 4 files changed, 280 insertions(+), 2 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8cc3ad00..827e85ea 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -21,7 +21,8 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control - [3.6.3 Controls](#363-controls) - [3.6.4 System](#364-system) - [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) - - [3.6.6 KOReader Sync Quick Setup](#366-koreader-sync-quick-setup) + - [3.6.6 Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds) + - [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup) - [3.7 Sleep Screen](#37-sleep-screen) - [4. Reading Mode](#4-reading-mode) - [Page Turning](#page-turning) @@ -223,7 +224,24 @@ You can also manage OPDS servers from the web interface while in File Transfer m 2. Open `http:///settings`. 3. Use the **OPDS Servers** card to add, edit, or delete entries. -#### 3.6.6 KOReader Sync Quick Setup +For web-based WiFi network management, see [Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds). + +#### 3.6.6 Web Settings (WiFi + OPDS) + +While in **File Transfer** mode, the web settings page includes management cards for both **WiFi Networks** and **OPDS Servers**. + +1. On device: open **File Transfer** and connect to WiFi. +1. In a browser, open `http:///settings` or `http://crosspoint.local`. +1. In **WiFi Networks**, add, edit, or delete saved network entries (SSID + optional password). +1. In **OPDS Servers**, add, edit, or delete OPDS catalogs. + +Behavior notes: + +- Passwords are never shown back in the web UI after saving. +- Leaving Password blank while editing keeps the existing saved password unchanged. +- The web UI can save hidden-network SSIDs, but connecting to hidden networks still depends on device-side WiFi connection flow. + +#### 3.6.7 KOReader Sync Quick Setup CrossPoint can sync reading progress with KOReader-compatible sync servers. It also interoperates with KOReader apps/devices when they use the same server and credentials. diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 713981ea..7c795503 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -14,6 +14,7 @@ #include "OpdsServerStore.h" #include "SettingsList.h" #include "WebDAVHandler.h" +#include "WifiCredentialStore.h" #include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" #include "html/SettingsPageHtml.generated.h" @@ -168,6 +169,11 @@ void CrossPointWebServer::begin() { server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); }); server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); }); + // Wi-Fi credential endpoints + server->on("/api/wifi", HTTP_GET, [this] { handleGetWifiNetworks(); }); + server->on("/api/wifi", HTTP_POST, [this] { handlePostWifiNetwork(); }); + server->on("/api/wifi/delete", HTTP_POST, [this] { handleDeleteWifiNetwork(); }); + server->onNotFound([this] { handleNotFound(); }); LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap()); @@ -1368,6 +1374,140 @@ void CrossPointWebServer::handleDeleteOpdsServer() { server->send(200, "text/plain", "OK"); } +// ---- Wi-Fi Credentials API ---- + +void CrossPointWebServer::handleGetWifiNetworks() const { + const auto& credentials = WIFI_STORE.getCredentials(); + const std::string& lastConnectedSsid = WIFI_STORE.getLastConnectedSsid(); + + // Stream JSON array incrementally to avoid allocating the full response in memory + server->setContentLength(CONTENT_LENGTH_UNKNOWN); + server->send(200, "application/json", ""); + server->sendContent("["); + + char output[320]; + constexpr size_t outputSize = sizeof(output); + JsonDocument doc; + + for (size_t i = 0; i < credentials.size(); i++) { + doc.clear(); + doc["index"] = i; + doc["ssid"] = credentials[i].ssid; + // Never expose Wi-Fi passwords over the API — only indicate whether one is set + doc["hasPassword"] = !credentials[i].password.empty(); + doc["isLastConnected"] = credentials[i].ssid == lastConnectedSsid; + + const size_t written = serializeJson(doc, output, outputSize); + if (written >= outputSize) continue; + + if (i > 0) server->sendContent(","); + server->sendContent(output); + } + + server->sendContent("]"); + server->sendContent(""); + LOG_DBG("WEB", "Served Wi-Fi credentials API (%zu network(s))", credentials.size()); +} + +void CrossPointWebServer::handlePostWifiNetwork() { + if (!server->hasArg("plain")) { + server->send(400, "text/plain", "Missing JSON body"); + return; + } + + const String body = server->arg("plain"); + JsonDocument doc; + const DeserializationError err = deserializeJson(doc, body); + if (err) { + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); + return; + } + + std::string ssid = doc["ssid"] | std::string(""); + if (ssid.empty()) { + server->send(400, "text/plain", "SSID is required"); + return; + } + + // The password field is optional in the JSON payload. When absent (vs. present but empty), + // preserve the existing password for updates. Empty passwords are valid for open networks. + bool hasPasswordField = doc["password"].is() || doc["password"].is(); + std::string password = doc["password"] | std::string(""); + + if (doc["index"].is()) { + int idx = doc["index"].as(); + const auto& credentials = WIFI_STORE.getCredentials(); + if (idx < 0 || idx >= static_cast(credentials.size())) { + server->send(400, "text/plain", "Invalid network index"); + return; + } + + const std::string oldSsid = credentials[static_cast(idx)].ssid; + if (!hasPasswordField) { + password = credentials[static_cast(idx)].password; + } + + bool ok = true; + if (oldSsid != ssid) { + ok = WIFI_STORE.removeCredential(oldSsid) && WIFI_STORE.addCredential(ssid, password); + } else { + ok = WIFI_STORE.addCredential(ssid, password); + } + + if (!ok) { + server->send(400, "text/plain", "Failed to update Wi-Fi network"); + return; + } + + LOG_DBG("WEB", "Updated Wi-Fi network at index %d (SSID: %s)", idx, ssid.c_str()); + } else { + if (!WIFI_STORE.addCredential(ssid, password)) { + server->send(400, "text/plain", "Cannot add network (limit reached)"); + return; + } + LOG_DBG("WEB", "Added Wi-Fi network: %s", ssid.c_str()); + } + + server->send(200, "text/plain", "OK"); +} + +// Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body. +void CrossPointWebServer::handleDeleteWifiNetwork() { + if (!server->hasArg("plain")) { + server->send(400, "text/plain", "Missing JSON body"); + return; + } + + const String body = server->arg("plain"); + JsonDocument doc; + const DeserializationError err = deserializeJson(doc, body); + if (err) { + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); + return; + } + + if (!doc["index"].is()) { + server->send(400, "text/plain", "Missing index"); + return; + } + + int idx = doc["index"].as(); + const auto& credentials = WIFI_STORE.getCredentials(); + if (idx < 0 || idx >= static_cast(credentials.size())) { + server->send(400, "text/plain", "Invalid network index"); + return; + } + + const std::string ssid = credentials[static_cast(idx)].ssid; + if (!WIFI_STORE.removeCredential(ssid)) { + server->send(400, "text/plain", "Failed to delete Wi-Fi network"); + return; + } + + LOG_DBG("WEB", "Deleted Wi-Fi network at index %d (SSID: %s)", idx, ssid.c_str()); + server->send(200, "text/plain", "OK"); +} + // WebSocket callback trampoline void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { if (wsInstance) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index aac60e19..6850b687 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -112,4 +112,9 @@ class CrossPointWebServer { void handleGetOpdsServers() const; void handlePostOpdsServer(); void handleDeleteOpdsServer(); + + // Wi-Fi credential handlers + void handleGetWifiNetworks() const; + void handlePostWifiNetwork(); + void handleDeleteWifiNetwork(); }; diff --git a/src/network/html/SettingsPage.html b/src/network/html/SettingsPage.html index 9b9ac8ec..47d846f2 100644 --- a/src/network/html/SettingsPage.html +++ b/src/network/html/SettingsPage.html @@ -299,6 +299,7 @@ +
@@ -480,6 +481,119 @@ loadSettings(); + // --- Wi-Fi Network Management --- + // Renders an editable list of saved Wi-Fi networks using /api/wifi endpoints. + // Password fields are never pre-filled; when left blank during edit, existing + // passwords remain unchanged server-side. + let wifiNetworks = []; + + function renderWifiNetwork(net, idx) { + const isNew = idx === -1; + const id = isNew ? 'new' : idx; + const lastConnected = net.isLastConnected + ? '
Last connected network
' + : ''; + + return '
' + + '
' + + 'SSID' + + '' + + '
' + + '
' + + 'Password' + + '' + + '
' + + lastConnected + + '
' + + '' + + (isNew ? '' : '') + + '
' + + '
'; + } + + function renderWifiSection() { + const container = document.getElementById('wifi-container'); + let html = '

Wi-Fi Networks

'; + + if (wifiNetworks.length === 0) { + html += '

No Wi-Fi networks saved

'; + } else { + wifiNetworks.forEach(function(net, idx) { + html += renderWifiNetwork(net, idx); + }); + } + + html += '
' + + '' + + '
'; + container.innerHTML = html; + } + + async function loadWifiNetworks() { + try { + const resp = await fetch('/api/wifi'); + if (!resp.ok) throw new Error('Failed to load'); + wifiNetworks = await resp.json(); + renderWifiSection(); + } catch (e) { + console.error('Wi-Fi load error:', e); + } + } + + function addWifiNetwork() { + const container = document.getElementById('wifi-container'); + const card = container.querySelector('.card'); + const addBtn = card.querySelector('.btn-add').parentElement; + // Prevent multiple unsaved new-network forms at once (idx -1 -> id "new") + if (document.getElementById('wifi-new')) return; + addBtn.insertAdjacentHTML('beforebegin', renderWifiNetwork({ssid:'',hasPassword:false,isLastConnected:false}, -1)); + } + + async function saveWifiNetwork(idx) { + const id = idx === -1 ? 'new' : idx; + const ssid = document.getElementById('wifi-ssid-' + id).value.trim(); + if (!ssid) { + showMessage('SSID is required.', true); + return; + } + + const data = { ssid: ssid }; + // Only include password when the user actually typed something; omitting it + // tells the server to preserve an existing password. + const pass = document.getElementById('wifi-pass-' + id).value; + if (pass) data.password = pass; + if (idx >= 0) data.index = idx; + + try { + const resp = await fetch('/api/wifi', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data) + }); + if (!resp.ok) throw new Error(await resp.text()); + showMessage('Wi-Fi network saved!', false); + await loadWifiNetworks(); + } catch (e) { + showMessage('Error: ' + e.message, true); + } + } + + async function deleteWifiNetwork(idx) { + if (!confirm('Delete this Wi-Fi network?')) return; + try { + const resp = await fetch('/api/wifi/delete', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({index: idx}) + }); + if (!resp.ok) throw new Error(await resp.text()); + showMessage('Wi-Fi network deleted', false); + await loadWifiNetworks(); + } catch (e) { + showMessage('Error: ' + e.message, true); + } + } + // --- OPDS Server Management --- // Dynamically renders an editable list of OPDS servers, communicating with the // /api/opds REST endpoints. Password fields are never pre-filled for security; @@ -594,6 +708,7 @@ } } + loadWifiNetworks(); loadOpdsServers(); From e3fb3bba373c65b1a5af436c5025d8f221fdfbca Mon Sep 17 00:00:00 2001 From: xiro-codes <146094155+xiro-codes@users.noreply.github.com> Date: Fri, 8 May 2026 03:41:06 -0500 Subject: [PATCH 17/21] feat(sleep-screen): add sleep screen orientation setting (#1748) Co-authored-by: Travis Davis --- lib/I18n/translations/english.yaml | 1 + src/CrossPointSettings.h | 2 ++ src/SettingsList.h | 3 +++ src/activities/boot_sleep/SleepActivity.cpp | 3 ++- 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index b6623899..df5f893f 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -63,6 +63,7 @@ STR_CAT_CONTROLS: "Controls" STR_CAT_SYSTEM: "System" STR_SLEEP_SCREEN: "Sleep Screen" STR_SLEEP_COVER_MODE: "Sleep Screen Cover Mode" +STR_SLEEP_ORIENTATION: "Sleep Screen Orientation" STR_HIDE_BATTERY: "Hide Battery %" STR_EXTRA_SPACING: "Extra Paragraph Spacing" STR_TEXT_AA: "Text Anti-Aliasing" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 01d7cc56..2a488133 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -153,6 +153,8 @@ class CrossPointSettings { uint8_t sleepScreenCoverMode = FIT; // Sleep screen cover filter uint8_t sleepScreenCoverFilter = NO_FILTER; + // Sleep screen orientation + uint8_t sleepScreenOrientation = PORTRAIT; // Status bar settings (statusBar retained for migration only) uint8_t statusBar = FULL; uint8_t statusBarChapterPageCount = 1; diff --git a/src/SettingsList.h b/src/SettingsList.h index bbe9a774..d28b7c35 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -22,6 +22,9 @@ inline const std::vector& getSettingsList() { "sleepScreen", StrId::STR_CAT_DISPLAY), SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode, {StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY), + SettingInfo::Enum(StrId::STR_SLEEP_ORIENTATION, &CrossPointSettings::sleepScreenOrientation, + {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW}, + "sleepScreenOrientation", StrId::STR_CAT_DISPLAY), SettingInfo::Enum(StrId::STR_SLEEP_COVER_FILTER, &CrossPointSettings::sleepScreenCoverFilter, {StrId::STR_NONE_OPT, StrId::STR_FILTER_CONTRAST, StrId::STR_INVERTED}, "sleepScreenCoverFilter", StrId::STR_CAT_DISPLAY), diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 19f443b0..aed42879 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -22,11 +22,12 @@ void SleepActivity::onEnter() { if (APP_STATE.lastSleepFromReader) { ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); - renderer.setOrientation(GfxRenderer::Orientation::Portrait); } else { GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); } + ReaderUtils::applyOrientation(renderer, SETTINGS.sleepScreenOrientation); + switch (SETTINGS.sleepScreen) { case (CrossPointSettings::SLEEP_SCREEN_MODE::BLANK): return renderBlankSleepScreen(); From a48ad3cd61e030c9f2881bffb8252c4b75896d33 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Fri, 8 May 2026 12:29:21 -0500 Subject: [PATCH 18/21] chore: Updated docs to reflect DESTRUCTOR_CLOSES_FILE=1 (#1878) ## Summary Follow-up to 23aad213 which removed redundant FsFile close() calls from the codebase. This updates CLAUDE.md so AI agents and contributors stop reintroducing them. Adds the build flag to the Critical Build Flags section with a clear "do not add file.close() on local FsFile variables" rule, enumerates the three cases where explicit close is still required (close before Storage.remove, close before reopening the same variable, and member variables that outlive function scope), and updates the HalStorage example, RAII guidance, and Activity Lifecycle sections to be consistent with the new policy. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**YES**_ --- .skills/SKILL.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.skills/SKILL.md b/.skills/SKILL.md index 90a00dd7..8e5e8f8f 100644 --- a/.skills/SKILL.md +++ b/.skills/SKILL.md @@ -104,8 +104,17 @@ These flags in `platformio.ini` fundamentally affect firmware behavior: -DUSE_UTF8_LONG_NAMES=1 // SD card long filename support -DMINIZ_NO_ZLIB_COMPATIBLE_NAMES=1 // Avoid zlib name conflicts -DXML_GE=0 // Disable XML general entities (security) +-DDESTRUCTOR_CLOSES_FILE=1 // FsFile destructor auto-closes (SdFat) ``` +**DESTRUCTOR_CLOSES_FILE implications**: +- SdFat's `FsBaseFile` destructor calls `close()` automatically when the object goes out of scope +- **Do NOT add explicit `file.close()` calls** for local `FsFile` variables — the destructor handles it +- Explicit `close()` is still required in these cases: + 1. **Close before delete**: Must close before `Storage.remove()` on the same path + 2. **Close before reopen**: Must close before reopening the same `FsFile` variable (e.g., write then reopen for read, or rewrite the same path) + 3. **Member variables**: `FsFile` members persist beyond any single function scope, so close at the intended release point (e.g., in `onExit()`) + **SINGLE_BUFFER_MODE implications**: - Only ONE framebuffer exists (not double-buffered) - Grayscale rendering requires temporary buffer allocation (`renderer.storeBwBuffer()`) @@ -145,11 +154,11 @@ These flags in `platformio.ini` fundamentally affect firmware behavior: FsFile file; if (Storage.openFileForRead("MODULE", "/path/to/file.bin", file)) { // Read from file - file.close(); // Explicit close required + // No file.close() needed — DESTRUCTOR_CLOSES_FILE=1 handles it at scope exit } ``` -**Usage**: See example above. Uses `FsFile` (SdFat), NOT Arduino `File`. +**Usage**: See example above. Uses `FsFile` (SdFat), NOT Arduino `File`. Do NOT add `file.close()` for local variables (see DESTRUCTOR_CLOSES_FILE above). --- @@ -167,7 +176,7 @@ if (Storage.openFileForRead("MODULE", "/path/to/file.bin", file)) { ### Memory Safety and RAII * Smart Pointers: Prefer std::unique_ptr. Avoid std::shared_ptr (unnecessary atomic overhead for a single-core RISC-V). -* RAII: Use destructors for cleanup, but call file.close() or vTaskDelete() explicitly for deterministic resource release. +* RAII: Use destructors for cleanup. Call `vTaskDelete()` explicitly for deterministic task release. Do NOT call `file.close()` on local `FsFile` variables — `DESTRUCTOR_CLOSES_FILE=1` handles it at scope exit (see Critical Build Flags). ### ESP32-C3 Platform Pitfalls @@ -376,13 +385,13 @@ void enterNewActivity(Activity* activity) { - Activity navigation = `delete` old activity + `new` create next activity - Any memory allocated in `onEnter()` MUST be freed in `onExit()` - FreeRTOS tasks MUST be deleted in `onExit()` before activity destruction -- File handles MUST be closed in `onExit()` +- Member `FsFile` handles MUST be closed in `onExit()` (local `FsFile` variables auto-close via destructor) **Activity Pattern**: ```cpp void onEnter() { Activity::onEnter(); /* alloc: buffer, tasks */ render(); } void loop() { mappedInput.update(); /* handle input */ } -void onExit() { /* free: vTaskDelete, free buffer, close files */ Activity::onExit(); } +void onExit() { /* free: vTaskDelete, free buffer, close member FsFiles */ Activity::onExit(); } ``` **Critical**: Free resources in reverse order. Delete tasks BEFORE activity destruction. From b463966045b5f911bf0d4f8eeb8e861de2052485 Mon Sep 17 00:00:00 2001 From: pablohc Date: Fri, 8 May 2026 19:31:02 +0200 Subject: [PATCH 19/21] Revert "feat(sleep-screen): add sleep screen orientation setting" (#1877) Reverts crosspoint-reader/crosspoint-reader#1748 The images in landscape mode are borked. Cover: image Sleep Screen: image --- lib/I18n/translations/english.yaml | 1 - src/CrossPointSettings.h | 2 -- src/SettingsList.h | 3 --- src/activities/boot_sleep/SleepActivity.cpp | 3 +-- 4 files changed, 1 insertion(+), 8 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index df5f893f..b6623899 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -63,7 +63,6 @@ STR_CAT_CONTROLS: "Controls" STR_CAT_SYSTEM: "System" STR_SLEEP_SCREEN: "Sleep Screen" STR_SLEEP_COVER_MODE: "Sleep Screen Cover Mode" -STR_SLEEP_ORIENTATION: "Sleep Screen Orientation" STR_HIDE_BATTERY: "Hide Battery %" STR_EXTRA_SPACING: "Extra Paragraph Spacing" STR_TEXT_AA: "Text Anti-Aliasing" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 2a488133..01d7cc56 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -153,8 +153,6 @@ class CrossPointSettings { uint8_t sleepScreenCoverMode = FIT; // Sleep screen cover filter uint8_t sleepScreenCoverFilter = NO_FILTER; - // Sleep screen orientation - uint8_t sleepScreenOrientation = PORTRAIT; // Status bar settings (statusBar retained for migration only) uint8_t statusBar = FULL; uint8_t statusBarChapterPageCount = 1; diff --git a/src/SettingsList.h b/src/SettingsList.h index d28b7c35..bbe9a774 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -22,9 +22,6 @@ inline const std::vector& getSettingsList() { "sleepScreen", StrId::STR_CAT_DISPLAY), SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode, {StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY), - SettingInfo::Enum(StrId::STR_SLEEP_ORIENTATION, &CrossPointSettings::sleepScreenOrientation, - {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW}, - "sleepScreenOrientation", StrId::STR_CAT_DISPLAY), SettingInfo::Enum(StrId::STR_SLEEP_COVER_FILTER, &CrossPointSettings::sleepScreenCoverFilter, {StrId::STR_NONE_OPT, StrId::STR_FILTER_CONTRAST, StrId::STR_INVERTED}, "sleepScreenCoverFilter", StrId::STR_CAT_DISPLAY), diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index aed42879..19f443b0 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -22,12 +22,11 @@ void SleepActivity::onEnter() { if (APP_STATE.lastSleepFromReader) { ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); + renderer.setOrientation(GfxRenderer::Orientation::Portrait); } else { GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); } - ReaderUtils::applyOrientation(renderer, SETTINGS.sleepScreenOrientation); - switch (SETTINGS.sleepScreen) { case (CrossPointSettings::SLEEP_SCREEN_MODE::BLANK): return renderBlankSleepScreen(); From 29fd29f537742bbf82f10c28ed521b47a8524533 Mon Sep 17 00:00:00 2001 From: Chun Ming Lee <95391408+leecming82@users.noreply.github.com> Date: Sat, 9 May 2026 03:34:42 +0800 Subject: [PATCH 20/21] feat: Status bar for XTC files (#1849) ## Summary Add ability to show a status bar for 1-bit XTC files (closes #1848) Overlays a status bar (similar style to that for epubs) over the image. Defaults to hidden, users can set to show and either top or bottom of the screen within "Customize Status Bar" reader settings. I've leaned toward a simple overlay approach rather than trying anything clever e.g. resizing the original image or allowing for shifting the image around. I think it's more straight forward for the user to create a buffer zone when generating the XTC files. For 2-bit image files, it'd require more work to handle the multiple render passes so I'm holding off on that. Sample from a Japanese text XTC image ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< YES | PARTIALLY | NO >**_ YES - Codex --------- Co-authored-by: Zach Nelson --- lib/I18n/translations/english.yaml | 3 + src/CrossPointSettings.h | 7 ++ src/SettingsList.h | 3 + src/activities/reader/XtcReaderActivity.cpp | 78 ++++++++++++++++++- src/activities/reader/XtcReaderActivity.h | 12 +++ .../settings/StatusBarSettingsActivity.cpp | 17 +++- 6 files changed, 117 insertions(+), 3 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index b6623899..bcc1a20b 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -229,6 +229,9 @@ STR_EXAMPLE_BOOK: "Book Title" STR_PREVIEW: "Preview" STR_TITLE: "Title" STR_BATTERY: "Battery" +STR_XTC_STATUS_BAR: "XTC Status Bar" +STR_BOTTOM: "Bottom" +STR_TOP: "Top" STR_UI_THEME: "UI Theme" STR_THEME_CLASSIC: "Classic" STR_THEME_LYRA: "Lyra" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 01d7cc56..cf5c695d 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -57,6 +57,12 @@ class CrossPointSettings { STATUS_BAR_PROGRESS_BAR_THICKNESS_COUNT }; enum STATUS_BAR_TITLE { BOOK_TITLE = 0, CHAPTER_TITLE = 1, HIDE_TITLE = 2, STATUS_BAR_TITLE_COUNT }; + enum XTC_STATUS_BAR_MODE { + XTC_STATUS_BAR_HIDE = 0, + XTC_STATUS_BAR_BOTTOM = 1, + XTC_STATUS_BAR_TOP = 2, + XTC_STATUS_BAR_MODE_COUNT + }; enum ORIENTATION { PORTRAIT = 0, // 480x800 logical coordinates (current default) @@ -161,6 +167,7 @@ class CrossPointSettings { uint8_t statusBarProgressBarThickness = PROGRESS_BAR_NORMAL; uint8_t statusBarTitle = CHAPTER_TITLE; uint8_t statusBarBattery = 1; + uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE; // Text rendering settings uint8_t extraParagraphSpacing = 1; uint8_t textAntiAliasing = 1; diff --git a/src/SettingsList.h b/src/SettingsList.h index bbe9a774..cf2d02ed 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -131,6 +131,9 @@ inline const std::vector& getSettingsList() { StrId::STR_CUSTOMISE_STATUS_BAR), SettingInfo::Toggle(StrId::STR_BATTERY, &CrossPointSettings::statusBarBattery, "statusBarBattery", StrId::STR_CUSTOMISE_STATUS_BAR), + SettingInfo::Enum(StrId::STR_XTC_STATUS_BAR, &CrossPointSettings::xtcStatusBarMode, + {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}, "xtcStatusBarMode", + StrId::STR_CUSTOMISE_STATUS_BAR), }; // Only show tilt page turn setting when the QMI8658 IMU is present (X3) if (halTiltSensor.isAvailable()) { diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 8e904fb3..897d3f52 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -12,6 +12,8 @@ #include #include +#include + #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" @@ -131,6 +133,76 @@ void XtcReaderActivity::render(RenderLock&&) { saveProgress(); } +XtcReaderActivity::StatusBarInfo XtcReaderActivity::getStatusBarInfo() const { + const int bookPageCount = static_cast(xtc->getPageCount()); + const int bookPage = static_cast(currentPage) + 1; + std::string title = + SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE ? xtc->getTitle() : ""; + + if (!xtc->hasChapters()) { + return StatusBarInfo{bookPage, bookPageCount, std::move(title)}; + } + + const auto& chapters = xtc->getChapters(); + const auto chapterIt = std::find_if(chapters.begin(), chapters.end(), [this](const xtc::ChapterInfo& chapter) { + return currentPage >= chapter.startPage && currentPage <= chapter.endPage; + }); + + if (chapterIt == chapters.end() || chapterIt->endPage < chapterIt->startPage) { + return StatusBarInfo{bookPage, bookPageCount, std::move(title)}; + } + + if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) { + title = chapterIt->name.empty() ? tr(STR_UNNAMED) : chapterIt->name; + } + + return StatusBarInfo{static_cast(currentPage - chapterIt->startPage) + 1, + static_cast(chapterIt->endPage - chapterIt->startPage) + 1, std::move(title)}; +} + +void XtcReaderActivity::renderStatusBarOverlay(const StatusBarOverlayPosition position) const { + const bool drawBottom = SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_BOTTOM && + position == StatusBarOverlayPosition::Bottom; + const bool drawTop = SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP && + position == StatusBarOverlayPosition::Top; + if (!drawBottom && !drawTop) { + return; + } + + const int statusBarHeight = UITheme::getInstance().getStatusBarHeight(); + if (statusBarHeight <= 0) { + return; + } + + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + + int clearY; + int paddingBottom = 0; + if (position == StatusBarOverlayPosition::Bottom) { + clearY = renderer.getScreenHeight() - orientedMarginBottom - statusBarHeight - 4; + if (clearY < 0) { + clearY = 0; + } + } else { + clearY = orientedMarginTop; + paddingBottom = renderer.getScreenHeight() - statusBarHeight - orientedMarginBottom - orientedMarginTop - 4; + } + const int clearHeight = position == StatusBarOverlayPosition::Bottom + ? renderer.getScreenHeight() - orientedMarginBottom - clearY + : statusBarHeight + 4; + if (clearHeight > 0) { + renderer.fillRect(0, clearY, renderer.getScreenWidth(), clearHeight, false); + } + + const int pageCount = static_cast(xtc->getPageCount()); + const int displayPage = static_cast(currentPage) + 1; + const float progress = pageCount > 0 ? (static_cast(displayPage) * 100.0f) / pageCount : 0.0f; + const auto pageInfo = getStatusBarInfo(); + GUI.drawStatusBar(renderer, progress, pageInfo.currentPage, pageInfo.pageCount, pageInfo.title, paddingBottom); +} + void XtcReaderActivity::renderPage() { const uint16_t pageWidth = xtc->getPageWidth(); const uint16_t pageHeight = xtc->getPageHeight(); @@ -291,7 +363,11 @@ void XtcReaderActivity::renderPage() { free(pageBuffer); - // XTC pages already have status bar pre-rendered, no need to add our own + if (SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP) { + renderStatusBarOverlay(StatusBarOverlayPosition::Top); + } else { + renderStatusBarOverlay(StatusBarOverlayPosition::Bottom); + } ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index 282e8d2c..f020b0b0 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -9,6 +9,9 @@ #include +#include +#include + #include "activities/Activity.h" class XtcReaderActivity final : public Activity { @@ -17,7 +20,16 @@ class XtcReaderActivity final : public Activity { uint32_t currentPage = 0; int pagesUntilFullRefresh = 0; + enum class StatusBarOverlayPosition { Bottom, Top }; + struct StatusBarInfo { + int currentPage; + int pageCount; + std::string title; + }; + void renderPage(); + void renderStatusBarOverlay(StatusBarOverlayPosition position) const; + StatusBarInfo getStatusBarInfo() const; void saveProgress() const; void loadProgress(); diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 6ff0b34d..f62ce4d4 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -11,13 +11,14 @@ #include "fontIds.h" namespace { -constexpr int MENU_ITEMS = 6; +constexpr int MENU_ITEMS = 7; const StrId menuNames[MENU_ITEMS] = {StrId::STR_CHAPTER_PAGE_COUNT, StrId::STR_BOOK_PROGRESS_PERCENTAGE, StrId::STR_PROGRESS_BAR, StrId::STR_PROGRESS_BAR_THICKNESS, StrId::STR_TITLE, - StrId::STR_BATTERY}; + StrId::STR_BATTERY, + StrId::STR_XTC_STATUS_BAR}; constexpr int PROGRESS_BAR_ITEMS = 3; const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; @@ -28,6 +29,9 @@ const StrId progressBarThicknessNames[PROGRESS_BAR_THICKNESS_ITEMS] = { constexpr int TITLE_ITEMS = 3; const StrId titleNames[TITLE_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; +constexpr int XTC_STATUS_BAR_ITEMS = 3; +const StrId xtcStatusBarNames[XTC_STATUS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}; + const int widthMargin = 10; const int verticalPreviewPadding = 50; const int verticalPreviewTextPadding = 40; @@ -51,6 +55,10 @@ void StatusBarSettingsActivity::onEnter() { SETTINGS.statusBarTitle = CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE; } + if (SETTINGS.xtcStatusBarMode >= XTC_STATUS_BAR_ITEMS) { + SETTINGS.xtcStatusBarMode = CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_HIDE; + } + requestUpdate(); } @@ -110,6 +118,9 @@ void StatusBarSettingsActivity::handleSelection() { } else if (selectedIndex == 5) { // Show Battery SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2; + } else if (selectedIndex == 6) { + // XTC Status Bar + SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS; } SETTINGS.saveToFile(); } @@ -143,6 +154,8 @@ void StatusBarSettingsActivity::render(RenderLock&&) { return I18N.get(titleNames[SETTINGS.statusBarTitle]); } else if (index == 5) { return SETTINGS.statusBarBattery ? tr(STR_SHOW) : tr(STR_HIDE); + } else if (index == 6) { + return I18N.get(xtcStatusBarNames[SETTINGS.xtcStatusBarMode]); } else { return tr(STR_HIDE); } From 7993b2bb976b2447c4a5221fc415f1f284e5f840 Mon Sep 17 00:00:00 2001 From: Adrian Wilkins-Caruana Date: Fri, 8 May 2026 21:36:35 -0500 Subject: [PATCH 21/21] feat: add SD card font support with on-device download and web management Add a complete SD card font subsystem that enables users to install and use custom fonts beyond the three built-in families. This combines the back-end firmware support (#1327) with the font configuration, build pipeline, CI distribution, and user-facing management UI (#1392). Core font system: - Custom .cpfont binary format (v4) with multi-style support (regular, bold, italic, bold-italic) packed into a single file per size - On-demand glyph loading from SD card with two-pass prewarm rendering to bulk-read glyphs per page, achieving near-flash performance for Latin text (~697ms vs ~681ms) and viable CJK rendering (~32% slower) - Persistent advance cache for layout measurement without SD I/O - Overflow ring buffer for glyph cache misses during rendering - Memory-conscious design: only advance tables kept in RAM; glyph bitmaps, kern tables, and ligatures loaded on demand from SD Font management: - On-device WiFi download from GitHub Releases with manifest-based discovery, install/update detection, and progress UI - Web interface font upload, listing, and deletion via /fonts page - Manual SD card copy to /fonts/ or /.fonts/ directories - Font selection integrated into Settings > Reader > Font Family Build pipeline: - Declarative YAML config (sd-fonts.yaml) as single source of truth for the 17-family font library (serif, sans, mono, accessibility) - Python converter (fontconvert_sdcard.py) for TTF/OTF to .cpfont with FreeType rasterization, class-based kerning, and ligature extraction - Parallel build orchestrator with variable font instance extraction - CI workflow publishing versioned + stable releases to a dedicated crosspoint-fonts repository with auto-incrementing revision tags - Centralized version constants (cpfont_version.py) shared across build tooling and CI, with firmware headers as manual sync points Additional fixes: - CJK characters no longer get hyphens inserted at line breaks - Advance table eliminates 30+ second stalls during CJK section indexing for paragraphs with >512 unique codepoints Closes #930 Co-authored-by: Zach Nelson Co-authored-by: Justin Co-authored-by: jpirnay Co-authored-by: mcrosson --- .github/workflows/release-fonts.yml | 106 ++ .gitignore | 4 + docs/sd-card-fonts.md | 94 ++ lib/EpdFont/EpdFont.cpp | 32 +- lib/EpdFont/EpdFontData.h | 12 + lib/EpdFont/FontDecompressor.cpp | 37 + lib/EpdFont/SdCardFont.cpp | 1295 +++++++++++++++++ lib/EpdFont/SdCardFont.h | 241 +++ lib/EpdFont/SdCardFontManager.cpp | 98 ++ lib/EpdFont/SdCardFontManager.h | 50 + lib/EpdFont/SdCardFontRegistry.cpp | 230 +++ lib/EpdFont/SdCardFontRegistry.h | 58 + lib/EpdFont/builtinFonts/source/.gitignore | 12 + lib/EpdFont/scripts/build-sd-fonts.py | 331 +++++ lib/EpdFont/scripts/cpfont_version.py | 15 + lib/EpdFont/scripts/fontconvert_sdcard.py | 898 ++++++++++++ lib/EpdFont/scripts/sd-fonts.yaml | 211 +++ lib/Epub/Epub/ParsedText.cpp | 31 + lib/Epub/Epub/hyphenation/Hyphenator.cpp | 13 +- lib/GfxRenderer/FontCacheManager.cpp | 25 +- lib/GfxRenderer/FontCacheManager.h | 4 +- lib/GfxRenderer/GfxRenderer.cpp | 60 +- lib/GfxRenderer/GfxRenderer.h | 22 + lib/I18n/translations/english.yaml | 15 + lib/Utf8/Utf8.h | 18 + scripts/generate-font-manifest.py | 255 ++++ src/CrossPointSettings.cpp | 20 + src/CrossPointSettings.h | 11 +- src/FontInstaller.cpp | 156 ++ src/FontInstaller.h | 59 + src/JsonSettingsIO.cpp | 17 + src/SdCardFontGlobals.h | 12 + src/SdCardFontSystem.cpp | 109 ++ src/SdCardFontSystem.h | 52 + src/SettingsList.h | 104 +- src/activities/ActivityManager.cpp | 2 + src/activities/reader/EpubReaderActivity.cpp | 14 +- .../settings/FontDownloadActivity.cpp | 426 ++++++ .../settings/FontDownloadActivity.h | 91 ++ .../settings/FontSelectionActivity.cpp | 126 ++ .../settings/FontSelectionActivity.h | 34 + src/activities/settings/SettingsActivity.cpp | 73 +- src/activities/settings/SettingsActivity.h | 3 + src/components/themes/BaseTheme.cpp | 13 +- src/components/themes/BaseTheme.h | 4 +- src/components/themes/lyra/LyraTheme.cpp | 12 +- src/components/themes/lyra/LyraTheme.h | 2 +- .../themes/roundedraff/RoundedRaffTheme.cpp | 4 +- .../themes/roundedraff/RoundedRaffTheme.h | 4 +- src/fontIds.h | 18 + src/main.cpp | 10 +- src/network/CrossPointWebServer.cpp | 227 ++- src/network/CrossPointWebServer.h | 22 + src/network/html/FilesPage.html | 1 + src/network/html/FontsPage.html | 323 ++++ src/network/html/HomePage.html | 1 + src/network/html/SettingsPage.html | 1 + 57 files changed, 6064 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/release-fonts.yml create mode 100644 docs/sd-card-fonts.md create mode 100644 lib/EpdFont/SdCardFont.cpp create mode 100644 lib/EpdFont/SdCardFont.h create mode 100644 lib/EpdFont/SdCardFontManager.cpp create mode 100644 lib/EpdFont/SdCardFontManager.h create mode 100644 lib/EpdFont/SdCardFontRegistry.cpp create mode 100644 lib/EpdFont/SdCardFontRegistry.h create mode 100644 lib/EpdFont/builtinFonts/source/.gitignore create mode 100755 lib/EpdFont/scripts/build-sd-fonts.py create mode 100644 lib/EpdFont/scripts/cpfont_version.py create mode 100755 lib/EpdFont/scripts/fontconvert_sdcard.py create mode 100644 lib/EpdFont/scripts/sd-fonts.yaml create mode 100755 scripts/generate-font-manifest.py create mode 100644 src/FontInstaller.cpp create mode 100644 src/FontInstaller.h create mode 100644 src/SdCardFontGlobals.h create mode 100644 src/SdCardFontSystem.cpp create mode 100644 src/SdCardFontSystem.h create mode 100644 src/activities/settings/FontDownloadActivity.cpp create mode 100644 src/activities/settings/FontDownloadActivity.h create mode 100644 src/activities/settings/FontSelectionActivity.cpp create mode 100644 src/activities/settings/FontSelectionActivity.h create mode 100644 src/network/html/FontsPage.html diff --git a/.github/workflows/release-fonts.yml b/.github/workflows/release-fonts.yml new file mode 100644 index 00000000..0fcf9874 --- /dev/null +++ b/.github/workflows/release-fonts.yml @@ -0,0 +1,106 @@ +name: Build & Publish SD Card Fonts + +# Fonts change rarely — run manually when font sources or the conversion +# pipeline are updated. Publishes .cpfont files + fonts.json manifest as +# GitHub Release assets on the crosspoint-fonts repo so font releases don't +# clutter the firmware releases page. +# +# Requires a repository secret FONTS_REPO_TOKEN — a fine-grained PAT (or +# classic PAT) with contents:write permission on the target fonts repo. +on: + workflow_dispatch: + +env: + FONTS_REPO: crosspoint-reader/crosspoint-fonts + +jobs: + build-fonts: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + + - name: Install font tools + run: pip install freetype-py fonttools pyyaml + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libfreetype6-dev + + - name: Read version constants + id: versions + run: | + cd lib/EpdFont/scripts + echo "binary=$(python3 -c 'from cpfont_version import CPFONT_VERSION; print(CPFONT_VERSION)')" >> "$GITHUB_OUTPUT" + 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 + + - name: Flatten output for release assets + run: | + mkdir -p dist + find lib/EpdFont/scripts/output -name '*.cpfont' -exec cp {} dist/ \; + + - name: Compute release tags + id: tags + env: + GH_TOKEN: ${{ secrets.FONTS_REPO_TOKEN }} + run: | + BASE="sd-fonts-m${{ steps.versions.outputs.metadata }}-b${{ steps.versions.outputs.binary }}" + echo "base=$BASE" >> "$GITHUB_OUTPUT" + + # Find the highest existing revision for this m/b pair + LAST=$(gh release list --repo "${{ env.FONTS_REPO }}" \ + --json tagName --jq \ + '[.[] | select(.tagName | startswith("'"${BASE}-r"'")) | .tagName | split("-r")[1] | tonumber] | max // 0') + NEXT=$((LAST + 1)) + echo "revision=$NEXT" >> "$GITHUB_OUTPUT" + echo "versioned=${BASE}-r${NEXT}" >> "$GITHUB_OUTPUT" + + - name: Generate manifest + run: | + python3 scripts/generate-font-manifest.py \ + --input dist \ + --base-url "https://github.com/${{ env.FONTS_REPO }}/releases/download/${{ steps.tags.outputs.base }}/" \ + --output dist/fonts.json \ + --descriptions-from lib/EpdFont/scripts/sd-fonts.yaml + + - name: Publish versioned release to fonts repo + env: + GH_TOKEN: ${{ secrets.FONTS_REPO_TOKEN }} + run: | + VERSIONED="${{ steps.tags.outputs.versioned }}" + TITLE="SD Card Fonts (${VERSIONED#sd-fonts-})" + + gh release create "$VERSIONED" dist/* \ + --repo "${{ env.FONTS_REPO }}" \ + --title "$TITLE" \ + --notes "Pre-built \`.cpfont\` font files for CrossPoint Reader. + + Download individual files or use **Settings > System > Download Fonts** on the device. + + See [SD Card Fonts documentation](https://github.com/${{ github.repository }}/blob/main/docs/sd-card-fonts.md) for details." + + - name: Update stable tag for device downloads + env: + GH_TOKEN: ${{ secrets.FONTS_REPO_TOKEN }} + run: | + BASE="${{ steps.tags.outputs.base }}" + + # Delete the old stable release for this m/b pair (the versioned releases are kept) + gh release delete "$BASE" --repo "${{ env.FONTS_REPO }}" --yes 2>/dev/null || true + + gh release create "$BASE" dist/* \ + --repo "${{ env.FONTS_REPO }}" \ + --title "SD Card Fonts (${BASE#sd-fonts-})" \ + --notes "Current font build for manifest v${{ steps.versions.outputs.metadata }}, binary format v${{ steps.versions.outputs.binary }}. Devices with this firmware version download from this release. + + 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." diff --git a/.gitignore b/.gitignore index f4bbcdfc..088d756c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ build .history/ /.venv *.local* +*.cpfont +lib/EpdFont/scripts/downloaded_fonts/ +lib/EpdFont/scripts/instanced_fonts/ +lib/EpdFont/scripts/output/ diff --git a/docs/sd-card-fonts.md b/docs/sd-card-fonts.md new file mode 100644 index 00000000..39f56e5e --- /dev/null +++ b/docs/sd-card-fonts.md @@ -0,0 +1,94 @@ +# SD Card Fonts + +CrossPoint supports loading additional fonts from the SD card, including fonts +with extended Unicode coverage (CJK, Cyrillic, Greek, etc.). + +## Installing Fonts + +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** +3. Browse available font families and tap to download +4. Downloaded fonts appear immediately in **Settings > Reader > Font Family** + +### Option 2: Upload via web browser + +1. Connect your CrossPoint reader to WiFi +2. Open the web interface in your browser (shown on the WiFi screen) +3. Navigate to the **Fonts** tab +4. Upload `.cpfont` files using the upload form + +### 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: + + SD Card Root/ + └── .crosspoint/ + └── fonts/ + ├── Bookerly-SD/ + │ ├── Bookerly-SD_12.cpfont + │ ├── Bookerly-SD_14.cpfont + │ ├── Bookerly-SD_16.cpfont + │ └── Bookerly-SD_18.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 | + +## Converting Custom Fonts + +To convert your own TrueType/OpenType fonts: + +### Prerequisites + + pip install freetype-py fonttools + +### Single font (one style) + + python3 lib/EpdFont/scripts/fontconvert_sdcard.py \ + MyFont-Regular.ttf \ + --intervals latin-ext \ + --sizes 12,14,16,18 \ + --style regular \ + --name MyFont \ + --output-dir ./MyFont/ + +### Multi-style font + + python3 lib/EpdFont/scripts/fontconvert_sdcard.py \ + --regular MyFont-Regular.ttf \ + --bold MyFont-Bold.ttf \ + --italic MyFont-Italic.ttf \ + --bolditalic MyFont-BoldItalic.ttf \ + --intervals latin-ext \ + --sizes 12,14,16,18 \ + --name MyFont \ + --output-dir ./MyFont/ + +### Available Unicode interval presets + +| Preset | Coverage | +|--------|----------| +| `ascii` | U+0020-U+007E (Basic Latin) | +| `latin-ext` | European languages (Latin + Extended-A/B) | +| `greek` | Greek + Extended Greek | +| `cyrillic` | Cyrillic + Supplement | +| `cjk` | CJK Unified Ideographs + Hiragana + Katakana + Fullwidth | +| `hangul` | Korean Hangul syllables | +| `builtin` | Matches built-in Bookerly coverage exactly | + +Combine presets with commas: `--intervals latin-ext,greek,cyrillic` + +Install custom fonts via WiFi upload or manual SD card copy. diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index fbcc3299..599554b7 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -153,24 +153,32 @@ uint32_t EpdFont::applyLigatures(uint32_t cp, const char*& text) const { const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const { const int count = data->intervalCount; - if (count == 0) return nullptr; + if (count == 0 && !data->glyphMissHandler) return nullptr; - const EpdUnicodeInterval* intervals = data->intervals; - const auto* end = intervals + count; + if (count > 0) { + const EpdUnicodeInterval* intervals = data->intervals; + const auto* end = intervals + count; - // upper_bound: range lookup. Finds the first interval with first > cp, so the - // interval just before it is the last one with first <= cp. That's the only - // candidate that could contain cp. Then we verify cp <= candidate.last. - const auto it = std::upper_bound( - intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; }); + // upper_bound: range lookup. Finds the first interval with first > cp, so the + // interval just before it is the last one with first <= cp. That's the only + // candidate that could contain cp. Then we verify cp <= candidate.last. + const auto it = std::upper_bound( + intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; }); - if (it != intervals) { - const auto& interval = *(it - 1); - if (cp <= interval.last) { - return &data->glyph[interval.offset + (cp - interval.first)]; + if (it != intervals) { + const auto& interval = *(it - 1); + if (cp <= interval.last) { + return &data->glyph[interval.offset + (cp - interval.first)]; + } } } + // Codepoint not in interval table — try on-demand loading (SD card fonts). + if (data->glyphMissHandler) { + const EpdGlyph* loaded = data->glyphMissHandler(data->glyphMissCtx, cp); + if (loaded) return loaded; + } + if (cp != REPLACEMENT_GLYPH) { return getGlyph(REPLACEMENT_GLYPH); } diff --git a/lib/EpdFont/EpdFontData.h b/lib/EpdFont/EpdFontData.h index 380c5733..cb1d5250 100644 --- a/lib/EpdFont/EpdFontData.h +++ b/lib/EpdFont/EpdFontData.h @@ -129,4 +129,16 @@ typedef struct { uint8_t kernRightClassCount; ///< Number of distinct right classes (matrix cols) const EpdLigaturePair* ligaturePairs; ///< Sorted ligature pair table (nullptr if none) uint32_t ligaturePairCount; ///< Number of entries in ligaturePairs + + /// On-demand glyph loading for fonts that don't keep all glyphs in RAM (e.g. SD card fonts). + /// Called by getGlyph() when a codepoint is not found in the interval table. + /// Returns a valid EpdGlyph* with correct metadata, or nullptr to fall back to the + /// replacement glyph. The returned pointer is valid until the next glyphMissHandler + /// call that causes a ring-buffer eviction — callers must consume it (measure or draw) + /// before requesting another missed glyph. + const EpdGlyph* (*glyphMissHandler)(void* ctx, uint32_t codepoint); + + /// Context pointer for glyphMissHandler (typically SdCardFont*). Also used by + /// GfxRenderer::getGlyphBitmap() to retrieve overflow bitmaps via SdCardFont. + void* glyphMissCtx; } EpdFontData; diff --git a/lib/EpdFont/FontDecompressor.cpp b/lib/EpdFont/FontDecompressor.cpp index 10af14df..adfbb628 100644 --- a/lib/EpdFont/FontDecompressor.cpp +++ b/lib/EpdFont/FontDecompressor.cpp @@ -284,6 +284,43 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8 } } + // Add ligature output glyphs: if both input codepoints of a ligature pair are + // in the needed set, the output glyph will be queried during rendering. + if (fontData->ligaturePairs && fontData->ligaturePairCount > 0) { + for (uint32_t li = 0; li < fontData->ligaturePairCount && glyphCount < MAX_PAGE_GLYPHS; li++) { + uint32_t leftCp = fontData->ligaturePairs[li].pair >> 16; + uint32_t rightCp = fontData->ligaturePairs[li].pair & 0xFFFF; + + int32_t leftIdx = findGlyphIndex(fontData, leftCp); + int32_t rightIdx = findGlyphIndex(fontData, rightCp); + if (leftIdx < 0 || rightIdx < 0) continue; + + // Check if both inputs are in neededGlyphs + bool hasLeft = false, hasRight = false; + for (uint16_t i = 0; i < glyphCount; i++) { + if (neededGlyphs[i] == static_cast(leftIdx)) hasLeft = true; + if (neededGlyphs[i] == static_cast(rightIdx)) hasRight = true; + if (hasLeft && hasRight) break; + } + if (!hasLeft || !hasRight) continue; + + int32_t outIdx = findGlyphIndex(fontData, fontData->ligaturePairs[li].ligatureCp); + if (outIdx < 0) continue; + + // Deduplicate + bool found = false; + for (uint16_t i = 0; i < glyphCount; i++) { + if (neededGlyphs[i] == static_cast(outIdx)) { + found = true; + break; + } + } + if (!found) { + neededGlyphs[glyphCount++] = static_cast(outIdx); + } + } + } + if (glyphCount == 0) return 0; // Step 2: Compute total buffer size and collect unique groups diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp new file mode 100644 index 00000000..2a3329da --- /dev/null +++ b/lib/EpdFont/SdCardFont.cpp @@ -0,0 +1,1295 @@ +#include "SdCardFont.h" + +#include +#include +#include + +#include +#include +#include +#include + +static_assert(sizeof(EpdGlyph) == 16, "EpdGlyph must be 16 bytes to match .cpfont file layout"); +static_assert(sizeof(EpdUnicodeInterval) == 12, "EpdUnicodeInterval must be 12 bytes to match .cpfont file layout"); +static_assert(sizeof(EpdKernClassEntry) == 3, "EpdKernClassEntry must be 3 bytes to match .cpfont file layout"); +static_assert(sizeof(EpdLigaturePair) == 8, "EpdLigaturePair must be 8 bytes to match .cpfont file layout"); + +// FNV-1a hash for content-based font ID generation +static constexpr uint32_t FNV_OFFSET = 2166136261u; +static constexpr uint32_t FNV_PRIME = 16777619u; + +static uint32_t fnv1a(const uint8_t* data, size_t len, uint32_t hash = FNV_OFFSET) { + for (size_t i = 0; i < len; i++) { + hash ^= data[i]; + hash *= FNV_PRIME; + } + return hash; +} + +// .cpfont magic bytes +static constexpr char CPFONT_MAGIC[8] = {'C', 'P', 'F', 'O', 'N', 'T', '\0', '\0'}; +// CPFONT_VERSION is defined as a #define in SdCardFont.h so it can be +// stringified into FONT_MANIFEST_URL. +static constexpr uint32_t HEADER_SIZE = 32; +static constexpr uint32_t STYLE_TOC_ENTRY_SIZE = 32; + +// Helper to read little-endian values from byte buffer +static inline uint16_t readU16(const uint8_t* p) { return p[0] | (p[1] << 8); } +static inline int16_t readI16(const uint8_t* p) { return static_cast(p[0] | (p[1] << 8)); } +static inline uint32_t readU32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); } + +SdCardFont::~SdCardFont() { freeAll(); } + +// --- Per-style free/cleanup --- + +void SdCardFont::freeStyleMiniData(PerStyle& s) { + delete[] s.miniIntervals; + s.miniIntervals = nullptr; + delete[] s.miniGlyphs; + s.miniGlyphs = nullptr; + delete[] s.miniBitmap; + s.miniBitmap = nullptr; + s.miniIntervalCount = 0; + s.miniGlyphCount = 0; + freeStyleMiniKern(s); + memset(&s.miniData, 0, sizeof(s.miniData)); + s.epdFont.data = &s.stubData; +} + +void SdCardFont::freeStyleKernLigatureData(PerStyle& s) { + delete[] s.kernLeftClasses; + s.kernLeftClasses = nullptr; + delete[] s.kernRightClasses; + s.kernRightClasses = nullptr; + delete[] s.ligaturePairs; + s.ligaturePairs = nullptr; + s.kernLigLoaded = false; +} + +void SdCardFont::freeStyleMiniKern(PerStyle& s) { + delete[] s.miniKernLeftClasses; + s.miniKernLeftClasses = nullptr; + delete[] s.miniKernRightClasses; + s.miniKernRightClasses = nullptr; + delete[] s.miniKernMatrix; + s.miniKernMatrix = nullptr; + s.miniKernLeftEntryCount = 0; + s.miniKernRightEntryCount = 0; + s.miniKernLeftClassCount = 0; + s.miniKernRightClassCount = 0; +} + +void SdCardFont::freeStyleAll(PerStyle& s) { + freeStyleMiniData(s); + delete[] s.fullIntervals; + s.fullIntervals = nullptr; + freeStyleKernLigatureData(s); + s.present = false; +} + +// --- Global free/cleanup --- + +void SdCardFont::freeAll() { + clearOverflow(); + clearPersistentCache(); + for (uint8_t i = 0; i < MAX_STYLES; i++) { + freeStyleAll(styles_[i]); + } + styleCount_ = 0; + contentHash_ = 0; + loaded_ = false; +} + +void SdCardFont::clearOverflow() { + for (uint32_t i = 0; i < overflowCount_; i++) { + delete[] overflow_[i].bitmap; + overflow_[i].bitmap = nullptr; + overflow_[i].codepoint = 0; + } + overflowCount_ = 0; + overflowNext_ = 0; +} + +// --- Per-style kern/ligature --- + +void SdCardFont::applyKernLigaturePointers(PerStyle& s, EpdFontData& data) const { + // Kern data uses the per-page mini tables (renumbered class IDs). The full + // kern matrix is never resident — see PerStyle::miniKernMatrix comment. + data.kernLeftClasses = s.miniKernLeftClasses; + data.kernRightClasses = s.miniKernRightClasses; + data.kernMatrix = s.miniKernMatrix; + data.kernLeftEntryCount = s.miniKernLeftEntryCount; + data.kernRightEntryCount = s.miniKernRightEntryCount; + data.kernLeftClassCount = s.miniKernLeftClassCount; + data.kernRightClassCount = s.miniKernRightClassCount; + // Ligatures are small (typically < 1KB) so they stay resident. + data.ligaturePairs = s.ligaturePairs; + data.ligaturePairCount = s.header.ligaturePairCount; +} + +bool SdCardFont::loadStyleKernLigatureData(PerStyle& s) { + if (s.kernLigLoaded) return true; + bool hasKern = s.header.kernLeftEntryCount > 0; + bool hasLig = s.header.ligaturePairCount > 0; + if (!hasKern && !hasLig) { + s.kernLigLoaded = true; + return true; + } + + FsFile file; + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "Failed to open .cpfont for kern/lig: %s", filePath_); + return false; + } + + if (hasKern) { + // Load only the small class-lookup tables (~3KB each). The full matrix + // (~36KB contiguous for Literata) is built per-page from SD in + // buildMiniKernMatrix(). + s.kernLeftClasses = new (std::nothrow) EpdKernClassEntry[s.header.kernLeftEntryCount]; + s.kernRightClasses = new (std::nothrow) EpdKernClassEntry[s.header.kernRightEntryCount]; + + if (!s.kernLeftClasses || !s.kernRightClasses) { + LOG_ERR("SDCF", "Failed to allocate kern classes (%u+%u bytes)", s.header.kernLeftEntryCount * 3u, + s.header.kernRightEntryCount * 3u); + freeStyleKernLigatureData(s); + return false; + } + + if (!file.seekSet(s.kernLeftFileOffset)) { + LOG_ERR("SDCF", "Failed to seek to kern data"); + freeStyleKernLigatureData(s); + return false; + } + size_t leftSz = s.header.kernLeftEntryCount * sizeof(EpdKernClassEntry); + size_t rightSz = s.header.kernRightEntryCount * sizeof(EpdKernClassEntry); + if (file.read(reinterpret_cast(s.kernLeftClasses), leftSz) != static_cast(leftSz) || + file.read(reinterpret_cast(s.kernRightClasses), rightSz) != static_cast(rightSz)) { + LOG_ERR("SDCF", "Failed to read kern classes"); + freeStyleKernLigatureData(s); + return false; + } + } + + if (hasLig) { + s.ligaturePairs = new (std::nothrow) EpdLigaturePair[s.header.ligaturePairCount]; + if (!s.ligaturePairs) { + LOG_ERR("SDCF", "Failed to allocate ligature pairs"); + freeStyleKernLigatureData(s); + return false; + } + if (!file.seekSet(s.ligatureFileOffset)) { + LOG_ERR("SDCF", "Failed to seek to ligature data"); + freeStyleKernLigatureData(s); + return false; + } + size_t sz = s.header.ligaturePairCount * sizeof(EpdLigaturePair); + if (file.read(reinterpret_cast(s.ligaturePairs), sz) != static_cast(sz)) { + LOG_ERR("SDCF", "Failed to read ligature pairs"); + freeStyleKernLigatureData(s); + return false; + } + } + + s.kernLigLoaded = true; + + // Make ligatures visible to the stub (used when no mini data built yet). + // Kern stays nullptr on the stub — it is only wired in miniData via + // applyKernLigaturePointers() after buildMiniKernMatrix() runs. + s.stubData.ligaturePairs = s.ligaturePairs; + s.stubData.ligaturePairCount = s.header.ligaturePairCount; + + LOG_DBG("SDCF", "Kern classes + lig loaded: kernL=%u, kernR=%u, ligs=%u", s.header.kernLeftEntryCount, + s.header.kernRightEntryCount, s.header.ligaturePairCount); + return true; +} + +// --- Per-page mini kern matrix --- + +// Local copy of EpdFont.cpp's lookupKernClass (that one is file-static there). +// Returns the 1-based class ID for `cp`, or 0 if the codepoint has no kerning class. +static uint8_t miniLookupKernClass(const EpdKernClassEntry* entries, uint16_t count, uint32_t cp) { + if (!entries || count == 0 || cp > 0xFFFF) return 0; + const auto target = static_cast(cp); + const auto* end = entries + count; + const auto it = + std::lower_bound(entries, end, target, [](const EpdKernClassEntry& e, uint16_t v) { return e.codepoint < v; }); + return (it != end && it->codepoint == target) ? it->classId : 0; +} + +// Build a small per-page kern matrix containing ONLY the (leftClass, rightClass) +// pairs reachable from codepoints in the current text. Class IDs are renumbered +// to a dense 1..N range so the resulting matrix is usedLeft × usedRight (typical +// Latin page: ~25×25 bytes) instead of the font's full ~180×200 (~36KB). +// +// Correctness: EpdFont::getKerning only touches `kernLeftClasses` / +// `kernRightClasses` / `kernMatrix` / the count fields — we swap all of them to +// the mini versions together in applyKernLigaturePointers, so a codepoint not +// on this page simply returns class 0 (no kerning), which was the pre-existing +// behavior for any codepoint outside the kern classes. +bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, uint32_t cpCount) { + freeStyleMiniKern(s); + if (!s.kernLeftClasses || !s.kernRightClasses || s.header.kernLeftEntryCount == 0 || + s.header.kernRightEntryCount == 0) { + return true; // font has no kern classes — nothing to build + } + + // Step 1: mark used left/right classes via a 256-wide bitmap (class IDs are uint8_t). + bool usedLeft[256] = {}; + bool usedRight[256] = {}; + for (uint32_t i = 0; i < cpCount; i++) { + uint8_t lc = miniLookupKernClass(s.kernLeftClasses, s.header.kernLeftEntryCount, codepoints[i]); + if (lc) usedLeft[lc] = true; + uint8_t rc = miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]); + if (rc) usedRight[rc] = true; + } + + // Step 2: build renumber maps (oldClassId -> newClassId, 1-based) and + // reverse maps (newClassId -> oldClassId) for the SD read step. + uint8_t leftRenumber[256] = {}; + uint8_t rightRenumber[256] = {}; + uint8_t newToOldLeft[256] = {}; + uint8_t newToOldRight[256] = {}; + uint8_t numLeft = 0, numRight = 0; + for (int i = 1; i < 256; i++) { + if (usedLeft[i]) { + numLeft++; + leftRenumber[i] = numLeft; + newToOldLeft[numLeft] = static_cast(i); + } + if (usedRight[i]) { + numRight++; + rightRenumber[i] = numRight; + newToOldRight[numRight] = static_cast(i); + } + } + if (numLeft == 0 || numRight == 0) { + return true; // no kern pairs applicable on this page + } + + // Step 3: count how many codepoint→classId entries the mini class tables need. + // Each resident class table has one entry per kerned codepoint in the page. + uint16_t miniLeftCount = 0; + uint16_t miniRightCount = 0; + for (uint32_t i = 0; i < cpCount; i++) { + if (miniLookupKernClass(s.kernLeftClasses, s.header.kernLeftEntryCount, codepoints[i]) != 0) miniLeftCount++; + if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++; + } + + // Step 4: allocate the three mini buffers. The matrix is <1KB in practice + // (<30 × <30 × 1 byte) so fragmentation is a non-issue. + const uint32_t matrixBytes = static_cast(numLeft) * numRight; + s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount]; + s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount]; + s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes]; + if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) { + LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u, + matrixBytes); + freeStyleMiniKern(s); + return false; + } + + // Step 5: populate mini class tables. `codepoints` is already sorted (see + // prewarm()) so the output is sorted by codepoint — required for binary + // search in lookupKernClass during render. + uint16_t lIdx = 0, rIdx = 0; + for (uint32_t i = 0; i < cpCount; i++) { + uint32_t cp = codepoints[i]; + if (cp > 0xFFFF) continue; // kern class entries are uint16_t + uint8_t lc = miniLookupKernClass(s.kernLeftClasses, s.header.kernLeftEntryCount, cp); + if (lc) { + s.miniKernLeftClasses[lIdx].codepoint = static_cast(cp); + s.miniKernLeftClasses[lIdx].classId = leftRenumber[lc]; + lIdx++; + } + uint8_t rc = miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, cp); + if (rc) { + s.miniKernRightClasses[rIdx].codepoint = static_cast(cp); + s.miniKernRightClasses[rIdx].classId = rightRenumber[rc]; + rIdx++; + } + } + + // Step 6: read the full matrix's rows for each used left class, keep only + // columns for used right classes. One SD seek + one read per used left class; + // a row is kernRightClassCount bytes (~200 for Literata). + FsFile file; + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "Failed to open .cpfont for mini kern: %s", filePath_); + freeStyleMiniKern(s); + return false; + } + + std::unique_ptr rowBuf(new (std::nothrow) int8_t[s.header.kernRightClassCount]); + if (!rowBuf) { + LOG_ERR("SDCF", "Failed to allocate row buffer (%u bytes)", s.header.kernRightClassCount); + freeStyleMiniKern(s); + return false; + } + + for (uint8_t newL = 1; newL <= numLeft; newL++) { + const uint8_t oldL = newToOldLeft[newL]; + const uint32_t rowFileOff = s.kernMatrixFileOffset + (oldL - 1u) * s.header.kernRightClassCount; + if (!file.seekSet(rowFileOff)) { + LOG_ERR("SDCF", "Failed to seek to kern row %u", oldL); + freeStyleMiniKern(s); + return false; + } + if (file.read(reinterpret_cast(rowBuf.get()), s.header.kernRightClassCount) != + static_cast(s.header.kernRightClassCount)) { + LOG_ERR("SDCF", "Failed to read kern row %u", oldL); + freeStyleMiniKern(s); + return false; + } + int8_t* miniRow = s.miniKernMatrix + (newL - 1u) * numRight; + for (uint8_t newR = 1; newR <= numRight; newR++) { + miniRow[newR - 1] = rowBuf[newToOldRight[newR] - 1u]; + } + } + + s.miniKernLeftEntryCount = lIdx; + s.miniKernRightEntryCount = rIdx; + s.miniKernLeftClassCount = numLeft; + s.miniKernRightClassCount = numRight; + + LOG_DBG("SDCF", "Built mini kern: %u×%u matrix (%u bytes, full was %u×%u = %u bytes)", numLeft, numRight, matrixBytes, + s.header.kernLeftClassCount, s.header.kernRightClassCount, + static_cast(s.header.kernLeftClassCount) * s.header.kernRightClassCount); + return true; +} + +// --- Glyph miss callback --- + +void SdCardFont::applyGlyphMissCallback(uint8_t styleIdx) { + overflowCtx_[styleIdx].self = this; + overflowCtx_[styleIdx].styleIdx = styleIdx; + + auto& s = styles_[styleIdx]; + s.stubData.glyphMissHandler = &SdCardFont::onGlyphMiss; + s.stubData.glyphMissCtx = &overflowCtx_[styleIdx]; +} + +// --- Compute per-style file offsets from a base data offset --- + +void SdCardFont::computeStyleFileOffsets(PerStyle& s, uint32_t baseOffset) { + s.intervalsFileOffset = baseOffset; + s.glyphsFileOffset = s.intervalsFileOffset + s.header.intervalCount * sizeof(EpdUnicodeInterval); + s.kernLeftFileOffset = s.glyphsFileOffset + s.header.glyphCount * sizeof(EpdGlyph); + s.kernRightFileOffset = s.kernLeftFileOffset + s.header.kernLeftEntryCount * sizeof(EpdKernClassEntry); + s.kernMatrixFileOffset = s.kernRightFileOffset + s.header.kernRightEntryCount * sizeof(EpdKernClassEntry); + s.ligatureFileOffset = + s.kernMatrixFileOffset + static_cast(s.header.kernLeftClassCount) * s.header.kernRightClassCount; + s.bitmapFileOffset = s.ligatureFileOffset + s.header.ligaturePairCount * sizeof(EpdLigaturePair); +} + +// --- Load --- + +bool SdCardFont::load(const char* path) { + freeAll(); + if (strlen(path) >= sizeof(filePath_)) { + LOG_ERR("SDCF", "Path too long (%zu bytes, max %zu)", strlen(path), sizeof(filePath_) - 1); + return false; + } + strncpy(filePath_, path, sizeof(filePath_) - 1); + filePath_[sizeof(filePath_) - 1] = '\0'; + + FsFile file; + if (!Storage.openFileForRead("SDCF", path, file)) { + LOG_ERR("SDCF", "Failed to open .cpfont: %s", path); + return false; + } + + // Read and validate global header + uint8_t headerBuf[HEADER_SIZE]; + if (file.read(headerBuf, HEADER_SIZE) != HEADER_SIZE) { + LOG_ERR("SDCF", "Failed to read header"); + return false; + } + + if (memcmp(headerBuf, CPFONT_MAGIC, 8) != 0) { + LOG_ERR("SDCF", "Invalid magic bytes"); + return false; + } + + uint16_t fileVersion = readU16(headerBuf + 8); + if (fileVersion != CPFONT_VERSION) { + LOG_ERR("SDCF", "Unsupported version: %u (expected %u)", fileVersion, CPFONT_VERSION); + return false; + } + + // Begin content hash: accumulate global header + uint32_t hash = fnv1a(headerBuf, HEADER_SIZE); + + bool is2Bit = (readU16(headerBuf + 10) & 1) != 0; + + uint8_t styleCount = headerBuf[12]; + if (styleCount == 0 || styleCount > MAX_STYLES) { + LOG_ERR("SDCF", "Invalid style count: %u", styleCount); + return false; + } + + // Read style TOC + for (uint8_t i = 0; i < styleCount; i++) { + uint8_t tocBuf[STYLE_TOC_ENTRY_SIZE]; + if (file.read(tocBuf, STYLE_TOC_ENTRY_SIZE) != STYLE_TOC_ENTRY_SIZE) { + LOG_ERR("SDCF", "Failed to read style TOC entry %u", i); + freeAll(); + return false; + } + + // Accumulate TOC entry into content hash + hash = fnv1a(tocBuf, STYLE_TOC_ENTRY_SIZE, hash); + + uint8_t styleId = tocBuf[0]; + if (styleId >= MAX_STYLES) { + LOG_ERR("SDCF", "Invalid styleId %u in TOC", styleId); + file.close(); + freeAll(); + return false; + } + + auto& s = styles_[styleId]; + s.present = true; + s.header.intervalCount = readU32(tocBuf + 4); + s.header.glyphCount = readU32(tocBuf + 8); + s.header.advanceY = tocBuf[12]; + s.header.ascender = readI16(tocBuf + 13); + s.header.descender = readI16(tocBuf + 15); + s.header.kernLeftEntryCount = readU16(tocBuf + 17); + s.header.kernRightEntryCount = readU16(tocBuf + 19); + s.header.kernLeftClassCount = tocBuf[21]; + s.header.kernRightClassCount = tocBuf[22]; + s.header.ligaturePairCount = tocBuf[23]; + s.header.is2Bit = is2Bit; + + // Sanity-check counts to reject malformed files before allocating. + // Kern class counts are uint8 (bounded by type). Entry counts are uint16 + // but in practice a sane font has far fewer than 4096 per-side kern entries. + static constexpr uint32_t MAX_INTERVALS = 4096; + static constexpr uint32_t MAX_GLYPHS = 65536; + static constexpr uint32_t MAX_KERN_ENTRIES = 4096; + if (s.header.intervalCount > MAX_INTERVALS || s.header.glyphCount > MAX_GLYPHS || + s.header.kernLeftEntryCount > MAX_KERN_ENTRIES || s.header.kernRightEntryCount > MAX_KERN_ENTRIES) { + LOG_ERR("SDCF", "Style %u: unreasonable counts (iv=%u, gl=%u, kL=%u, kR=%u)", styleId, s.header.intervalCount, + s.header.glyphCount, s.header.kernLeftEntryCount, s.header.kernRightEntryCount); + file.close(); + freeAll(); + return false; + } + + uint32_t dataOffset = readU32(tocBuf + 24); + computeStyleFileOffsets(s, dataOffset); + } + + styleCount_ = styleCount; + contentHash_ = hash; + + // Load full intervals into RAM for each present style + for (uint8_t i = 0; i < MAX_STYLES; i++) { + auto& s = styles_[i]; + if (!s.present) continue; + + s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount]; + if (!s.fullIntervals) { + LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i); + freeAll(); + return false; + } + + if (!file.seekSet(s.intervalsFileOffset)) { + LOG_ERR("SDCF", "Failed to seek to intervals for style %u", i); + freeAll(); + return false; + } + size_t intervalsBytes = s.header.intervalCount * sizeof(EpdUnicodeInterval); + if (file.read(reinterpret_cast(s.fullIntervals), intervalsBytes) != static_cast(intervalsBytes)) { + LOG_ERR("SDCF", "Failed to read intervals for style %u", i); + freeAll(); + return false; + } + + // Validate interval contents before any later code (findGlobalGlyphIndex, + // glyph reads) trusts them. A malformed file could otherwise drive + // out-of-range glyph indices into bogus on-disk reads. + { + uint32_t expectedOffset = 0; + uint32_t prevLast = 0; + for (uint32_t j = 0; j < s.header.intervalCount; ++j) { + const auto& iv = s.fullIntervals[j]; + if (iv.first > iv.last) { + LOG_ERR("SDCF", "Style %u: invalid interval %u (first 0x%lX > last 0x%lX)", i, j, + static_cast(iv.first), static_cast(iv.last)); + file.close(); + freeAll(); + return false; + } + const uint32_t span = iv.last - iv.first + 1; + const bool overlapsPrev = (j > 0 && iv.first <= prevLast); + const bool spanTooBig = (span > s.header.glyphCount); + const bool offsetMismatch = (iv.offset != expectedOffset); + const bool offsetOverruns = (iv.offset > s.header.glyphCount - span); + if (overlapsPrev || spanTooBig || offsetMismatch || offsetOverruns) { + LOG_ERR("SDCF", "Style %u: invalid interval layout at %u (overlap=%d span=%u offMis=%d offOver=%d)", i, j, + overlapsPrev, span, offsetMismatch, offsetOverruns); + file.close(); + freeAll(); + return false; + } + expectedOffset += span; + prevLast = iv.last; + } + } + + // Initialize stub data + memset(&s.stubData, 0, sizeof(s.stubData)); + s.stubData.advanceY = s.header.advanceY; + s.stubData.ascender = s.header.ascender; + s.stubData.descender = s.header.descender; + s.stubData.is2Bit = s.header.is2Bit; + + s.epdFont.data = &s.stubData; + applyGlyphMissCallback(i); + } + + loaded_ = true; + + LOG_DBG("SDCF", "Loaded: %s (v%u, %u styles)", path, CPFONT_VERSION, styleCount_); + for (uint8_t i = 0; i < MAX_STYLES; i++) { + if (!styles_[i].present) continue; + const auto& h = styles_[i].header; + LOG_DBG("SDCF", " style[%u]: %u intervals, %u glyphs, advY=%u, asc=%d, desc=%d, kernL=%u, kernR=%u, ligs=%u", i, + h.intervalCount, h.glyphCount, h.advanceY, h.ascender, h.descender, h.kernLeftEntryCount, + h.kernRightEntryCount, h.ligaturePairCount); + } + return true; +} + +// --- Codepoint lookup --- + +int32_t SdCardFont::findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) const { + int left = 0; + int right = static_cast(s.header.intervalCount) - 1; + while (left <= right) { + int mid = left + (right - left) / 2; + const auto& interval = s.fullIntervals[mid]; + if (codepoint < interval.first) { + right = mid - 1; + } else if (codepoint > interval.last) { + left = mid + 1; + } else { + return static_cast(interval.offset + (codepoint - interval.first)); + } + } + return -1; +} + +// --- Prewarm --- + +int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOnly) { + if (!loaded_) return -1; + + unsigned long startMs = millis(); + + // Step 1: Extract unique codepoints from UTF-8 text (shared across all styles). + // Dedup uses O(n^2) linear scan — worst case is MAX_PAGE_GLYPHS (512) unique codepoints + // = ~131K comparisons, but in practice pages contain far fewer unique codepoints so the + // actual cost is much lower. This is dwarfed by SD I/O that follows. Alternatives (hash + // set, bitmap) exceed the 256-byte stack limit or add template bloat. + // Heap-allocated: MAX_PAGE_GLYPHS * 4 = 2048 bytes, too large for stack (limit < 256 bytes) + std::unique_ptr codepoints(new (std::nothrow) uint32_t[MAX_PAGE_GLYPHS]); + if (!codepoints) { + LOG_ERR("SDCF", "Failed to allocate codepoint buffer (%u bytes)", MAX_PAGE_GLYPHS * 4); + return -1; + } + uint32_t cpCount = 0; + + const unsigned char* p = reinterpret_cast(utf8Text); + while (*p && cpCount < MAX_PAGE_GLYPHS) { + uint32_t cp = utf8NextCodepoint(&p); + if (cp == 0) break; + + bool found = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == cp) { + found = true; + break; + } + } + if (!found) { + codepoints[cpCount++] = cp; + } + } + + // Always include the replacement character + { + bool hasReplacement = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == REPLACEMENT_GLYPH) { + hasReplacement = true; + break; + } + } + if (!hasReplacement && cpCount < MAX_PAGE_GLYPHS) { + codepoints[cpCount++] = REPLACEMENT_GLYPH; + } + } + + // Add ligature output codepoints from all styles being prewarmed. + // Skip during metadata-only prewarm (layout measurement) to avoid loading + // kern/lig data for all styles upfront (~22KB per style). Kern/lig is + // loaded per-style in prewarmStyle() during the full render prewarm instead. + if (!metadataOnly) { + for (uint8_t si = 0; si < MAX_STYLES; si++) { + if (!(styleMask & (1 << si)) || !styles_[si].present) continue; + auto& s = styles_[si]; + + loadStyleKernLigatureData(s); + if (s.ligaturePairs && s.header.ligaturePairCount > 0) { + for (uint8_t li = 0; li < s.header.ligaturePairCount && cpCount < MAX_PAGE_GLYPHS; li++) { + uint32_t leftCp = s.ligaturePairs[li].pair >> 16; + uint32_t rightCp = s.ligaturePairs[li].pair & 0xFFFF; + uint32_t outCp = s.ligaturePairs[li].ligatureCp; + + bool hasLeft = false, hasRight = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == leftCp) hasLeft = true; + if (codepoints[i] == rightCp) hasRight = true; + if (hasLeft && hasRight) break; + } + if (!hasLeft || !hasRight) continue; + + bool hasOut = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == outCp) { + hasOut = true; + break; + } + } + if (!hasOut) { + codepoints[cpCount++] = outCp; + } + } + } + } + } + + // Sort codepoints for ordered interval building + std::sort(codepoints.get(), codepoints.get() + cpCount); + + // Prewarm each requested style + int totalMissed = 0; + for (uint8_t si = 0; si < MAX_STYLES; si++) { + if (!(styleMask & (1 << si)) || !styles_[si].present) continue; + totalMissed += prewarmStyle(si, codepoints.get(), cpCount, metadataOnly); + } + + stats_.prewarmTotalMs = millis() - startMs; + return totalMissed; +} + +int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly) { + auto& s = styles_[styleIdx]; + + // Map codepoints to global glyph indices for this style + struct CpGlyphMapping { + uint32_t codepoint; + int32_t globalIndex; + }; + CpGlyphMapping* mappings = new (std::nothrow) CpGlyphMapping[cpCount]; + if (!mappings) { + LOG_ERR("SDCF", "Failed to allocate mapping array for style %u", styleIdx); + return static_cast(cpCount); + } + + uint32_t validCount = 0; + for (uint32_t i = 0; i < cpCount; i++) { + int32_t idx = findGlobalGlyphIndex(s, codepoints[i]); + if (idx >= 0) { + mappings[validCount].codepoint = codepoints[i]; + mappings[validCount].globalIndex = idx; + validCount++; + } + } + int missed = static_cast(cpCount - validCount); + + if (validCount == 0) { + freeStyleMiniData(s); + delete[] mappings; + s.epdFont.data = &s.stubData; + return missed; + } + + // Build mini intervals from sorted codepoints + freeStyleMiniData(s); + + uint32_t intervalCapacity = validCount; + s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity]; + if (!s.miniIntervals) { + LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx); + delete[] mappings; + return static_cast(cpCount); + } + + s.miniIntervalCount = 0; + uint32_t rangeStart = 0; + for (uint32_t i = 1; i <= validCount; i++) { + if (i == validCount || mappings[i].codepoint != mappings[i - 1].codepoint + 1) { + s.miniIntervals[s.miniIntervalCount].first = mappings[rangeStart].codepoint; + s.miniIntervals[s.miniIntervalCount].last = mappings[i - 1].codepoint; + s.miniIntervals[s.miniIntervalCount].offset = rangeStart; + s.miniIntervalCount++; + rangeStart = i; + } + } + + // Allocate mini glyph array + s.miniGlyphCount = validCount; + s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount]; + if (!s.miniGlyphs) { + LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx); + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + + // Build sorted read order for sequential I/O + uint32_t* readOrder = new (std::nothrow) uint32_t[validCount]; + if (!readOrder) { + LOG_ERR("SDCF", "Failed to allocate read order for style %u", styleIdx); + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + for (uint32_t i = 0; i < validCount; i++) readOrder[i] = i; + std::sort(readOrder, readOrder + validCount, + [&](uint32_t a, uint32_t b) { return mappings[a].globalIndex < mappings[b].globalIndex; }); + + FsFile file; + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "Failed to reopen .cpfont for prewarm (style %u)", styleIdx); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + + unsigned long sdStart = millis(); + uint32_t seekCount = 0; + + // Read glyph metadata. lastReadIndex tracks sequential reads to skip redundant + // seeks; INT32_MIN guarantees the first iteration always seeks to the correct + // offset (otherwise when gIdx == 0, the "gIdx != lastReadIndex + 1" check would + // be false and we'd read from the file's current position — the header — which + // decodes to a garbage EpdGlyph with a massive advanceX, inflating any word + // containing that codepoint beyond page width). + int32_t lastReadIndex = INT32_MIN; + for (uint32_t i = 0; i < validCount; i++) { + uint32_t mapIdx = readOrder[i]; + int32_t gIdx = mappings[mapIdx].globalIndex; + + uint32_t fileOff = s.glyphsFileOffset + static_cast(gIdx) * sizeof(EpdGlyph); + if (gIdx != lastReadIndex + 1) { + if (!file.seekSet(fileOff)) { + LOG_ERR("SDCF", "Prewarm: failed to seek to glyph %d (style %u)", gIdx, styleIdx); + file.close(); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + seekCount++; + } + if (file.read(reinterpret_cast(&s.miniGlyphs[mapIdx]), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { + LOG_ERR("SDCF", "Prewarm: short glyph read (style %u, glyph %d)", styleIdx, gIdx); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + lastReadIndex = gIdx; + } + + uint32_t totalBitmapSize = 0; + + if (!metadataOnly) { + // Compute total bitmap size + for (uint32_t i = 0; i < validCount; i++) { + totalBitmapSize += s.miniGlyphs[i].dataLength; + } + + s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1]; + if (!s.miniBitmap) { + LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + + // Read bitmap data sorted by file offset + std::sort(readOrder, readOrder + validCount, + [&](uint32_t a, uint32_t b) { return s.miniGlyphs[a].dataOffset < s.miniGlyphs[b].dataOffset; }); + + uint32_t miniBitmapOffset = 0; + uint32_t lastBitmapEnd = UINT32_MAX; + for (uint32_t i = 0; i < validCount; i++) { + uint32_t mapIdx = readOrder[i]; + EpdGlyph& glyph = s.miniGlyphs[mapIdx]; + + if (glyph.dataLength == 0) { + glyph.dataOffset = miniBitmapOffset; + continue; + } + + uint32_t fileOff = s.bitmapFileOffset + glyph.dataOffset; + if (fileOff != lastBitmapEnd) { + if (!file.seekSet(fileOff)) { + LOG_ERR("SDCF", "Prewarm: failed to seek to bitmap (style %u)", styleIdx); + file.close(); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + seekCount++; + } + if (file.read(s.miniBitmap + miniBitmapOffset, glyph.dataLength) != static_cast(glyph.dataLength)) { + LOG_ERR("SDCF", "Prewarm: short bitmap read (style %u)", styleIdx); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + lastBitmapEnd = fileOff + glyph.dataLength; + + glyph.dataOffset = miniBitmapOffset; + miniBitmapOffset += glyph.dataLength; + } + } + + uint32_t sdTime = millis() - sdStart; + delete[] readOrder; + delete[] mappings; + + // Full render prewarm: load the persistent kern classes + ligatures (one-time + // per style, small — the big matrix is NOT loaded here) and then build the + // per-page mini kern matrix restricted to class pairs reachable from this + // page's codepoints. Skip during metadata-only prewarm — layout only needs + // advanceX and the mini kern would be thrown away before rendering. + bool kernLigOk = false; + if (!metadataOnly) { + if (loadStyleKernLigatureData(s)) { + kernLigOk = buildMiniKernMatrix(s, codepoints, cpCount); + } + } + + // Populate miniData and swap + memset(&s.miniData, 0, sizeof(s.miniData)); + s.miniData.bitmap = s.miniBitmap; + s.miniData.glyph = s.miniGlyphs; + s.miniData.intervals = s.miniIntervals; + s.miniData.intervalCount = s.miniIntervalCount; + s.miniData.advanceY = s.header.advanceY; + s.miniData.ascender = s.header.ascender; + s.miniData.descender = s.header.descender; + s.miniData.is2Bit = s.header.is2Bit; + if (kernLigOk) { + applyKernLigaturePointers(s, s.miniData); + } + s.miniData.glyphMissHandler = &SdCardFont::onGlyphMiss; + s.miniData.glyphMissCtx = &overflowCtx_[styleIdx]; + + s.epdFont.data = &s.miniData; + + // Accumulate stats + stats_.sdReadTimeMs += sdTime; + stats_.seekCount += seekCount; + stats_.uniqueGlyphs += validCount; + stats_.bitmapBytes += totalBitmapSize; + + return missed; +} + +// --- Cache management --- + +void SdCardFont::clearCache() { + clearOverflow(); + // Note: advance table is intentionally preserved here. It persists across + // layout passes so repeated section indexing amortizes SD reads. Use + // clearPersistentCache() to wipe it. + for (uint8_t i = 0; i < MAX_STYLES; i++) { + if (!styles_[i].present) continue; + freeStyleMiniData(styles_[i]); + applyGlyphMissCallback(i); + } +} + +// --- Advance table --- + +void SdCardFont::clearPersistentCache() { + for (uint8_t i = 0; i < MAX_STYLES; i++) { + delete[] advanceTable_[i]; + advanceTable_[i] = nullptr; + advanceTableSize_[i] = 0; + } +} + +bool SdCardFont::advanceTableLookup(uint8_t styleIdx, uint32_t codepoint, uint16_t* outAdvance) const { + const AdvanceEntry* table = advanceTable_[styleIdx]; + const uint32_t size = advanceTableSize_[styleIdx]; + if (!table || size == 0) return false; + uint32_t lo = 0, hi = size; + while (lo < hi) { + uint32_t mid = lo + (hi - lo) / 2; + if (table[mid].codepoint < codepoint) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo < size && table[lo].codepoint == codepoint) { + if (outAdvance) *outAdvance = table[lo].advanceX; + return true; + } + return false; +} + +void SdCardFont::mergeIntoAdvanceTable(uint8_t styleIdx, const AdvanceEntry* sortedNew, uint32_t newCount) { + if (newCount == 0) return; + const uint32_t oldSize = advanceTableSize_[styleIdx]; + if (oldSize >= ADVANCE_CACHE_LIMIT) return; // already full + + // Cap the merged size at ADVANCE_CACHE_LIMIT. Anything past the cap is + // dropped from the tail of the sorted merge — a deterministic, bounded loss + // that doesn't bias which codepoints get cached on subsequent passes. + uint32_t mergedCap = oldSize + newCount; + if (mergedCap > ADVANCE_CACHE_LIMIT) mergedCap = ADVANCE_CACHE_LIMIT; + + AdvanceEntry* merged = new (std::nothrow) AdvanceEntry[mergedCap]; + if (!merged) { + LOG_ERR("SDCF", "mergeIntoAdvanceTable: alloc failed (%u entries) style %u", mergedCap, styleIdx); + return; + } + + const AdvanceEntry* a = advanceTable_[styleIdx]; + const AdvanceEntry* b = sortedNew; + uint32_t i = 0, j = 0, k = 0; + while (k < mergedCap && (i < oldSize || j < newCount)) { + if (i < oldSize && (j >= newCount || a[i].codepoint <= b[j].codepoint)) { + merged[k++] = a[i++]; + } else { + merged[k++] = b[j++]; + } + } + + delete[] advanceTable_[styleIdx]; + advanceTable_[styleIdx] = merged; + advanceTableSize_[styleIdx] = k; +} + +bool SdCardFont::hasAdvanceTable() const { + for (uint8_t i = 0; i < MAX_STYLES; i++) { + if (advanceTable_[i]) return true; + } + return false; +} + +uint16_t SdCardFont::getAdvance(uint32_t codepoint, uint8_t style) const { + style &= (MAX_STYLES - 1); + if (!advanceTable_[style]) return 0; + const AdvanceEntry* table = advanceTable_[style]; + const uint32_t size = advanceTableSize_[style]; + // Binary search sorted by codepoint + uint32_t lo = 0, hi = size; + while (lo < hi) { + uint32_t mid = lo + (hi - lo) / 2; + if (table[mid].codepoint < codepoint) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo < size && table[lo].codepoint == codepoint) { + return table[lo].advanceX; + } + return 0; +} + +int SdCardFont::buildAdvanceTable(const char* utf8Text, uint8_t styleMask) { + if (!loaded_) return -1; + + // Note: advance table is preserved across calls. We only fetch codepoints + // not already present, then merge them in. Use clearPersistentCache() to + // wipe the table when the font/size/family changes. + + unsigned long startMs = millis(); + + // Step 1: Extract unique codepoints, capped at MAX_UNIQUE_CODEPOINTS. + // The dedup buffer is sized to the cap, not total chars — a large EPUB section + // may contain 50K+ characters but real text has far fewer unique codepoints. + // 4096 × 4 bytes = 16KB temporary; bounded regardless of input size. + static constexpr uint32_t MAX_UNIQUE_CODEPOINTS = 4096; + uint32_t* codepoints = new (std::nothrow) uint32_t[MAX_UNIQUE_CODEPOINTS]; + if (!codepoints) { + LOG_ERR("SDCF", "buildAdvanceTable: failed to allocate codepoint buffer (%u bytes)", MAX_UNIQUE_CODEPOINTS * 4); + return -1; + } + uint32_t cpCount = 0; + bool hitCap = false; + + // Second pass: collect unique codepoints via O(n²) dedup. + // Bounded by uniqueCount × totalChars comparisons. For 2000 unique from 2291 total, + // worst case ~4.6M comparisons of uint32_t — ~30ms on 160MHz RISC-V, acceptable + // for one-time section indexing. + const unsigned char* p = reinterpret_cast(utf8Text); + while (*p) { + uint32_t cp = utf8NextCodepoint(&p); + if (cp == 0) break; + + bool found = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == cp) { + found = true; + break; + } + } + if (!found) { + if (cpCount >= MAX_UNIQUE_CODEPOINTS) { + hitCap = true; + break; + } + codepoints[cpCount++] = cp; + } + } + if (hitCap) { + LOG_ERR("SDCF", "buildAdvanceTable: unique codepoint cap (%u) hit, layout may be approximate", + MAX_UNIQUE_CODEPOINTS); + } + + // Sort for ordered glyph index mapping and final table output + std::sort(codepoints, codepoints + cpCount); + + // Step 2: For each requested style, fetch any codepoints not yet cached and + // merge them into the persistent advance table. + int totalMissed = 0; + for (uint8_t si = 0; si < MAX_STYLES; si++) { + if (!(styleMask & (1 << si)) || !styles_[si].present) continue; + const auto& s = styles_[si]; + + // Stop fetching once the cache is full — further inserts would be dropped + // by the merge anyway. The renderer fast path tolerates missing entries + // (returns 0); the slow path is still correct for those codepoints. + if (advanceTableSize_[si] >= ADVANCE_CACHE_LIMIT) continue; + + // For each codepoint in `codepoints`, skip those already cached, then + // resolve to a glyph index. Build a parallel array sorted by glyph index + // for sequential SD reads. + struct CpIdx { + uint32_t codepoint; + int32_t glyphIndex; + }; + std::unique_ptr mappings(new (std::nothrow) CpIdx[cpCount]); + if (!mappings) { + LOG_ERR("SDCF", "buildAdvanceTable: failed to allocate mappings for style %u", si); + totalMissed += cpCount; + continue; + } + + uint32_t needCount = 0; + uint32_t missedThisStyle = 0; + for (uint32_t i = 0; i < cpCount; i++) { + const uint32_t cp = codepoints[i]; + if (advanceTableLookup(si, cp, nullptr)) continue; // already cached + int32_t idx = findGlobalGlyphIndex(s, cp); + if (idx < 0) { + missedThisStyle++; + continue; + } + mappings[needCount].codepoint = cp; + mappings[needCount].glyphIndex = idx; + needCount++; + } + totalMissed += static_cast(missedThisStyle); + + if (needCount == 0) continue; + + // Sort by glyph index so SD reads are mostly sequential. + std::sort(mappings.get(), mappings.get() + needCount, + [](const CpIdx& a, const CpIdx& b) { return a.glyphIndex < b.glyphIndex; }); + + // Open file once and read advanceX for each needed glyph. + FsFile file; + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "buildAdvanceTable: failed to open .cpfont for style %u", si); + continue; + } + + std::unique_ptr staged(new (std::nothrow) AdvanceEntry[needCount]); + if (!staged) { + LOG_ERR("SDCF", "buildAdvanceTable: failed to allocate staging for style %u", si); + file.close(); + continue; + } + + uint32_t fetched = 0; + EpdGlyph tempGlyph; + int32_t lastReadIndex = INT32_MIN; + for (uint32_t i = 0; i < needCount; i++) { + int32_t gIdx = mappings[i].glyphIndex; + uint32_t fileOff = s.glyphsFileOffset + static_cast(gIdx) * sizeof(EpdGlyph); + if (gIdx != lastReadIndex + 1) { + if (!file.seekSet(fileOff)) { + LOG_ERR("SDCF", "buildAdvanceTable: failed to seek to glyph %d (style %u)", gIdx, si); + break; + } + } + if (file.read(reinterpret_cast(&tempGlyph), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { + LOG_ERR("SDCF", "buildAdvanceTable: short glyph read (style %u, glyph %d)", si, gIdx); + break; + } + lastReadIndex = gIdx; + staged[fetched].codepoint = mappings[i].codepoint; + staged[fetched].advanceX = tempGlyph.advanceX; + fetched++; + } + file.close(); + + if (fetched > 0) { + // Sort staged by codepoint, then merge into the persistent table. + std::sort(staged.get(), staged.get() + fetched, + [](const AdvanceEntry& a, const AdvanceEntry& b) { return a.codepoint < b.codepoint; }); + mergeIntoAdvanceTable(si, staged.get(), fetched); + } + + LOG_DBG("SDCF", "Advance table style %u: +%u from SD, total=%u/%u", si, fetched, advanceTableSize_[si], + ADVANCE_CACHE_LIMIT); + } + + delete[] codepoints; + + stats_.prewarmTotalMs = millis() - startMs; + return totalMissed; +} + +// --- Stats --- + +void SdCardFont::logStats(const char* label) { + LOG_DBG("SDCF", "[%s] total=%ums sd_read=%ums seeks=%u glyphs=%u bitmap=%u bytes", label, stats_.prewarmTotalMs, + stats_.sdReadTimeMs, stats_.seekCount, stats_.uniqueGlyphs, stats_.bitmapBytes); +} + +void SdCardFont::resetStats() { stats_ = Stats{}; } + +// --- Public accessors --- + +EpdFont* SdCardFont::getEpdFont(uint8_t style) { + style &= (MAX_STYLES - 1); + if (!styles_[style].present) return nullptr; + return &styles_[style].epdFont; +} + +bool SdCardFont::hasStyle(uint8_t style) const { return styles_[style & (MAX_STYLES - 1)].present; } + +// --- On-demand glyph loading (overflow buffer) --- + +const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { + auto* oc = static_cast(ctx); + auto* self = oc->self; + uint8_t styleIdx = oc->styleIdx; + + if (!self->loaded_ || styleIdx >= MAX_STYLES || !self->styles_[styleIdx].present) return nullptr; + const auto& s = self->styles_[styleIdx]; + if (!s.fullIntervals) return nullptr; + + // Check overflow cache first (matching both codepoint and style) + for (uint32_t i = 0; i < self->overflowCount_; i++) { + if (self->overflow_[i].codepoint == codepoint && self->overflow_[i].styleIdx == styleIdx) { + return &self->overflow_[i].glyph; + } + } + + // Look up global glyph index via full intervals + int32_t globalIdx = self->findGlobalGlyphIndex(s, codepoint); + if (globalIdx < 0) return nullptr; + + // Pick overflow slot (ring buffer). Read into temporaries first so the + // existing slot stays valid if SD I/O fails. Bookkeeping (count/next) + // is deferred until after all I/O succeeds to avoid inconsistent state. + uint32_t slot = self->overflowNext_; + bool wasAtCapacity = (self->overflowCount_ == OVERFLOW_CAPACITY); + + // Read glyph metadata into temporary + FsFile file; + if (!Storage.openFileForRead("SDCF", self->filePath_, file)) { + LOG_ERR("SDCF", "Overflow: failed to open .cpfont"); + return nullptr; + } + + EpdGlyph tempGlyph = {}; + uint32_t glyphFileOff = s.glyphsFileOffset + static_cast(globalIdx) * sizeof(EpdGlyph); + if (!file.seekSet(glyphFileOff)) { + LOG_ERR("SDCF", "Overflow: failed to seek to glyph for U+%04X style %u", codepoint, styleIdx); + file.close(); + return nullptr; + } + if (file.read(reinterpret_cast(&tempGlyph), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { + LOG_ERR("SDCF", "Overflow: failed to read glyph metadata for U+%04X style %u", codepoint, styleIdx); + return nullptr; + } + + // Read bitmap data into temporary (if any) + uint8_t* tempBitmap = nullptr; + if (tempGlyph.dataLength > 0) { + tempBitmap = new (std::nothrow) uint8_t[tempGlyph.dataLength]; + if (!tempBitmap) { + LOG_ERR("SDCF", "Overflow: failed to allocate %u bytes for U+%04X bitmap", tempGlyph.dataLength, codepoint); + return nullptr; + } + if (!file.seekSet(s.bitmapFileOffset + tempGlyph.dataOffset)) { + LOG_ERR("SDCF", "Overflow: failed to seek to bitmap for U+%04X", codepoint); + delete[] tempBitmap; + file.close(); + return nullptr; + } + if (file.read(tempBitmap, tempGlyph.dataLength) != static_cast(tempGlyph.dataLength)) { + LOG_ERR("SDCF", "Overflow: failed to read bitmap for U+%04X", codepoint); + delete[] tempBitmap; + return nullptr; + } + } + + // All reads succeeded — commit to slot and advance ring buffer + if (wasAtCapacity) { + delete[] self->overflow_[slot].bitmap; + } else { + self->overflowCount_++; + } + self->overflowNext_ = (slot + 1) % OVERFLOW_CAPACITY; + self->overflow_[slot].glyph = tempGlyph; + self->overflow_[slot].bitmap = tempBitmap; + self->overflow_[slot].codepoint = codepoint; + self->overflow_[slot].styleIdx = styleIdx; + + LOG_DBG("SDCF", "Overflow: loaded U+%04X style %u on demand (slot %u/%u)", codepoint, styleIdx, slot, + OVERFLOW_CAPACITY); + + return &self->overflow_[slot].glyph; +} + +bool SdCardFont::isOverflowGlyph(const EpdGlyph* glyph) const { + for (uint32_t i = 0; i < overflowCount_; i++) { + if (&overflow_[i].glyph == glyph) return true; + } + return false; +} + +const uint8_t* SdCardFont::getOverflowBitmap(const EpdGlyph* glyph) const { + for (uint32_t i = 0; i < overflowCount_; i++) { + if (&overflow_[i].glyph == glyph) { + return overflow_[i].bitmap; + } + } + return nullptr; +} + +SdCardFont* SdCardFont::fromMissCtx(void* ctx) { return static_cast(ctx)->self; } diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h new file mode 100644 index 00000000..821697ee --- /dev/null +++ b/lib/EpdFont/SdCardFont.h @@ -0,0 +1,241 @@ +#pragma once + +#include + +#include "EpdFont.h" +#include "EpdFontData.h" + +// On-disk binary format version for .cpfont files. Defined as a preprocessor +// macro (rather than a constexpr) so it can be stringified into the SD-fonts +// release URL — see FONT_MANIFEST_URL in FontDownloadActivity.h. No integer +// suffix because stringification would include it (e.g. `4U` → `"4U"`). +// +// The canonical version for the build tooling lives in +// lib/EpdFont/scripts/cpfont_version.py. This firmware-side copy must be +// bumped manually when the firmware is updated to support a new format. +// Reader enforcement: SdCardFont::load(). +#define CPFONT_VERSION 4 + +class SdCardFont { + public: + static constexpr uint16_t MAX_PAGE_GLYPHS = 512; + static constexpr uint8_t MAX_STYLES = 4; + + SdCardFont() = default; + ~SdCardFont(); + // Owns raw buffers freed in dtor — no shallow-copy semantics. Make any + // accidental pass-by-value or move a compile-time error. + SdCardFont(const SdCardFont&) = delete; + SdCardFont& operator=(const SdCardFont&) = delete; + SdCardFont(SdCardFont&&) = delete; + SdCardFont& operator=(SdCardFont&&) = delete; + + // Load .cpfont file: reads header + intervals into RAM, records file layout offsets. + // Supports v4 (multi-style) format. + // Returns true on success. + bool load(const char* path); + + // Pre-read glyphs needed for the given UTF-8 text from SD card. + // styleMask: bitmask of styles to prewarm (bit 0=regular, 1=bold, 2=italic, 3=bolditalic). + // Default 0x0F = all present styles. + // When metadataOnly=true, only glyph metrics are loaded (no bitmap data). + // Returns number of glyphs that couldn't be loaded (0 on full success). + int prewarm(const char* utf8Text, uint8_t styleMask = 0x0F, bool metadataOnly = false); + + // Build a compact advance-only table for layout measurement. + // Extracts ALL unique codepoints from utf8Text (no MAX_PAGE_GLYPHS cap), + // batch-reads advanceX from SD, stores in a sorted per-style table. + // Returns number of codepoints not found in font coverage. + int buildAdvanceTable(const char* utf8Text, uint8_t styleMask = 0x0F); + + // Look up advanceX for a codepoint from the advance table. + // Returns the 12.4 fixed-point advance, or 0 if not found. + uint16_t getAdvance(uint32_t codepoint, uint8_t style) const; + + // Returns true if advance table is populated for at least one style. + bool hasAdvanceTable() const; + + // Free mini data for all styles, restore stub EpdFontData. + // Also clears the temporary advance table (built per layout pass) but + // preserves the persistent advance cache (reused across passes). + void clearCache(); + + // Drop the persistent advance cache. Call when unloading the SD font or + // when font/size/family/glyph-table state changes. + void clearPersistentCache(); + + // Returns pointer to the managed EpdFont for a given style. + // Returns nullptr if the style is not present. + EpdFont* getEpdFont(uint8_t style = 0); + + // Returns true if the given style is present in this font file. + bool hasStyle(uint8_t style) const; + + // Number of styles present in this font file. + uint8_t styleCount() const { return styleCount_; } + + // Returns true if the glyph pointer points into the overflow buffer. + bool isOverflowGlyph(const EpdGlyph* glyph) const; + + // Returns the bitmap for an on-demand-loaded (overflow) glyph. + const uint8_t* getOverflowBitmap(const EpdGlyph* glyph) const; + + // Extract SdCardFont* from an opaque glyphMissCtx pointer. + // Used by GfxRenderer::getGlyphBitmap() to recover the SdCardFont from EpdFontData::glyphMissCtx. + static SdCardFont* fromMissCtx(void* ctx); + + struct Stats { + uint32_t prewarmTotalMs = 0; + uint32_t sdReadTimeMs = 0; + uint32_t seekCount = 0; + uint32_t uniqueGlyphs = 0; + uint32_t bitmapBytes = 0; + }; + void logStats(const char* label = "SDCF"); + void resetStats(); + const Stats& getStats() const { return stats_; } + + // Content hash of the file header + style TOC entries (computed during load). + // Used to generate deterministic font IDs for section cache invalidation. + uint32_t contentHash() const { return contentHash_; } + + private: + // Per-style metadata (parsed from file header/TOC) + struct CpFontHeader { + uint32_t intervalCount = 0; + uint32_t glyphCount = 0; + uint8_t advanceY = 0; + int16_t ascender = 0; + int16_t descender = 0; + bool is2Bit = false; + uint16_t kernLeftEntryCount = 0; + uint16_t kernRightEntryCount = 0; + uint8_t kernLeftClassCount = 0; + uint8_t kernRightClassCount = 0; + uint8_t ligaturePairCount = 0; + }; + + // All per-style data: file offsets, intervals, kern/lig, prewarm cache, EpdFont + struct PerStyle { + CpFontHeader header{}; + + // File layout offsets for this style's data sections + uint32_t intervalsFileOffset = 0; + uint32_t glyphsFileOffset = 0; + uint32_t kernLeftFileOffset = 0; + uint32_t kernRightFileOffset = 0; + uint32_t kernMatrixFileOffset = 0; + uint32_t ligatureFileOffset = 0; + uint32_t bitmapFileOffset = 0; + + // Full intervals loaded from file (kept in RAM for codepoint lookup) + EpdUnicodeInterval* fullIntervals = nullptr; + + // Persistent kern-class + ligature tables (lazy-loaded on first prewarm). + // The full kern MATRIX is NOT resident — on Literata-class fonts a single + // style's matrix is ~36-42KB contiguous, and 4 styles' worth won't fit + // alongside bitmaps + framebuffer on a 380KB device. Only kernLeftClasses + // and kernRightClasses (small codepoint→classId tables, ~3KB each) stay + // resident; the matrix is reconstructed per-page as miniKernMatrix. + EpdKernClassEntry* kernLeftClasses = nullptr; + EpdKernClassEntry* kernRightClasses = nullptr; + EpdLigaturePair* ligaturePairs = nullptr; + bool kernLigLoaded = false; + + // Stub EpdFontData returned when not prewarmed + EpdFontData stubData{}; + + // Mini EpdFontData built during prewarm + EpdFontData miniData{}; + EpdUnicodeInterval* miniIntervals = nullptr; + EpdGlyph* miniGlyphs = nullptr; + uint8_t* miniBitmap = nullptr; + uint32_t miniIntervalCount = 0; + uint32_t miniGlyphCount = 0; + + // Per-page mini kern matrix (built by buildMiniKernMatrix on each full + // prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints + // used on the current page to renumbered class IDs (1..miniKern*ClassCount). + // miniKernMatrix is a small miniKernLeftClassCount × miniKernRightClassCount + // flat matrix. Typical Latin page: ~25×25 matrix = ~625 bytes per style vs + // ~36KB for the full Literata matrix — ~50× reduction. + EpdKernClassEntry* miniKernLeftClasses = nullptr; + EpdKernClassEntry* miniKernRightClasses = nullptr; + uint16_t miniKernLeftEntryCount = 0; + uint16_t miniKernRightEntryCount = 0; + uint8_t miniKernLeftClassCount = 0; + uint8_t miniKernRightClassCount = 0; + int8_t* miniKernMatrix = nullptr; + + // The EpdFont whose data pointer we manage + EpdFont epdFont{&stubData}; + + bool present = false; + }; + + PerStyle styles_[MAX_STYLES] = {}; + uint8_t styleCount_ = 0; + + char filePath_[128] = {}; + + // Overflow context: glyphMissHandler needs to know which style it's serving + struct OverflowContext { + SdCardFont* self; + uint8_t styleIdx; + }; + OverflowContext overflowCtx_[MAX_STYLES] = {}; + + // Shared on-demand overflow buffer (ring buffer of glyphs loaded via glyphMissHandler) + static constexpr uint32_t OVERFLOW_CAPACITY = 8; + struct OverflowEntry { + EpdGlyph glyph; + uint8_t* bitmap = nullptr; + uint32_t codepoint = 0; + uint8_t styleIdx = 0; + }; + OverflowEntry overflow_[OVERFLOW_CAPACITY] = {}; + uint32_t overflowCount_ = 0; + uint32_t overflowNext_ = 0; + + // Compact advance-only table for layout measurement (per-style). + // Built by buildAdvanceTable(), queried by getAdvance(). + struct AdvanceEntry { + uint32_t codepoint; + uint16_t advanceX; // 12.4 fixed-point + }; + // Per-style advance table. Sorted by codepoint for binary lookup. + // Bounded to ADVANCE_CACHE_LIMIT entries; persists across layout passes + // (across calls to clearCache()) so repeated indexing of the same font + // amortizes SD reads. Cleared only on font unload or clearPersistentCache(). + static constexpr uint32_t ADVANCE_CACHE_LIMIT = 768; + AdvanceEntry* advanceTable_[MAX_STYLES] = {}; + uint32_t advanceTableSize_[MAX_STYLES] = {}; + bool advanceTableLookup(uint8_t styleIdx, uint32_t codepoint, uint16_t* outAdvance) const; + // Merge sortedNew (sorted by codepoint, no overlap with existing) into the + // advance table for styleIdx, preserving sort order; cap-truncates the tail. + void mergeIntoAdvanceTable(uint8_t styleIdx, const AdvanceEntry* sortedNew, uint32_t newCount); + + Stats stats_; + uint32_t contentHash_ = 0; + bool loaded_ = false; + + // Per-style helpers + void freeStyleMiniData(PerStyle& s); + void freeStyleAll(PerStyle& s); + void freeStyleKernLigatureData(PerStyle& s); + void freeStyleMiniKern(PerStyle& s); + bool loadStyleKernLigatureData(PerStyle& s); + bool buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, uint32_t cpCount); + void applyKernLigaturePointers(PerStyle& s, EpdFontData& data) const; + void applyGlyphMissCallback(uint8_t styleIdx); + int32_t findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) const; + int prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly); + + // Global helpers + void freeAll(); + void clearOverflow(); + static void computeStyleFileOffsets(PerStyle& s, uint32_t baseOffset); + + // Static callback for EpdFontData::glyphMissHandler (per-style via OverflowContext) + static const EpdGlyph* onGlyphMiss(void* ctx, uint32_t codepoint); +}; diff --git a/lib/EpdFont/SdCardFontManager.cpp b/lib/EpdFont/SdCardFontManager.cpp new file mode 100644 index 00000000..a6032336 --- /dev/null +++ b/lib/EpdFont/SdCardFontManager.cpp @@ -0,0 +1,98 @@ +#include "SdCardFontManager.h" + +#include +#include +#include +#include +#include + +SdCardFontManager::~SdCardFontManager() { + for (auto& lf : loaded_) { + delete lf.font; + } +} + +// FNV-1a continuation: seeds with contentHash, then hashes family name + point size. +// Produces a deterministic ID that is stable across load/unload cycles and reboots, +// and changes when font content changes (different header/TOC = different contentHash). +int SdCardFontManager::computeFontId(uint32_t contentHash, const char* familyName, uint8_t pointSize) { + static constexpr uint32_t FNV_PRIME = 16777619u; + uint32_t hash = contentHash; + while (*familyName) { + hash ^= static_cast(*familyName++); + hash *= FNV_PRIME; + } + hash ^= pointSize; + hash *= FNV_PRIME; + int id = static_cast(hash); + return id != 0 ? id : 1; // 0 is reserved as "not found" sentinel +} + +bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum) { + // Unload any previously loaded family first + if (!loadedFamilyName_.empty()) { + unloadAll(renderer); + } + + // Select by ordinal position: sort available sizes, then map the font size + // enum (SMALL=0 .. EXTRA_LARGE=3) to the corresponding slot. When the + // family has fewer sizes than 4, clamp to the last available size. + auto sizes = family.availableSizes(); + if (sizes.empty()) { + 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()); + return false; + } + + if (!font->load(selected->path.c_str())) { + LOG_ERR("SDMGR", "Failed to load %s", selected->path.c_str()); + delete font; + return false; + } + + int fontId = computeFontId(font->contentHash(), family.name.c_str(), selected->pointSize); + // Guard against collision with built-in font IDs (astronomically unlikely + // with FNV-1a hashes, but provides a safety net) + if (renderer.getFontMap().count(fontId) != 0) { + LOG_ERR("SDMGR", "Font ID %d collides with existing font, skipping %s", fontId, selected->path.c_str()); + delete font; + return false; + } + renderer.registerSdCardFont(fontId, font); + loaded_.push_back({font, fontId, selected->pointSize}); + + 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); + + loadedFamilyName_ = family.name; + loadedPointSize_ = selected->pointSize; + return true; +} + +void SdCardFontManager::unloadAll(GfxRenderer& renderer) { + renderer.clearSdCardFonts(); + for (auto& lf : loaded_) { + renderer.removeFont(lf.fontId); + delete lf.font; + } + loaded_.clear(); + loadedFamilyName_.clear(); + loadedPointSize_ = 0; +} + +int SdCardFontManager::getFontId(const std::string& familyName) const { + if (familyName != loadedFamilyName_ || loaded_.empty()) return 0; + return loaded_.front().fontId; +} diff --git a/lib/EpdFont/SdCardFontManager.h b/lib/EpdFont/SdCardFontManager.h new file mode 100644 index 00000000..aec07472 --- /dev/null +++ b/lib/EpdFont/SdCardFontManager.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +class GfxRenderer; +class SdCardFont; +struct SdCardFontFamilyInfo; + +class SdCardFontManager { + public: + SdCardFontManager() = default; + ~SdCardFontManager(); + SdCardFontManager(const SdCardFontManager&) = delete; + SdCardFontManager& operator=(const SdCardFontManager&) = delete; + + // Load the font file matching fontSizeEnum (SMALL=0 .. EXTRA_LARGE=3) by + // ordinal position in the family's sorted size list. Only one .cpfont file + // is loaded; other sizes remain on disk. This keeps resident interval + + // kern/ligature tables to one size's worth of memory. + // Returns true on success. + bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum); + + // Unload everything, unregister from renderer. + void unloadAll(GfxRenderer& renderer); + + // Look up the font ID for the loaded family. Returns 0 if nothing loaded + // or familyName doesn't match. + int getFontId(const std::string& familyName) const; + + // Get name of currently loaded family (empty if none). + const std::string& currentFamilyName() const { return loadedFamilyName_; }; + + // Point size that was actually loaded (closest match to targetPtSize). + // 0 if nothing loaded. + uint8_t currentPointSize() const { return loadedPointSize_; }; + + private: + struct LoadedFont { + SdCardFont* font; // heap-allocated, owned + int fontId; + uint8_t size; + }; + static int computeFontId(uint32_t contentHash, const char* familyName, uint8_t pointSize); + + std::string loadedFamilyName_; + uint8_t loadedPointSize_ = 0; + std::vector loaded_; +}; diff --git a/lib/EpdFont/SdCardFontRegistry.cpp b/lib/EpdFont/SdCardFontRegistry.cpp new file mode 100644 index 00000000..2e0f145c --- /dev/null +++ b/lib/EpdFont/SdCardFontRegistry.cpp @@ -0,0 +1,230 @@ +#include "SdCardFontRegistry.h" + +#include +#include + +#include +#include + +// --- SdCardFontFamilyInfo helpers --- + +const SdCardFontFileInfo* SdCardFontFamilyInfo::findFile(uint8_t size, uint8_t style) const { + for (const auto& f : files) { + if (f.pointSize == size && f.style == style) return &f; + } + return nullptr; +} + +bool SdCardFontFamilyInfo::hasSize(uint8_t size) const { + for (const auto& f : files) { + if (f.pointSize == size) return true; + } + return false; +} + +std::vector SdCardFontFamilyInfo::availableSizes() const { + std::vector sizes; + for (const auto& f : files) { + bool found = false; + for (uint8_t s : sizes) { + if (s == f.pointSize) { + found = true; + break; + } + } + if (!found) sizes.push_back(f.pointSize); + } + std::sort(sizes.begin(), sizes.end()); + return sizes; +} + +// --- SdCardFontRegistry --- + +bool SdCardFontRegistry::parseFilename(const char* filename, uint8_t& size, uint8_t& style) { + // V4 naming: _.cpfont (e.g. Bookerly-SD_14.cpfont) + // Use an ends-with check rather than strstr() so that in-progress downloads + // like "Foo_14.cpfont.tmp" or backups like "Foo_14.cpfont~" aren't accepted. + static constexpr char kExt[] = ".cpfont"; + static constexpr size_t kExtLen = sizeof(kExt) - 1; + const size_t nameLen = strlen(filename); + if (nameLen <= kExtLen) return false; + if (strcmp(filename + nameLen - kExtLen, kExt) != 0) return false; + const char* ext = filename + nameLen - kExtLen; + + size_t baseLen = ext - filename; + if (baseLen == 0 || baseLen > 127) return false; + + char base[128]; + memcpy(base, filename, baseLen); + base[baseLen] = '\0'; + + char* lastUnderscore = strrchr(base, '_'); + if (!lastUnderscore || lastUnderscore == base) return false; + + const char* sizeStr = lastUnderscore + 1; + char* endPtr; + long sizeVal = strtol(sizeStr, &endPtr, 10); + if (endPtr == sizeStr || *endPtr != '\0' || sizeVal < 1 || sizeVal > 255) return false; + size = static_cast(sizeVal); + // V4 .cpfont files bundle every style (regular/bold/italic/bold-italic) into + // one file, so style is always 0 at the registry level. The per-style + // bitstream is selected later by SdCardFont::getEpdFont(style). The `style` + // field in SdCardFontFileInfo is reserved for future formats that split + // styles across files; scanDirectory() defends against accidental + // (pointSize, style) collisions in that scenario. + style = 0; + return true; +} + +void SdCardFontRegistry::scanDirectory(const char* dirPath, SdCardFontFamilyInfo& family) { + FsFile dir = Storage.open(dirPath); + if (!dir || !dir.isDirectory()) return; + + char nameBuffer[128]; + while (true) { + FsFile entry = dir.openNextFile(); + if (!entry) break; + if (entry.isDirectory()) { + entry.close(); + continue; + } + + entry.getName(nameBuffer, sizeof(nameBuffer)); + entry.close(); + + // Skip macOS resource fork files (._*) and other hidden files + if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue; + + uint8_t size, style; + if (!parseFilename(nameBuffer, size, style)) continue; + + // Reject duplicate (pointSize, style) entries in the same family. With + // v4's bundle-everything design parseFilename always returns style=0, so + // two files at the same size in the same family would silently shadow + // each other in findFile(). Skip the duplicate and warn. + bool duplicate = false; + for (const auto& existing : family.files) { + if (existing.pointSize == size && existing.style == style) { + duplicate = true; + break; + } + } + if (duplicate) { + LOG_ERR("SDREG", "Duplicate font %s in %s — skipping", nameBuffer, dirPath); + continue; + } + + SdCardFontFileInfo info; + info.path = std::string(dirPath) + "/" + nameBuffer; + info.pointSize = size; + info.style = style; + family.files.push_back(std::move(info)); + } +} + +// Scan a single root (e.g. "/.fonts") and append its families to `out`. +// Skips families whose names already exist in `out` (de-duplicates between +// the hidden and visible roots — first scan wins). +void SdCardFontRegistry::scanRoot(const char* rootPath, std::vector& out) { + FsFile root = Storage.open(rootPath); + if (!root) { + LOG_DBG("SDREG", "Fonts directory not found: %s", rootPath); + return; + } + if (!root.isDirectory()) { + LOG_ERR("SDREG", "Fonts path is not a directory: %s", rootPath); + return; + } + + char nameBuffer[128]; + while (true) { + FsFile entry = root.openNextFile(); + if (!entry) break; + if (entry.isDirectory()) { + entry.getName(nameBuffer, sizeof(nameBuffer)); + entry.close(); + + // Skip hidden/system directories inside the root (macOS ._*, .Trashes, etc.) + if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue; + + // De-dup by family name across roots. + bool exists = false; + for (const auto& fam : out) { + if (fam.name == nameBuffer) { + exists = true; + break; + } + } + if (exists) continue; + + SdCardFontFamilyInfo family; + family.name = nameBuffer; + std::string subDirPath = std::string(rootPath) + "/" + nameBuffer; + SdCardFontRegistry::scanDirectory(subDirPath.c_str(), family); + + if (!family.files.empty()) { + out.push_back(std::move(family)); + LOG_DBG("SDREG", "Found family: %s (%d files) in %s", out.back().name.c_str(), + static_cast(out.back().files.size()), rootPath); + } + } else { + entry.close(); + } + } +} + +bool SdCardFontRegistry::discover() { + families_.clear(); + families_.reserve(MAX_SD_FAMILIES); + + // Hidden root is scanned first so it wins on name collisions, matching the + // sleep-folder pattern (/.sleep preferred over /sleep). + scanRoot(FONTS_DIR_HIDDEN, families_); + scanRoot(FONTS_DIR_VISIBLE, families_); + + // Sort families alphabetically + std::sort(families_.begin(), families_.end(), + [](const SdCardFontFamilyInfo& a, const SdCardFontFamilyInfo& b) { return a.name < b.name; }); + + // Cap at MAX_SD_FAMILIES + if (static_cast(families_.size()) > MAX_SD_FAMILIES) { + families_.resize(MAX_SD_FAMILIES); + } + + LOG_DBG("SDREG", "Discovery complete: %d families", static_cast(families_.size())); + return !families_.empty(); +} + +const char* SdCardFontRegistry::findFamilyRoot(const char* familyName) { + if (!familyName || !*familyName) return nullptr; + char path[160]; + snprintf(path, sizeof(path), "%s/%s", FONTS_DIR_HIDDEN, familyName); + if (Storage.exists(path)) return FONTS_DIR_HIDDEN; + snprintf(path, sizeof(path), "%s/%s", FONTS_DIR_VISIBLE, familyName); + if (Storage.exists(path)) return FONTS_DIR_VISIBLE; + return nullptr; +} + +const char* SdCardFontRegistry::defaultWriteRoot() { + // If exactly one of the roots already exists, keep using it. Otherwise + // (neither exists, or both exist) prefer the hidden root for new installs. + bool hiddenExists = Storage.exists(FONTS_DIR_HIDDEN); + bool visibleExists = Storage.exists(FONTS_DIR_VISIBLE); + if (hiddenExists) return FONTS_DIR_HIDDEN; + if (visibleExists) return FONTS_DIR_VISIBLE; + return FONTS_DIR_HIDDEN; +} + +const SdCardFontFamilyInfo* SdCardFontRegistry::findFamily(const std::string& name) const { + for (const auto& f : families_) { + if (f.name == name) return &f; + } + return nullptr; +} + +int SdCardFontRegistry::getFamilyIndex(const std::string& name) const { + for (int i = 0; i < static_cast(families_.size()); i++) { + if (families_[i].name == name) return i; + } + return -1; +} diff --git a/lib/EpdFont/SdCardFontRegistry.h b/lib/EpdFont/SdCardFontRegistry.h new file mode 100644 index 00000000..f96035ed --- /dev/null +++ b/lib/EpdFont/SdCardFontRegistry.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include + +struct SdCardFontFileInfo { + std::string path; // v4 on-disk naming: "///_.cpfont" + // where is "/.fonts" (preferred, hidden) or "/fonts" (visible). + // e.g. "/.fonts/NotoSansCJK/NotoSansCJK_14.cpfont" + uint8_t pointSize; // parsed from filename: 14 + uint8_t style; // always 0 in v4 (all 4 styles bundled in one file); + // kept for potential future formats +}; + +struct SdCardFontFamilyInfo { + std::string name; // directory name, e.g. "NotoSansCJK" + std::vector files; + + const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const; + bool hasSize(uint8_t size) const; + std::vector availableSizes() const; +}; + +class SdCardFontRegistry { + public: + static constexpr int MAX_SD_FAMILIES = 128; + // Two top-level roots are scanned at discovery time. Hidden is preferred + // when creating new installs; both are read from if present. + static constexpr const char* FONTS_DIR_HIDDEN = "/.fonts"; + static constexpr const char* FONTS_DIR_VISIBLE = "/fonts"; + + // Returns the existing root for `familyName` (the one that contains + // ///), or nullptr if the family is not installed in + // either root. Used by writers to keep re-installs in their existing dir. + static const char* findFamilyRoot(const char* familyName); + + // Returns the root path that should be used when creating a brand-new + // family on disk (no prior install): the existing root if exactly one of + // the two roots exists, otherwise the hidden root. + static const char* defaultWriteRoot(); + + // Scan SD card, populate families_. Returns true if any families found. + bool discover(); + + const std::vector& getFamilies() const { return families_; } + const SdCardFontFamilyInfo* findFamily(const std::string& name) const; + int getFamilyIndex(const std::string& name) const; + int getFamilyCount() const { return static_cast(families_.size()); } + + private: + std::vector families_; // sorted alphabetically + + static bool parseFilename(const char* filename, uint8_t& size, uint8_t& style); + static void scanDirectory(const char* dirPath, SdCardFontFamilyInfo& family); + // Scan one root (e.g. "/.fonts"), append families to `out`, dedup by name. + static void scanRoot(const char* rootPath, std::vector& out); +}; diff --git a/lib/EpdFont/builtinFonts/source/.gitignore b/lib/EpdFont/builtinFonts/source/.gitignore new file mode 100644 index 00000000..1c3f6a00 --- /dev/null +++ b/lib/EpdFont/builtinFonts/source/.gitignore @@ -0,0 +1,12 @@ +# Ignore all font directories except those committed to the repo. +# Fonts like NotoSansCJK are downloaded on demand by build-sd-fonts.py. +* +!.gitignore +!NotoSerif/ +!NotoSerif/** +!NotoSans/ +!NotoSans/** +!OpenDyslexic/ +!OpenDyslexic/** +!Ubuntu/ +!Ubuntu/** diff --git a/lib/EpdFont/scripts/build-sd-fonts.py b/lib/EpdFont/scripts/build-sd-fonts.py new file mode 100755 index 00000000..50bc67a8 --- /dev/null +++ b/lib/EpdFont/scripts/build-sd-fonts.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Build SD card fonts from a declarative YAML config. + +Reads sd-fonts.yaml, downloads any missing source fonts, runs +fontconvert_sdcard.py in parallel for each family, and optionally +generates the fonts.json manifest. + +Usage: + # Generate fonts (output in ./output/) + python3 build-sd-fonts.py + + # Generate fonts + manifest + python3 build-sd-fonts.py --manifest --base-url "http://localhost:8000/" + + # Custom config / output paths + python3 build-sd-fonts.py --config my-fonts.yaml --output-dir dist/ + + # Generate only specific families + python3 build-sd-fonts.py --only Literata,IBMPlexMono +""" + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +import urllib.request +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path + +import yaml + +SCRIPT_DIR = Path(__file__).parent +FONTCONVERT = SCRIPT_DIR / "fontconvert_sdcard.py" +EPDFONTS_DIR = SCRIPT_DIR.parent # lib/EpdFont +DEFAULT_CONFIG = SCRIPT_DIR / "sd-fonts.yaml" +DEFAULT_OUTPUT = SCRIPT_DIR / "output" +DOWNLOAD_DIR = SCRIPT_DIR / "downloaded_fonts" +INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts" + + +def download_font(url: str, dest: Path) -> Path: + """Download a font file if not already cached. Returns the local path.""" + if dest.exists(): + return dest + dest.parent.mkdir(parents=True, exist_ok=True) + print(f" Downloading {dest.name}...") + try: + urllib.request.urlretrieve(url, dest) + except Exception as e: + dest.unlink(missing_ok=True) + raise RuntimeError(f"Failed to download {url}: {e}") from e + size_kb = dest.stat().st_size / 1024 + print(f" Downloaded {dest.name} ({size_kb:.0f} KB)") + return dest + + +def extract_static_instance(source_path: Path, axes: dict, family_name: str, style_name: str) -> Path: + """Use fonttools instancer to pin variable font axes, producing a static TTF. + + Caches the result in INSTANCE_DIR// + + +

📚 CrossPoint Reader

+ + + +
+

Installed Fonts

+

Loading...

+
+ +
+

Upload Font

+
+ + +
+

+
+
+ + + + diff --git a/src/network/html/HomePage.html b/src/network/html/HomePage.html index e073e6d8..a67ddbd1 100644 --- a/src/network/html/HomePage.html +++ b/src/network/html/HomePage.html @@ -104,6 +104,7 @@ Home File Manager Settings + Fonts
diff --git a/src/network/html/SettingsPage.html b/src/network/html/SettingsPage.html index 47d846f2..0a73f9bd 100644 --- a/src/network/html/SettingsPage.html +++ b/src/network/html/SettingsPage.html @@ -285,6 +285,7 @@ Home File Manager Settings + Fonts