Add TOC support

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
jpirnay
2026-04-23 21:27:14 +02:00
co-authored by Copilot
parent a90290a596
commit 01a9d7cb36
7 changed files with 502 additions and 21 deletions
+26
View File
@@ -17,6 +17,17 @@ static std::string trimLeft(const std::string& s) {
return s.substr(i);
}
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;
@@ -65,6 +76,11 @@ std::vector<Span> parseInline(const std::string& text) {
// *** or ___ — toggle both bold and italic
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();
bold = !bold;
italic = !italic;
@@ -74,6 +90,11 @@ std::vector<Span> parseInline(const std::string& text) {
// ** 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();
bold = !bold;
i += 2;
@@ -82,6 +103,11 @@ std::vector<Span> parseInline(const std::string& text) {
// * or _ — toggle italic
if (c == '*' || c == '_') {
if (c == '_' && !isUnderscoreEmphasis(text, i, 1)) {
current.push_back(c);
i += 1;
continue;
}
emitSpan();
italic = !italic;
i += 1;
+19
View File
@@ -8,6 +8,13 @@
#include "CrossPointSettings.h"
#include "activities/Activity.h"
struct MdHeading {
size_t offset = 0;
int level = 1;
std::string title;
int pageIndex = -1;
};
class MdReaderActivity final : public Activity {
std::unique_ptr<Txt> txt;
@@ -26,6 +33,11 @@ class MdReaderActivity final : public Activity {
std::vector<size_t> pageOffsets;
std::vector<uint8_t> pageCodeBlockState; // 1 if page starts inside a code block
std::vector<RenderedLine> currentPageLines;
std::vector<uint8_t> pageBuffer;
std::vector<MdHeading> headings;
int currentHeadingIndex = -1;
int linesPerPage = 0;
int viewportWidth = 0;
bool initialized = false;
@@ -55,6 +67,13 @@ class MdReaderActivity final : public Activity {
void savePageIndexCache() const;
void saveProgress() const;
void loadProgress();
void scanHeadings();
void assignHeadingPageNumbers();
int getHeadingIndexForOffset(size_t offset) const;
void jumpToHeading(bool next);
void scanHeadings();
int getHeadingIndexForOffset(size_t offset) const;
void jumpToHeading(bool next);
// Word-wrap a parsed markdown line into one or more RenderedLines.
// Returns true if all content was emitted, false if truncated by maxLines.
+224 -20
View File
@@ -7,11 +7,13 @@
#include <Serialization.h>
#include <Utf8.h>
#include <algorithm>
#include <numeric>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "MdReaderTocSelectionActivity.h"
#include "ReaderUtils.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
@@ -19,8 +21,18 @@
namespace {
constexpr size_t CHUNK_SIZE = 8 * 1024;
constexpr size_t MAX_LINE_LENGTH = 64 * 1024;
constexpr unsigned long HEADING_SKIP_MS = 700;
constexpr uint32_t CACHE_MAGIC = 0x4D4B4449; // "MKDI"
constexpr uint8_t CACHE_VERSION = 3; // Bumped: nested list indent + task checkboxes
static std::string flattenHeadingText(const MdParser::ParsedLine& parsed) {
std::string result;
for (const auto& span : parsed.spans) {
result += span.text;
}
return result;
}
} // namespace
void MdReaderActivity::onEnter() {
@@ -38,11 +50,163 @@ void MdReaderActivity::onEnter() {
auto fileName = filePath.substr(filePath.rfind('/') + 1);
APP_STATE.openEpubPath = filePath;
APP_STATE.saveToFile();
RECENT_BOOKS.addBook(filePath, fileName, "", "");
RECENT_BOOKS.addBook(filePath, fileName, "", "", "");
requestUpdate();
}
void MdReaderActivity::assignHeadingPageNumbers() {
if (pageOffsets.empty()) {
return;
}
for (auto& heading : headings) {
const auto it = std::upper_bound(pageOffsets.begin(), pageOffsets.end(), heading.offset);
heading.pageIndex = static_cast<int>((it == pageOffsets.begin()) ? 0 : (it - pageOffsets.begin() - 1));
}
}
int MdReaderActivity::getHeadingIndexForOffset(size_t offset) const {
if (headings.empty()) {
return -1;
}
int index = -1;
for (int i = 0; i < static_cast<int>(headings.size()); i++) {
if (headings[i].offset <= offset) {
index = i;
} else {
break;
}
}
return index;
}
void MdReaderActivity::jumpToHeading(bool next) {
if (headings.empty() || pageOffsets.empty()) {
return;
}
const size_t currentOffset = (currentPage >= 0 && currentPage < totalPages) ? pageOffsets[currentPage] : 0;
int headingIndex = getHeadingIndexForOffset(currentOffset);
if (headingIndex < 0) {
headingIndex = next ? 0 : static_cast<int>(headings.size()) - 1;
} else {
headingIndex += next ? 1 : -1;
}
if (headingIndex < 0) {
headingIndex = 0;
} else if (headingIndex >= static_cast<int>(headings.size())) {
headingIndex = static_cast<int>(headings.size()) - 1;
}
const size_t headingOffset = headings[headingIndex].offset;
const auto it = std::upper_bound(pageOffsets.begin(), pageOffsets.end(), headingOffset);
if (it == pageOffsets.begin()) {
currentPage = 0;
} else {
currentPage = static_cast<int>(it - pageOffsets.begin() - 1);
}
currentHeadingIndex = headingIndex;
requestUpdate();
}
void MdReaderActivity::scanHeadings() {
headings.clear();
if (!txt) {
return;
}
const size_t fileSize = txt->getFileSize();
if (fileSize == 0) {
return;
}
std::string pending;
size_t pendingOffset = 0;
bool hasPending = false;
bool inCodeBlock = false;
pageBuffer.resize(CHUNK_SIZE + 1);
size_t offset = 0;
while (offset < fileSize) {
const size_t toRead = std::min(CHUNK_SIZE, fileSize - offset);
if (!txt->readContent(pageBuffer.data(), offset, toRead)) {
return;
}
pageBuffer[toRead] = '\0';
size_t pos = 0;
while (pos < toRead) {
size_t lineEnd = pos;
while (lineEnd < toRead && pageBuffer[lineEnd] != '\n') {
lineEnd++;
}
const bool hasNewline = (lineEnd < toRead && pageBuffer[lineEnd] == '\n');
const bool fileHasMore = (offset + toRead < fileSize);
const size_t rawLen = lineEnd - pos;
const bool hasCR = (rawLen > 0 && pageBuffer[pos + rawLen - 1] == '\r');
const size_t displayLen = hasCR ? rawLen - 1 : rawLen;
const size_t lineStartOffset = hasPending ? pendingOffset : (offset + pos);
std::string rawLine;
if (hasPending) {
rawLine = std::move(pending);
pending.clear();
hasPending = false;
}
rawLine.append(reinterpret_cast<char*>(pageBuffer.data() + pos), displayLen);
if (!hasNewline && fileHasMore) {
if (!hasPending) {
pendingOffset = lineStartOffset;
}
pending = std::move(rawLine);
hasPending = true;
break;
}
if (MdParser::isCodeFence(rawLine)) {
inCodeBlock = !inCodeBlock;
}
const MdParser::ParsedLine parsed = MdParser::parseLine(rawLine, inCodeBlock);
if (parsed.blockType == MdParser::BlockType::Header1 || parsed.blockType == MdParser::BlockType::Header2 ||
parsed.blockType == MdParser::BlockType::Header3) {
int level = 1;
if (parsed.blockType == MdParser::BlockType::Header2) {
level = 2;
} else if (parsed.blockType == MdParser::BlockType::Header3) {
level = 3;
}
headings.push_back({lineStartOffset, level, flattenHeadingText(parsed)});
}
pos = hasNewline ? lineEnd + 1 : lineEnd;
}
offset += toRead;
}
if (hasPending) {
if (MdParser::isCodeFence(pending)) {
inCodeBlock = !inCodeBlock;
}
const MdParser::ParsedLine parsed = MdParser::parseLine(pending, inCodeBlock);
if (parsed.blockType == MdParser::BlockType::Header1 || parsed.blockType == MdParser::BlockType::Header2 ||
parsed.blockType == MdParser::BlockType::Header3) {
int level = 1;
if (parsed.blockType == MdParser::BlockType::Header2) {
level = 2;
} else if (parsed.blockType == MdParser::BlockType::Header3) {
level = 3;
}
headings.push_back({pendingOffset, level, flattenHeadingText(parsed)});
}
}
}
void MdReaderActivity::onExit() {
Activity::onExit();
@@ -68,17 +232,40 @@ void MdReaderActivity::loop() {
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && !headings.empty()) {
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
ReaderUtils::enforceExitFullRefresh(renderer);
startActivityForResult(
std::make_unique<MdReaderTocSelectionActivity>(renderer, mappedInput, headings, currentHeadingIndex),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
currentPage = std::get<PageResult>(result.data).page;
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
requestUpdate();
}
});
return;
}
auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput);
if (!prevTriggered && !nextTriggered) {
return;
}
const bool headingSkip = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > HEADING_SKIP_MS;
if (headingSkip && !headings.empty()) {
jumpToHeading(nextTriggered);
return;
}
if (prevTriggered && currentPage > 0) {
currentPage--;
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
requestUpdate();
} else if (nextTriggered) {
if (currentPage < totalPages - 1) {
currentPage++;
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
requestUpdate();
} else {
onGoHome();
@@ -107,6 +294,9 @@ void MdReaderActivity::initializeReader() {
const int viewportHeight = renderer.getScreenHeight() - cachedOrientedMarginTop - cachedOrientedMarginBottom;
const int lineHeight = renderer.getLineHeight(cachedFontId);
pageBuffer.reserve(CHUNK_SIZE + 1);
scanHeadings();
linesPerPage = viewportHeight / lineHeight;
if (linesPerPage < 1) linesPerPage = 1;
@@ -116,6 +306,7 @@ void MdReaderActivity::initializeReader() {
buildPageIndex();
savePageIndexCache();
}
assignHeadingPageNumbers();
loadProgress();
@@ -259,42 +450,57 @@ bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, st
return false;
}
size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset);
auto* buffer = static_cast<uint8_t*>(malloc(chunkSize + 1));
if (!buffer) {
LOG_ERR("MDR", "Failed to allocate %zu bytes", chunkSize);
size_t bufferSize = std::min(CHUNK_SIZE, fileSize - offset);
pageBuffer.resize(bufferSize + 1);
if (!txt->readContent(pageBuffer.data(), offset, bufferSize)) {
return false;
}
if (!txt->readContent(buffer, offset, chunkSize)) {
free(buffer);
return false;
}
buffer[chunkSize] = '\0';
pageBuffer[bufferSize] = '\0';
bool inCodeBlock = startInCodeBlock;
size_t pos = 0;
while (pos < chunkSize && static_cast<int>(outLines.size()) < linesPerPage) {
// Find end of line
while (pos < bufferSize && static_cast<int>(outLines.size()) < linesPerPage) {
// Find end of line and extend the read buffer if we are at chunk boundary.
size_t lineEnd = pos;
while (lineEnd < chunkSize && buffer[lineEnd] != '\n') {
while (lineEnd < bufferSize && pageBuffer[lineEnd] != '\n') {
lineEnd++;
}
// Check if we have a complete line
bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize);
while (lineEnd == bufferSize && offset + bufferSize < fileSize && bufferSize < MAX_LINE_LENGTH) {
size_t extra = std::min(CHUNK_SIZE, fileSize - offset - bufferSize);
if (bufferSize + extra > MAX_LINE_LENGTH) {
extra = MAX_LINE_LENGTH - bufferSize;
}
if (extra == 0) {
break;
}
pageBuffer.resize(bufferSize + extra + 1);
if (!txt->readContent(pageBuffer.data() + bufferSize, offset + bufferSize, extra)) {
return false;
}
bufferSize += extra;
pageBuffer[bufferSize] = '\0';
while (lineEnd < bufferSize && pageBuffer[lineEnd] != '\n') {
lineEnd++;
}
}
const bool isAtBufferEnd = (lineEnd == bufferSize);
const bool isEOF = (offset + bufferSize >= fileSize);
const bool lineComplete = (lineEnd < bufferSize) || isEOF || (isAtBufferEnd && bufferSize >= MAX_LINE_LENGTH);
if (!lineComplete && !outLines.empty()) {
// Incomplete line at chunk boundary and we already have content — stop here
break;
}
size_t lineContentLen = lineEnd - pos;
bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r');
bool hasCR = (lineContentLen > 0 && pageBuffer[pos + lineContentLen - 1] == '\r');
size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen;
std::string rawLine(reinterpret_cast<char*>(buffer + pos), displayLen);
std::string rawLine(reinterpret_cast<char*>(pageBuffer.data() + pos), displayLen);
// Check for code fence toggle
bool wasFence = false;
@@ -363,8 +569,6 @@ bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, st
}
endInCodeBlock = inCodeBlock;
free(buffer);
return !outLines.empty();
}
@@ -0,0 +1,110 @@
#include "MdReaderTocSelectionActivity.h"
#include <algorithm>
#include <GfxRenderer.h>
#include <I18n.h>
#include "components/UITheme.h"
#include "fontIds.h"
int MdReaderTocSelectionActivity::getTotalItems() const { return static_cast<int>(headings.size()); }
int MdReaderTocSelectionActivity::getPageItems() const {
constexpr int lineHeight = 30;
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
const int startY = 60 + contentRect.y;
const int availableHeight = contentRect.y + contentRect.height - startY - lineHeight;
return std::max(1, availableHeight / lineHeight);
}
void MdReaderTocSelectionActivity::onEnter() {
Activity::onEnter();
if (selectorIndex < 0 || selectorIndex >= getTotalItems()) {
selectorIndex = 0;
}
requestUpdate();
}
void MdReaderTocSelectionActivity::onExit() { Activity::onExit(); }
void MdReaderTocSelectionActivity::loop() {
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectorIndex >= 0 && selectorIndex < totalItems) {
setResult(PageResult{static_cast<uint32_t>(headings[selectorIndex].pageIndex)});
} else {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
}
finish();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
return;
}
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
requestUpdate();
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
requestUpdate();
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
});
}
void MdReaderTocSelectionActivity::render(RenderLock&&) {
renderer.clearScreen();
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
const int titleX =
contentRect.x +
(contentRect.width - renderer.getTextWidth(UI_12_FONT_ID, tr(STR_SELECT_CHAPTER), EpdFontFamily::BOLD)) / 2;
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentRect.y, tr(STR_SELECT_CHAPTER), true, EpdFontFamily::BOLD);
const int pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(contentRect.x, 60 + contentRect.y + (selectorIndex % pageItems) * 30 - 2, contentRect.width - 1,
30);
for (int i = 0; i < pageItems; i++) {
int itemIndex = pageStartIndex + i;
if (itemIndex >= totalItems) break;
const int displayY = 60 + contentRect.y + i * 30;
const bool isSelected = (itemIndex == selectorIndex);
const auto& heading = headings[itemIndex];
const int indentSize = contentRect.x + 20 + (heading.level - 1) * 10;
const std::string title = renderer.truncatedText(UI_10_FONT_ID, heading.title.c_str(), contentRect.width - 40 - indentSize);
renderer.drawText(UI_10_FONT_ID, indentSize, displayY, title.c_str(), !isSelected);
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -0,0 +1,30 @@
#pragma once
#include <vector>
#include "MdReaderActivity.h"
#include "../Activity.h"
#include "util/ButtonNavigator.h"
class MdReaderTocSelectionActivity final : public Activity {
std::vector<MdHeading> headings;
ButtonNavigator buttonNavigator;
int selectorIndex = 0;
int getPageItems() const;
int getTotalItems() const;
public:
explicit MdReaderTocSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::vector<MdHeading> headings, int currentHeadingIndex)
: Activity("MdReaderTocSelection", renderer, mappedInput), headings(std::move(headings)), selectorIndex(0) {
if (currentHeadingIndex >= 0 && currentHeadingIndex < static_cast<int>(headings.size())) {
selectorIndex = currentHeadingIndex;
}
}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
};
+1 -1
View File
@@ -167,7 +167,7 @@ void ReaderActivity::onEnter() {
return;
}
onGoToXtcReader(std::move(xtc));
} else if (isMdFile(initialBookPath)) {
} else if (isMDFile(initialBookPath)) {
auto txt = loadTxt(initialBookPath);
if (!txt) {
onGoBack();
+92
View File
@@ -0,0 +1,92 @@
#include <cstdio>
#include <string>
#include <vector>
#include "../../lib/Md/MdParser.h"
static int testsPassed = 0;
static int testsFailed = 0;
#define ASSERT_EQ(a, b) \
do { \
if ((a) != (b)) { \
fprintf(stderr, " FAIL: %s:%d: %s != %s\n", __FILE__, __LINE__, #a, #b); \
testsFailed++; \
return; \
} \
} while (0)
#define PASS() testsPassed++
static std::string flattenText(const std::vector<MdParser::Span>& spans) {
std::string result;
for (const auto& span : spans) {
result += span.text;
}
return result;
}
static bool allRegular(const std::vector<MdParser::Span>& spans) {
for (const auto& span : spans) {
if (span.style != EpdFontFamily::REGULAR) {
return false;
}
}
return true;
}
void testSnakeCaseUnderscoresRemainLiteral() {
printf("testSnakeCaseUnderscoresRemainLiteral...\n");
auto spans = MdParser::parseInline("foo_bar_baz");
ASSERT_EQ(flattenText(spans), "foo_bar_baz");
ASSERT_EQ(allRegular(spans), true);
PASS();
}
void testUnderscoreWithinExpressionRemainsLiteral() {
printf("testUnderscoreWithinExpressionRemainsLiteral...\n");
auto spans = MdParser::parseInline("a_b + c_d");
ASSERT_EQ(flattenText(spans), "a_b + c_d");
ASSERT_EQ(allRegular(spans), true);
PASS();
}
void testUnderscoreEmphasisStillWorks() {
printf("testUnderscoreEmphasisStillWorks...\n");
auto spans = MdParser::parseInline("foo _bar_ baz");
ASSERT_EQ(flattenText(spans), "foo bar baz");
ASSERT_EQ(spans.size(), 3);
ASSERT_EQ(spans[1].style == EpdFontFamily::ITALIC || spans[1].style == EpdFontFamily::BOLD_ITALIC, true);
PASS();
}
void testAsteriskEmphasisStillWorks() {
printf("testAsteriskEmphasisStillWorks...\n");
auto spans = MdParser::parseInline("foo *bar* baz");
ASSERT_EQ(flattenText(spans), "foo bar baz");
ASSERT_EQ(spans.size(), 3);
ASSERT_EQ(spans[1].style == EpdFontFamily::ITALIC || spans[1].style == EpdFontFamily::BOLD_ITALIC, true);
PASS();
}
void testUnderscoreBoldWorks() {
printf("testUnderscoreBoldWorks...\n");
auto spans = MdParser::parseInline("foo __bar__ baz");
ASSERT_EQ(flattenText(spans), "foo bar baz");
ASSERT_EQ(spans.size(), 3);
ASSERT_EQ(spans[1].style == EpdFontFamily::BOLD || spans[1].style == EpdFontFamily::BOLD_ITALIC, true);
PASS();
}
int main() {
printf("=== Markdown Parser Tests ===\n\n");
testSnakeCaseUnderscoresRemainLiteral();
testUnderscoreWithinExpressionRemainsLiteral();
testUnderscoreEmphasisStillWorks();
testAsteriskEmphasisStillWorks();
testUnderscoreBoldWorks();
printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed);
return testsFailed > 0 ? 1 : 0;
}