Merge branch 'master' into release/1.3.0

This commit is contained in:
Zach Nelson
2026-05-10 11:07:12 -05:00
16 changed files with 447 additions and 65 deletions
+8 -12
View File
@@ -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);
+9 -5
View File
@@ -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);
+17
View File
@@ -4,6 +4,8 @@
#include <Logging.h>
#include <algorithm>
#include <climits>
#include <cstdlib>
#include <cstring>
// --- SdCardFontFamilyInfo helpers ---
@@ -38,6 +40,21 @@ std::vector<uint8_t> 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<int>(f.pointSize) - static_cast<int>(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) {
+9
View File
@@ -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<uint8_t> 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 {
+18 -4
View File
@@ -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
+39 -2
View File
@@ -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.")
@@ -792,6 +798,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 +824,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 +839,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))
+96 -21
View File
@@ -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))
@@ -266,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()
@@ -425,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
@@ -468,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
@@ -497,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 = []
@@ -514,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
@@ -550,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)
@@ -582,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,
@@ -593,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",
@@ -895,4 +970,4 @@ def main():
if __name__ == "__main__":
main()
main()
+2 -2
View File
@@ -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 ─────────────────────────────────────────────────────────
+65 -2
View File
@@ -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"
+19
View File
@@ -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:"
+11
View File
@@ -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),
}
)
+17 -14
View File
@@ -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);
@@ -6,6 +6,7 @@
#include <I18n.h>
#include <Logging.h>
#include <WiFi.h>
#include <esp_rom_crc.h>
#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<uint32_t>()) {
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<uint32_t>();
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<uint32_t>(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());
@@ -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<int>(families_.size()) + 1; }
@@ -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<RecentBook>& 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<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
@@ -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<std::string(int index)>& buttonLabel,
const std::function<UIIcon(int index)>& 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<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle = nullptr,