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 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 // Unload any previously loaded family first
if (!loadedFamilyName_.empty()) { if (!loadedFamilyName_.empty()) {
unloadAll(renderer); unloadAll(renderer);
} }
// Select by ordinal position: sort available sizes, then map the font size // Pick the single file whose size is closest to targetPtSize. Loading
// enum (SMALL=0 .. EXTRA_LARGE=3) to the corresponding slot. When the // only one size bounds resident memory (intervals + kern/ligature tables
// family has fewer sizes than 4, clamp to the last available size. // per style) to one file's worth, vs. N_sizes × per-file overhead.
auto sizes = family.availableSizes(); const SdCardFontFileInfo* selected = family.pickClosestSize(targetPtSize);
if (sizes.empty()) { if (!selected) {
LOG_ERR("SDMGR", "Family %s has no files to load", family.name.c_str()); LOG_ERR("SDMGR", "Family %s has no files to load", family.name.c_str());
return false; 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(); auto* font = new (std::nothrow) SdCardFont();
if (!font) { if (!font) {
LOG_ERR("SDMGR", "Failed to allocate SdCardFont for %s", selected->path.c_str()); 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); renderer.registerSdCardFont(fontId, font);
loaded_.push_back({font, fontId, selected->pointSize}); 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, LOG_DBG("SDMGR", "Loaded %s size=%u id=%d styles=%u (target=%u)", selected->path.c_str(), selected->pointSize, fontId,
fontId, font->styleCount(), fontSizeEnum); font->styleCount(), targetPtSize);
EpdFontFamily fontFamily(font->getEpdFont(0), font->getEpdFont(1), font->getEpdFont(2), font->getEpdFont(3)); EpdFontFamily fontFamily(font->getEpdFont(0), font->getEpdFont(1), font->getEpdFont(2), font->getEpdFont(3));
renderer.insertFont(fontId, fontFamily); renderer.insertFont(fontId, fontFamily);
+9 -5
View File
@@ -15,12 +15,16 @@ class SdCardFontManager {
SdCardFontManager(const SdCardFontManager&) = delete; SdCardFontManager(const SdCardFontManager&) = delete;
SdCardFontManager& operator=(const SdCardFontManager&) = delete; SdCardFontManager& operator=(const SdCardFontManager&) = delete;
// Load the font file matching fontSizeEnum (SMALL=0 .. EXTRA_LARGE=3) by // Load the single size whose pointSize is closest to targetPtSize. Only one
// ordinal position in the family's sorted size list. Only one .cpfont file // .cpfont file is loaded; other sizes remain on disk. This keeps resident
// is loaded; other sizes remain on disk. This keeps resident interval + // interval + kern/ligature tables to one size's worth of memory.
// 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. // 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. // Unload everything, unregister from renderer.
void unloadAll(GfxRenderer& renderer); void unloadAll(GfxRenderer& renderer);
+17
View File
@@ -4,6 +4,8 @@
#include <Logging.h> #include <Logging.h>
#include <algorithm> #include <algorithm>
#include <climits>
#include <cstdlib>
#include <cstring> #include <cstring>
// --- SdCardFontFamilyInfo helpers --- // --- SdCardFontFamilyInfo helpers ---
@@ -38,6 +40,21 @@ std::vector<uint8_t> SdCardFontFamilyInfo::availableSizes() const {
return sizes; 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 --- // --- SdCardFontRegistry ---
bool SdCardFontRegistry::parseFilename(const char* filename, uint8_t& size, uint8_t& style) { 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; const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const;
bool hasSize(uint8_t size) const; bool hasSize(uint8_t size) const;
std::vector<uint8_t> availableSizes() 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 { 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) tmp_fd, tmp_name = tempfile.mkstemp(suffix=".ttf", dir=cached.parent)
os.close(tmp_fd) os.close(tmp_fd)
tmp_path = Path(tmp_name) 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: try:
instantiateVariableFont(font, axes) font = instantiateVariableFont(source_font, axes, updateFontNames=True, optimize=False)
font.save(str(tmp_path)) try:
font.save(str(tmp_path))
finally:
font.close()
except Exception: except Exception:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
raise raise
finally: finally:
font.close() source_font.close()
tmp_path.replace(cached) tmp_path.replace(cached)
return cached return cached
+39 -2
View File
@@ -8,6 +8,12 @@ import argparse
from collections import namedtuple from collections import namedtuple
from fontTools.ttLib import TTFont 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 # Originally from https://github.com/vroland/epdiy
parser = argparse.ArgumentParser(description="Generate a header file from a font to be used with 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. # are grouped together for efficient LRU caching on the embedded target.
# Since glyphs are in codepoint order, glyphs in the same Unicode block # Since glyphs are in codepoint order, glyphs in the same Unicode block
# are contiguous in the array and form natural groups. # 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 = [ SCRIPT_GROUP_RANGES = [
(0x0000, 0x007F), # ASCII (0x0000, 0x007F), # ASCII
(0x0080, 0x00FF), # Latin-1 Supplement (0x0080, 0x00FF), # Latin-1 Supplement
@@ -809,6 +824,11 @@ if compress:
(0xFFFD, 0xFFFD), # Replacement Character (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): def get_script_group(code_point):
for i, (start, end) in enumerate(SCRIPT_GROUP_RANGES): for i, (start, end) in enumerate(SCRIPT_GROUP_RANGES):
if start <= code_point <= end: if start <= code_point <= end:
@@ -819,17 +839,34 @@ if compress:
current_group_id = None current_group_id = None
group_start = 0 group_start = 0
group_count = 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) 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: if group_count > 0:
groups.append((group_start, group_count)) groups.append((group_start, group_count))
current_group_id = sg current_group_id = sg
group_start = i group_start = i
group_count = 1 group_count = 1
group_uncompressed = glyph_aligned_size
else: else:
group_count += 1 group_count += 1
group_uncompressed += glyph_aligned_size
if group_count > 0: if group_count > 0:
groups.append((group_start, group_count)) groups.append((group_start, group_count))
+96 -21
View File
@@ -26,6 +26,7 @@ import freetype
import struct import struct
import sys import sys
import os import os
import re
import math import math
import argparse import argparse
from collections import namedtuple from collections import namedtuple
@@ -75,17 +76,39 @@ INTERVAL_PRESETS = {
(0xFB00, 0xFB06)], (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): def resolve_intervals(preset_str):
"""Resolve comma-separated preset names into a merged, sorted, deduplicated interval list.""" """Resolve comma-separated preset names into a merged, sorted, deduplicated interval list."""
all_intervals = [] all_intervals = []
for name in preset_str.split(","): for name in preset_str.split(","):
name = name.strip().lower() 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"Error: unknown interval preset '{name}'", file=sys.stderr)
print(f"Available presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}", 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) 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 # Always add replacement character
all_intervals.append((0xFFFD, 0xFFFD)) all_intervals.append((0xFFFD, 0xFFFD))
@@ -266,11 +289,29 @@ def extract_kerning_fonttools(font_path, codepoints, ppem):
lookup = gpos.LookupList.Lookup[li] lookup = gpos.LookupList.Lookup[li]
for st in lookup.SubTable: for st in lookup.SubTable:
actual = st 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'): if lookup.LookupType == 9 and hasattr(st, 'ExtSubTable'):
actual = st.ExtSubTable actual = st.ExtSubTable
effective_type = getattr(st, 'ExtensionLookupType', lookup.LookupType)
if hasattr(actual, 'Format'): 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() font.close()
@@ -425,11 +466,20 @@ def extract_ligatures_fonttools(font_path, codepoints):
font.close() font.close()
# Filter: only keep ligatures where all input and output codepoints are # 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) codepoints_set = set(codepoints)
filtered = {} filtered = {}
for seq, lig_cp in raw_ligatures.items(): 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 continue
if all(cp in codepoints_set for cp in seq): if all(cp in codepoints_set for cp in seq):
filtered[seq] = lig_cp 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)) style_label = style_names.get(style_id, str(style_id))
face = freetype.Face(fontfile) 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 load_flags = freetype.FT_LOAD_RENDER
if force_autohint: if force_autohint:
load_flags |= freetype.FT_LOAD_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) 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) 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 # Rasterize all glyphs
total_bitmap_size = 0 total_bitmap_size = 0
all_glyphs = [] all_glyphs = []
@@ -514,18 +567,28 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F
bitmap = f.glyph.bitmap 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 = [] pixels4g = []
px = 0 px = 0
for i, v in enumerate(bitmap.buffer): abs_pitch = abs(bitmap.pitch)
x = i % bitmap.width for y in range(bitmap.rows):
if x % 2 == 0: row_offset = y * abs_pitch if bitmap.pitch >= 0 else (bitmap.rows - 1 - y) * abs_pitch
px = (v >> 4) for x in range(bitmap.width):
else: v = bitmap.buffer[row_offset + x]
px = px | (v & 0xF0) if x % 2 == 0:
pixels4g.append(px) px = (v >> 4)
px = 0 else:
if x == bitmap.width - 1 and bitmap.width % 2 > 0: px = px | (v & 0xF0)
pixels4g.append(px)
px = 0
if bitmap.width % 2 > 0:
pixels4g.append(px) pixels4g.append(px)
px = 0 px = 0
@@ -550,7 +613,12 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F
pixels2b.append(px) pixels2b.append(px)
px = 0 px = 0
if (bitmap.width * bitmap.rows) % 4 != 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) pixels2b.append(px)
packed = bytes(pixels2b) 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) all_cps = set(g.code_point for g, _ in all_glyphs)
kern_map = extract_kerning_fonttools(fontfile, all_cps, ppem) 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) print(f" [{style_label}] Kerning: {len(kern_map)} pairs extracted", file=sys.stderr)
(kern_left_classes, kern_right_classes, kern_matrix, (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, " 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) 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) ligature_pairs = extract_ligatures_fonttools(fontfile, all_cps)
if len(ligature_pairs) > 255: if len(ligature_pairs) > 255:
print(f" [{style_label}] WARNING: {len(ligature_pairs)} ligature pairs exceeds uint8_t max (255), truncating", 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__": if __name__ == "__main__":
main() main()
+2 -2
View File
@@ -108,9 +108,9 @@ families:
intervals: latin-ext,cyrillic intervals: latin-ext,cyrillic
sizes: [12, 14, 16, 18] sizes: [12, 14, 16, 18]
styles: 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}} 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}} bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
# ── Sans-serif ───────────────────────────────────────────────────────── # ── Sans-serif ─────────────────────────────────────────────────────────
+65 -2
View File
@@ -38,7 +38,7 @@ STR_JOIN_NETWORK: "Dołącz do sieci"
STR_CREATE_HOTSPOT: "Stwórz Hotspot" STR_CREATE_HOTSPOT: "Stwórz Hotspot"
STR_JOIN_DESC: "Podłącz do istniejącej sieci WiFi" 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_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_HOTSPOT_MODE: "Tryb Hotspot"
STR_CONNECT_WIFI_HINT: "Podłącz swoje urządzenie do tej sieci WiFi" STR_CONNECT_WIFI_HINT: "Podłącz swoje urządzenie do tej sieci WiFi"
STR_OPEN_URL_HINT: "Otwórz ten URL w przeglądarce" 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_SYNC_READY: "KOReader sync gotowy"
STR_AUTH_FAILED: "Błąd uwierzytelniania" STR_AUTH_FAILED: "Błąd uwierzytelniania"
STR_DONE: "Zrobione" 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_2: "Cały postęp czytania zostanie stracony!"
STR_CLEAR_CACHE_WARNING_3: "Książki trzeba będzie ponownie indeksować" STR_CLEAR_CACHE_WARNING_3: "Książki trzeba będzie ponownie indeksować"
STR_CLEAR_CACHE_WARNING_4: "po ponownym otwarciu." STR_CLEAR_CACHE_WARNING_4: "po ponownym otwarciu."
@@ -130,6 +130,7 @@ STR_ALWAYS: "Zawsze"
STR_IGNORE: "Ignoruj" STR_IGNORE: "Ignoruj"
STR_SLEEP: "Uśpienie" STR_SLEEP: "Uśpienie"
STR_PAGE_TURN: "Nast. str." STR_PAGE_TURN: "Nast. str."
STR_FORCE_REFRESH: "Odśwież ekran"
STR_PORTRAIT: "Pionowo" STR_PORTRAIT: "Pionowo"
STR_LANDSCAPE_CW: "Poziomo P" STR_LANDSCAPE_CW: "Poziomo P"
STR_INVERTED: "Odwrócony" STR_INVERTED: "Odwrócony"
@@ -170,6 +171,7 @@ STR_NO_UPDATE: "Brak aktualizacji"
STR_UPDATE_FAILED: "Aktualizacja nieudana" STR_UPDATE_FAILED: "Aktualizacja nieudana"
STR_UPDATE_COMPLETE: "Aktualizacja zakończona" STR_UPDATE_COMPLETE: "Aktualizacja zakończona"
STR_POWER_ON_HINT: "Przyciśnij i przytrzymaj przycisk zasilania aby włączyć ponownie" 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_NO_ENTRIES: "Brak wpisów"
STR_DOWNLOADING: "Pobieranie..." STR_DOWNLOADING: "Pobieranie..."
STR_DOWNLOAD_FAILED: "Błąd pobierania" STR_DOWNLOAD_FAILED: "Błąd pobierania"
@@ -178,6 +180,8 @@ STR_UNNAMED: "Nienazwany"
STR_NO_SERVER_URL: "Brak skonfigurowanego serwera URL" STR_NO_SERVER_URL: "Brak skonfigurowanego serwera URL"
STR_FETCH_FEED_FAILED: "Nie udało się pobrać kanału" STR_FETCH_FEED_FAILED: "Nie udało się pobrać kanału"
STR_PARSE_FEED_FAILED: "Nie udało się przeanalizować 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_NETWORK_PREFIX: "Sieć: "
STR_IP_ADDRESS_PREFIX: "Adres IP: " STR_IP_ADDRESS_PREFIX: "Adres IP: "
STR_ERROR_GENERAL_FAILURE: "Błąd: Ogólny" STR_ERROR_GENERAL_FAILURE: "Błąd: Ogólny"
@@ -225,13 +229,18 @@ STR_EXAMPLE_BOOK: "Tytuł książki"
STR_PREVIEW: "Podgląd" STR_PREVIEW: "Podgląd"
STR_TITLE: "Tytuł" STR_TITLE: "Tytuł"
STR_BATTERY: "Bateria" STR_BATTERY: "Bateria"
STR_XTC_STATUS_BAR: "Pasek statusu XTC"
STR_BOTTOM: "Dół"
STR_TOP: "Góra"
STR_UI_THEME: "Skórka UI" STR_UI_THEME: "Skórka UI"
STR_THEME_CLASSIC: "Classic" STR_THEME_CLASSIC: "Classic"
STR_THEME_LYRA: "Lyra" STR_THEME_LYRA: "Lyra"
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_EXTENDED: "Lyra Extended"
STR_SUNLIGHT_FADING_FIX: "Przeciwdziałanie blaknięciu od słońca" STR_SUNLIGHT_FADING_FIX: "Przeciwdziałanie blaknięciu od słońca"
STR_REMAP_FRONT_BUTTONS: "Skonfiguruj przednie przyciski" STR_REMAP_FRONT_BUTTONS: "Skonfiguruj przednie przyciski"
STR_OPDS_BROWSER: "OPDS Browser" STR_OPDS_BROWSER: "OPDS Browser"
STR_SEARCH: "Szukaj"
STR_COVER_CUSTOM: "Okładka + Własne" STR_COVER_CUSTOM: "Okładka + Własne"
STR_MENU_RECENT_BOOKS: "Ostatnio czytane" STR_MENU_RECENT_BOOKS: "Ostatnio czytane"
STR_NO_RECENT_BOOKS: "Brak ostatnio czytanych" STR_NO_RECENT_BOOKS: "Brak ostatnio czytanych"
@@ -288,10 +297,64 @@ STR_UPLOAD: "Wyślij"
STR_BOOK_S_STYLE: "Styl książki" STR_BOOK_S_STYLE: "Styl książki"
STR_EMBEDDED_STYLE: "Style wbudowane w EPUB" STR_EMBEDDED_STYLE: "Style wbudowane w EPUB"
STR_OPDS_SERVER_URL: "URL serwera OPDS" STR_OPDS_SERVER_URL: "URL serwera OPDS"
STR_SET_SLEEP_COVER: "Ustaw okładkę"
STR_FOOTNOTES: "Przypisy" STR_FOOTNOTES: "Przypisy"
STR_NO_FOOTNOTES: "Brak przypisów na tej stronie" STR_NO_FOOTNOTES: "Brak przypisów na tej stronie"
STR_LINK: "[link]" STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Zrób zrzut ekranu" 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_ENABLED: "Auto-kartkowanie: "
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-kartkowanie (str./min)" 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_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_PREVIEW: "Förhandsgranskning"
STR_TITLE: "Titel" STR_TITLE: "Titel"
STR_BATTERY: "Batteri" STR_BATTERY: "Batteri"
STR_XTC_STATUS_BAR: "XTC-statusfält"
STR_BOTTOM: "Botten"
STR_TOP: "Överst"
STR_UI_THEME: "Användargränssnittstema" STR_UI_THEME: "Användargränssnittstema"
STR_THEME_CLASSIC: "Klassisk" STR_THEME_CLASSIC: "Klassisk"
STR_THEME_LYRA: "Lyra" STR_THEME_LYRA: "Lyra"
@@ -294,6 +297,7 @@ STR_UPLOAD: "Uppladdning"
STR_BOOK_S_STYLE: "Bokstil" STR_BOOK_S_STYLE: "Bokstil"
STR_EMBEDDED_STYLE: "Inbäddad stil" STR_EMBEDDED_STYLE: "Inbäddad stil"
STR_OPDS_SERVER_URL: "OPDS-serveradress" STR_OPDS_SERVER_URL: "OPDS-serveradress"
STR_SET_SLEEP_COVER: "Ställ in omslag"
STR_FOOTNOTES: "Fotnoter" STR_FOOTNOTES: "Fotnoter"
STR_NO_FOOTNOTES: "Inga fotnoter på den här sidan" STR_NO_FOOTNOTES: "Inga fotnoter på den här sidan"
STR_LINK: "[länk]" 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_OPDS_SERVERS: "OPDS-servrar"
STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: " STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: "
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)" 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_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_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:" STR_CRASH_REASON: "Orsak till kraschen:"
+11
View File
@@ -21,6 +21,7 @@ import json
import os import os
import struct import struct
import sys import sys
import zlib
from pathlib import Path from pathlib import Path
# Import canonical version constants from the shared file in lib/EpdFont/scripts/ # 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 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]]: def scan_cpfont_files(input_dir: Path) -> dict[str, list[Path]]:
"""Scan input directory for .cpfont files, grouped by family name. """Scan input directory for .cpfont files, grouped by family name.
@@ -164,6 +174,7 @@ def build_manifest(
{ {
"name": filepath.name, "name": filepath.name,
"size": filepath.stat().st_size, "size": filepath.stat().st_size,
"crc32": compute_crc32(filepath),
} }
) )
+17 -14
View File
@@ -5,10 +5,15 @@
#include "CrossPointSettings.h" #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; uint8_t e = SETTINGS.fontSize;
if (e >= CrossPointSettings::FONT_SIZE_COUNT) e = 1; // default to MEDIUM if (e >= CrossPointSettings::FONT_SIZE_COUNT) e = 1; // default to MEDIUM
return e; return FONT_SIZE_TO_PT[e];
} }
void SdCardFontSystem::begin(GfxRenderer& renderer) { void SdCardFontSystem::begin(GfxRenderer& renderer) {
@@ -25,7 +30,7 @@ void SdCardFontSystem::begin(GfxRenderer& renderer) {
if (SETTINGS.sdFontFamilyName[0] != '\0') { if (SETTINGS.sdFontFamilyName[0] != '\0') {
const auto* family = registry_.findFamily(SETTINGS.sdFontFamilyName); const auto* family = registry_.findFamily(SETTINGS.sdFontFamilyName);
if (family) { 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); LOG_DBG("SDFS", "Loaded SD card font family: %s", SETTINGS.sdFontFamilyName);
} else { } else {
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName); 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 char* wantedFamily = SETTINGS.sdFontFamilyName;
const std::string& currentFamily = manager_.currentFamilyName(); const std::string& currentFamily = manager_.currentFamilyName();
const uint8_t sizeEnum = fontSizeEnumFromSettings(); const uint8_t targetPt = targetPtSizeFromSettings();
if (wantedFamily[0] == '\0') { if (wantedFamily[0] == '\0') {
if (!currentFamily.empty()) { if (!currentFamily.empty()) {
@@ -62,8 +67,8 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
return; return;
} }
// Reload if family changed OR if the user-selected size maps to a // Reload if family changed OR if the user-selected size now resolves to a
// different file than what's currently loaded OR if the registry was // different on-disk file than what's currently loaded OR if the registry was
// just rediscovered (file may have been replaced on disk). // just rediscovered (file may have been replaced on disk).
bool familyMatches = (currentFamily == wantedFamily); bool familyMatches = (currentFamily == wantedFamily);
if (familyMatches) { if (familyMatches) {
@@ -74,13 +79,11 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
SETTINGS.sdFontFamilyName[0] = '\0'; SETTINGS.sdFontFamilyName[0] = '\0';
return; return;
} }
auto sizes = family->availableSizes(); const auto* best = family->pickClosestSize(targetPt);
uint8_t idx = sizeEnum; const uint8_t bestPt = best ? best->pointSize : 0;
if (idx >= sizes.size()) idx = sizes.size() - 1; if (!registryWasDirty && bestPt == manager_.currentPointSize()) return;
uint8_t wantedPt = sizes.empty() ? 0 : sizes[idx]; LOG_DBG("SDFS", "Reloading %s: size %u -> %u (target %u)%s", wantedFamily, manager_.currentPointSize(), bestPt,
if (!registryWasDirty && wantedPt == manager_.currentPointSize()) return; targetPt, registryWasDirty ? " [registry dirty]" : "");
LOG_DBG("SDFS", "Reloading %s: size %u -> %u (enum %u)%s", wantedFamily, manager_.currentPointSize(), wantedPt,
sizeEnum, registryWasDirty ? " [registry dirty]" : "");
} }
if (!currentFamily.empty()) { if (!currentFamily.empty()) {
@@ -89,7 +92,7 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
const auto* family = registry_.findFamily(wantedFamily); const auto* family = registry_.findFamily(wantedFamily);
if (family) { if (family) {
if (manager_.loadFamily(*family, renderer, sizeEnum)) { if (manager_.loadFamily(*family, renderer, targetPt)) {
LOG_DBG("SDFS", "Loaded SD font family: %s", wantedFamily); LOG_DBG("SDFS", "Loaded SD font family: %s", wantedFamily);
} else { } else {
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily); LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily);
@@ -6,6 +6,7 @@
#include <I18n.h> #include <I18n.h>
#include <Logging.h> #include <Logging.h>
#include <WiFi.h> #include <WiFi.h>
#include <esp_rom_crc.h>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "SdCardFontGlobals.h" #include "SdCardFontGlobals.h"
@@ -123,6 +124,14 @@ bool FontDownloadActivity::fetchAndParseManifest() {
ManifestFile file; ManifestFile file;
file.name = fileObj["name"] | ""; file.name = fileObj["name"] | "";
file.size = fileObj["size"] | 0; 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.totalSize += file.size;
family.files.push_back(std::move(file)); family.files.push_back(std::move(file));
} }
@@ -181,6 +190,24 @@ size_t FontDownloadActivity::totalUninstalledSize() const {
return total; 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) { void FontDownloadActivity::downloadFamily(ManifestFamily& family) {
{ {
RenderLock lock(*this); RenderLock lock(*this);
@@ -233,6 +260,29 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) {
return; 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)) { if (!fontInstaller_.validateCpfontFile(destPath)) {
LOG_ERR("FONT", "Invalid .cpfont: %s", destPath); LOG_ERR("FONT", "Invalid .cpfont: %s", destPath);
fontInstaller_.deleteFamily(family.name.c_str()); fontInstaller_.deleteFamily(family.name.c_str());
@@ -50,6 +50,7 @@ class FontDownloadActivity : public Activity {
struct ManifestFile { struct ManifestFile {
std::string name; std::string name;
size_t size = 0; size_t size = 0;
uint32_t crc32 = 0;
}; };
struct ManifestFamily { struct ManifestFamily {
@@ -83,6 +84,7 @@ class FontDownloadActivity : public Activity {
bool fetchAndParseManifest(); bool fetchAndParseManifest();
void downloadFamily(ManifestFamily& family); void downloadFamily(ManifestFamily& family);
void downloadAll(); void downloadAll();
static bool computeFileCrc32(const char* path, uint32_t& outCrc);
bool isDownloadAllSelected() const { return selectedIndex_ == 0 && !families_.empty(); } bool isDownloadAllSelected() const { return selectedIndex_ == 0 && !families_.empty(); }
int familyIndexFromList(int listIndex) const { return listIndex - 1; } int familyIndexFromList(int listIndex) const { return listIndex - 1; }
int listItemCount() const { return families_.empty() ? 0 : static_cast<int>(families_.size()) + 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. // 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, 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); 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, 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)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle, const std::function<std::string(int index)>& rowSubtitle,
@@ -36,8 +36,14 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.keyboardKeyWidth = 22, .keyboardKeyWidth = 22,
.keyboardKeyHeight = 30, .keyboardKeyHeight = 30,
.keyboardKeySpacing = 10, .keyboardKeySpacing = 10,
.keyboardBottomAligned = false, .keyboardBottomKeyHeight = 30,
.keyboardCenteredText = false}; .keyboardBottomKeySpacing = 5,
.keyboardBottomAligned = true,
.keyboardCenteredText = false,
.keyboardVerticalOffset = 0,
.keyboardTextFieldWidthPercent = 85,
.keyboardWidthPercent = 90,
.keyboardKeyCornerRadius = 0};
} }
class RoundedRaffTheme : public BaseTheme { class RoundedRaffTheme : public BaseTheme {
@@ -52,6 +58,11 @@ class RoundedRaffTheme : public BaseTheme {
void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, const std::function<std::string(int index)>& buttonLabel,
const std::function<UIIcon(int index)>& rowIcon) const override; 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, 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)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle = nullptr, const std::function<std::string(int index)>& rowSubtitle = nullptr,