From 2e2ea6a9e8ebc59ea773ecbc0f96e74cce27c8b1 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 4 May 2026 07:56:02 +0200 Subject: [PATCH 01/27] feat: Page turn button orientation change (#1069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Егор Мартынов --- lib/I18n/translations/catalan.yaml | 5 ++++- lib/I18n/translations/czech.yaml | 5 ++++- lib/I18n/translations/danish.yaml | 5 ++++- lib/I18n/translations/dutch.yaml | 5 ++++- lib/I18n/translations/english.yaml | 5 ++++- lib/I18n/translations/finnish.yaml | 5 ++++- lib/I18n/translations/french.yaml | 5 ++++- lib/I18n/translations/german.yaml | 5 ++++- lib/I18n/translations/italian.yaml | 4 ++++ lib/I18n/translations/polish.yaml | 5 ++++- lib/I18n/translations/portuguese.yaml | 5 ++++- lib/I18n/translations/romanian.yaml | 5 ++++- lib/I18n/translations/russian.yaml | 5 ++++- lib/I18n/translations/spanish.yaml | 5 ++++- lib/I18n/translations/swedish.yaml | 5 ++++- lib/I18n/translations/turkish.yaml | 5 ++++- lib/I18n/translations/ukrainian.yaml | 5 ++++- open-x4-sdk | 2 +- src/CrossPointSettings.cpp | 2 +- src/CrossPointSettings.h | 12 ++++++++++-- src/SettingsList.h | 7 ++++--- src/activities/reader/EpubReaderActivity.cpp | 14 +++++++++++--- src/activities/reader/ReaderUtils.h | 2 +- src/activities/reader/XtcReaderActivity.cpp | 5 +++-- 24 files changed, 99 insertions(+), 29 deletions(-) diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index ec377bb9c8..5bc09c1068 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -73,7 +73,10 @@ STR_IMAGES_SUPPRESS: "Suprimir" STR_SHORT_PWR_BTN: "Clic curt del botó d'engegada" STR_ORIENTATION: "Orientació de lectura" STR_SIDE_BTN_LAYOUT: "Disposició botons laterals" -STR_LONG_PRESS_SKIP: "Pressió llarga omet el capítol" +STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó" +STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítols" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Canvi d'orientació" STR_FONT_FAMILY: "Tipus de lletra" STR_FONT_SIZE: "Mida de la lletra (UI)" STR_LINE_SPACING: "Interlineat del lector" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 27113b56fc..e2b0ef51e8 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -69,7 +69,10 @@ STR_TEXT_AA: "Vyhlazování textu" STR_SHORT_PWR_BTN: "Krátké stisknutí tlačítka napájení" STR_ORIENTATION: "Orientace čtení" STR_SIDE_BTN_LAYOUT: "Rozvržení bočních tlačítek (čtečka)" -STR_LONG_PRESS_SKIP: "Dlouhé stisknutí Přeskočit kapitolu" +STR_LONG_PRESS_BEHAVIOR: "Chování při dlouhém stisknutí tlačítka" +STR_LONG_PRESS_BEHAVIOR_OFF: "VYP" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Přeskočení kapitoly" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Změna orientace" STR_FONT_FAMILY: "Rodina písem čtečky" STR_FONT_SIZE: "Velikost písma rozhraní" STR_LINE_SPACING: "Řádkování čtečky" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index 2b631872ad..36ebc506ea 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -73,7 +73,10 @@ STR_IMAGES_SUPPRESS: "Skjul" STR_SHORT_PWR_BTN: "Kort tryk på tænd/sluk-knap" STR_ORIENTATION: "Læseretning" STR_SIDE_BTN_LAYOUT: "Knaplayout på siden (læser)" -STR_LONG_PRESS_SKIP: "Langt tryk spring kapitel over" +STR_LONG_PRESS_BEHAVIOR: "Comportamiento al mantener pulsado el botón" +STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivado" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Cambio de orientación" STR_FONT_FAMILY: "Læser skrifttype" STR_FONT_SIZE: "Læser skriftstørrelse" STR_LINE_SPACING: "Linjeafstand" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 7c6bdfaf35..63a37be9ff 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -73,7 +73,10 @@ STR_IMAGES_SUPPRESS: "Verbergen" STR_SHORT_PWR_BTN: "Korte klik aan/uit-knop" STR_ORIENTATION: "Leesstand" STR_SIDE_BTN_LAYOUT: "Indeling zijknoppen (lezer)" -STR_LONG_PRESS_SKIP: "Hoofdstuk overslaan (lang indrukken)" +STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior" +STR_LONG_PRESS_BEHAVIOR_OFF: "OFF" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change" STR_FONT_FAMILY: "Lettertype lezer" STR_FONT_SIZE: "Lettergrootte lezer" STR_LINE_SPACING: "Regelafstand lezer" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index ee5dc84d5a..d29b81c313 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -73,7 +73,10 @@ STR_IMAGES_SUPPRESS: "Suppress" STR_SHORT_PWR_BTN: "Short Power Button Click" STR_ORIENTATION: "Reading Orientation" STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)" -STR_LONG_PRESS_SKIP: "Long-press Chapter Skip" +STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior" +STR_LONG_PRESS_BEHAVIOR_OFF: "OFF" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change" STR_FONT_FAMILY: "Reader Font Family" STR_FONT_SIZE: "Reader Font Size" STR_LINE_SPACING: "Reader Line Spacing" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 71785d4306..6832dd269c 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -69,7 +69,10 @@ STR_TEXT_AA: "Tekstin reunanpehmennys" STR_SHORT_PWR_BTN: "Lyhyt virtapainikkeen painallus" STR_ORIENTATION: "Lukusuunta" STR_SIDE_BTN_LAYOUT: "Sivupainikkeiden asettelu (lukija)" -STR_LONG_PRESS_SKIP: "Pitkä painallus: lukuhyppy" +STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior" +STR_LONG_PRESS_BEHAVIOR_OFF: "OFF" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change" STR_FONT_FAMILY: "Lukijan fonttiperhe" STR_FONT_SIZE: "Käyttöliittymän fonttikoko" STR_LINE_SPACING: "Lukijan riviväli" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index b4118a1cc5..fe5e062a9e 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -73,7 +73,10 @@ STR_IMAGES_SUPPRESS: "Masquer" STR_SHORT_PWR_BTN: "Appui court alim." STR_ORIENTATION: "Orientation de lecture" STR_SIDE_BTN_LAYOUT: "Boutons latéraux" -STR_LONG_PRESS_SKIP: "Appui long saut de chapitre" +STR_LONG_PRESS_BEHAVIOR: "Comportement lors d'un appui long" +STR_LONG_PRESS_BEHAVIOR_OFF: "Désactivé" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Saut de chapitre" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Changement d'orientation" STR_FONT_FAMILY: "Police de caractères du lecteur" STR_FONT_SIZE: "Taille texte interface" STR_LINE_SPACING: "Interligne" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 2a5fcf5621..61d36a1c39 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -69,7 +69,10 @@ STR_TEXT_AA: "Schriftglättung" STR_SHORT_PWR_BTN: "An-Taste kurz drücken" STR_ORIENTATION: "Leseausrichtung" STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)" -STR_LONG_PRESS_SKIP: "Langes Drücken springt Kap." +STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck" +STR_LONG_PRESS_BEHAVIOR_OFF: "AUS" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Kapitel überspringen" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ausrichtung ändern" STR_FONT_FAMILY: "Lese-Schriftfamilie" STR_FONT_SIZE: "Schriftgröße" STR_LINE_SPACING: "Lese-Zeilenabstand" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index 9f452bac85..25de985305 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -73,6 +73,10 @@ STR_IMAGES_SUPPRESS: "Nascondi" STR_SHORT_PWR_BTN: "Pressione breve tasto accensione" STR_ORIENTATION: "Orientamento lettura" STR_SIDE_BTN_LAYOUT: "Pulsanti laterali (lettore)" +STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior" +STR_LONG_PRESS_BEHAVIOR_OFF: "OFF" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change" STR_LONG_PRESS_SKIP: "Pressione lunga: salta capitolo" STR_FONT_FAMILY: "Font lettore" STR_FONT_SIZE: "Dimensione font lettore" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 8807ee659b..4a5c0af754 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -73,7 +73,10 @@ STR_IMAGES_SUPPRESS: "Pomijaj" STR_SHORT_PWR_BTN: "Krótkie naciśnięcie zasilania" STR_ORIENTATION: "Układ czytania" STR_SIDE_BTN_LAYOUT: "Układ przycisków bocznych" -STR_LONG_PRESS_SKIP: "Przytrzymaj aby przeskoczyć rozdział" +STR_LONG_PRESS_BEHAVIOR: "Funkcja długiego przyciśnięcia" +STR_LONG_PRESS_BEHAVIOR_OFF: "Wył." +STR_LONG_PRESS_BEHAVIOR_SKIP: "Przeskocz rozdział" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientacja ekranu" STR_FONT_FAMILY: "Czcionka" STR_FONT_SIZE: "Rozmiar czcionki" STR_LINE_SPACING: "Odstępy między wierszami" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 5ff8526719..f7379c1649 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -69,7 +69,10 @@ STR_TEXT_AA: "Suavização de texto" STR_SHORT_PWR_BTN: "Clique curto botão ligar" STR_ORIENTATION: "Orientação de leitura" STR_SIDE_BTN_LAYOUT: "Disposição botões laterais" -STR_LONG_PRESS_SKIP: "Pular capítulo com pressão longa" +STR_LONG_PRESS_BEHAVIOR: "Comportamento do botão de premir e segurar" +STR_LONG_PRESS_BEHAVIOR_OFF: "Desligado" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação" STR_FONT_FAMILY: "Fonte do leitor" STR_FONT_SIZE: "Tam. fonte UI" STR_LINE_SPACING: "Espaçamento entre linhas" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 3d557264dd..2a4856a351 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -73,7 +73,10 @@ STR_IMAGES_SUPPRESS: "Suprimare" STR_SHORT_PWR_BTN: "Apăsare scurtă întrerupător" STR_ORIENTATION: "Orientare lectură" STR_SIDE_BTN_LAYOUT: "Aspect butoane laterale (lectură)" -STR_LONG_PRESS_SKIP: "Sărire capitol la apăsare lungă" +STR_LONG_PRESS_BEHAVIOR: "Comportament buton apăsat lung" +STR_LONG_PRESS_BEHAVIOR_OFF: "Dezactivat" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Sărire capitol" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Schimbă orientarea" STR_FONT_FAMILY: "Familie font lectură" STR_FONT_SIZE: "Dimensiune font" STR_LINE_SPACING: "Spaţiere între rânduri" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 1391cbccbe..dda0acc02c 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -73,7 +73,10 @@ STR_IMAGES_SUPPRESS: "Скрыть" STR_SHORT_PWR_BTN: "Короткое нажатие PWR" STR_ORIENTATION: "Ориентация чтения" STR_SIDE_BTN_LAYOUT: "Боковые кнопки" -STR_LONG_PRESS_SKIP: "Долгое нажатие - смена главы" +STR_LONG_PRESS_BEHAVIOR: "Долгое нажатие" +STR_LONG_PRESS_BEHAVIOR_OFF: "Ничего" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Пропуск главы" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Изменить ориентацию" STR_FONT_FAMILY: "Шрифт чтения" STR_FONT_SIZE: "Размер шрифта интерфейса" STR_LINE_SPACING: "Межстрочный интервал" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index c37353f890..5cb76a23ab 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -73,7 +73,10 @@ STR_IMAGES_SUPPRESS: "Ocultar" STR_SHORT_PWR_BTN: "Toque corto botón encendido" STR_ORIENTATION: "Orientación" STR_SIDE_BTN_LAYOUT: "Función botones laterales (lector)" -STR_LONG_PRESS_SKIP: "Saltar capítulo (pulsación larga)" +STR_LONG_PRESS_BEHAVIOR: "Comportamiento al mantener pulsado el botón" +STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivado" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Cambio de orientación" STR_FONT_FAMILY: "Tipografía" STR_FONT_SIZE: "Tamaño" STR_LINE_SPACING: "Interlineado" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 815d0cc319..fb934a2820 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -73,7 +73,10 @@ STR_IMAGES_SUPPRESS: "Dölj" STR_SHORT_PWR_BTN: "Kort strömknappsklick" STR_ORIENTATION: "Läsrikting" STR_SIDE_BTN_LAYOUT: "Sidoknappslayout (Läsare)" -STR_LONG_PRESS_SKIP: "Lång-tryck Kapitelskippning" +STR_LONG_PRESS_BEHAVIOR: "Beteende vid lång knapptryckning" +STR_LONG_PRESS_BEHAVIOR_OFF: "AV" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Hoppa över kapitel" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ändra orientering" STR_FONT_FAMILY: "Eboksläsarens typsnittsfamilj" STR_FONT_SIZE: "Eboksläsarens typsnittsstorlek" STR_LINE_SPACING: "Eboksläsarens linjemellanrum" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index a517eb2749..3c8c679a3f 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -68,7 +68,10 @@ STR_TEXT_AA: "Metin Yumuşatma (AA)" STR_SHORT_PWR_BTN: "Kısa Güç Tuşu Tıklaması" STR_ORIENTATION: "Okuma Yönü" STR_SIDE_BTN_LAYOUT: "Yan Tuş Dizilimi (okuyucu)" -STR_LONG_PRESS_SKIP: "Uzun Basışla Bölüm Atla" +STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior" +STR_LONG_PRESS_BEHAVIOR_OFF: "OFF" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change" STR_FONT_FAMILY: "Okuyucu Yazı Tipi Ailesi" STR_FONT_SIZE: "Arayüz Yazı Boyutu" STR_LINE_SPACING: "Okuyucu Satır Aralığı" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index d271ac4593..845a0aaddd 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -73,7 +73,10 @@ STR_IMAGES_SUPPRESS: "Приховати" STR_SHORT_PWR_BTN: "Короткий натиск кн. живл." STR_ORIENTATION: "Орієнтація читання" STR_SIDE_BTN_LAYOUT: "Схема бічних кнопок" -STR_LONG_PRESS_SKIP: "Наступ. розділ (утримув.)" +STR_LONG_PRESS_BEHAVIOR: "Поведінка при довгому настику" +STR_LONG_PRESS_BEHAVIOR_OFF: "Немає" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Наступ. розділ (утримув.)" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Зміна орієнтації екрану" STR_FONT_FAMILY: "Шрифт" STR_FONT_SIZE: "Розмір шрифту" STR_LINE_SPACING: "Міжрядковий інтервал" diff --git a/open-x4-sdk b/open-x4-sdk index a64a3c29be..7d86603ad2 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit a64a3c29bebc59b2ccdfe15492cfc4b5e4c26360 +Subproject commit 7d86603ad27709a9a766bb5ad893cfc39e60777e diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 48d6d642a6..6516a5b277 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -205,7 +205,7 @@ bool CrossPointSettings::loadFromBinaryFile() { if (++settingsRead >= fileSettingsCount) break; readAndValidate(inputFile, hideBatteryPercentage, HIDE_BATTERY_PERCENTAGE_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, longPressChapterSkip); + readAndValidate(inputFile, longPressButtonBehavior, LONG_PRESS_BUTTON_BEHAVIOR_COUNT); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hyphenationEnabled); if (++settingsRead >= fileSettingsCount) break; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 1c58b73e8f..01d7cc5618 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -131,6 +131,14 @@ class CrossPointSettings { // Hide battery percentage enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT }; + // Page turn button long press behavior + enum LONG_PRESS_BUTTON_BEHAVIOR { + OFF = 0, + CHAPTER_SKIP = 1, + ORIENTATION_CHANGE = 2, + LONG_PRESS_BUTTON_BEHAVIOR_COUNT + }; + // UI Theme enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2, ROUNDEDRAFF = 3 }; @@ -189,8 +197,8 @@ class CrossPointSettings { char opdsPassword[64] = ""; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; - // Long-press chapter skip on side buttons - uint8_t longPressChapterSkip = 1; + // Long-press page turn button behavior + uint8_t longPressButtonBehavior = OFF; // UI Theme uint8_t uiTheme = LYRA; // Sunlight fading compensation diff --git a/src/SettingsList.h b/src/SettingsList.h index 9a9b5b0922..bbe9a77458 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -71,12 +71,13 @@ inline const std::vector& getSettingsList() { // --- Controls --- SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout, {StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV}, "sideButtonLayout", StrId::STR_CAT_CONTROLS), - SettingInfo::Toggle(StrId::STR_LONG_PRESS_SKIP, &CrossPointSettings::longPressChapterSkip, - "longPressChapterSkip", StrId::STR_CAT_CONTROLS), + SettingInfo::Enum(StrId::STR_LONG_PRESS_BEHAVIOR, &CrossPointSettings::longPressButtonBehavior, + {StrId::STR_LONG_PRESS_BEHAVIOR_OFF, StrId::STR_LONG_PRESS_BEHAVIOR_SKIP, + StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION}, + "longPressButtonBehavior", StrId::STR_CAT_CONTROLS), SettingInfo::Enum(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn, {StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH}, "shortPwrBtn", StrId::STR_CAT_CONTROLS), - // --- System --- SettingInfo::Enum(StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeout, {StrId::STR_MIN_1, StrId::STR_MIN_5, StrId::STR_MIN_10, StrId::STR_MIN_15, StrId::STR_MIN_30}, diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 8415020a4a..57eeb46257 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -202,15 +202,14 @@ void EpubReaderActivity::loop() { return; } - const bool skipChapter = !fromTilt && SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipChapterMs; + const bool longPress = !fromTilt && mappedInput.getHeldTime() > skipChapterMs; // Don't skip chapter after screenshot if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) { return; } - if (skipChapter) { - lastPageTurnTime = millis(); + if (longPress && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP) { // We don't want to delete the section mid-render, so grab the semaphore { RenderLock lock(*this); @@ -222,6 +221,15 @@ void EpubReaderActivity::loop() { return; } + if (longPress && SETTINGS.longPressButtonBehavior == SETTINGS.ORIENTATION_CHANGE) { + const uint8_t newOrientation = + nextTriggered ? (SETTINGS.orientation - 1 + SETTINGS.ORIENTATION_COUNT) % SETTINGS.ORIENTATION_COUNT + : (SETTINGS.orientation + 1) % SETTINGS.ORIENTATION_COUNT; + applyOrientation(newOrientation); + requestUpdate(); + return; + } + // No current section, attempt to rerender the book if (!section) { requestUpdate(); diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 8f8eb0730d..13480696b2 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -37,7 +37,7 @@ struct PageTurnResult { }; inline PageTurnResult detectPageTurn(const MappedInputManager& input) { - const bool usePress = !SETTINGS.longPressChapterSkip; + const bool usePress = SETTINGS.longPressButtonBehavior == SETTINGS.OFF; const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward(); const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack(); const bool prev = tiltPrev || (usePress ? (input.wasPressed(MappedInputManager::Button::PageBack) || diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 00dff8f8df..02043265a7 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -82,7 +82,7 @@ void XtcReaderActivity::loop() { } // When long-press chapter skip is disabled, turn pages on press instead of release. - const bool usePressForPageTurn = !SETTINGS.longPressChapterSkip; + const bool usePressForPageTurn = SETTINGS.longPressButtonBehavior == SETTINGS.OFF; const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward(); const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack(); const bool prevTriggered = @@ -114,7 +114,8 @@ void XtcReaderActivity::loop() { } const bool fromTilt = tiltPrev || tiltNext; - const bool skipPages = !fromTilt && SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipPageMs; + const bool skipPages = + !fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP && mappedInput.getHeldTime() > skipPageMs; const int skipAmount = skipPages ? 10 : 1; if (prevTriggered) { From a5fac320ab8060e5f24540e0665a366d37516f75 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Mon, 4 May 2026 08:45:15 -0500 Subject: [PATCH 02/27] refactor: Simplify sort in GfxRenderer::fillPolygon (#1817) ## Summary Small simplification to node sorting algorithm in GfxRenderer::fillPolygon. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- lib/GfxRenderer/GfxRenderer.cpp | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 70e282c568..0fa56b7753 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -5,6 +5,8 @@ #include #include +#include + #include "FontCacheManager.h" const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const { @@ -882,16 +884,8 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi j = i; } - // Sort nodes by X (simple bubble sort, numPoints is small) - for (int i = 0; i < nodes - 1; i++) { - for (int k = i + 1; k < nodes; k++) { - if (nodeX[i] > nodeX[k]) { - int temp = nodeX[i]; - nodeX[i] = nodeX[k]; - nodeX[k] = temp; - } - } - } + // Sort nodes by X + std::sort(nodeX, nodeX + nodes); // Fill between pairs of nodes for (int i = 0; i < nodes - 1; i += 2) { From 6c4ae7c41a4957b8b39245a55f20df2ca6d9d4e6 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Mon, 4 May 2026 08:45:44 -0500 Subject: [PATCH 03/27] refactor: Avoid vector for page turn rates list (#1818) ## Summary Small cleanup to avoid a dynamically allocated static structure for auto page-turn rate values. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- src/activities/reader/EpubReaderActivity.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 57eeb46257..ea6ad2ccbd 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include "CrossPointSettings.h" @@ -31,7 +32,7 @@ namespace { // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() constexpr unsigned long skipChapterMs = 700; // pages per minute, first item is 1 to prevent division by zero if accessed -const std::vector PAGE_TURN_LABELS = {1, 1, 3, 6, 12}; +constexpr int PAGE_TURN_RATES[] = {1, 1, 3, 6, 12}; int clampPercent(int percent) { if (percent < 0) { @@ -469,14 +470,14 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) { } void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption) { - if (selectedPageTurnOption == 0 || selectedPageTurnOption >= PAGE_TURN_LABELS.size()) { + if (selectedPageTurnOption == 0 || selectedPageTurnOption >= std::size(PAGE_TURN_RATES)) { automaticPageTurnActive = false; return; } lastPageTurnTime = millis(); // calculates page turn duration by dividing by number of pages - pageTurnDuration = (1UL * 60 * 1000) / PAGE_TURN_LABELS[selectedPageTurnOption]; + pageTurnDuration = (1UL * 60 * 1000) / PAGE_TURN_RATES[selectedPageTurnOption]; automaticPageTurnActive = true; const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); From a1007a4660697c7e83fec117ce3ab8e6953bc1de Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 5 May 2026 01:34:09 +1000 Subject: [PATCH 04/27] fix: Track block style stack for nested styles (#1582) ## Summary * Add new block style stack to track accumulated styles * When dropping into nested block, apply new styles on last BlockStyle in the stack and push to the back * When leaving block, pop the stack * Fix issue where the image % width always went off screen width, use current container width based on accumulated styles * Bump section cache version to regenerate pages ## Additional Context Fixes https://github.com/crosspoint-reader/crosspoint-reader/pull/1581 more correctly, and includes fix from https://github.com/crosspoint-reader/crosspoint-reader/pull/1580 Consider: ```html

text

text2

``` | Element | Expected | Before #1581 (leaked) | After #1581 (reset) | This PR (style stack) | | --- | --- | --- | --- | --- | | `c3` | 10+20+5 = 35px | 35px | 25px | 35px | | `c4` | 10+5 = 15px | 35px (wrong, leaked c2) | 5px (wrong, lost c1) | 15px | This PR replaces the reset-on-close approach with a proper block style stack. When a block element opens, its resolved style is pushed onto the stack. When it closes, the stack pops back to the parent's style. This correctly accumulates nested margins/padding while preventing style leakage to siblings. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? Yes --------- Co-authored-by: Zach Nelson --- lib/Epub/Epub/Section.cpp | 2 +- lib/Epub/Epub/blocks/BlockStyle.h | 75 ++++++---- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 137 +++++++++++++----- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 1 + 4 files changed, 146 insertions(+), 69 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index a9ecd9549a..d4ddbc6633 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -10,7 +10,7 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -constexpr uint8_t SECTION_FILE_VERSION = 21; +constexpr uint8_t SECTION_FILE_VERSION = 22; constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t); diff --git a/lib/Epub/Epub/blocks/BlockStyle.h b/lib/Epub/Epub/blocks/BlockStyle.h index 4166fceb65..b63b57b615 100644 --- a/lib/Epub/Epub/blocks/BlockStyle.h +++ b/lib/Epub/Epub/blocks/BlockStyle.h @@ -30,42 +30,61 @@ struct BlockStyle { bool textIndentDefined = false; // true if text-indent was explicitly set in CSS bool textAlignDefined = false; // true if text-align was explicitly set in CSS - // Combined horizontal insets (margin + padding) + // Combined insets (margin + padding) [[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; } [[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; } [[nodiscard]] int16_t totalHorizontalInset() const { return leftInset() + rightInset(); } + [[nodiscard]] int16_t topInset() const { return marginTop + paddingTop; } + [[nodiscard]] int16_t bottomInset() const { return marginBottom + paddingBottom; } - // Combine with another block style. Useful for parent -> child styles, where the child style should be - // applied on top of the parent's style to get the combined style. - BlockStyle getCombinedBlockStyle(const BlockStyle& child) const { - BlockStyle combinedBlockStyle; + // Return a copy with bottom margins/padding zeroed out. + [[nodiscard]] BlockStyle withoutBottom() const { + BlockStyle result = *this; + result.marginBottom = 0; + result.paddingBottom = 0; + return result; + } - combinedBlockStyle.marginTop = static_cast(child.marginTop + marginTop); - combinedBlockStyle.marginBottom = static_cast(child.marginBottom + marginBottom); - combinedBlockStyle.marginLeft = static_cast(child.marginLeft + marginLeft); - combinedBlockStyle.marginRight = static_cast(child.marginRight + marginRight); + // Return a copy with bottom margins/padding collapsed (max) with the source's. + // Uses CSS margin collapsing: adjacent parent-child margins resolve to the larger value. + [[nodiscard]] BlockStyle addBottom(const BlockStyle& source) const { + BlockStyle result = *this; + result.marginBottom = std::max(marginBottom, source.marginBottom); + result.paddingBottom = static_cast(paddingBottom + source.paddingBottom); + return result; + } - combinedBlockStyle.paddingTop = static_cast(child.paddingTop + paddingTop); - combinedBlockStyle.paddingBottom = static_cast(child.paddingBottom + paddingBottom); - combinedBlockStyle.paddingLeft = static_cast(child.paddingLeft + paddingLeft); - combinedBlockStyle.paddingRight = static_cast(child.paddingRight + paddingRight); - // Text indent: use child's if defined - if (child.textIndentDefined) { - combinedBlockStyle.textIndent = child.textIndent; - combinedBlockStyle.textIndentDefined = true; - } else { - combinedBlockStyle.textIndent = textIndent; - combinedBlockStyle.textIndentDefined = textIndentDefined; - } - // Text align: use child's if defined - if (child.textAlignDefined) { - combinedBlockStyle.alignment = child.alignment; - combinedBlockStyle.textAlignDefined = true; + enum class CombineAxis : uint8_t { + Horizontal = 1, // margins left/right, padding left/right, text-align, text-indent + Vertical = 2, // margins top/bottom, padding top/bottom + }; + + // Combine this style's properties with a child style along the specified axis. + // Properties on the other axis are kept from the child unchanged. + [[nodiscard]] BlockStyle getCombinedBlockStyle(const BlockStyle& child, CombineAxis axis) const { + BlockStyle result = child; + + if (axis == CombineAxis::Horizontal) { + result.marginLeft = static_cast(child.marginLeft + marginLeft); + result.marginRight = static_cast(child.marginRight + marginRight); + result.paddingLeft = static_cast(child.paddingLeft + paddingLeft); + result.paddingRight = static_cast(child.paddingRight + paddingRight); + if (!child.textIndentDefined && textIndentDefined) { + result.textIndent = textIndent; + result.textIndentDefined = true; + } + if (!child.textAlignDefined && textAlignDefined) { + result.alignment = alignment; + result.textAlignDefined = true; + } } else { - combinedBlockStyle.alignment = alignment; - combinedBlockStyle.textAlignDefined = textAlignDefined; + result.marginTop = std::max(child.marginTop, marginTop); + result.marginBottom = std::max(child.marginBottom, marginBottom); + result.paddingTop = static_cast(child.paddingTop + paddingTop); + result.paddingBottom = static_cast(child.paddingBottom + paddingBottom); } - return combinedBlockStyle; + + return result; } // Create a BlockStyle from CSS style properties, resolving CssLength values to pixels diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index eee24c4949..3c8694af73 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -131,10 +131,13 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { if (currentTextBlock) { // already have a text block running and it is empty - just reuse it if (currentTextBlock->isEmpty()) { - // Merge with existing block style to accumulate CSS styling from parent block elements. - // This handles cases like

text

where the - // div's margin should be preserved, even though it has no direct text content. - currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle)); + // The stack accumulates horizontal margins and text properties from ancestors. + // Vertical margins are per-element and not inherited through the stack, but + // container elements deposit their vertical margins on the empty block when they + // open. Merge those into the new style so the first child in a container inherits + // the container's vertical spacing. + const auto style = currentTextBlock->getBlockStyle(); + currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(blockStyle, BlockStyle::CombineAxis::Vertical)); if (!pendingAnchorId.empty()) { anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); @@ -344,18 +347,29 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* const bool hasCssHeight = imgStyle.hasImageHeight(); const bool hasCssWidth = imgStyle.hasImageWidth(); + // Compute effective container width for percentage-based image sizes. + // If the image is inside a block with horizontal margins/padding (e.g. + //
), percentage widths like width:100% + // should resolve against the container width, not the full viewport. + int containerWidth = self->viewportWidth; + if (self->currentTextBlock) { + const int inset = self->currentTextBlock->getBlockStyle().totalHorizontalInset(); + if (inset > 0 && inset < self->viewportWidth) { + containerWidth = self->viewportWidth - inset; + } + } + if (hasCssHeight && hasCssWidth && dims.width > 0 && dims.height > 0) { // Both CSS height and width set: resolve both, then clamp to viewport preserving requested ratio displayHeight = static_cast( imgStyle.imageHeight.toPixels(emSize, static_cast(self->viewportHeight)) + 0.5f); - displayWidth = static_cast( - imgStyle.imageWidth.toPixels(emSize, static_cast(self->viewportWidth)) + 0.5f); + displayWidth = + static_cast(imgStyle.imageWidth.toPixels(emSize, static_cast(containerWidth)) + 0.5f); if (displayHeight < 1) displayHeight = 1; if (displayWidth < 1) displayWidth = 1; - if (displayWidth > self->viewportWidth || displayHeight > self->viewportHeight) { - float scaleX = (displayWidth > self->viewportWidth) - ? static_cast(self->viewportWidth) / displayWidth - : 1.0f; + if (displayWidth > containerWidth || displayHeight > self->viewportHeight) { + float scaleX = + (displayWidth > containerWidth) ? static_cast(containerWidth) / displayWidth : 1.0f; float scaleY = (displayHeight > self->viewportHeight) ? static_cast(self->viewportHeight) / displayHeight : 1.0f; @@ -380,8 +394,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* static_cast(displayHeight * (static_cast(dims.width) / dims.height) + 0.5f); if (displayWidth < 1) displayWidth = 1; } - if (displayWidth > self->viewportWidth) { - displayWidth = self->viewportWidth; + if (displayWidth > containerWidth) { + displayWidth = containerWidth; // Rescale height to preserve aspect ratio when width is clamped displayHeight = static_cast(displayWidth * (static_cast(dims.height) / dims.width) + 0.5f); @@ -390,10 +404,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (displayWidth < 1) displayWidth = 1; LOG_DBG("EHP", "Display size from CSS height: %dx%d", displayWidth, displayHeight); } else if (hasCssWidth && !hasCssHeight && dims.width > 0 && dims.height > 0) { - // Use CSS width (resolve % against viewport width) and derive height from aspect ratio - displayWidth = static_cast( - imgStyle.imageWidth.toPixels(emSize, static_cast(self->viewportWidth)) + 0.5f); - if (displayWidth > self->viewportWidth) displayWidth = self->viewportWidth; + // Use CSS width (resolve % against container width) and derive height from aspect ratio + displayWidth = + static_cast(imgStyle.imageWidth.toPixels(emSize, static_cast(containerWidth)) + 0.5f); + if (displayWidth > containerWidth) displayWidth = containerWidth; if (displayWidth < 1) displayWidth = 1; displayHeight = static_cast(displayWidth * (static_cast(dims.height) / dims.width) + 0.5f); @@ -407,8 +421,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (displayHeight < 1) displayHeight = 1; LOG_DBG("EHP", "Display size from CSS width: %dx%d", displayWidth, displayHeight); } else { - // Scale to fit viewport while maintaining aspect ratio - int maxWidth = self->viewportWidth; + // Scale to fit container while maintaining aspect ratio + int maxWidth = containerWidth; int maxHeight = self->viewportHeight; float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f; float scaleY = (dims.height > maxHeight) ? (float)maxHeight / dims.height : 1.0f; @@ -429,9 +443,24 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->startNewTextBlock(parentBlockStyle); } + // Apply vertical margins from the container to the image. + // Top margin lives on the empty text block (deposited via vertical merge + // in startNewTextBlock). Bottom margin was stripped by withoutBottom() for + // deferred application at element close, so read it from the stack. + int16_t imageMarginTop = 0; + int16_t imageMarginBottom = 0; + if (self->currentTextBlock && self->currentTextBlock->isEmpty()) { + const auto& bs = self->currentTextBlock->getBlockStyle(); + imageMarginTop = bs.topInset(); + if (self->blockStyleStack.size() > 1) { + imageMarginBottom = self->blockStyleStack.back().bottomInset(); + } + } + // Create page for image - only break if image won't fit remaining space if (self->currentPage && !self->currentPage->elements.empty() && - (self->currentPageNextY + displayHeight > self->viewportHeight)) { + (self->currentPageNextY + imageMarginTop + displayHeight + imageMarginBottom > + self->viewportHeight)) { self->completePageFn(std::move(self->currentPage), self->xpathParagraphIndex); self->completedPageCount++; self->currentPage.reset(new Page()); @@ -449,6 +478,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->currentPageNextY = 0; } + // Apply top margin from container block + self->currentPageNextY += imageMarginTop; + // Create ImageBlock and add to page auto imageBlock = std::make_shared(cachedImagePath, displayWidth, displayHeight); if (!imageBlock) { @@ -462,7 +494,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* return; } self->currentPage->elements.push_back(pageImage); - self->currentPageNextY += displayHeight; + self->currentPageNextY += displayHeight + imageMarginBottom; + + // The image consumed the empty block's accumulated vertical spacing. + // Reset the block so the Vertical merge in startNewTextBlock doesn't + // re-apply the same margins to the next text paragraph. + if (self->currentTextBlock && self->currentTextBlock->isEmpty()) { + BlockStyle resetStyle; + resetStyle.alignment = (self->paragraphAlignment == static_cast(CssTextAlign::None)) + ? CssTextAlign::Justify + : static_cast(self->paragraphAlignment); + self->currentTextBlock->setBlockStyle(resetStyle); + } self->depth += 1; return; @@ -480,7 +523,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* // Fallback to alt text if image processing fails if (!alt.empty()) { alt = "[Image: " + alt + "]"; - self->startNewTextBlock(centeredBlockStyle); + self->startNewTextBlock(self->blockStyleStack.back() + .getCombinedBlockStyle(centeredBlockStyle, BlockStyle::CombineAxis::Horizontal) + .withoutBottom()); self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth); self->depth += 1; self->characterData(userData, alt.c_str(), alt.length()); @@ -570,7 +615,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (self->embeddedStyle && cssStyle.hasTextAlign()) { headerBlockStyle.alignment = cssStyle.textAlign; } - self->startNewTextBlock(headerBlockStyle); + const auto accumulated = + self->blockStyleStack.back().getCombinedBlockStyle(headerBlockStyle, BlockStyle::CombineAxis::Horizontal); + self->blockStyleStack.push_back(accumulated); + self->startNewTextBlock(accumulated.withoutBottom()); self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); self->updateEffectiveInlineStyle(); } else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) { @@ -579,10 +627,13 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* // flush word preceding
to currentTextBlock before calling startNewTextBlock self->flushPartWordBuffer(); } - self->startNewTextBlock(self->currentTextBlock->getBlockStyle()); + self->startNewTextBlock(self->blockStyleStack.back().withoutBottom()); } else { self->currentCssStyle = cssStyle; - self->startNewTextBlock(userAlignmentBlockStyle); + const auto accumulated = self->blockStyleStack.back().getCombinedBlockStyle(userAlignmentBlockStyle, + BlockStyle::CombineAxis::Horizontal); + self->blockStyleStack.push_back(accumulated); + self->startNewTextBlock(accumulated.withoutBottom()); self->updateEffectiveInlineStyle(); if (strcmp(name, "li") == 0) { @@ -975,29 +1026,35 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n self->currentCssStyle.reset(); self->updateEffectiveInlineStyle(); - // Reset alignment on empty text blocks to prevent stale alignment from bleeding - // into the next sibling element. This fixes issue #1026 where an empty

(default - // Center) followed by an image-only

causes Center to persist through the chain - // of empty block reuse into subsequent text paragraphs. - // Margins/padding are preserved so parent element spacing still accumulates correctly. - if (self->currentTextBlock && self->currentTextBlock->isEmpty()) { - auto style = self->currentTextBlock->getBlockStyle(); - style.textAlignDefined = false; - style.alignment = (self->paragraphAlignment == static_cast(CssTextAlign::None)) - ? CssTextAlign::Justify - : static_cast(self->paragraphAlignment); - self->currentTextBlock->setBlockStyle(style); + // br is self-closing and not a container — it doesn't push/pop the stack. + if (strcmp(name, "br") != 0 && self->blockStyleStack.size() > 1) { + // Apply closing element's bottom margin to the current text block so + // container spacing appears after the element's content (on the last child), + // not on the first child via the empty-block merge in startNewTextBlock. + if (self->currentTextBlock) { + const auto style = self->currentTextBlock->getBlockStyle(); + self->currentTextBlock->setBlockStyle(style.addBottom(self->blockStyleStack.back())); + } + self->blockStyleStack.pop_back(); } } } bool ChapterHtmlSlimParser::parseAndBuildPages() { + // Initialize block style stack with a root entry representing "no ancestor block elements". + // The user's paragraph alignment is set as the default so child elements without explicit + // text-align inherit it correctly through getCombinedBlockStyle. + BlockStyle rootBlockStyle; + rootBlockStyle.alignment = (this->paragraphAlignment == static_cast(CssTextAlign::None)) + ? CssTextAlign::Justify + : static_cast(this->paragraphAlignment); + blockStyleStack.clear(); + blockStyleStack.reserve(8); + blockStyleStack.push_back(rootBlockStyle); + auto paragraphAlignmentBlockStyle = BlockStyle(); paragraphAlignmentBlockStyle.textAlignDefined = true; - // Resolve None sentinel to Justify for initial block (no CSS context yet) - const auto align = (this->paragraphAlignment == static_cast(CssTextAlign::None)) - ? CssTextAlign::Justify - : static_cast(this->paragraphAlignment); + const auto align = rootBlockStyle.alignment; paragraphAlignmentBlockStyle.alignment = align; startNewTextBlock(paragraphAlignmentBlockStyle); diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 8e61b5acd2..9f4cf2b58d 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -62,6 +62,7 @@ class ChapterHtmlSlimParser { bool hasUnderline = false, underline = false; }; std::vector inlineStyleStack; + std::vector blockStyleStack; // accumulated block styles from open ancestor elements CssStyle currentCssStyle; bool effectiveBold = false; bool effectiveItalic = false; From e026bcb9dce3af3a992662d3c480dc1843c056bc Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 5 May 2026 01:34:49 +1000 Subject: [PATCH 05/27] fix: Track block style stack for nested styles (#1582) ## Summary * Add new block style stack to track accumulated styles * When dropping into nested block, apply new styles on last BlockStyle in the stack and push to the back * When leaving block, pop the stack * Fix issue where the image % width always went off screen width, use current container width based on accumulated styles * Bump section cache version to regenerate pages ## Additional Context Fixes https://github.com/crosspoint-reader/crosspoint-reader/pull/1581 more correctly, and includes fix from https://github.com/crosspoint-reader/crosspoint-reader/pull/1580 Consider: ```html

text

text2

``` | Element | Expected | Before #1581 (leaked) | After #1581 (reset) | This PR (style stack) | | --- | --- | --- | --- | --- | | `c3` | 10+20+5 = 35px | 35px | 25px | 35px | | `c4` | 10+5 = 15px | 35px (wrong, leaked c2) | 5px (wrong, lost c1) | 15px | This PR replaces the reset-on-close approach with a proper block style stack. When a block element opens, its resolved style is pushed onto the stack. When it closes, the stack pops back to the parent's style. This correctly accumulates nested margins/padding while preventing style leakage to siblings. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? Yes --------- Co-authored-by: Zach Nelson From 333286acfc0c5cd8b4a85d107dab422b60e2af6f Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Mon, 4 May 2026 12:41:27 -0500 Subject: [PATCH 06/27] refactor: Use std::size instead of sizeof/sizeof (#1819) ## Summary Simplify code calculating compile-time array sizes with `sizeof(array)/sizeof(element)` to use `std::size`. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**PARTIALLY**_ --- lib/Epub/Epub/htmlEntities.cpp | 7 +-- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 62 ++++++++----------- src/network/CrossPointWebServer.cpp | 19 +++--- src/network/WebDAVHandler.cpp | 11 ++-- 4 files changed, 42 insertions(+), 57 deletions(-) diff --git a/lib/Epub/Epub/htmlEntities.cpp b/lib/Epub/Epub/htmlEntities.cpp index 6fdcb71cde..2fd35b8a77 100644 --- a/lib/Epub/Epub/htmlEntities.cpp +++ b/lib/Epub/Epub/htmlEntities.cpp @@ -4,6 +4,7 @@ #include "htmlEntities.h" #include +#include struct EntityPair { const char* key; @@ -62,8 +63,6 @@ static constexpr EntityPair ENTITY_LOOKUP[] = { {"¥", "¥"}, {"ÿ", "ÿ"}, {"ζ", "ζ"}, {"‍", "\u200D"}, {"‌", "\u200C"}, }; -static const size_t ENTITY_LOOKUP_COUNT = sizeof(ENTITY_LOOKUP) / sizeof(ENTITY_LOOKUP[0]); - // Verify the table is sorted at compile time. static constexpr int constexprStrcmp(const char* a, const char* b) { for (size_t i = 0;; i++) { @@ -73,7 +72,7 @@ static constexpr int constexprStrcmp(const char* a, const char* b) { } static constexpr bool isTableSorted() { - for (size_t i = 1; i < ENTITY_LOOKUP_COUNT; i++) { + for (size_t i = 1; i < std::size(ENTITY_LOOKUP); i++) { if (constexprStrcmp(ENTITY_LOOKUP[i - 1].key, ENTITY_LOOKUP[i].key) >= 0) return false; } return true; @@ -85,7 +84,7 @@ const char* lookupHtmlEntity(const char* entity, size_t len) { if (entity == nullptr || len == 0) return nullptr; size_t lo = 0; - size_t hi = ENTITY_LOOKUP_COUNT; + size_t hi = std::size(ENTITY_LOOKUP); while (lo < hi) { const size_t mid = lo + (hi - lo) / 2; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 3c8694af73..92f0280a6e 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -8,42 +8,30 @@ #include #include +#include + #include "../../Epub.h" #include "../Page.h" #include "../converters/ImageDecoderFactory.h" #include "../converters/ImageToFramebufferDecoder.h" #include "../htmlEntities.h" -const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; -constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); - // Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB constexpr size_t PARSE_BUFFER_SIZE = 1024; -const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; -constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); - -const char* BOLD_TAGS[] = {"b", "strong"}; -constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]); - -const char* ITALIC_TAGS[] = {"i", "em"}; -constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]); - -const char* UNDERLINE_TAGS[] = {"u", "ins"}; -constexpr int NUM_UNDERLINE_TAGS = sizeof(UNDERLINE_TAGS) / sizeof(UNDERLINE_TAGS[0]); - -const char* IMAGE_TAGS[] = {"img"}; -constexpr int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]); - -const char* SKIP_TAGS[] = {"head"}; -constexpr int NUM_SKIP_TAGS = sizeof(SKIP_TAGS) / sizeof(SKIP_TAGS[0]); +constexpr const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; +constexpr const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; +constexpr const char* BOLD_TAGS[] = {"b", "strong"}; +constexpr const char* ITALIC_TAGS[] = {"i", "em"}; +constexpr const char* UNDERLINE_TAGS[] = {"u", "ins"}; +constexpr const char* IMAGE_TAGS[] = {"img"}; +constexpr const char* SKIP_TAGS[] = {"head"}; bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; } -// given the start and end of a tag, check to see if it matches a known tag -bool matches(const char* tag_name, const char* possible_tags[], const int possible_tag_count) { - for (int i = 0; i < possible_tag_count; i++) { +bool matches(const char* tag_name, const char* const* possible_tags, size_t count) { + for (size_t i = 0; i < count; i++) { if (strcmp(tag_name, possible_tags[i]) == 0) { return true; } @@ -70,7 +58,7 @@ bool isInternalEpubLink(const char* href) { } bool isHeaderOrBlock(const char* name) { - return matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS); + return matches(name, HEADER_TAGS, std::size(HEADER_TAGS)) || matches(name, BLOCK_TAGS, std::size(BLOCK_TAGS)); } bool isTableStructuralTag(const char* name) { @@ -271,7 +259,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* return; } - if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { + if (matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS))) { std::string src; std::string alt; if (atts != nullptr) { @@ -541,7 +529,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } } - if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) { + if (matches(name, SKIP_TAGS, std::size(SKIP_TAGS))) { // start skip self->skipUntilDepth = self->depth; self->depth += 1; @@ -608,7 +596,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* const auto userAlignmentBlockStyle = BlockStyle::fromCssStyle( cssStyle, emSize, static_cast(self->paragraphAlignment), self->viewportWidth); - if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) { + if (matches(name, HEADER_TAGS, std::size(HEADER_TAGS))) { self->currentCssStyle = cssStyle; auto headerBlockStyle = BlockStyle::fromCssStyle(cssStyle, emSize, CssTextAlign::Center, self->viewportWidth); headerBlockStyle.textAlignDefined = true; @@ -621,7 +609,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->startNewTextBlock(accumulated.withoutBottom()); self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); self->updateEffectiveInlineStyle(); - } else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) { + } else if (matches(name, BLOCK_TAGS, std::size(BLOCK_TAGS))) { if (strcmp(name, "br") == 0) { if (self->partWordBufferIndex > 0) { // flush word preceding
to currentTextBlock before calling startNewTextBlock @@ -640,7 +628,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); } } - } else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) { + } else if (matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS))) { // Flush buffer before style change so preceding text gets current style if (self->partWordBufferIndex > 0) { self->flushPartWordBuffer(); @@ -662,7 +650,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } self->inlineStyleStack.push_back(entry); self->updateEffectiveInlineStyle(); - } else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { + } else if (matches(name, BOLD_TAGS, std::size(BOLD_TAGS))) { // Flush buffer before style change so preceding text gets current style if (self->partWordBufferIndex > 0) { self->flushPartWordBuffer(); @@ -684,7 +672,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } self->inlineStyleStack.push_back(entry); self->updateEffectiveInlineStyle(); - } else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) { + } else if (matches(name, ITALIC_TAGS, std::size(ITALIC_TAGS))) { // Flush buffer before style change so preceding text gets current style if (self->partWordBufferIndex > 0) { self->flushPartWordBuffer(); @@ -946,12 +934,12 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n // Flush buffer with current style BEFORE any style changes if (self->partWordBufferIndex > 0) { // Flush if style will change OR if we're closing a block/structural element - const bool isInlineTag = - !headerOrBlockTag && !tableStructuralTag && !matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) && self->depth != 1; - const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || - matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || - matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || tableStructuralTag || - matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1; + const bool isInlineTag = !headerOrBlockTag && !tableStructuralTag && + !matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) && self->depth != 1; + const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, std::size(BOLD_TAGS)) || + matches(name, ITALIC_TAGS, std::size(ITALIC_TAGS)) || + matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS)) || tableStructuralTag || + matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) || self->depth == 1; if (shouldFlush) { self->flushPartWordBuffer(); diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 0ad2f1ab9c..2346f2217e 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -22,8 +22,7 @@ namespace { // Folders/files to hide from the web interface file browser // Note: Items starting with "." are automatically hidden -const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; -constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); +constexpr const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678}; constexpr uint16_t LOCAL_UDP_PORT = 8134; @@ -75,8 +74,8 @@ bool isProtectedItemName(const String& name) { if (name.startsWith(".")) { return true; } - for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { - if (name.equals(HIDDEN_ITEMS[i])) { + for (const auto* item : HIDDEN_ITEMS) { + if (name.equals(item)) { return true; } } @@ -389,8 +388,8 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function