From 3efc863038bf9e1107c66f1302bf185efa8c39ba Mon Sep 17 00:00:00 2001 From: WuTofu <5987870+WuTofu@users.noreply.github.com> Date: Sun, 10 May 2026 10:46:59 +0800 Subject: [PATCH 01/10] feat: verify CRC32 checksum for font files (#1904) ## Summary * **What is the goal of this PR?** Add end-to-end integrity verification for downloaded font files by including CRC32 checksums in the font manifest and validating downloaded `.cpfont` files on device. * **What changes are included?** - `generate-font-manifest.py`: compute and include `crc32` for each `.cpfont` asset in the generated `fonts.json` manifest. - `FontDownloadActivity.h`: extend manifest file metadata with `crc32` and declare checksum helper. - `FontDownloadActivity.cpp`: parse `crc32` from manifest, compute CRC32 of downloaded files using `esp_rom_crc32_le`. --- ### 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/generate-font-manifest.py | 11 ++++ .../settings/FontDownloadActivity.cpp | 50 +++++++++++++++++++ .../settings/FontDownloadActivity.h | 2 + 3 files changed, 63 insertions(+) diff --git a/scripts/generate-font-manifest.py b/scripts/generate-font-manifest.py index 695bb99b..e962c162 100755 --- a/scripts/generate-font-manifest.py +++ b/scripts/generate-font-manifest.py @@ -21,6 +21,7 @@ import json import os import struct import sys +import zlib from pathlib import Path # Import canonical version constants from the shared file in lib/EpdFont/scripts/ @@ -115,6 +116,15 @@ def parse_filename(filename: str) -> tuple[str, str] | None: return family, size_str +def compute_crc32(filepath: Path) -> int: + """Compute CRC32 of a file, matching esp_rom_crc32_le(0xFFFFFFFF, ...) ^ 0xFFFFFFFF.""" + crc = 0 + with open(filepath, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + crc = zlib.crc32(chunk, crc) + return crc & 0xFFFFFFFF + + def scan_cpfont_files(input_dir: Path) -> dict[str, list[Path]]: """Scan input directory for .cpfont files, grouped by family name. @@ -164,6 +174,7 @@ def build_manifest( { "name": filepath.name, "size": filepath.stat().st_size, + "crc32": compute_crc32(filepath), } ) diff --git a/src/activities/settings/FontDownloadActivity.cpp b/src/activities/settings/FontDownloadActivity.cpp index e7419c0e..ce18ce67 100644 --- a/src/activities/settings/FontDownloadActivity.cpp +++ b/src/activities/settings/FontDownloadActivity.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "MappedInputManager.h" #include "SdCardFontGlobals.h" @@ -123,6 +124,14 @@ bool FontDownloadActivity::fetchAndParseManifest() { ManifestFile file; file.name = fileObj["name"] | ""; file.size = fileObj["size"] | 0; + + if (!fileObj["crc32"].is()) { + LOG_ERR("FONT", "Malformed manifest file entry: missing or invalid crc32 for %s", file.name.c_str()); + errorMessage_ = "Invalid font manifest"; + return false; + } + file.crc32 = fileObj["crc32"].as(); + family.totalSize += file.size; family.files.push_back(std::move(file)); } @@ -181,6 +190,24 @@ size_t FontDownloadActivity::totalUninstalledSize() const { return total; } +// Standard CRC32 matching zlib/Python zlib.crc32(). +bool FontDownloadActivity::computeFileCrc32(const char* path, uint32_t& outCrc) { + FsFile f; + if (!Storage.openFileForRead("FONT", path, f)) { + return false; + } + constexpr size_t BUF_SIZE = 128; + uint8_t buf[BUF_SIZE]; + uint32_t crc = 0; + while (f.available()) { + const int n = f.read(buf, BUF_SIZE); + if (n <= 0) break; + crc = esp_rom_crc32_le(crc, buf, static_cast(n)); + } + outCrc = crc; + return true; +} + void FontDownloadActivity::downloadFamily(ManifestFamily& family) { { RenderLock lock(*this); @@ -233,6 +260,29 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { return; } + uint32_t actualCrc = 0; + if (!computeFileCrc32(destPath, actualCrc)) { + LOG_ERR("FONT", "Failed to open file for CRC check: %s", destPath); + fontInstaller_.deleteFamily(family.name.c_str()); + family.installed = false; + family.hasUpdate = false; + RenderLock lock(*this); + state_ = ERROR; + errorMessage_ = "Failed to compute checksum: " + file.name; + return; + } + if (actualCrc != file.crc32) { + LOG_ERR("FONT", "CRC32 mismatch for %s: got %08x expected %08x", file.name.c_str(), actualCrc, file.crc32); + fontInstaller_.deleteFamily(family.name.c_str()); + family.installed = false; + family.hasUpdate = false; + RenderLock lock(*this); + state_ = ERROR; + errorMessage_ = "Checksum mismatch: " + file.name; + return; + } + LOG_DBG("FONT", "Downloaded %s (size=%zu crc32=%08x)", file.name.c_str(), file.size, actualCrc); + if (!fontInstaller_.validateCpfontFile(destPath)) { LOG_ERR("FONT", "Invalid .cpfont: %s", destPath); fontInstaller_.deleteFamily(family.name.c_str()); diff --git a/src/activities/settings/FontDownloadActivity.h b/src/activities/settings/FontDownloadActivity.h index 1b4373c4..16d52fa9 100644 --- a/src/activities/settings/FontDownloadActivity.h +++ b/src/activities/settings/FontDownloadActivity.h @@ -50,6 +50,7 @@ class FontDownloadActivity : public Activity { struct ManifestFile { std::string name; size_t size = 0; + uint32_t crc32 = 0; }; struct ManifestFamily { @@ -83,6 +84,7 @@ class FontDownloadActivity : public Activity { bool fetchAndParseManifest(); void downloadFamily(ManifestFamily& family); void downloadAll(); + static bool computeFileCrc32(const char* path, uint32_t& outCrc); bool isDownloadAllSelected() const { return selectedIndex_ == 0 && !families_.empty(); } int familyIndexFromList(int listIndex) const { return listIndex - 1; } int listItemCount() const { return families_.empty() ? 0 : static_cast(families_.size()) + 1; } From d3e0aeb62ba6d09322bf94f8f4a6d368eaef8969 Mon Sep 17 00:00:00 2001 From: Stefan Blixten Karlsson Date: Sun, 10 May 2026 04:52:13 +0200 Subject: [PATCH 02/10] feat: allow unnamed intervals (#1903) ## Summary * Previous `fontconvert_sdcard.py` requires that the intervals are named (i.e. listed in `INTERVAL_PRESETS`, ex. "latin1"), this change will also allow them to be unnamed (ex. "(0x2100-0x214F)") for a more flexible usage. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- lib/EpdFont/scripts/fontconvert_sdcard.py | 29 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/EpdFont/scripts/fontconvert_sdcard.py b/lib/EpdFont/scripts/fontconvert_sdcard.py index 888f9532..1e410a97 100755 --- a/lib/EpdFont/scripts/fontconvert_sdcard.py +++ b/lib/EpdFont/scripts/fontconvert_sdcard.py @@ -26,6 +26,7 @@ import freetype import struct import sys import os +import re import math import argparse from collections import namedtuple @@ -75,17 +76,39 @@ INTERVAL_PRESETS = { (0xFB00, 0xFB06)], } +# Regex for parsing unnamed hex range intervals: (0xSTART-0xEND) +_HEX_RANGE_PATTERN = re.compile(r'^\(0x([0-9a-fA-F]+)-0x([0-9a-fA-F]+)\)$') + +def parse_hex_range(s: str) -> tuple[int, int] | None: + match = _HEX_RANGE_PATTERN.fullmatch(s) + if not match: + return None + + start_hex, end_hex = match.groups() + start, end = int(start_hex, 16), int(end_hex, 16) + + # Validating Unicode range bounds. + if start > end or end > 0x10FFFF: + return None + return start, end + def resolve_intervals(preset_str): """Resolve comma-separated preset names into a merged, sorted, deduplicated interval list.""" all_intervals = [] for name in preset_str.split(","): name = name.strip().lower() - if name not in INTERVAL_PRESETS: + unnamed_interval = parse_hex_range(name) + if name not in INTERVAL_PRESETS and unnamed_interval is None: print(f"Error: unknown interval preset '{name}'", file=sys.stderr) print(f"Available presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}", file=sys.stderr) + print("You can also specify unnamed hex ranges like (0x2100-0x214F)", file=sys.stderr) sys.exit(1) - all_intervals.extend(INTERVAL_PRESETS[name]) + + if unnamed_interval is not None: + all_intervals.append(unnamed_interval) + else: + all_intervals.extend(INTERVAL_PRESETS[name]) # Always add replacement character all_intervals.append((0xFFFD, 0xFFFD)) @@ -895,4 +918,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() From c7ad14eb367cfd31738efc309b0d1c8df16887cc Mon Sep 17 00:00:00 2001 From: Stefan Blixten Karlsson Date: Sun, 10 May 2026 05:00:45 +0200 Subject: [PATCH 03/10] fix: swedish translation (#1888) ## Summary * Add missing swedish translations --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- lib/I18n/translations/swedish.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 2505c935..84ac108e 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -229,6 +229,9 @@ STR_EXAMPLE_BOOK: "Boktitel" STR_PREVIEW: "Förhandsgranskning" STR_TITLE: "Titel" STR_BATTERY: "Batteri" +STR_XTC_STATUS_BAR: "XTC-statusfält" +STR_BOTTOM: "Botten" +STR_TOP: "Överst" STR_UI_THEME: "Användargränssnittstema" STR_THEME_CLASSIC: "Klassisk" STR_THEME_LYRA: "Lyra" @@ -294,6 +297,7 @@ STR_UPLOAD: "Uppladdning" STR_BOOK_S_STYLE: "Bokstil" STR_EMBEDDED_STYLE: "Inbäddad stil" STR_OPDS_SERVER_URL: "OPDS-serveradress" +STR_SET_SLEEP_COVER: "Ställ in omslag" STR_FOOTNOTES: "Fotnoter" STR_NO_FOOTNOTES: "Inga fotnoter på den här sidan" STR_LINK: "[länk]" @@ -306,6 +310,21 @@ STR_DELETE_CONFIRM: "Vill du ta bort den här servern?" STR_OPDS_SERVERS: "OPDS-servrar" STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: " STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)" +STR_DOWNLOAD_FONTS: "Ladda ner teckensnitt" +STR_FONT_DOWNLOAD: "Nedladdning av teckensnitt" +STR_LOADING_FONT_LIST: "Laddar teckensnittslista..." +STR_NO_FONTS_AVAILABLE: "Inga teckensnitt tillgängliga" +STR_FONT_INSTALLED: "Teckensnitt installerat!" +STR_FONT_INSTALL_FAILED: "Installationen av teckensnitt misslyckades" +STR_INSTALLED: "Installerad" +STR_CONFIRM_DOWNLOAD_PROMPT: "Nedladdning?" +STR_SD_CARD_FULL: "Otillräckligt utrymme på SD-kortet" +STR_FILES_LABEL: "Filer: " +STR_SIZE_LABEL: "Storlek: " +STR_REDOWNLOAD: "Ladda ner igen" +STR_DOWNLOAD_ALL: "Ladda ner / Uppdatera alla" +STR_ALL_FONTS_INSTALLED: "Alla teckensnitt installerade!" +STR_UPDATE_AVAILABLE: "Uppdatering" STR_CRASH_TITLE: "Systemkrasch" STR_CRASH_DESCRIPTION: "En detaljerad rapport sparades till crash_report.txt. Vänligen inkludera den här filen i din felrapport." STR_CRASH_REASON: "Orsak till kraschen:" From 91de6ac278e0e879f607dff59bec12240b7db31d Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Sun, 10 May 2026 07:35:16 +0300 Subject: [PATCH 04/10] fix: two roundedraff bugs (#1851) --- .../themes/roundedraff/RoundedRaffTheme.cpp | 73 ++++++++++++++++++- .../themes/roundedraff/RoundedRaffTheme.h | 15 +++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.cpp b/src/components/themes/roundedraff/RoundedRaffTheme.cpp index 6d80e8f3..3e0ede09 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.cpp +++ b/src/components/themes/roundedraff/RoundedRaffTheme.cpp @@ -122,7 +122,7 @@ void RoundedRaffTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const } // Full-width divider between tabs and setting rows. - renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width, rect.y + rect.height - 1, true); + renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true); } void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, @@ -243,6 +243,77 @@ void RoundedRaffTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int butt drawScrollBar(renderer, rect, buttonCount, pageStartIndex, pageItems); } +void RoundedRaffTheme::drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode, + int contentStartX, int contentWidth) const { + const auto& metrics = UITheme::getInstance().getMetrics(); + const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int lineY = rect.y + rect.height + lineHeight + metrics.verticalSpacing; + const int thickness = cursorMode ? 3 : 2; + + if (contentWidth > 0) { + renderer.drawLine(rect.x + contentStartX, lineY, rect.x + contentStartX + contentWidth - 1, lineY, thickness, true); + return; + } + + constexpr int hPadding = 8; + const int lineW = textWidth + hPadding * 2; + const int lineStart = rect.x + (rect.width - lineW) / 2; + renderer.drawLine(lineStart, lineY, lineStart + lineW - 1, lineY, thickness, true); +} + +void RoundedRaffTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected, + const char* secondaryLabel, const KeyboardKeyType keyType, + const bool inactiveSelection) const { + constexpr int keyRadius = 10; + const bool disabled = keyType == KeyboardKeyType::Disabled; + const bool invert = isSelected && !inactiveSelection; + + if (isSelected) { + const Color fillColor = (inactiveSelection || disabled) ? Color::LightGray : Color::Black; + renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, keyRadius, fillColor); + } else { + if (disabled) { + renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, keyRadius, Color::LightGray); + } else { + renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, keyRadius, Color::White); + } + renderer.drawRoundedRect(rect.x, rect.y, rect.width, rect.height, 1, keyRadius, true); + } + + if (keyType == KeyboardKeyType::Space) { + const int lineHalfWidth = rect.width * 3 / 10; + const int centerX = rect.x + rect.width / 2; + const int lineY = rect.y + rect.height / 2 + 3; + renderer.drawLine(centerX - lineHalfWidth, lineY, centerX + lineHalfWidth, lineY, 3, !invert); + return; + } + + if (keyType == KeyboardKeyType::Del) { + const int centerX = rect.x + rect.width / 2; + const int centerY = rect.y + rect.height / 2; + const int arrowLen = rect.width / 4; + const int arrowHead = std::max(1, arrowLen / 2); + renderer.drawLine(centerX - arrowLen / 2, centerY, centerX + arrowLen / 2, centerY, 3, !invert); + renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY - arrowHead, 3, + !invert); + renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY + arrowHead, 3, + !invert); + return; + } + + if (label != nullptr && label[0] != '\0') { + const int itemWidth = renderer.getTextWidth(UI_12_FONT_ID, label); + const int textX = rect.x + (rect.width - itemWidth) / 2; + const int textY = rect.y + (rect.height - renderer.getLineHeight(UI_12_FONT_ID)) / 2; + renderer.drawText(UI_12_FONT_ID, textX, textY, label, !invert); + } + + if (secondaryLabel != nullptr && secondaryLabel[0] != '\0') { + const int secWidth = renderer.getTextWidth(SMALL_FONT_ID, secondaryLabel); + renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - secWidth - 3, rect.y + 1, secondaryLabel, !invert); + } +} + void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, const std::function& rowSubtitle, diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.h b/src/components/themes/roundedraff/RoundedRaffTheme.h index 5a03a037..b73a5b55 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.h +++ b/src/components/themes/roundedraff/RoundedRaffTheme.h @@ -36,8 +36,14 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .keyboardKeyWidth = 22, .keyboardKeyHeight = 30, .keyboardKeySpacing = 10, - .keyboardBottomAligned = false, - .keyboardCenteredText = false}; + .keyboardBottomKeyHeight = 30, + .keyboardBottomKeySpacing = 5, + .keyboardBottomAligned = true, + .keyboardCenteredText = false, + .keyboardVerticalOffset = 0, + .keyboardTextFieldWidthPercent = 85, + .keyboardWidthPercent = 90, + .keyboardKeyCornerRadius = 0}; } class RoundedRaffTheme : public BaseTheme { @@ -52,6 +58,11 @@ class RoundedRaffTheme : public BaseTheme { void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, const std::function& buttonLabel, const std::function& rowIcon) const override; + void drawTextField(const GfxRenderer& renderer, Rect rect, int textWidth, bool cursorMode = false, + int contentStartX = 0, int contentWidth = 0) const override; + void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, bool isSelected, + const char* secondaryLabel = nullptr, KeyboardKeyType keyType = KeyboardKeyType::Normal, + bool inactiveSelection = false) const override; void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, const std::function& rowSubtitle = nullptr, From c5d2dc2e0028868646441cde81127d4b84b47c0f Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 10 May 2026 17:04:59 +0200 Subject: [PATCH 05/10] feat: cap compressed group size at 64 KB (#1913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GROUP_MAX_UNCOMPRESSED_BYTES=65536 on top of the existing script- based grouping. When adding the next glyph would push a group past the cap, the group is closed and a new one started with the same script ID. Without the cap, the size of a script group is bounded only by however many glyphs the font supplies in that block. Dense scripts (CJK, Vietnamese precomposed, user-supplied fonts with large Unicode blocks) can produce a single group whose uncompressed size exceeds what fits in the embedded decompressor's transient malloc on the ESP32-C3, which manifests as a runtime allocation failure instead of a build error. The 64 KB ceiling is large enough to hold any single built-in script group with headroom and small enough to be a comfortable transient allocation on-device. The check uses byte-aligned size (4-pixel-aligned row stride × height), which is what the decompressor actually consumes — not the packed on-disk length. A defensive guard rejects any single glyph whose own size exceeds the cap with a clear error pointing at the offending codepoint. ## 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 | PARTIALLY | NO >**_ --- lib/EpdFont/scripts/fontconvert.py | 35 ++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/EpdFont/scripts/fontconvert.py b/lib/EpdFont/scripts/fontconvert.py index 2b904eca..2a16e016 100755 --- a/lib/EpdFont/scripts/fontconvert.py +++ b/lib/EpdFont/scripts/fontconvert.py @@ -792,6 +792,15 @@ if compress: # are grouped together for efficient LRU caching on the embedded target. # Since glyphs are in codepoint order, glyphs in the same Unicode block # are contiguous in the array and form natural groups. + # + # On top of script boundaries, a hard size cap (GROUP_MAX_UNCOMPRESSED_BYTES) + # is applied: if adding the next glyph would push the uncompressed group + # size over the cap, the group is closed and a new one started with the + # same script ID. This bounds the embedded decompressor's transient + # malloc regardless of font density (CJK, Vietnamese, user-supplied + # fonts with large Unicode blocks). Without it, a single dense script + # group can balloon past what fits in a transient page-decompress + # allocation on the device. SCRIPT_GROUP_RANGES = [ (0x0000, 0x007F), # ASCII (0x0080, 0x00FF), # Latin-1 Supplement @@ -809,6 +818,11 @@ if compress: (0xFFFD, 0xFFFD), # Replacement Character ] + # 64 KB cap: large enough to hold any single built-in script group with + # headroom, small enough to be a comfortable transient malloc on the + # ESP32-C3. + GROUP_MAX_UNCOMPRESSED_BYTES = 65536 + def get_script_group(code_point): for i, (start, end) in enumerate(SCRIPT_GROUP_RANGES): if start <= code_point <= end: @@ -819,17 +833,34 @@ if compress: current_group_id = None group_start = 0 group_count = 0 + group_uncompressed = 0 - for i, (props, packed) in enumerate(all_glyphs): + for i, (props, _) in enumerate(all_glyphs): sg = get_script_group(props.code_point) - if sg != current_group_id: + # Use the byte-aligned size (4-pixel-aligned row stride) rather than + # the packed length, since the decompressor consumes byte-aligned + # buffers. Empty glyphs contribute zero. + glyph_aligned_size = (((props.width + 3) // 4) * props.height + if props.width > 0 and props.height > 0 else 0) + if glyph_aligned_size > GROUP_MAX_UNCOMPRESSED_BYTES: + raise ValueError( + f"Glyph {i} (code point U+{props.code_point:04X}) byte-aligned size " + f"{glyph_aligned_size} exceeds GROUP_MAX_UNCOMPRESSED_BYTES=" + f"{GROUP_MAX_UNCOMPRESSED_BYTES}. Consider: (1) increasing GROUP_MAX_UNCOMPRESSED_BYTES, " + f"(2) reducing font size, or (3) excluding this codepoint." + ) + size_overflow = group_uncompressed + glyph_aligned_size > GROUP_MAX_UNCOMPRESSED_BYTES + + if sg != current_group_id or size_overflow: if group_count > 0: groups.append((group_start, group_count)) current_group_id = sg group_start = i group_count = 1 + group_uncompressed = glyph_aligned_size else: group_count += 1 + group_uncompressed += glyph_aligned_size if group_count > 0: groups.append((group_start, group_count)) From 5d5533b3d255b9f3940c77d3b4c551a588d6febb Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 10 May 2026 17:07:08 +0200 Subject: [PATCH 06/10] fix: capture instantiateVariableFont return value (#1911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit instantiateVariableFont() returns a *new* TTFont with the requested axes pinned; the existing call discards that return and proceeds to font.save() the original (still-variable) font. The "static instance" written to disk is therefore identical to the source variable font and the requested axis values are silently ignored. For sd-fonts.yaml entries that use the `variable: {wght: 400}` form (e.g. Bitter, Lora) this means every weight ends up rasterised from the same default-weight glyphs — bold and regular look the same. Capture the return value, and pass two diagnostically useful kwargs while we're touching the call: * updateFontNames=True — rewrite the name table so the saved TTF reports its actual weight/style instead of retaining the source variable-font names. * optimize=False — skip the gvar interpolation optimisation; fully pinning every axis drops gvar anyway, so the work would be wasted. The atomic-write scaffolding around the call is left untouched. ## 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 | PARTIALLY | NO >**_ --- lib/EpdFont/scripts/build-sd-fonts.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/EpdFont/scripts/build-sd-fonts.py b/lib/EpdFont/scripts/build-sd-fonts.py index 50bc67a8..9f29c00d 100755 --- a/lib/EpdFont/scripts/build-sd-fonts.py +++ b/lib/EpdFont/scripts/build-sd-fonts.py @@ -85,15 +85,29 @@ def extract_static_instance(source_path: Path, axes: dict, family_name: str, sty tmp_fd, tmp_name = tempfile.mkstemp(suffix=".ttf", dir=cached.parent) os.close(tmp_fd) tmp_path = Path(tmp_name) - font = TTFont(str(source_path)) + # Keep separate handles for the source variable font and the static + # instance: instantiateVariableFont with default inplace=False returns a + # *new* TTFont, so rebinding `font` would otherwise strand the source's + # file handle open until GC runs. + # + # updateFontNames=True — rewrite the name table so the saved font + # reports its weight/style accurately rather + # than retaining the variable-font names. + # optimize=False — skip the gvar interpolation optimisation; + # fully pinning every axis drops gvar anyway, + # so the work would be wasted. + source_font = TTFont(str(source_path)) try: - instantiateVariableFont(font, axes) - font.save(str(tmp_path)) + font = instantiateVariableFont(source_font, axes, updateFontNames=True, optimize=False) + try: + font.save(str(tmp_path)) + finally: + font.close() except Exception: tmp_path.unlink(missing_ok=True) raise finally: - font.close() + source_font.close() tmp_path.replace(cached) return cached From 5917c561b32e6b0ab0850f5eddbb233c31d07cdb Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 10 May 2026 17:54:04 +0200 Subject: [PATCH 07/10] fix: build-script bug fixes for fontconvert{,_sdcard}.py (#1910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fontconvert_sdcard.py: * Move face.set_char_size() before any load_glyph() call. The validation loop runs load_glyph() with FT_LOAD_RENDER, which renders at the *active* size — calling it before set_char_size() wastes work at the default size and triggers Invalid_Size_Handle on some fonts. * Walk bitmap.buffer using bitmap.pitch with negative-pitch handling. The previous linear iteration assumed pitch == width and a top-down layout, which silently corrupts output for any padded or bottom-up bitmap FreeType returns. * Fix operator-precedence bug in 2-bit tail-padding: px << (4 - … % 4) * 2 evaluated as (px << (4 - … % 4)) * 2 due to << binding tighter than *. Add the missing outer parens. * Drop SMP codepoints (> U+FFFF) from kern and ligature tables before packing — the binary format uses uint16/uint32 codepoint fields and would raise struct.error otherwise. * Skip GPOS kern subtables that aren't lookup type 2 (PairPos). _extract_pairpos_subtable assumes Type-2 layout; cursive attachment and other types reachable through the kern feature crash inside it. Fontconvert.py: * Force UTF-8 stdout so `python fontconvert.py … > foo.h` on Windows doesn't emit UTF-16 / replacement characters in the generated header. ## 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 | PARTIALLY | NO >**_ --- lib/EpdFont/scripts/fontconvert.py | 6 ++ lib/EpdFont/scripts/fontconvert_sdcard.py | 88 ++++++++++++++++++----- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/lib/EpdFont/scripts/fontconvert.py b/lib/EpdFont/scripts/fontconvert.py index 2a16e016..8e7b17a9 100755 --- a/lib/EpdFont/scripts/fontconvert.py +++ b/lib/EpdFont/scripts/fontconvert.py @@ -8,6 +8,12 @@ import argparse from collections import namedtuple from fontTools.ttLib import TTFont +# Force UTF-8 stdout so that `python fontconvert.py … > foo.h` on Windows +# (default cp1252) doesn't emit UTF-16 LE / replacement chars in the generated +# header. Wrapped in a hasattr guard so it's a no-op on older Pythons. +if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8') + # Originally from https://github.com/vroland/epdiy parser = argparse.ArgumentParser(description="Generate a header file from a font to be used with epdiy.") diff --git a/lib/EpdFont/scripts/fontconvert_sdcard.py b/lib/EpdFont/scripts/fontconvert_sdcard.py index 1e410a97..baf3875f 100755 --- a/lib/EpdFont/scripts/fontconvert_sdcard.py +++ b/lib/EpdFont/scripts/fontconvert_sdcard.py @@ -289,11 +289,29 @@ def extract_kerning_fonttools(font_path, codepoints, ppem): lookup = gpos.LookupList.Lookup[li] for st in lookup.SubTable: actual = st - # Unwrap Extension (lookup type 9) wrappers + # Unwrap Extension (lookup type 9) wrappers. After unwrapping, + # `lookup.LookupType` is still 9, so we must look at the + # *effective* type carried on the extension subtable to know + # whether `actual` is a PairPos table. if lookup.LookupType == 9 and hasattr(st, 'ExtSubTable'): actual = st.ExtSubTable + effective_type = getattr(st, 'ExtensionLookupType', lookup.LookupType) if hasattr(actual, 'Format'): - _extract_pairpos_subtable(actual, glyph_to_cp, raw_kern) + # _extract_pairpos_subtable assumes a Type-2 (PairPos) + # subtable. Other lookup types reachable through the kern + # feature (cursive attachment, mark-to-mark, contextual, + # etc.) have a different shape and crash inside the + # extractor. Skip them with a debug note rather than + # aborting the whole build. Modern fonts often ship kern + # via Extension-wrapped PairPos, so checking the effective + # type instead of the outer type is what makes those + # lookups actually reach the extractor. + if effective_type == 2: + _extract_pairpos_subtable(actual, glyph_to_cp, raw_kern) + else: + print(f" Debug: skipping unsupported GPOS kern lookupType=" + f"{effective_type} (outer={lookup.LookupType}, Format={actual.Format})", + file=sys.stderr) font.close() @@ -448,11 +466,20 @@ def extract_ligatures_fonttools(font_path, codepoints): font.close() # Filter: only keep ligatures where all input and output codepoints are - # in our generated glyph set + # in our generated glyph set, and all codepoints fit in 16 bits. + # + # The on-disk format packs each component as a uint16 (the 3+ chained + # path packs `intermediate_cp << 16 | last_cp`, where `intermediate_cp` + # is the lig_cp of the prefix). Dropping any seq with an SMP cp here — + # plus any lig_cp > 0xFFFF — means every cp that reaches `packed = … << + # 16 | …` below is already 16-bit safe, including the chained path + # (intermediate_cp = filtered[prefix] is filtered too). codepoints_set = set(codepoints) filtered = {} for seq, lig_cp in raw_ligatures.items(): - if lig_cp not in codepoints_set: + if lig_cp not in codepoints_set or lig_cp > 0xFFFF: + continue + if any(cp > 0xFFFF for cp in seq): continue if all(cp in codepoints_set for cp in seq): filtered[seq] = lig_cp @@ -491,6 +518,12 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F style_label = style_names.get(style_id, str(style_id)) face = freetype.Face(fontfile) + # Set font size at 150 DPI (matching fontconvert.py) BEFORE any glyph load. + # load_glyph() with FT_LOAD_RENDER renders at the active size, so calling + # it before set_char_size() would waste work at the default size and risk + # Invalid_Size_Handle on some fonts. + face.set_char_size(size << 6, size << 6, 150, 150) + load_flags = freetype.FT_LOAD_RENDER if force_autohint: load_flags |= freetype.FT_LOAD_FORCE_AUTOHINT @@ -520,9 +553,6 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F total_glyphs = sum(end - start + 1 for start, end in intervals) print(f" [{style_label}] Validated: {len(intervals)} intervals, {total_glyphs} glyphs", file=sys.stderr) - # Set font size at 150 DPI (matching fontconvert.py) - face.set_char_size(size << 6, size << 6, 150, 150) - # Rasterize all glyphs total_bitmap_size = 0 all_glyphs = [] @@ -537,18 +567,28 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F bitmap = f.glyph.bitmap - # Build 4-bit greyscale bitmap (same logic as fontconvert.py) + # Build 4-bit greyscale bitmap (same logic as fontconvert.py). + # + # FreeType returns the buffer with bitmap.pitch as the row stride + # in bytes, which can be negative when the bitmap is stored + # bottom-up. Iterating bitmap.buffer linearly assumes + # pitch == width and a top-down layout — that holds in the common + # case but breaks on padded or flipped bitmaps and corrupts the + # output. Walk by (row, col) using the real pitch instead. pixels4g = [] px = 0 - for i, v in enumerate(bitmap.buffer): - x = i % bitmap.width - if x % 2 == 0: - px = (v >> 4) - else: - px = px | (v & 0xF0) - pixels4g.append(px) - px = 0 - if x == bitmap.width - 1 and bitmap.width % 2 > 0: + abs_pitch = abs(bitmap.pitch) + for y in range(bitmap.rows): + row_offset = y * abs_pitch if bitmap.pitch >= 0 else (bitmap.rows - 1 - y) * abs_pitch + for x in range(bitmap.width): + v = bitmap.buffer[row_offset + x] + if x % 2 == 0: + px = (v >> 4) + else: + px = px | (v & 0xF0) + pixels4g.append(px) + px = 0 + if bitmap.width % 2 > 0: pixels4g.append(px) px = 0 @@ -573,7 +613,12 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F pixels2b.append(px) px = 0 if (bitmap.width * bitmap.rows) % 4 != 0: - px = px << (4 - (bitmap.width * bitmap.rows) % 4) * 2 + # Outer parens are for clarity: in Python `*` binds tighter + # than `<<`, so the original `px << (4 - … % 4) * 2` already + # evaluates as `px << ((4 - … % 4) * 2)`. Match the explicit + # bracketing here so the shift width is obvious at a glance, + # mirroring the inner-loop style in fontconvert.py. + px = px << ((4 - (bitmap.width * bitmap.rows) % 4) * 2) pixels2b.append(px) packed = bytes(pixels2b) @@ -605,6 +650,10 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F all_cps = set(g.code_point for g, _ in all_glyphs) kern_map = extract_kerning_fonttools(fontfile, all_cps, ppem) + # SMP codepoints (> U+FFFF) cannot be stored in the uint16 kern codepoint + # field; drop them before class derivation to avoid a downstream + # struct.error when packing the binary kern tables. + kern_map = {(lcp, rcp): v for (lcp, rcp), v in kern_map.items() if lcp <= 0xFFFF and rcp <= 0xFFFF} print(f" [{style_label}] Kerning: {len(kern_map)} pairs extracted", file=sys.stderr) (kern_left_classes, kern_right_classes, kern_matrix, @@ -616,6 +665,9 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F print(f" [{style_label}] Kerning classes: {kern_left_class_count} left, {kern_right_class_count} right, " f"{matrix_size + entries_size} bytes", file=sys.stderr) + # SMP codepoints in ligature inputs / outputs are filtered inside + # extract_ligatures_fonttools (see the codepoints_set filter), so every + # entry returned here is already 16-bit safe. ligature_pairs = extract_ligatures_fonttools(fontfile, all_cps) if len(ligature_pairs) > 255: print(f" [{style_label}] WARNING: {len(ligature_pairs)} ligature pairs exceeds uint8_t max (255), truncating", From cff54d776ca4012870ede3245f04bb7f9624128f Mon Sep 17 00:00:00 2001 From: Julia Date: Sun, 10 May 2026 11:56:30 -0400 Subject: [PATCH 08/10] feat: increase default weight of Bitter font for improved rendering (#1922) ## Summary **What is the goal of this PR?** * Provide a better default weight of Bitter font for improved rendering **What changes are included?** * Changes the default weight for Bitter font that is defined in the custom font catalog to medium (500) from regular (400) for improved rendering on e-ink, especially when font anti-aliasing is turned on ## Additional Context * Bitter font at regular weight can become washed out when AA is on due to some of the thinner stems and posts of the font. Bumping the weight up to medium makes it sturdier and holds up better when AA is turned 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? _**< NO >**_ --- lib/EpdFont/scripts/sd-fonts.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/EpdFont/scripts/sd-fonts.yaml b/lib/EpdFont/scripts/sd-fonts.yaml index 6ba71c59..6ef1247c 100644 --- a/lib/EpdFont/scripts/sd-fonts.yaml +++ b/lib/EpdFont/scripts/sd-fonts.yaml @@ -108,9 +108,9 @@ families: intervals: latin-ext,cyrillic sizes: [12, 14, 16, 18] styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 400}} + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 500}} bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 700}} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 400}} + italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 500}} bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 700}} # ── Sans-serif ───────────────────────────────────────────────────────── From 45441e078981938696cce3c94e97987059250fb8 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 10 May 2026 18:01:24 +0200 Subject: [PATCH 09/10] feat: closest-pt size selection instead of ordinal slot (#1912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SdCardFontFamilyInfo::pickClosestSize(targetPtSize) and have the manager and SdCardFontSystem drive size selection from a target point size derived from the user's font-size enum (SMALL=12, MEDIUM=14, LARGE=16, EXTRA_LARGE=18) rather than indexing the family's sorted size list by enum ordinal. The ordinal-slot approach mis-selects whenever a family doesn't ship the canonical {12,14,16,18} set: a family with only [10,14,18] would map SMALL/MEDIUM/LARGE/EXTRA_LARGE to 10/14/18/18 — fine for SMALL but arbitrary for the rest. Closest-pt always picks the on-disk file nearest to the user-intended point size, with a deterministic smaller-pt tie-break. No change for canonical-sized families. ## 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 | PARTIALLY | NO >**_ --- lib/EpdFont/SdCardFontManager.cpp | 20 ++++++++----------- lib/EpdFont/SdCardFontManager.h | 14 +++++++++----- lib/EpdFont/SdCardFontRegistry.cpp | 17 ++++++++++++++++ lib/EpdFont/SdCardFontRegistry.h | 9 +++++++++ src/SdCardFontSystem.cpp | 31 ++++++++++++++++-------------- 5 files changed, 60 insertions(+), 31 deletions(-) diff --git a/lib/EpdFont/SdCardFontManager.cpp b/lib/EpdFont/SdCardFontManager.cpp index a6032336..804df07d 100644 --- a/lib/EpdFont/SdCardFontManager.cpp +++ b/lib/EpdFont/SdCardFontManager.cpp @@ -28,25 +28,21 @@ int SdCardFontManager::computeFontId(uint32_t contentHash, const char* familyNam return id != 0 ? id : 1; // 0 is reserved as "not found" sentinel } -bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum) { +bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t targetPtSize) { // 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()) { + // Pick the single file whose size is closest to targetPtSize. Loading + // only one size bounds resident memory (intervals + kern/ligature tables + // per style) to one file's worth, vs. N_sizes × per-file overhead. + const SdCardFontFileInfo* selected = family.pickClosestSize(targetPtSize); + if (!selected) { 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()); @@ -70,8 +66,8 @@ bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRender renderer.registerSdCardFont(fontId, font); loaded_.push_back({font, fontId, selected->pointSize}); - LOG_DBG("SDMGR", "Loaded %s size=%u id=%d styles=%u (sizeEnum=%u)", selected->path.c_str(), selected->pointSize, - fontId, font->styleCount(), fontSizeEnum); + LOG_DBG("SDMGR", "Loaded %s size=%u id=%d styles=%u (target=%u)", selected->path.c_str(), selected->pointSize, fontId, + font->styleCount(), targetPtSize); EpdFontFamily fontFamily(font->getEpdFont(0), font->getEpdFont(1), font->getEpdFont(2), font->getEpdFont(3)); renderer.insertFont(fontId, fontFamily); diff --git a/lib/EpdFont/SdCardFontManager.h b/lib/EpdFont/SdCardFontManager.h index aec07472..def66e8d 100644 --- a/lib/EpdFont/SdCardFontManager.h +++ b/lib/EpdFont/SdCardFontManager.h @@ -15,12 +15,16 @@ class SdCardFontManager { SdCardFontManager(const SdCardFontManager&) = delete; SdCardFontManager& operator=(const SdCardFontManager&) = delete; - // Load the font file matching fontSizeEnum (SMALL=0 .. EXTRA_LARGE=3) by - // ordinal position in the family's sorted size list. Only one .cpfont file - // is loaded; other sizes remain on disk. This keeps resident interval + - // kern/ligature tables to one size's worth of memory. + // Load the single size whose pointSize is closest to targetPtSize. Only one + // .cpfont file is loaded; other sizes remain on disk. This keeps resident + // interval + kern/ligature tables to one size's worth of memory. + // + // Closest-pt selection is robust against families that don't ship the + // canonical {12,14,16,18}: a family with only [10,14,18] still resolves + // any reasonable target, where ordinal slot-mapping by SMALL..EXTRA_LARGE + // would mis-select. // Returns true on success. - bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum); + bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t targetPtSize); // Unload everything, unregister from renderer. void unloadAll(GfxRenderer& renderer); diff --git a/lib/EpdFont/SdCardFontRegistry.cpp b/lib/EpdFont/SdCardFontRegistry.cpp index 2e0f145c..af1009b0 100644 --- a/lib/EpdFont/SdCardFontRegistry.cpp +++ b/lib/EpdFont/SdCardFontRegistry.cpp @@ -4,6 +4,8 @@ #include #include +#include +#include #include // --- SdCardFontFamilyInfo helpers --- @@ -38,6 +40,21 @@ std::vector SdCardFontFamilyInfo::availableSizes() const { return sizes; } +const SdCardFontFileInfo* SdCardFontFamilyInfo::pickClosestSize(uint8_t targetPtSize) const { + const SdCardFontFileInfo* selected = nullptr; + int bestDiff = INT_MAX; + for (const auto& f : files) { + int diff = std::abs(static_cast(f.pointSize) - static_cast(targetPtSize)); + // Strict < ensures the first scan wins on ties; then tie-break by smaller + // pointSize to make the choice independent of filesystem enumeration order. + if (diff < bestDiff || (diff == bestDiff && selected && f.pointSize < selected->pointSize)) { + bestDiff = diff; + selected = &f; + } + } + return selected; +} + // --- SdCardFontRegistry --- bool SdCardFontRegistry::parseFilename(const char* filename, uint8_t& size, uint8_t& style) { diff --git a/lib/EpdFont/SdCardFontRegistry.h b/lib/EpdFont/SdCardFontRegistry.h index f96035ed..aae932d9 100644 --- a/lib/EpdFont/SdCardFontRegistry.h +++ b/lib/EpdFont/SdCardFontRegistry.h @@ -20,6 +20,15 @@ struct SdCardFontFamilyInfo { const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const; bool hasSize(uint8_t size) const; std::vector availableSizes() const; + + // Pick the file whose pointSize is closest to targetPtSize. On ties (equal + // distance) prefers the smaller pointSize so behaviour is deterministic + // across SD card layouts. Returns nullptr when files is empty. + // + // Robust against families that don't ship the canonical {12,14,16,18} set: + // a family with only [10,14,18] resolves target 12 → 10 (or 14 on tie), + // target 16 → 14 or 18, etc., instead of mis-indexing by ordinal slot. + const SdCardFontFileInfo* pickClosestSize(uint8_t targetPtSize) const; }; class SdCardFontRegistry { diff --git a/src/SdCardFontSystem.cpp b/src/SdCardFontSystem.cpp index b771bede..8e59d6ed 100644 --- a/src/SdCardFontSystem.cpp +++ b/src/SdCardFontSystem.cpp @@ -5,10 +5,15 @@ #include "CrossPointSettings.h" -static uint8_t fontSizeEnumFromSettings() { +// Map fontSize enum (SMALL=0, MEDIUM=1, LARGE=2, EXTRA_LARGE=3) to the point +// sizes shipped with the built-in fonts. Used to drive closest-pt selection +// in the SD card font registry (see SdCardFontFamilyInfo::pickClosestSize). +static constexpr uint8_t FONT_SIZE_TO_PT[CrossPointSettings::FONT_SIZE_COUNT] = {12, 14, 16, 18}; + +static uint8_t targetPtSizeFromSettings() { uint8_t e = SETTINGS.fontSize; if (e >= CrossPointSettings::FONT_SIZE_COUNT) e = 1; // default to MEDIUM - return e; + return FONT_SIZE_TO_PT[e]; } void SdCardFontSystem::begin(GfxRenderer& renderer) { @@ -25,7 +30,7 @@ void SdCardFontSystem::begin(GfxRenderer& renderer) { if (SETTINGS.sdFontFamilyName[0] != '\0') { const auto* family = registry_.findFamily(SETTINGS.sdFontFamilyName); if (family) { - if (manager_.loadFamily(*family, renderer, fontSizeEnumFromSettings())) { + if (manager_.loadFamily(*family, renderer, targetPtSizeFromSettings())) { LOG_DBG("SDFS", "Loaded SD card font family: %s", SETTINGS.sdFontFamilyName); } else { LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName); @@ -53,7 +58,7 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { const char* wantedFamily = SETTINGS.sdFontFamilyName; const std::string& currentFamily = manager_.currentFamilyName(); - const uint8_t sizeEnum = fontSizeEnumFromSettings(); + const uint8_t targetPt = targetPtSizeFromSettings(); if (wantedFamily[0] == '\0') { if (!currentFamily.empty()) { @@ -62,8 +67,8 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { return; } - // Reload if family changed OR if the user-selected size maps to a - // different file than what's currently loaded OR if the registry was + // Reload if family changed OR if the user-selected size now resolves to a + // different on-disk file than what's currently loaded OR if the registry was // just rediscovered (file may have been replaced on disk). bool familyMatches = (currentFamily == wantedFamily); if (familyMatches) { @@ -74,13 +79,11 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { SETTINGS.sdFontFamilyName[0] = '\0'; return; } - auto sizes = family->availableSizes(); - uint8_t idx = sizeEnum; - if (idx >= sizes.size()) idx = sizes.size() - 1; - uint8_t wantedPt = sizes.empty() ? 0 : sizes[idx]; - if (!registryWasDirty && wantedPt == manager_.currentPointSize()) return; - LOG_DBG("SDFS", "Reloading %s: size %u -> %u (enum %u)%s", wantedFamily, manager_.currentPointSize(), wantedPt, - sizeEnum, registryWasDirty ? " [registry dirty]" : ""); + const auto* best = family->pickClosestSize(targetPt); + const uint8_t bestPt = best ? best->pointSize : 0; + if (!registryWasDirty && bestPt == manager_.currentPointSize()) return; + LOG_DBG("SDFS", "Reloading %s: size %u -> %u (target %u)%s", wantedFamily, manager_.currentPointSize(), bestPt, + targetPt, registryWasDirty ? " [registry dirty]" : ""); } if (!currentFamily.empty()) { @@ -89,7 +92,7 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { const auto* family = registry_.findFamily(wantedFamily); if (family) { - if (manager_.loadFamily(*family, renderer, sizeEnum)) { + if (manager_.loadFamily(*family, renderer, targetPt)) { LOG_DBG("SDFS", "Loaded SD font family: %s", wantedFamily); } else { LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily); From aba393f900d147454d26cb40d849f8be199adc6d Mon Sep 17 00:00:00 2001 From: th0m4sek <53303628+th0m4sek@users.noreply.github.com> Date: Sun, 10 May 2026 18:02:09 +0200 Subject: [PATCH 10/10] fix: Polish translation (#1909) ## 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 | PARTIALLY | NO >**_ --- lib/I18n/translations/polish.yaml | 67 ++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 565fad83..b85a8719 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -38,7 +38,7 @@ STR_JOIN_NETWORK: "Dołącz do sieci" STR_CREATE_HOTSPOT: "Stwórz Hotspot" STR_JOIN_DESC: "Podłącz do istniejącej sieci WiFi" STR_HOTSPOT_DESC: "Stwórz sieć WiFi do której podłączyć mogą się inni" -STR_STARTING_HOTSPOT: "Startowanie Hotspot'a..." +STR_STARTING_HOTSPOT: "Startowanie Hotspota..." STR_HOTSPOT_MODE: "Tryb Hotspot" STR_CONNECT_WIFI_HINT: "Podłącz swoje urządzenie do tej sieci WiFi" STR_OPEN_URL_HINT: "Otwórz ten URL w przeglądarce" @@ -107,7 +107,7 @@ STR_KOREADER_AUTH: "KOReader Auth" STR_SYNC_READY: "KOReader sync gotowy" STR_AUTH_FAILED: "Błąd uwierzytelniania" STR_DONE: "Zrobione" -STR_CLEAR_CACHE_WARNING_1: "To wyczyści całą pamięc podręczną książek." +STR_CLEAR_CACHE_WARNING_1: "To wyczyści całą pamięć podręczną książek." STR_CLEAR_CACHE_WARNING_2: "Cały postęp czytania zostanie stracony!" STR_CLEAR_CACHE_WARNING_3: "Książki trzeba będzie ponownie indeksować" STR_CLEAR_CACHE_WARNING_4: "po ponownym otwarciu." @@ -130,6 +130,7 @@ STR_ALWAYS: "Zawsze" STR_IGNORE: "Ignoruj" STR_SLEEP: "Uśpienie" STR_PAGE_TURN: "Nast. str." +STR_FORCE_REFRESH: "Odśwież ekran" STR_PORTRAIT: "Pionowo" STR_LANDSCAPE_CW: "Poziomo P" STR_INVERTED: "Odwrócony" @@ -170,6 +171,7 @@ STR_NO_UPDATE: "Brak aktualizacji" STR_UPDATE_FAILED: "Aktualizacja nieudana" STR_UPDATE_COMPLETE: "Aktualizacja zakończona" STR_POWER_ON_HINT: "Przyciśnij i przytrzymaj przycisk zasilania aby włączyć ponownie" +STR_RESTARTING_HINT: "Restartowanie... Jeśli urządzenie się nie zrestartuje, przytrzymaj przycisk zasilania przez kilka sekund." STR_NO_ENTRIES: "Brak wpisów" STR_DOWNLOADING: "Pobieranie..." STR_DOWNLOAD_FAILED: "Błąd pobierania" @@ -178,6 +180,8 @@ STR_UNNAMED: "Nienazwany" STR_NO_SERVER_URL: "Brak skonfigurowanego serwera URL" STR_FETCH_FEED_FAILED: "Nie udało się pobrać kanału" STR_PARSE_FEED_FAILED: "Nie udało się przeanalizować kanału" +STR_NEXT_PAGE: "Następna strona »" +STR_PREV_PAGE: "« Poprzednia strona" STR_NETWORK_PREFIX: "Sieć: " STR_IP_ADDRESS_PREFIX: "Adres IP: " STR_ERROR_GENERAL_FAILURE: "Błąd: Ogólny" @@ -225,13 +229,18 @@ STR_EXAMPLE_BOOK: "Tytuł książki" STR_PREVIEW: "Podgląd" STR_TITLE: "Tytuł" STR_BATTERY: "Bateria" +STR_XTC_STATUS_BAR: "Pasek statusu XTC" +STR_BOTTOM: "Dół" +STR_TOP: "Góra" STR_UI_THEME: "Skórka UI" STR_THEME_CLASSIC: "Classic" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Przeciwdziałanie blaknięciu od słońca" STR_REMAP_FRONT_BUTTONS: "Skonfiguruj przednie przyciski" STR_OPDS_BROWSER: "OPDS Browser" +STR_SEARCH: "Szukaj" STR_COVER_CUSTOM: "Okładka + Własne" STR_MENU_RECENT_BOOKS: "Ostatnio czytane" STR_NO_RECENT_BOOKS: "Brak ostatnio czytanych" @@ -288,10 +297,64 @@ STR_UPLOAD: "Wyślij" STR_BOOK_S_STYLE: "Styl książki" STR_EMBEDDED_STYLE: "Style wbudowane w EPUB" STR_OPDS_SERVER_URL: "URL serwera OPDS" +STR_SET_SLEEP_COVER: "Ustaw okładkę" STR_FOOTNOTES: "Przypisy" STR_NO_FOOTNOTES: "Brak przypisów na tej stronie" STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Zrób zrzut ekranu" +STR_ADD_SERVER: "Dodaj serwer" +STR_SERVER_NAME: "Nazwa serwera" +STR_NO_SERVERS: "Brak skonfigurowanych serwerów OPDS" +STR_DELETE_SERVER: "Usuń serwer" +STR_DELETE_CONFIRM: "Usunąć ten serwer?" +STR_OPDS_SERVERS: "Serwery OPDS" STR_AUTO_TURN_ENABLED: "Auto-kartkowanie: " STR_AUTO_TURN_PAGES_PER_MIN: "Auto-kartkowanie (str./min)" +STR_DOWNLOAD_FONTS: "Pobierz czcionki" +STR_FONT_DOWNLOAD: "Pobierz czcionkę" +STR_LOADING_FONT_LIST: "Ładowanie listy czcionek..." +STR_NO_FONTS_AVAILABLE: "Brak dostępnych czcionek" +STR_FONT_INSTALLED: "Czcionka zainstalowana!" +STR_FONT_INSTALL_FAILED: "Instalacja czcionki nieudana" +STR_INSTALLED: "Zainstalowany" +STR_CONFIRM_DOWNLOAD_PROMPT: "Pobrać?" +STR_SD_CARD_FULL: "Za mało pamięci na karcie SD" +STR_FILES_LABEL: "Pliki: " +STR_SIZE_LABEL: "Rozmiar: " +STR_REDOWNLOAD: "Re-download" +STR_DOWNLOAD_ALL: "Pobierz / Uaktualnij wszystkie" +STR_ALL_FONTS_INSTALLED: "Wszystkie czcionki zainstalowane!" +STR_UPDATE_AVAILABLE: "Uaktualnij" +STR_CRASH_TITLE: "Awaria systemu" +STR_CRASH_DESCRIPTION: "Szczegółowy raport zapisany do crash_report.txt. Prosimy o dołączenie tego pliku do zgłoszenia błędu." +STR_CRASH_REASON: "Powód awarii:" +STR_CRASH_NO_REASON: "(Nie odnotowano powodu)" STR_TILT_PAGE_TURN: "Kartkowanie przechyłem" +STR_KB_HINT_MOVE_CURSOR: "Naciśnij LEWO lub PRAWO aby poruszyć kursor" +STR_KB_HINT_RETURN_CURSOR: "Naciśnij LEWO aby powrócić do pozycji kursora" +STR_KB_HINT_HIDE_PASSWORD: "Przytrzymaj PRAWO potem naciśnij [***] aby ukryć hasło" +STR_KB_HINT_SHOW_PASSWORD: "Przytrzymaj PRAWO potem naciśnij [abc] aby pokazać hasło" +STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Naciśnij [***] aby ukryć hasło" +STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Naciśnij [abc] aby pokazać hasło" +STR_KB_HINT_EDIT_ENTRY: "Przytrzymaj GÓRA aby edytować wpis" +STR_KB_TIPS: "Porady:" +STR_KB_HINT_RETURN_KEYBOARD: "Naciśnij DÓŁ aby powrócić do klawiatury" +STR_KB_HINT_EXIT_URL_MODE: "Naciśnij ABC aby wyjść z trybu URL" +STR_KB_HINT_CLEAR_TEXT: "Przytrzymaj 'Backspace' aby wyczyścić cały tekst" +STR_KB_HINT_SECONDARY_CHAR: "Przytrz. 'Wybierz' dla znaku drugorzędnego" +STR_KB_HINT_UPPER_SECONDARY: "Przytrz. 'Wybierz' dla W. LITERY lub znaku drugorzędnego" +STR_KB_HINT_LOWER_SECONDARY: "Przytrz. 'Wybierz' dla m. litery lub znaku drugorzędnego" +STR_KB_HINT_URL_SNIPPETS: "Naciśnij URL dla adresów" +STR_SD_FIRMWARE_UPDATE: "Uaktualnienie oprogramowania z karty SD" +STR_SELECT_FIRMWARE_FILE: "Wybierz plik z oprogramowaniem (.bin)" +STR_NO_BIN_FILES: "Nie znaleziono plików .bin" +STR_VALIDATING_FIRMWARE: "Sprawdzanie oprogramowania..." +STR_INVALID_FIRMWARE: "Nieprawidłowy plik oprogramowania" +STR_FIRMWARE_TOO_LARGE: "Oprogramowanie za duże na partycję" +STR_FIRMWARE_TOO_SMALL: "Plik oprogramowania za mały" +STR_FIRMWARE_UPDATE_PROMPT: "Uaktualnić oprogramowanie?" +STR_FIRMWARE_FILE_OPEN_FAILED: "Nie można otworzyć pliku" +STR_FIRMWARE_WRITE_FAILED: "Zapis oprogramowania nieudany" +STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nie wyłączać!" +STR_RECOVERY_MODE: "Tryb przywracania" +STR_RECOVERY_MODE_HINT: "Umieść firmware.bin w głównym katalogu karty SD i wybierz go"