Merge branch 'master' of https://github.com/jpirnay/crosspoint-reader into feat-rendering-perf

This commit is contained in:
jpirnay
2026-04-24 15:47:47 +02:00
70 changed files with 3665 additions and 487 deletions
+5 -2
View File
@@ -2,9 +2,12 @@
#include <cstring>
#define FOOTNOTE_NUMBER_LEN 32
#define FOOTNOTE_HREF_LEN 96
struct FootnoteEntry {
char number[24];
char href[64];
char number[FOOTNOTE_NUMBER_LEN];
char href[FOOTNOTE_HREF_LEN];
FootnoteEntry() {
number[0] = '\0';
+1 -1
View File
@@ -12,7 +12,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) + // SECTION_FILE_VERSION
sizeof(int) + // fontId
sizeof(float) + // lineCompression
+25 -30
View File
@@ -170,6 +170,17 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
nextWordContinues = false;
}
// Emit the current page, keeping paragraphLutPerPage and completedPageCount in lockstep.
// Callers must ensure currentPage is non-null and carries content; the helper resets
// currentPage to a fresh Page and zeroes currentPageNextY so the caller can keep building.
void ChapterHtmlSlimParser::emitPage(uint32_t xhtmlByteOffset) {
paragraphLutPerPage.push_back({xhtmlByteOffset, xpathParagraphIndex});
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
currentPageNextY = 0;
}
// start a new text block if needed
void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
nextWordContinues = false; // New block = new paragraph, no continuation
@@ -198,10 +209,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
if (!pendingAnchorId.empty()) {
if (std::find(tocAnchors.begin(), tocAnchors.end(), pendingAnchorId) != tocAnchors.end()) {
if (currentPage && !currentPage->elements.empty()) {
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
currentPageNextY = 0;
emitPage(lastBodyChildByteOffset);
}
}
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
@@ -218,10 +226,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
if (!pendingAnchorId.empty() &&
std::find(tocAnchors.begin(), tocAnchors.end(), pendingAnchorId) != tocAnchors.end()) {
if (currentPage && !currentPage->elements.empty()) {
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
currentPageNextY = 0;
emitPage(lastBodyChildByteOffset);
}
}
// Record deferred anchor after previous block is flushed (and any TOC page break)
@@ -593,15 +598,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
(self->currentPageNextY + totalImageHeightWithSpacing > self->viewportHeight)) {
LOG_DBG("EHP", "Image page break: currentY=%d needed=%d viewportH=%d", self->currentPageNextY,
totalImageHeightWithSpacing, self->viewportHeight);
self->paragraphLutPerPage.push_back({self->lastBodyChildByteOffset, self->xpathParagraphIndex});
self->completePageFn(std::move(self->currentPage));
self->completedPageCount++;
self->currentPage.reset(new Page());
self->emitPage(self->lastBodyChildByteOffset);
if (!self->currentPage) {
LOG_ERR("EHP", "Failed to create new page");
return;
}
self->currentPageNextY = 0;
} else if (!self->currentPage) {
self->currentPage.reset(new Page());
if (!self->currentPage) {
@@ -742,9 +743,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
self->insideFootnoteLink = true;
self->footnoteLinkDepth = self->depth;
strncpy(self->currentFootnoteLinkHref, href, sizeof(self->currentFootnoteLinkHref) - 1);
self->currentFootnoteLinkHref[sizeof(self->currentFootnoteLinkHref) - 1] = '\0';
self->currentFootnoteLinkText[0] = '\0';
strncpy(self->currentFootnote.href, href, sizeof(self->currentFootnote.href) - 1);
self->currentFootnote.href[sizeof(self->currentFootnote.href) - 1] = '\0';
self->currentFootnote.number[0] = '\0';
self->currentFootnoteLinkTextLen = 0;
// Apply underline style to visually indicate the link
@@ -985,11 +986,11 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
}
// Extract footnote link text
for (int i = start; (self->currentFootnoteLinkTextLen < sizeof(self->currentFootnoteLinkText) - 1) && (i <= end);
for (int i = start; (self->currentFootnoteLinkTextLen < sizeof(self->currentFootnote.number) - 1) && (i <= end);
++i) {
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen++] = s[i];
self->currentFootnote.number[self->currentFootnoteLinkTextLen++] = s[i];
}
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen] = '\0';
self->currentFootnote.number[self->currentFootnoteLinkTextLen] = '\0';
}
for (int i = 0; i < len; i++) {
@@ -1220,11 +1221,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
// Closing a footnote link — create entry from collected text and href
if (self->insideFootnoteLink && self->depth == self->footnoteLinkDepth) {
if (self->currentFootnoteLinkText[0] != '\0' && self->currentFootnoteLinkHref[0] != '\0') {
if (self->currentFootnote.number[0] != '\0' && self->currentFootnote.href[0] != '\0') {
FootnoteEntry entry;
strncpy(entry.number, self->currentFootnoteLinkText, sizeof(entry.number) - 1);
strncpy(entry.number, self->currentFootnote.number, sizeof(entry.number) - 1);
entry.number[sizeof(entry.number) - 1] = '\0';
strncpy(entry.href, self->currentFootnoteLinkHref, sizeof(entry.href) - 1);
strncpy(entry.href, self->currentFootnote.href, sizeof(entry.href) - 1);
entry.href[sizeof(entry.href) - 1] = '\0';
int wordIndex =
self->wordsExtractedInBlock + (self->currentTextBlock ? static_cast<int>(self->currentTextBlock->size()) : 0);
@@ -1423,9 +1424,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
paragraphLutPerPage.push_back({0u, xpathParagraphIndex}); // post-parse: no byte offset available
completePageFn(std::move(currentPage));
completedPageCount++;
emitPage(0u); // post-parse: no byte offset available
currentPage.reset();
currentTextBlock.reset();
}
@@ -1444,11 +1443,7 @@ ParsedText::LineProcessResult ChapterHtmlSlimParser::addLineToPage(std::shared_p
}
if (currentPageNextY + lineHeight > viewportHeight) {
paragraphLutPerPage.push_back({lastBodyChildByteOffset, xpathParagraphIndex});
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
currentPageNextY = 0;
emitPage(lastBodyChildByteOffset);
}
const bool noRoomForAnotherLine =
@@ -113,9 +113,8 @@ class ChapterHtmlSlimParser {
// Footnote link tracking
bool insideFootnoteLink = false;
int footnoteLinkDepth = -1;
char currentFootnoteLinkText[24] = {};
FootnoteEntry currentFootnote = {};
int currentFootnoteLinkTextLen = 0;
char currentFootnoteLinkHref[64] = {};
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
int wordsExtractedInBlock = 0;
@@ -128,6 +127,10 @@ class ChapterHtmlSlimParser {
void startNewTextBlock(const BlockStyle& blockStyle);
void flushPartWordBuffer();
void makePages();
// Emit currentPage to the consumer while keeping paragraphLutPerPage and completedPageCount
// in lockstep. Every page break MUST go through this helper; open-coded completePageFn
// calls risk desynchronising paragraphLutPerPage and failing the size check in Section.cpp.
void emitPage(uint32_t xhtmlByteOffset);
// XML callbacks
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void XMLCALL characterData(void* userData, const XML_Char* s, int len);
+8
View File
@@ -152,6 +152,7 @@ STR_DST_ACTIVE: "DST: active"
STR_DST_INACTIVE: "DST: inactive"
STR_DST_UNKNOWN: "DST: unknown"
STR_REFRESH_FREQ: "Refresh Frequency"
STR_REFRESH_AFTER_IMAGE_PAGES: "Refresh after image pages"
STR_KOREADER_SYNC: "KOReader Sync"
STR_CHECK_UPDATES: "Check for updates"
STR_LANGUAGE: "Language"
@@ -380,6 +381,12 @@ STR_FOOTNOTES: "Footnotes"
STR_NO_FOOTNOTES: "No footnotes on this page"
STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Take screenshot"
STR_ADD_SERVER: "Add Server"
STR_SERVER_NAME: "Server Name"
STR_NO_SERVERS: "No OPDS servers configured"
STR_DELETE_SERVER: "Delete Server"
STR_DELETE_CONFIRM: "Delete this server?"
STR_OPDS_SERVERS: "OPDS Servers"
STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: "
STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"
STR_WEATHER: "Weather"
@@ -511,6 +518,7 @@ STR_IMAGE_DISPLAY_GRAYSCALE: ">> Gray"
STR_WEATHER_MOON_INFO: "Moon"
STR_WEATHER_SUN_INFO: "Sun"
STR_READER_BOOKMARKS: "Bookmarks & Footnotes"
STR_READER_OVERRIDES: "Book-specific overrides"
STR_READER_UTILS: "Helper"
STR_READER_TOOLS: "Tools"
STR_READER_NAVIGATION: "Navigation"
+1 -1
View File
@@ -318,7 +318,7 @@ STR_UPLOAD: "Carica"
STR_BOOK_S_STYLE: "Stile libro"
STR_EMBEDDED_STYLE: "Stile integrato dell'epub"
STR_OPDS_SERVER_URL: "Server OPDS"
STR_FOOTNOTES: "Note piè pagina"
STR_FOOTNOTES: "Note a piè pagina"
STR_NO_FOOTNOTES: "Nessuna nota in questa pagina"
STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Screenshot"
+2 -2
View File
@@ -316,8 +316,8 @@ STR_FOOTNOTES: "Pie de página"
STR_NO_FOOTNOTES: "No hay notas al pie de esta página"
STR_LINK: "[enlace]"
STR_SCREENSHOT_BUTTON: "Tomar captura de pantalla"
STR_AUTO_TURN_ENABLED: "Paso automático de páginas: "
STR_AUTO_TURN_PAGES_PER_MIN: "Paso automático de páginas (pág./min.)"
STR_AUTO_TURN_ENABLED: "Avance activado: "
STR_AUTO_TURN_PAGES_PER_MIN: "Avance auto. (pág./min)"
STR_REGISTER: "Registrarse"
STR_REGISTERING: "Registrando..."
STR_REGISTER_SUCCESS: "¡Cuenta creada correctamente!"
+17 -2
View File
@@ -2,7 +2,7 @@ _language_name: "Svenska"
_language_code: "SV"
_order: "7"
STR_CROSSPOINT: "Crosspoint"
STR_CROSSPOINT: "CrossPoint"
STR_BOOTING: "STARTAR"
STR_SLEEPING: "VILA"
STR_ENTERING_SLEEP: "Går i vila"
@@ -135,7 +135,7 @@ STR_PREV_NEXT: "Förra/Nästa"
STR_NEXT_PREV: "Nästa/Förra"
STR_BOOKERLY: "Bookerly"
STR_NOTO_SANS: "Noto Sans"
STR_OPEN_DYSLEXIC: "Öppen dyslektisk"
STR_OPEN_DYSLEXIC: "Öppen Dyslexic"
STR_SMALL: "Liten"
STR_MEDIUM: "Medium"
STR_LARGE: "Stor"
@@ -290,3 +290,18 @@ STR_LINK: "[länk]"
STR_SCREENSHOT_BUTTON: "Ta en skärmdump"
STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: "
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)"
STR_KB_HINT_MOVE_CURSOR: "Tryck VÄNSTER eller HÖGER för att flytta markören"
STR_KB_HINT_RETURN_CURSOR: "Tryck VÄNSTER för att återgå till markörpositionen"
STR_KB_HINT_HIDE_PASSWORD: "Håll HÖGER och tryck sedan på [***] för att dölja lösenordet"
STR_KB_HINT_SHOW_PASSWORD: "Håll HÖGER och tryck sedan på [abc] för att visa lösenordet"
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Tryck på [***] för att dölja lösenordet"
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Tryck på [abc] för att visa lösenordet"
STR_KB_HINT_EDIT_ENTRY: "Håll UPP för att redigera fältet"
STR_KB_TIPS: "Tips:"
STR_KB_HINT_RETURN_KEYBOARD: "Tryck NER för att återgå till tangentbordet"
STR_KB_HINT_EXIT_URL_MODE: "Tryck på ABC för att avsluta URL-läget"
STR_KB_HINT_CLEAR_TEXT: "Håll DEL för att rensa all text"
STR_KB_HINT_SECONDARY_CHAR: "Håll VÄLJ för sekundärt tecken"
STR_KB_HINT_UPPER_SECONDARY: "Håll VÄLJ för VERSALER eller sekundärt tecken"
STR_KB_HINT_LOWER_SECONDARY: "Håll VÄLJ för gemener eller sekundärt tecken"
STR_KB_HINT_URL_SNIPPETS: "Tryck på URL för URL-fragment"
+27 -38
View File
@@ -247,6 +247,15 @@ void resetSessionClientForRetry() {
}
}
void applyAuthHeaders(esp_http_client_handle_t client) {
esp_http_client_set_header(client, "Accept", "application/vnd.koreader.v1+json");
esp_http_client_set_header(client, "x-auth-user", KOREADER_STORE.getUsername().c_str());
esp_http_client_set_header(client, "x-auth-key", KOREADER_STORE.getMd5Password().c_str());
std::string credentials = KOREADER_STORE.getUsername() + ":" + KOREADER_STORE.getPassword();
esp_http_client_set_header(client, "Authorization", ("Basic " + base64Encode(credentials)).c_str());
}
// Create configured esp_http_client with small TLS buffers
esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf,
esp_http_client_method_t method = HTTP_METHOD_GET) {
@@ -256,14 +265,7 @@ esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf,
esp_http_client_set_url(g_sessionClient, url);
esp_http_client_set_method(g_sessionClient, method);
// KOSync auth headers
esp_http_client_set_header(g_sessionClient, "Accept", "application/vnd.koreader.v1+json");
esp_http_client_set_header(g_sessionClient, "x-auth-user", KOREADER_STORE.getUsername().c_str());
esp_http_client_set_header(g_sessionClient, "x-auth-key", KOREADER_STORE.getMd5Password().c_str());
std::string credentials = KOREADER_STORE.getUsername() + ":" + KOREADER_STORE.getPassword();
std::string authHeader = "Basic " + base64Encode(credentials);
esp_http_client_set_header(g_sessionClient, "Authorization", authHeader.c_str());
applyAuthHeaders(g_sessionClient);
return g_sessionClient;
}
@@ -285,15 +287,7 @@ esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf,
esp_http_client_handle_t client = esp_http_client_init(&config);
if (!client) return nullptr;
// KOSync auth headers
esp_http_client_set_header(client, "Accept", "application/vnd.koreader.v1+json");
esp_http_client_set_header(client, "x-auth-user", KOREADER_STORE.getUsername().c_str());
esp_http_client_set_header(client, "x-auth-key", KOREADER_STORE.getMd5Password().c_str());
// HTTP Basic Auth for Calibre-Web-Automated compatibility
std::string credentials = KOREADER_STORE.getUsername() + ":" + KOREADER_STORE.getPassword();
std::string authHeader = "Basic " + base64Encode(credentials);
esp_http_client_set_header(client, "Authorization", authHeader.c_str());
applyAuthHeaders(client);
if (g_keepSessionOpen) {
g_sessionClient = client;
@@ -303,6 +297,13 @@ esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf,
}
} // namespace
// Returns true if credentials are present; logs and returns false otherwise.
static inline bool hasCredentials() {
if (KOREADER_STORE.hasCredentials()) return true;
LOG_INF("KOSync", "No credentials configured");
return false;
}
void KOReaderSyncClient::beginPersistentSession() {
g_keepSessionOpen = true;
clearResponseBuffer(&g_sessionResponseBuf);
@@ -318,16 +319,13 @@ void KOReaderSyncClient::endPersistentSession() {
}
KOReaderSyncClient::Error KOReaderSyncClient::registerUser() {
if (!KOREADER_STORE.hasCredentials()) {
LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS;
}
if (!hasCredentials()) return NO_CREDENTIALS;
beginRequest("register");
if (!checkHeapForTls()) return NETWORK_ERROR;
std::string url = KOREADER_STORE.getBaseUrl() + "/users/create";
LOG_DBG("KOSync", "Registering user: %s (heap: %u, contig: %u)", url.c_str(), lastHeapAtFailure,
LOG_INF("KOSync", "Registering user: %s (heap: %u, contig: %u)", url.c_str(), lastHeapAtFailure,
lastContigHeapAtFailure);
JsonDocument doc;
@@ -390,10 +388,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::registerUser() {
}
KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
if (!KOREADER_STORE.hasCredentials()) {
LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS;
}
if (!hasCredentials()) return NO_CREDENTIALS;
beginRequest("auth");
if (!checkHeapForTls()) return NETWORK_ERROR;
@@ -436,10 +431,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
KOReaderProgress& outProgress) {
if (!KOREADER_STORE.hasCredentials()) {
LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS;
}
if (!hasCredentials()) return NO_CREDENTIALS;
beginRequest("get progress");
if (!checkHeapForTls()) return NETWORK_ERROR;
@@ -522,7 +514,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
if (doc["progress"].isNull()) {
std::string jsonDump;
serializeJson(doc, jsonDump);
LOG_DBG("KOSync", "Empty progress payload — treating as not found | payload=%s", jsonDump.c_str());
LOG_INF("KOSync", "Empty progress payload — treating as not found | payload=%s", jsonDump.c_str());
return NOT_FOUND;
}
@@ -533,23 +525,20 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
outProgress.deviceId = doc["device_id"].as<std::string>();
outProgress.timestamp = doc["timestamp"].as<int64_t>();
LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
LOG_INF("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
return OK;
}
if (httpCode == 401) return AUTH_FAILED;
if (httpCode == 404) {
LOG_DBG("KOSync", "GET progress returned 404 for %s - treating as NOT_FOUND", url.c_str());
LOG_INF("KOSync", "GET progress returned 404 for %s - treating as NOT_FOUND", url.c_str());
return NOT_FOUND;
}
return SERVER_ERROR;
}
KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgress& progress) {
if (!KOREADER_STORE.hasCredentials()) {
LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS;
}
if (!hasCredentials()) return NO_CREDENTIALS;
beginRequest("update progress");
if (!checkHeapForTls()) return NETWORK_ERROR;
@@ -569,7 +558,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
std::string body;
serializeJson(doc, body);
LOG_DBG("KOSync", "Request body: %s", body.c_str());
LOG_INF("KOSync", "Request body: %s", body.c_str());
ResponseBuffer buf;
ResponseBuffer* activeBuf = effectiveResponseBuffer(&buf);
+5 -18
View File
@@ -57,24 +57,11 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, c
// Calculate overall book progress (0.0-1.0)
result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress);
// Generate XPath for the current position.
// When we have a paragraph index from the section LUT, target that specific <p> element
// directly — this produces a structurally precise full-ancestry path even for chapters
// where paragraphs are nested inside divs/sections. Fall back to the progress-based
// scan (which works for any content) when no paragraph index is available.
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
// When a seek hint is set, the LUT entry's paragraphIndex equals pos.paragraphIndex
// (both describe the same page). The byte offset now points at the body-child element
// that was current at the page break, so re-parsing from there will re-encounter that
// paragraph — seed startParagraphCount with paragraphIndex-1 to avoid double counting.
const uint16_t startCount =
pos.xhtmlSeekHint > 0 && pos.paragraphIndex > 0 ? static_cast<uint16_t>(pos.paragraphIndex - 1) : 0;
result.xpath = ChapterXPathIndexer::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex,
pos.xhtmlSeekHint, startCount);
}
if (result.xpath.empty()) {
result.xpath = ChapterXPathIndexer::findXPathForProgress(epub, pos.spineIndex, intraSpineProgress);
}
// Generate XPath for the current position via byte-offset scan. Targeting the
// paragraph LUT entry instead would snap to the start of the paragraph the user
// is inside, which causes pulled positions to land at the start of the chapter
// when an opening paragraph spans many pages.
result.xpath = ChapterXPathIndexer::findXPathForProgress(epub, pos.spineIndex, intraSpineProgress);
if (result.xpath.empty()) {
result.xpath = generateXPath(pos.spineIndex);
}
+368
View File
@@ -0,0 +1,368 @@
#include "MdParser.h"
#include <cctype>
namespace MdParser {
static EpdFontFamily::Style combineFlags(bool bold, bool italic) {
if (bold && italic) return EpdFontFamily::BOLD_ITALIC;
if (bold) return EpdFontFamily::BOLD;
if (italic) return EpdFontFamily::ITALIC;
return EpdFontFamily::REGULAR;
}
static constexpr int TAB_WIDTH = 4;
static std::string trimLeft(const std::string& s) {
size_t i = 0;
while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) i++;
return s.substr(i);
}
static uint8_t parseListIndentLevel(size_t leadingSpaces) {
// Top-level list markers may be preceded by up to 3 spaces.
// Nested list items require at least 4 spaces before the marker.
if (leadingSpaces < TAB_WIDTH) {
return 0;
}
return static_cast<uint8_t>((leadingSpaces - TAB_WIDTH) / TAB_WIDTH + 1);
}
static bool isWordChar(char c) { return std::isalnum(static_cast<unsigned char>(c)) || c == '_'; }
static bool isUnderscoreEmphasis(const std::string& text, size_t pos, size_t count) {
if (pos == 0 || pos + count >= text.size()) {
return true;
}
const char before = text[pos - 1];
const char after = text[pos + count];
return !(isWordChar(before) && isWordChar(after));
}
static bool isHorizontalRuleLine(const std::string& line) {
// Must be at least 3 chars of the same marker (-, *, _) with optional spaces
if (line.size() < 3) return false;
char marker = 0;
int count = 0;
for (char c : line) {
if (c == ' ' || c == '\t') continue;
if (c == '-' || c == '*' || c == '_') {
if (marker == 0) marker = c;
if (c != marker) return false;
count++;
} else {
return false;
}
}
return count >= 3;
}
std::vector<Span> parseInline(const std::string& text) {
std::vector<Span> spans;
std::string current;
bool bold = false;
bool italic = false;
size_t i = 0;
auto emitSpan = [&]() {
if (!current.empty()) {
spans.push_back({std::move(current), combineFlags(bold, italic)});
current.clear();
}
};
char boldMarker = 0;
char italicMarker = 0;
while (i < text.size()) {
char c = text[i];
// Escaped character
if (c == '\\' && i + 1 < text.size()) {
char next = text[i + 1];
if (next == '*' || next == '_' || next == '`' || next == '[' || next == '!' || next == '\\') {
current += next;
i += 2;
continue;
}
}
// *** or ___ — toggle both bold and italic when marker matches the current open markers
if ((c == '*' || c == '_') && i + 2 < text.size() && text[i + 1] == c && text[i + 2] == c) {
if (c == '_' && !isUnderscoreEmphasis(text, i, 3)) {
current.append(3, c);
i += 3;
continue;
}
emitSpan();
if (bold && italic && boldMarker == c && italicMarker == c) {
bold = false;
italic = false;
boldMarker = 0;
italicMarker = 0;
} else {
bold = true;
italic = true;
boldMarker = c;
italicMarker = c;
}
i += 3;
continue;
}
// ** or __ — toggle bold
if ((c == '*' || c == '_') && i + 1 < text.size() && text[i + 1] == c) {
if (c == '_' && !isUnderscoreEmphasis(text, i, 2)) {
current.append(2, c);
i += 2;
continue;
}
emitSpan();
if (bold && boldMarker == c) {
bold = false;
boldMarker = 0;
} else {
bold = true;
boldMarker = c;
}
i += 2;
continue;
}
// * or _ — toggle italic
if (c == '*' || c == '_') {
if (c == '_' && !isUnderscoreEmphasis(text, i, 1)) {
current.push_back(c);
i += 1;
continue;
}
emitSpan();
if (italic && italicMarker == c) {
italic = false;
italicMarker = 0;
} else {
italic = true;
italicMarker = c;
}
i += 1;
continue;
}
// Backtick code span — strip backticks, render as regular
if (c == '`') {
size_t end = text.find('`', i + 1);
if (end != std::string::npos) {
emitSpan();
spans.push_back({text.substr(i + 1, end - i - 1), EpdFontFamily::REGULAR});
i = end + 1;
continue;
}
current += c;
i++;
continue;
}
// Image ![alt](url) — show [alt]
if (c == '!' && i + 1 < text.size() && text[i + 1] == '[') {
size_t closeBracket = text.find(']', i + 2);
if (closeBracket != std::string::npos && closeBracket + 1 < text.size() && text[closeBracket + 1] == '(') {
size_t closeParen = text.find(')', closeBracket + 2);
if (closeParen != std::string::npos) {
std::string alt = text.substr(i + 2, closeBracket - i - 2);
current += "[";
current += alt;
current += "]";
i = closeParen + 1;
continue;
}
}
current += c;
i++;
continue;
}
// Link [text](url) — show text only
if (c == '[') {
size_t closeBracket = text.find(']', i + 1);
if (closeBracket != std::string::npos && closeBracket + 1 < text.size() && text[closeBracket + 1] == '(') {
size_t closeParen = text.find(')', closeBracket + 2);
if (closeParen != std::string::npos) {
current += text.substr(i + 1, closeBracket - i - 1);
i = closeParen + 1;
continue;
}
}
current += c;
i++;
continue;
}
current += c;
i++;
}
emitSpan();
// If bold/italic were left open, the text had unmatched markers.
// The spans are still usable — the trailing text just keeps the toggled style.
return spans;
}
bool isCodeFence(const std::string& line) {
auto trimmed = trimLeft(line);
if (trimmed.size() < 3) return false;
// Must start with ``` (with optional language tag after)
if (trimmed[0] == '`' && trimmed[1] == '`' && trimmed[2] == '`') return true;
// Also support ~~~ fences
if (trimmed[0] == '~' && trimmed[1] == '~' && trimmed[2] == '~') return true;
return false;
}
// Detect task list checkbox at start of list content, update prefix accordingly.
// Returns content with the checkbox marker stripped.
static std::string handleTaskList(const std::string& content, std::string& listPrefix) {
if (content.size() >= 3 && content[0] == '[' && content[2] == ']') {
char mark = content[1];
if (mark == 'x' || mark == 'X') {
listPrefix = "";
} else if (mark == ' ') {
listPrefix = "";
} else {
return content; // Not a checkbox — keep content as-is
}
size_t skip = 3;
if (skip < content.size() && content[skip] == ' ') skip++;
return content.substr(skip);
}
return content;
}
ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock) {
ParsedLine result;
// Inside a code block: either closing fence or verbatim text
if (inCodeBlock) {
if (isCodeFence(rawLine)) {
result.blockType = BlockType::CodeBlock;
return result;
}
result.blockType = BlockType::CodeBlock;
result.spans.push_back({rawLine, EpdFontFamily::REGULAR});
return result;
}
// Opening code fence
if (isCodeFence(rawLine)) {
result.blockType = BlockType::CodeBlock;
return result;
}
// Count leading whitespace for nesting level before trimming.
// Up to 3 spaces before a list marker are still top-level in CommonMark.
size_t leadingSpaces = 0;
for (size_t i = 0; i < rawLine.size(); i++) {
if (rawLine[i] == ' ')
leadingSpaces++;
else if (rawLine[i] == '\t')
leadingSpaces += TAB_WIDTH; // Treat tab as 4 spaces to match CommonMark nesting rules
else
break;
}
result.indentLevel = parseListIndentLevel(leadingSpaces);
std::string trimmed = trimLeft(rawLine);
// Blank line
if (trimmed.empty()) {
result.blockType = BlockType::BlankLine;
return result;
}
// Horizontal rule (must check BEFORE unordered list since --- and *** overlap)
if (isHorizontalRuleLine(trimmed)) {
result.blockType = BlockType::HorizontalRule;
return result;
}
// ATX headers: # H1, ## H2, ### H3+
if (trimmed[0] == '#') {
int level = 0;
size_t pos = 0;
while (pos < trimmed.size() && trimmed[pos] == '#') {
level++;
pos++;
}
if (pos < trimmed.size() && trimmed[pos] == ' ') {
std::string content = trimmed.substr(pos + 1);
// Strip optional trailing # sequence
size_t trail = content.size();
while (trail > 0 && content[trail - 1] == '#') trail--;
while (trail > 0 && content[trail - 1] == ' ') trail--;
if (trail < content.size()) content = content.substr(0, trail);
if (level <= 1)
result.blockType = BlockType::Header1;
else if (level == 2)
result.blockType = BlockType::Header2;
else
result.blockType = BlockType::Header3;
result.spans = parseInline(content);
// Force bold on all header spans
for (auto& span : result.spans) {
if (span.style == EpdFontFamily::REGULAR)
span.style = EpdFontFamily::BOLD;
else if (span.style == EpdFontFamily::ITALIC)
span.style = EpdFontFamily::BOLD_ITALIC;
}
return result;
}
}
// Unordered list: - , * , + (marker followed by space)
if (trimmed.size() > 1 && trimmed[1] == ' ' && (trimmed[0] == '-' || trimmed[0] == '*' || trimmed[0] == '+')) {
result.blockType = BlockType::UnorderedList;
result.listPrefix = "\xe2\x80\xa2 "; // "• "
std::string content = handleTaskList(trimmed.substr(2), result.listPrefix);
result.spans = parseInline(content);
return result;
}
// Ordered list: 1. , 2. , etc. (up to 3-digit number)
{
size_t dotPos = trimmed.find(". ");
if (dotPos != std::string::npos && dotPos <= 3 && dotPos > 0) {
bool allDigits = true;
for (size_t j = 0; j < dotPos; j++) {
if (!std::isdigit(static_cast<unsigned char>(trimmed[j]))) {
allDigits = false;
break;
}
}
if (allDigits) {
result.blockType = BlockType::OrderedList;
result.listPrefix = trimmed.substr(0, dotPos + 2); // e.g. "1. "
std::string content = handleTaskList(trimmed.substr(dotPos + 2), result.listPrefix);
result.spans = parseInline(content);
return result;
}
}
}
// Blockquote: > text
if (trimmed[0] == '>') {
result.blockType = BlockType::Blockquote;
std::string content = trimmed.substr(1);
if (!content.empty() && content[0] == ' ') content = content.substr(1);
result.spans = parseInline(content);
return result;
}
// Default: paragraph
result.blockType = BlockType::Paragraph;
result.spans = parseInline(trimmed);
return result;
}
} // namespace MdParser
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <EpdFontFamily.h>
#include <cstdint>
#include <string>
#include <vector>
namespace MdParser {
struct Span {
std::string text;
EpdFontFamily::Style style;
};
enum class BlockType : uint8_t {
Paragraph,
Header1,
Header2,
Header3,
UnorderedList,
OrderedList,
Blockquote,
CodeBlock,
HorizontalRule,
BlankLine
};
struct ParsedLine {
BlockType blockType = BlockType::Paragraph;
std::vector<Span> spans;
std::string listPrefix; // "• " or "1. " etc.
uint8_t indentLevel = 0; // Nesting depth (each 4 spaces = 1 level)
};
// Parse a single raw line of markdown into block type and styled spans.
// |inCodeBlock| indicates whether the line is inside a fenced code block.
ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock);
// Returns true if the line is a code fence (``` with optional language tag).
bool isCodeFence(const std::string& line);
// Parse inline markdown formatting (bold, italic, code spans, links, images).
std::vector<Span> parseInline(const std::string& text);
} // namespace MdParser
+3 -1
View File
@@ -40,9 +40,11 @@ std::string Txt::getTitle() const {
size_t lastSlash = filepath.find_last_of('/');
std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath;
// Remove .txt extension
// Remove .txt or .md extension
if (FsHelpers::hasTxtExtension(filename)) {
filename = filename.substr(0, filename.length() - 4);
} else if (FsHelpers::hasMarkdownExtension(filename)) {
filename = filename.substr(0, filename.length() - 3);
}
return filename;