From 01a9d7cb36d717b5ba9bfed7f1610fe4e2d048fe Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 23 Apr 2026 21:27:14 +0200 Subject: [PATCH] Add TOC support Co-authored-by: Copilot --- lib/Md/MdParser.cpp | 26 ++ src/activities/reader/MDReaderActivity.h | 19 ++ src/activities/reader/MdReaderActivity.cpp | 244 ++++++++++++++++-- .../reader/MdReaderTocSelectionActivity.cpp | 110 ++++++++ .../reader/MdReaderTocSelectionActivity.h | 30 +++ src/activities/reader/ReaderActivity.cpp | 2 +- test/md_parser/MdParserTest.cpp | 92 +++++++ 7 files changed, 502 insertions(+), 21 deletions(-) create mode 100644 src/activities/reader/MdReaderTocSelectionActivity.cpp create mode 100644 src/activities/reader/MdReaderTocSelectionActivity.h create mode 100644 test/md_parser/MdParserTest.cpp diff --git a/lib/Md/MdParser.cpp b/lib/Md/MdParser.cpp index 64bb7bc0..a1780874 100644 --- a/lib/Md/MdParser.cpp +++ b/lib/Md/MdParser.cpp @@ -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(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 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 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 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; diff --git a/src/activities/reader/MDReaderActivity.h b/src/activities/reader/MDReaderActivity.h index f62986b5..7fa9215d 100644 --- a/src/activities/reader/MDReaderActivity.h +++ b/src/activities/reader/MDReaderActivity.h @@ -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; @@ -26,6 +33,11 @@ class MdReaderActivity final : public Activity { std::vector pageOffsets; std::vector pageCodeBlockState; // 1 if page starts inside a code block std::vector currentPageLines; + std::vector pageBuffer; + + std::vector 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. diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 8b1fdee4..b4830ebc 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -7,11 +7,13 @@ #include #include +#include #include #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((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(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(headings.size()) - 1; + } else { + headingIndex += next ? 1 : -1; + } + + if (headingIndex < 0) { + headingIndex = 0; + } else if (headingIndex >= static_cast(headings.size())) { + headingIndex = static_cast(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(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(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(renderer, mappedInput, headings, currentHeadingIndex), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + currentPage = std::get(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(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(outLines.size()) < linesPerPage) { - // Find end of line + while (pos < bufferSize && static_cast(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(buffer + pos), displayLen); + std::string rawLine(reinterpret_cast(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(); } diff --git a/src/activities/reader/MdReaderTocSelectionActivity.cpp b/src/activities/reader/MdReaderTocSelectionActivity.cpp new file mode 100644 index 00000000..9a33ac7f --- /dev/null +++ b/src/activities/reader/MdReaderTocSelectionActivity.cpp @@ -0,0 +1,110 @@ +#include "MdReaderTocSelectionActivity.h" + +#include + +#include +#include + +#include "components/UITheme.h" +#include "fontIds.h" + +int MdReaderTocSelectionActivity::getTotalItems() const { return static_cast(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(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(); +} diff --git a/src/activities/reader/MdReaderTocSelectionActivity.h b/src/activities/reader/MdReaderTocSelectionActivity.h new file mode 100644 index 00000000..101591a9 --- /dev/null +++ b/src/activities/reader/MdReaderTocSelectionActivity.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include "MdReaderActivity.h" +#include "../Activity.h" +#include "util/ButtonNavigator.h" + +class MdReaderTocSelectionActivity final : public Activity { + std::vector headings; + ButtonNavigator buttonNavigator; + int selectorIndex = 0; + + int getPageItems() const; + int getTotalItems() const; + + public: + explicit MdReaderTocSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + std::vector headings, int currentHeadingIndex) + : Activity("MdReaderTocSelection", renderer, mappedInput), headings(std::move(headings)), selectorIndex(0) { + if (currentHeadingIndex >= 0 && currentHeadingIndex < static_cast(headings.size())) { + selectorIndex = currentHeadingIndex; + } + } + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; +}; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index fa976a0e..7e7f9325 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -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(); diff --git a/test/md_parser/MdParserTest.cpp b/test/md_parser/MdParserTest.cpp new file mode 100644 index 00000000..de1c9f34 --- /dev/null +++ b/test/md_parser/MdParserTest.cpp @@ -0,0 +1,92 @@ +#include +#include +#include + +#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& spans) { + std::string result; + for (const auto& span : spans) { + result += span.text; + } + return result; +} + +static bool allRegular(const std::vector& 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; +}