From a5469c0bb9e6566a8fa0aa8f600083de1d3c0665 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 2 May 2026 17:22:10 +0200 Subject: [PATCH] Review comments Co-authored-by: Copilot --- .github/skills/crosspoint-reader.md | 0 CLAUDE.md | 0 assets/sd-fonts/sd-fonts.yaml | 294 +----------------- lib/EpdFont/scripts/build-sd-fonts.py | 21 +- src/FontInstaller.cpp | 23 ++ src/FontInstaller.h | 2 + .../settings/FontDownloadActivity.cpp | 77 ++++- src/network/CrossPointWebServer.cpp | 198 ++++++++++++ src/network/CrossPointWebServer.h | 21 ++ src/network/html/FontsPage.html | 59 +++- src/network/html/WelcomePage.html | 1 + 11 files changed, 374 insertions(+), 322 deletions(-) mode change 120000 => 100644 .github/skills/crosspoint-reader.md mode change 120000 => 100644 CLAUDE.md mode change 100644 => 120000 assets/sd-fonts/sd-fonts.yaml diff --git a/.github/skills/crosspoint-reader.md b/.github/skills/crosspoint-reader.md deleted file mode 120000 index c9f69a18..00000000 --- a/.github/skills/crosspoint-reader.md +++ /dev/null @@ -1 +0,0 @@ -../../.skills/SKILL.md \ No newline at end of file diff --git a/.github/skills/crosspoint-reader.md b/.github/skills/crosspoint-reader.md new file mode 100644 index 00000000..c9f69a18 --- /dev/null +++ b/.github/skills/crosspoint-reader.md @@ -0,0 +1 @@ +../../.skills/SKILL.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index d4634fdb..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -.skills/SKILL.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d4634fdb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +.skills/SKILL.md \ No newline at end of file diff --git a/assets/sd-fonts/sd-fonts.yaml b/assets/sd-fonts/sd-fonts.yaml deleted file mode 100644 index 876dc43d..00000000 --- a/assets/sd-fonts/sd-fonts.yaml +++ /dev/null @@ -1,293 +0,0 @@ -# SD Card Font Families for CrossPoint Reader -# -# This file is the single source of truth for which fonts are generated, -# how they're sourced, and how they're described in the download manifest. -# -# Adding a new font family = adding a block here. No code changes needed. -# -# Fields: -# name: Output family name (used in filenames and on-device UI) -# description: Human-readable description (shown in download UI and manifest) -# intervals: Comma-separated Unicode interval presets for fontconvert_sdcard.py -# sizes: Point sizes to generate -# force_autohint: (optional) Force FreeType auto-hinter instead of native hinting -# styles: Map of style name -> font source -# path: relative to lib/EpdFont (for committed fonts) -# url: download URL (for fonts not in the repo) -# -# Variable fonts: -# Some fonts (Bitter, Inter, Alegreya) are distributed as variable fonts. -# freetype-py can't set variable font axis values, so the build script -# uses fonttools.instancer to extract static instances automatically. -# -# To use a variable font, add a 'variable' key to the style spec with -# axis values to pin: -# -# styles: -# regular: {url: "https://...Font[wght].ttf", variable: {wght: 400}} -# bold: {url: "https://...Font[wght].ttf", variable: {wght: 700}} -# -# Extracted static fonts are cached in instanced_fonts/ (gitignored). -# Requires fonttools: pip install -r requirements.txt - -families: - # ── Serif ────────────────────────────────────────────────────────────── - - - name: Literata - description: "Screen-optimized serif (Latin, Greek, Cyrillic)" - intervals: latin-ext,greek,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-BoldItalic.ttf"} - - - name: SourceSerif4 - description: "Adobe transitional serif (Latin, Greek, Cyrillic)" - intervals: latin-ext,greek,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-It.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-BoldIt.ttf"} - - - name: NotoSerifExtended - description: "Serif (Latin, Greek, Cyrillic)" - intervals: latin-ext,greek,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-BoldItalic.ttf"} - - - name: Merriweather - description: "Warm serif for long-form reading (Latin, Cyrillic)" - intervals: latin-ext,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-BoldItalic.ttf"} - - - name: Lora - description: "Calligraphic serif for literary reading (Latin, Cyrillic)" - intervals: latin-ext,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-BoldItalic.ttf"} - - - name: Gelasio - description: "Readable old-style serif (Latin, Cyrillic)" - intervals: latin-ext,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gelasio/Gelasio%5Bwght%5D.ttf", variable: {wght: 400}} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gelasio/Gelasio%5Bwght%5D.ttf", variable: {wght: 700}} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gelasio/Gelasio-Italic%5Bwght%5D.ttf", variable: {wght: 400}} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gelasio/Gelasio-Italic%5Bwght%5D.ttf", variable: {wght: 700}} - - - name: GentiumBookPlus - description: "Scholarly serif with wide Unicode coverage (Latin, Greek, Cyrillic, IPA)" - intervals: latin-ext,greek,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-BoldItalic.ttf"} - - - name: IBMPlexSerif - description: "Professional serif (Latin, Greek, Cyrillic)" - intervals: latin-ext,greek,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-BoldItalic.ttf"} - - - name: Bitter - description: "Slab serif designed for e-ink (Latin, Cyrillic)" - intervals: latin-ext,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 400}} - 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}} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 700}} - - # ── Sans-serif ───────────────────────────────────────────────────────── - - - name: NotoSansExtended - description: "Sans-serif (Latin, Greek, Cyrillic, Georgian, Armenian, Ethiopic)" - intervals: latin-ext,greek,cyrillic,georgian,armenian,ethiopic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {path: "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"} - bold: {path: "builtinFonts/source/NotoSans/NotoSans-Bold.ttf"} - italic: {path: "builtinFonts/source/NotoSans/NotoSans-Italic.ttf"} - bolditalic: {path: "builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf"} - - - name: Inter - description: "Modern sans-serif (Latin, Greek, Cyrillic)" - intervals: latin-ext,greek,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 700, opsz: 14}} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", variable: {wght: 700, opsz: 14}} - - - name: SourceSans3 - description: "Adobe humanist sans-serif (Latin, Greek, Cyrillic)" - intervals: latin-ext,greek,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-It.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-BoldIt.ttf"} - - - name: IBMPlexSans - description: "IBM corporate sans-serif (Latin, Greek, Cyrillic)" - intervals: latin-ext,greek,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-BoldItalic.ttf"} - - - name: Alegreya - description: "Calligraphic serif/display (Latin, Greek, Cyrillic)" - intervals: latin-ext,greek,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 400}} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 700}} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", variable: {wght: 400}} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", variable: {wght: 700}} - - # ── Monospace ────────────────────────────────────────────────────────── - - - name: IBMPlexMono - description: "Monospace for code and technical reading (Latin, Greek, Cyrillic)" - intervals: latin-ext,greek,cyrillic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-BoldItalic.ttf"} - - - name: SourceCodePro - description: "Adobe monospace with excellent hinting (Latin)" - intervals: latin-ext - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-It.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-BoldIt.ttf"} - - # ── Accessibility ────────────────────────────────────────────────────── - - - name: AtkinsonHyperlegibleNext - description: "Accessibility font for low vision (Latin)" - intervals: latin-ext - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-BoldItalic.ttf"} - - - name: LexicaUltralegible - description: "Accessibility font for low vision / dyslexia (Latin)" - intervals: latin-ext - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-BoldItalic.ttf"} - - # OpenDyslexic was previously baked into the firmware. It is now distributed - # as an SD-card font so users who don't need it don't pay the ~3MB flash cost. - # Sizes 8/10 retained for backward compatibility with the previous built-in mapping. - - name: OpenDyslexic - description: "Dyslexia-friendly font (Latin, Latin-Extended)" - intervals: latin-ext - sizes: [8, 10, 12, 14, 16] - styles: - regular: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf"} - bold: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf"} - italic: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf"} - bolditalic: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf"} - - # ── CJK & Hangul ────────────────────────────────────────────────────── - - - name: NotoSansCJK - description: "Sans-serif (Chinese, Japanese, Korean)" - intervals: ascii,latin1,punctuation,cjk - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf"} - - - name: NotoSerifCJK - description: "Serif (Chinese, Japanese, Korean)" - intervals: ascii,latin1,punctuation,cjk - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://github.com/notofonts/noto-cjk/raw/main/Serif/OTF/SimplifiedChinese/NotoSerifCJKsc-Regular.otf"} - - - name: NotoSansHangul - description: "Sans-serif (Korean Hangul)" - intervals: ascii,latin1,punctuation,hangul - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf"} - - # ── Additional scripts (no complex shaping needed) ───────────────────── - - - name: NotoSansArmenian - description: "Armenian script" - intervals: armenian - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/notosansarmenian/NotoSansArmenian%5Bwdth%2Cwght%5D.ttf", variable: {wght: 400, wdth: 100}} - - - name: NotoSansGeorgian - description: "Georgian script" - intervals: georgian - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/notosansgeorgian/NotoSansGeorgian%5Bwdth%2Cwght%5D.ttf", variable: {wght: 400, wdth: 100}} - - - name: NotoSansEthiopic - description: "Ethiopic/Ge'ez script" - intervals: ethiopic - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/notosansethiopic/NotoSansEthiopic%5Bwdth%2Cwght%5D.ttf", variable: {wght: 400, wdth: 100}} - - - name: NotoSansCherokee - description: "Cherokee syllabary" - intervals: cherokee - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/notosanscherokee/NotoSansCherokee%5Bwght%5D.ttf", variable: {wght: 400}} - - - name: NotoSansTifinagh - description: "Tifinagh script" - intervals: tifinagh - sizes: [10, 12, 14, 16, 18] - styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/notosanstifinagh/NotoSansTifinagh-Regular.ttf"} \ No newline at end of file diff --git a/assets/sd-fonts/sd-fonts.yaml b/assets/sd-fonts/sd-fonts.yaml new file mode 120000 index 00000000..fc0626f9 --- /dev/null +++ b/assets/sd-fonts/sd-fonts.yaml @@ -0,0 +1 @@ +../../lib/EpdFont/scripts/sd-fonts.yaml diff --git a/lib/EpdFont/scripts/build-sd-fonts.py b/lib/EpdFont/scripts/build-sd-fonts.py index 88552f0f..a6d126da 100644 --- a/lib/EpdFont/scripts/build-sd-fonts.py +++ b/lib/EpdFont/scripts/build-sd-fonts.py @@ -25,6 +25,7 @@ import argparse import shutil import subprocess import sys +import urllib.parse import urllib.request from concurrent.futures import ProcessPoolExecutor, as_completed from pathlib import Path @@ -34,7 +35,8 @@ import yaml SCRIPT_DIR = Path(__file__).parent FONTCONVERT = SCRIPT_DIR / "fontconvert_sdcard.py" EPDFONTS_DIR = SCRIPT_DIR.parent # lib/EpdFont -DEFAULT_CONFIG = SCRIPT_DIR / "sd-fonts.yaml" +PRIMARY_CONFIG = SCRIPT_DIR / "sd-fonts.yaml" +DEFAULT_CONFIG = PRIMARY_CONFIG DEFAULT_OUTPUT = SCRIPT_DIR / "output" DOWNLOAD_DIR = SCRIPT_DIR / "downloaded_fonts" INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts" @@ -99,6 +101,11 @@ def resolve_font_path(style_spec: dict, family_name: str, style_name: str) -> Pa raise FileNotFoundError(f"{family_name}/{style_name}: {resolved} not found") elif "url" in style_spec: url = style_spec["url"] + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError( + f"{family_name}/{style_name}: invalid URL scheme '{parsed.scheme or ''}'" + ) # Derive a stable filename from the URL filename = url.rsplit("/", 1)[-1] dest = DOWNLOAD_DIR / family_name / filename @@ -229,6 +236,9 @@ def main(): parser.add_argument("--clean", action="store_true", help="Clean output directory before building") args = parser.parse_args() + # The assets copy is expected to be a link to PRIMARY_CONFIG. + # This script reads PRIMARY_CONFIG directly. + if args.manifest and not args.base_url: parser.error("--base-url is required when using --manifest") @@ -287,7 +297,14 @@ def main(): for family in families } for future in as_completed(futures): - name, success, message = future.result() + name = futures[future] + success = False + message = "" + try: + name, success, message = future.result() + except Exception as e: + success = False + message = str(e) if success: # Count output files family_dir = output_base / name diff --git a/src/FontInstaller.cpp b/src/FontInstaller.cpp index 2b0375d8..f0b9d55d 100644 --- a/src/FontInstaller.cpp +++ b/src/FontInstaller.cpp @@ -13,6 +13,9 @@ FontInstaller::FontInstaller(SdCardFontRegistry& registry) : registry_(registry) bool FontInstaller::isValidFamilyName(const char* name) { if (name == nullptr || name[0] == '\0') return false; + const size_t nameLen = strlen(name); + if (nameLen == 0 || nameLen > MAX_FAMILY_NAME_LEN) return false; + if (strstr(name, "..") != nullptr) return false; if (strchr(name, '/') != nullptr) return false; if (strchr(name, '\\') != nullptr) return false; @@ -27,6 +30,19 @@ bool FontInstaller::isValidFamilyName(const char* name) { } bool FontInstaller::ensureFamilyDir(const char* familyName) { + if (!isValidFamilyName(familyName)) { + LOG_ERR("FONT", "Invalid family name: %s", familyName ? familyName : ""); + return false; + } + + const size_t baseLen = strlen(SdCardFontRegistry::FONTS_DIR); + const size_t familyLen = strlen(familyName); + const size_t neededLen = baseLen + 1 + familyLen + 1; // "/" + NUL + if (neededLen > 128) { + LOG_ERR("FONT", "Family dir path too long: %s", familyName); + return false; + } + if (!Storage.exists(SdCardFontRegistry::FONTS_DIR)) { if (!Storage.mkdir(SdCardFontRegistry::FONTS_DIR)) { LOG_ERR("FONT", "Failed to create fonts dir: %s", SdCardFontRegistry::FONTS_DIR); @@ -79,6 +95,13 @@ FontInstaller::Error FontInstaller::deleteFamily(const char* familyName) { return Error::INVALID_FAMILY_NAME; } + const size_t baseLen = strlen(SdCardFontRegistry::FONTS_DIR); + const size_t familyLen = strlen(familyName); + if (baseLen + 1 + familyLen + 1 > 128) { + LOG_ERR("FONT", "Family dir path too long: %s", familyName); + return Error::INVALID_FAMILY_NAME; + } + char dirPath[128]; snprintf(dirPath, sizeof(dirPath), "%s/%s", SdCardFontRegistry::FONTS_DIR, familyName); diff --git a/src/FontInstaller.h b/src/FontInstaller.h index b77330f9..0115d009 100644 --- a/src/FontInstaller.h +++ b/src/FontInstaller.h @@ -19,6 +19,8 @@ class FontInstaller { explicit FontInstaller(SdCardFontRegistry& registry); + static constexpr size_t MAX_FAMILY_NAME_LEN = 64; + /// Validate a family name: alphanumeric + hyphen + underscore only, no path traversal. static bool isValidFamilyName(const char* name); diff --git a/src/activities/settings/FontDownloadActivity.cpp b/src/activities/settings/FontDownloadActivity.cpp index 0fe8c7f3..dc6f35c5 100644 --- a/src/activities/settings/FontDownloadActivity.cpp +++ b/src/activities/settings/FontDownloadActivity.cpp @@ -7,6 +7,8 @@ #include #include +#include + #include "MappedInputManager.h" #include "SdCardFontGlobals.h" #include "activities/network/WifiSelectionActivity.h" @@ -183,10 +185,26 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { } requestUpdateAndWait(); - if (!fontInstaller_.ensureFamilyDir(family.name.c_str())) { + char liveDir[128]; + char stagingDir[128]; + char backupDir[128]; + snprintf(liveDir, sizeof(liveDir), "%s/%s", SdCardFontRegistry::FONTS_DIR, family.name.c_str()); + snprintf(stagingDir, sizeof(stagingDir), "%s/%s__staging", SdCardFontRegistry::FONTS_DIR, family.name.c_str()); + snprintf(backupDir, sizeof(backupDir), "%s/%s__backup", SdCardFontRegistry::FONTS_DIR, family.name.c_str()); + + if (Storage.exists(stagingDir) && !Storage.removeDir(stagingDir)) { + LOG_ERR("FONT", "Failed to clean staging dir: %s", stagingDir); RenderLock lock(*this); state_ = ERROR; - errorMessage_ = "Failed to create font directory"; + errorMessage_ = "Failed to prepare staging area"; + return; + } + + if (!Storage.mkdir(stagingDir)) { + LOG_ERR("FONT", "Failed to create staging dir: %s", stagingDir); + RenderLock lock(*this); + state_ = ERROR; + errorMessage_ = "Failed to create staging area"; return; } @@ -201,12 +219,12 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { } requestUpdateAndWait(); - char destPath[128]; - FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), destPath, sizeof(destPath)); + char stagedPath[128]; + snprintf(stagedPath, sizeof(stagedPath), "%s/%s", stagingDir, file.name.c_str()); std::string url = baseUrl_ + file.name; - auto result = HttpDownloader::downloadToFile(url, destPath, [this](size_t downloaded, size_t total) { + auto result = HttpDownloader::downloadToFile(url, stagedPath, [this](size_t downloaded, size_t total) { fileProgress_ = downloaded; fileTotal_ = total; requestUpdate(true); @@ -214,20 +232,16 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { if (result != HttpDownloader::OK) { LOG_ERR("FONT", "Download failed: %s (%d)", file.name.c_str(), result); - fontInstaller_.deleteFamily(family.name.c_str()); - family.installed = false; - family.hasUpdate = false; + Storage.removeDir(stagingDir); RenderLock lock(*this); state_ = ERROR; errorMessage_ = "Download failed: " + file.name; return; } - if (!fontInstaller_.validateCpfontFile(destPath)) { - LOG_ERR("FONT", "Invalid .cpfont: %s", destPath); - fontInstaller_.deleteFamily(family.name.c_str()); - family.installed = false; - family.hasUpdate = false; + if (!fontInstaller_.validateCpfontFile(stagedPath)) { + LOG_ERR("FONT", "Invalid .cpfont: %s", stagedPath); + Storage.removeDir(stagingDir); RenderLock lock(*this); state_ = ERROR; errorMessage_ = "Invalid font file: " + file.name; @@ -235,8 +249,45 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { } } + const bool hadLiveDir = Storage.exists(liveDir); + + if (Storage.exists(backupDir) && !Storage.removeDir(backupDir)) { + LOG_ERR("FONT", "Failed to clean backup dir: %s", backupDir); + Storage.removeDir(stagingDir); + RenderLock lock(*this); + state_ = ERROR; + errorMessage_ = "Failed to prepare backup area"; + return; + } + + if (hadLiveDir && !Storage.rename(liveDir, backupDir)) { + LOG_ERR("FONT", "Failed to move live family to backup: %s", liveDir); + Storage.removeDir(stagingDir); + RenderLock lock(*this); + state_ = ERROR; + errorMessage_ = "Failed to replace installed font"; + return; + } + + if (!Storage.rename(stagingDir, liveDir)) { + LOG_ERR("FONT", "Failed to activate staged family: %s", stagingDir); + if (hadLiveDir && Storage.exists(backupDir)) { + Storage.rename(backupDir, liveDir); + } + Storage.removeDir(stagingDir); + RenderLock lock(*this); + state_ = ERROR; + errorMessage_ = "Failed to finalize font install"; + return; + } + + if (Storage.exists(backupDir) && !Storage.removeDir(backupDir)) { + LOG_INF("FONT", "Failed to remove backup dir after successful install: %s", backupDir); + } + fontInstaller_.refreshRegistry(); family.installed = true; + family.hasUpdate = false; RenderLock lock(*this); state_ = COMPLETE; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 0feddf8c..9f3b7bc8 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -12,17 +12,22 @@ #include #include "CrossPointSettings.h" +#include "FontInstaller.h" #include "OpdsServerStore.h" +#include "SdCardFontGlobals.h" +#include "SdCardFontRegistry.h" #include "SettingsList.h" #include "SystemStatus.h" #include "WebDAVHandler.h" #include "WifiCredentialStore.h" #include "html/FilesPageHtml.generated.h" +#include "html/FontsPageHtml.generated.h" #include "html/HomePageHtml.generated.h" #include "html/SettingsPageHtml.generated.h" #include "html/WelcomePageHtml.generated.h" #include "html/js/jszip_minJs.generated.h" + namespace { // Folders/files to hide from the web interface file browser // Note: Items starting with "." are automatically hidden @@ -196,6 +201,12 @@ void CrossPointWebServer::begin() { server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); }); server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); }); + // Font management endpoints + server->on("/fonts", HTTP_GET, [this] { handleFontsPage(); }); + server->on("/api/fonts", HTTP_GET, [this] { handleFontList(); }); + server->on("/api/fonts/upload", HTTP_POST, [this] { handleFontUpload(); }, [this] { handleFontUploadData(); }); + server->on("/api/fonts/delete", HTTP_POST, [this] { handleFontDelete(); }); + // OPDS server endpoints server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); }); server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); }); @@ -1399,6 +1410,193 @@ void CrossPointWebServer::handlePostSettings() { server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)"); } +// ---- Font Management API ---- + +void CrossPointWebServer::handleFontsPage() const { + sendHtmlContent(server.get(), FontsPageHtml, sizeof(FontsPageHtml)); + LOG_DBG("WEB", "Served fonts page"); +} + +void CrossPointWebServer::handleFontList() { + FontInstaller installer(sdFontSystem.registry()); + installer.refreshRegistry(); + const auto& families = sdFontSystem.registry().getFamilies(); + + JsonDocument doc; + JsonArray arr = doc["families"].to(); + doc["maxFamilies"] = SdCardFontRegistry::MAX_SD_FAMILIES; + + for (const auto& family : families) { + JsonObject fObj = arr.add(); + fObj["name"] = family.name; + + JsonArray sizes = fObj["sizes"].to(); + for (uint8_t s : family.availableSizes()) { + sizes.add(s); + } + + JsonArray files = fObj["files"].to(); + for (const auto& file : family.files) { + JsonObject fileObj = files.add(); + const char* name = strrchr(file.path.c_str(), '/'); + fileObj["name"] = name ? name + 1 : file.path.c_str(); + + FsFile f; + if (Storage.openFileForRead("WEB", file.path.c_str(), f)) { + fileObj["size"] = static_cast(f.size()); + f.close(); + } else { + fileObj["size"] = 0; + } + } + } + + String json; + serializeJson(doc, json); + server->send(200, "application/json", json); +} + +void CrossPointWebServer::handleFontUploadData() { + HTTPUpload& up = server->upload(); + + switch (up.status) { + case UPLOAD_FILE_START: { + esp_task_wdt_reset(); + String family = server->arg("family"); + fontUpload.valid = false; + fontUpload.magicChecked = false; + fontUpload.bytesWritten = 0; + fontUpload.bufferPos = 0; + + if (!FontInstaller::isValidFamilyName(family.c_str())) { + LOG_ERR("WEB", "Invalid font family name: %s", family.c_str()); + break; + } + + String filename = up.filename; + if (!filename.endsWith(".cpfont")) { + LOG_ERR("WEB", "Not a .cpfont file: %s", filename.c_str()); + break; + } + + fontUpload.familyName = family.c_str(); + + FontInstaller installer(sdFontSystem.registry()); + if (!installer.ensureFamilyDir(family.c_str())) { + LOG_ERR("WEB", "Failed to create font family dir"); + break; + } + + char path[128]; + FontInstaller::buildFontPath(family.c_str(), filename.c_str(), path, sizeof(path)); + fontUpload.filePath = path; + + if (!Storage.openFileForWrite("WEB", path, fontUpload.file)) { + LOG_ERR("WEB", "Failed to open font file for write: %s", path); + break; + } + + fontUpload.valid = true; + LOG_DBG("WEB", "Font upload started: %s -> %s", filename.c_str(), path); + break; + } + + case UPLOAD_FILE_WRITE: { + if (!fontUpload.valid) break; + esp_task_wdt_reset(); + + if (!fontUpload.magicChecked && up.currentSize >= 8) { + if (memcmp(up.buf, "CPFONT\0\0", 8) != 0) { + LOG_ERR("WEB", "Invalid .cpfont magic bytes"); + fontUpload.valid = false; + break; + } + fontUpload.magicChecked = true; + } + + size_t remaining = up.currentSize; + const uint8_t* src = up.buf; + while (remaining > 0) { + size_t space = FontUploadState::BUFFER_SIZE - fontUpload.bufferPos; + size_t chunk = (remaining < space) ? remaining : space; + memcpy(fontUpload.buffer.data() + fontUpload.bufferPos, src, chunk); + fontUpload.bufferPos += chunk; + src += chunk; + remaining -= chunk; + + if (fontUpload.bufferPos >= FontUploadState::BUFFER_SIZE) { + fontUpload.file.write(fontUpload.buffer.data(), fontUpload.bufferPos); + fontUpload.bytesWritten += fontUpload.bufferPos; + fontUpload.bufferPos = 0; + esp_task_wdt_reset(); + } + } + break; + } + + case UPLOAD_FILE_END: { + if (fontUpload.valid && fontUpload.bufferPos > 0) { + fontUpload.file.write(fontUpload.buffer.data(), fontUpload.bufferPos); + fontUpload.bytesWritten += fontUpload.bufferPos; + fontUpload.bufferPos = 0; + } + fontUpload.file.close(); + + if (!fontUpload.valid && !fontUpload.filePath.empty()) { + Storage.remove(fontUpload.filePath.c_str()); + } + + LOG_DBG("WEB", "Font upload end: valid=%d, %zu bytes", fontUpload.valid, fontUpload.bytesWritten); + break; + } + + case UPLOAD_FILE_ABORTED: { + fontUpload.file.close(); + if (!fontUpload.filePath.empty()) { + Storage.remove(fontUpload.filePath.c_str()); + } + fontUpload.valid = false; + LOG_DBG("WEB", "Font upload aborted"); + break; + } + } +} + +void CrossPointWebServer::handleFontUpload() { + if (fontUpload.valid) { + FontInstaller installer(sdFontSystem.registry()); + installer.refreshRegistry(); + server->send(200, "application/json", "{\"ok\":true}"); + LOG_DBG("WEB", "Font upload complete: %s", fontUpload.filePath.c_str()); + } else { + server->send(400, "application/json", "{\"error\":\"Invalid .cpfont file\"}"); + } +} + +void CrossPointWebServer::handleFontDelete() { + String body = server->arg("plain"); + JsonDocument doc; + DeserializationError err = deserializeJson(doc, body); + + if (err || !doc["family"].is()) { + server->send(400, "application/json", "{\"error\":\"Invalid request\"}"); + return; + } + + const char* familyName = doc["family"]; + FontInstaller installer(sdFontSystem.registry()); + auto result = installer.deleteFamily(familyName); + + if (result == FontInstaller::Error::OK) { + installer.refreshRegistry(); + server->send(200, "application/json", "{\"ok\":true}"); + LOG_DBG("WEB", "Deleted font family: %s", familyName); + } else { + server->send(500, "application/json", "{\"error\":\"Delete failed\"}"); + LOG_ERR("WEB", "Failed to delete font family: %s", familyName); + } +} + // ---- Wi-Fi Credentials API ---- void CrossPointWebServer::handleGetWifiNetworks() const { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 283fc546..1a32c1ba 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -110,6 +110,27 @@ class CrossPointWebServer { void handleGetSettings() const; void handlePostSettings(); + // Font management handlers + void handleFontsPage() const; + void handleFontList(); + void handleFontUpload(); + void handleFontUploadData(); + void handleFontDelete(); + + struct FontUploadState { + FsFile file; + std::string familyName; + std::string filePath; + bool valid = false; + bool magicChecked = false; + size_t bytesWritten = 0; + static constexpr size_t BUFFER_SIZE = 4096; + std::vector buffer; + size_t bufferPos = 0; + + FontUploadState() { buffer.resize(BUFFER_SIZE); } + } fontUpload; + // OPDS server handlers void handleGetOpdsServers() const; void handlePostOpdsServer(); diff --git a/src/network/html/FontsPage.html b/src/network/html/FontsPage.html index 523f6e93..51eb5f50 100644 --- a/src/network/html/FontsPage.html +++ b/src/network/html/FontsPage.html @@ -181,22 +181,44 @@ const res = await fetch('/api/fonts'); const data = await res.json(); const el = document.getElementById('families'); + el.innerHTML = ''; if (!data.families || data.families.length === 0) { - el.innerHTML = '

No fonts installed

'; + const p = document.createElement('p'); + p.className = 'empty'; + p.textContent = 'No fonts installed'; + el.appendChild(p); return; } - el.innerHTML = data.families.map(f => ` -
-
-

${f.name}

- - ${f.sizes.join(', ')}pt · - ${f.files.map(fi => formatSize(fi.size)).join(' + ')} - -
- -
- `).join(''); + + data.families.forEach(f => { + const familyRow = document.createElement('div'); + familyRow.className = 'family'; + + const info = document.createElement('div'); + info.className = 'family-info'; + + const title = document.createElement('h3'); + title.textContent = f.name; + + const meta = document.createElement('span'); + meta.className = 'family-meta'; + const sizes = Array.isArray(f.sizes) ? f.sizes.join(', ') : ''; + const fileSizes = Array.isArray(f.files) ? f.files.map(fi => formatSize(fi.size)).join(' + ') : ''; + meta.textContent = sizes + 'pt - ' + fileSizes; + + info.appendChild(title); + info.appendChild(meta); + + const delBtn = document.createElement('button'); + delBtn.className = 'btn btn-danger'; + delBtn.dataset.family = f.name; + delBtn.textContent = 'Delete'; + + familyRow.appendChild(info); + familyRow.appendChild(delBtn); + el.appendChild(familyRow); + }); + el.querySelectorAll('button[data-family]').forEach(btn => { btn.addEventListener('click', () => deleteFamily(btn.dataset.family)); }); @@ -272,7 +294,16 @@ return; } - const family = sanitizeFamily(familyFromFilename(files[0].name)); + const families = files.map(f => sanitizeFamily(familyFromFilename(f.name))); + const uniqueFamilies = new Set(families); + if (uniqueFamilies.size !== 1) { + status.className = 'status-err'; + status.style.display = 'block'; + status.textContent = 'Selected files belong to multiple families.'; + return; + } + + const family = families[0]; status.className = ''; status.style.display = 'block'; diff --git a/src/network/html/WelcomePage.html b/src/network/html/WelcomePage.html index 8ccfe8ae..dab7f564 100644 --- a/src/network/html/WelcomePage.html +++ b/src/network/html/WelcomePage.html @@ -107,6 +107,7 @@