diff --git a/lib/Md/MdParser.cpp b/lib/Md/MdParser.cpp new file mode 100644 index 00000000..64bb7bc0 --- /dev/null +++ b/lib/Md/MdParser.cpp @@ -0,0 +1,306 @@ +#include "MdParser.h" + +#include + +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 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 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 parseInline(const std::string& text) { + std::vector 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(); + } + }; + + 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 + if ((c == '*' || c == '_') && i + 2 < text.size() && text[i + 1] == c && text[i + 2] == c) { + emitSpan(); + bold = !bold; + italic = !italic; + i += 3; + continue; + } + + // ** or __ — toggle bold + if ((c == '*' || c == '_') && i + 1 < text.size() && text[i + 1] == c) { + emitSpan(); + bold = !bold; + i += 2; + continue; + } + + // * or _ — toggle italic + if (c == '*' || c == '_') { + emitSpan(); + italic = !italic; + 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 = "[x] "; + } 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 + size_t leadingSpaces = 0; + for (size_t i = 0; i < rawLine.size(); i++) { + if (rawLine[i] == ' ') + leadingSpaces++; + else if (rawLine[i] == '\t') + leadingSpaces += 2; // Treat tab as 2 spaces + else + break; + } + result.indentLevel = static_cast(leadingSpaces / 2); + + 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(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 \ No newline at end of file diff --git a/lib/Md/MdParser.h b/lib/Md/MdParser.h new file mode 100644 index 00000000..936db7c2 --- /dev/null +++ b/lib/Md/MdParser.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +#include +#include +#include + +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; + std::vector spans; + std::string listPrefix; // "• " or "1. " etc. + uint8_t indentLevel = 0; // Nesting depth (each 2 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 parseInline(const std::string& text); + +} // namespace MdParser \ No newline at end of file diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp index 83ef123c..692e3850 100644 --- a/lib/Txt/Txt.cpp +++ b/lib/Txt/Txt.cpp @@ -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 .mdextension 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; diff --git a/open-x4-sdk b/open-x4-sdk index a931d452..ed5cb2f9 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit a931d452d4bf9f100683705dbb8da6c29283bee0 +Subproject commit ed5cb2f99dc319deaa3c4f2c41e297929f940386 diff --git a/src/activities/reader/MDReaderActivity.h b/src/activities/reader/MDReaderActivity.h new file mode 100644 index 00000000..f62986b5 --- /dev/null +++ b/src/activities/reader/MDReaderActivity.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "activities/Activity.h" + +class MdReaderActivity final : public Activity { + std::unique_ptr txt; + + int currentPage = 0; + int totalPages = 1; + int pagesUntilFullRefresh = 0; + + // A single rendered line on screen (after word-wrapping) + struct RenderedLine { + std::vector spans; + int indent = 0; // left indent in pixels + bool isHR = false; // draw as horizontal rule + }; + + // Streaming reader state + std::vector pageOffsets; + std::vector pageCodeBlockState; // 1 if page starts inside a code block + std::vector currentPageLines; + int linesPerPage = 0; + int viewportWidth = 0; + bool initialized = false; + + // Cached settings for cache validation + int cachedFontId = 0; + uint8_t cachedScreenMargin = 0; + uint8_t cachedParagraphAlignment = CrossPointSettings::LEFT_ALIGN; + int cachedOrientedMarginTop = 0; + int cachedOrientedMarginRight = 0; + int cachedOrientedMarginBottom = 0; + int cachedOrientedMarginLeft = 0; + + // Indent constants (in pixels) + static constexpr int LIST_INDENT = 20; + static constexpr int BLOCKQUOTE_INDENT = 16; + static constexpr int CODE_INDENT = 8; + + void renderPage(); + void renderStatusBar() const; + + void initializeReader(); + bool loadPageAtOffset(size_t offset, bool startInCodeBlock, std::vector& outLines, size_t& nextOffset, + bool& endInCodeBlock); + void buildPageIndex(); + bool loadPageIndexCache(); + void savePageIndexCache() const; + void saveProgress() const; + void loadProgress(); + + // Word-wrap a parsed markdown line into one or more RenderedLines. + // Returns true if all content was emitted, false if truncated by maxLines. + bool wordWrapParsedLine(const MdParser::ParsedLine& parsed, int indent, std::vector& outLines, + int maxLines); + + // Measure total pixel width of a span list + int measureSpans(const std::vector& spans) const; + + public: + explicit MdReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr txt) + : Activity("MdReader", renderer, mappedInput), txt(std::move(txt)) {} + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + bool isReaderActivity() const override { return true; } +}; \ No newline at end of file diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp new file mode 100644 index 00000000..8b1fdee4 --- /dev/null +++ b/src/activities/reader/MdReaderActivity.cpp @@ -0,0 +1,662 @@ +#include "MdReaderActivity.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "CrossPointState.h" +#include "MappedInputManager.h" +#include "ReaderUtils.h" +#include "RecentBooksStore.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +constexpr size_t CHUNK_SIZE = 8 * 1024; +constexpr uint32_t CACHE_MAGIC = 0x4D4B4449; // "MKDI" +constexpr uint8_t CACHE_VERSION = 3; // Bumped: nested list indent + task checkboxes +} // namespace + +void MdReaderActivity::onEnter() { + Activity::onEnter(); + + if (!txt) { + return; + } + + ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); + + txt->setupCacheDir(); + + auto filePath = txt->getPath(); + auto fileName = filePath.substr(filePath.rfind('/') + 1); + APP_STATE.openEpubPath = filePath; + APP_STATE.saveToFile(); + RECENT_BOOKS.addBook(filePath, fileName, "", ""); + + requestUpdate(); +} + +void MdReaderActivity::onExit() { + Activity::onExit(); + + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + + pageOffsets.clear(); + pageCodeBlockState.clear(); + currentPageLines.clear(); + APP_STATE.readerActivityLoadCount = 0; + APP_STATE.saveToFile(); + txt.reset(); +} + +void MdReaderActivity::loop() { + if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { + activityManager.goToFileBrowser(txt ? txt->getPath() : ""); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Back) && + mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) { + onGoHome(); + return; + } + + auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput); + if (!prevTriggered && !nextTriggered) { + return; + } + + if (prevTriggered && currentPage > 0) { + currentPage--; + requestUpdate(); + } else if (nextTriggered) { + if (currentPage < totalPages - 1) { + currentPage++; + requestUpdate(); + } else { + onGoHome(); + } + } +} + +void MdReaderActivity::initializeReader() { + if (initialized) { + return; + } + + cachedFontId = SETTINGS.getReaderFontId(); + cachedScreenMargin = SETTINGS.screenMargin; + cachedParagraphAlignment = SETTINGS.paragraphAlignment; + + renderer.getOrientedViewableTRBL(&cachedOrientedMarginTop, &cachedOrientedMarginRight, &cachedOrientedMarginBottom, + &cachedOrientedMarginLeft); + cachedOrientedMarginTop += cachedScreenMargin; + cachedOrientedMarginLeft += cachedScreenMargin; + cachedOrientedMarginRight += cachedScreenMargin; + cachedOrientedMarginBottom += + std::max(cachedScreenMargin, static_cast(UITheme::getInstance().getStatusBarHeight())); + + viewportWidth = renderer.getScreenWidth() - cachedOrientedMarginLeft - cachedOrientedMarginRight; + const int viewportHeight = renderer.getScreenHeight() - cachedOrientedMarginTop - cachedOrientedMarginBottom; + const int lineHeight = renderer.getLineHeight(cachedFontId); + + linesPerPage = viewportHeight / lineHeight; + if (linesPerPage < 1) linesPerPage = 1; + + LOG_DBG("MDR", "Viewport: %dx%d, lines per page: %d", viewportWidth, viewportHeight, linesPerPage); + + if (!loadPageIndexCache()) { + buildPageIndex(); + savePageIndexCache(); + } + + loadProgress(); + + initialized = true; +} + +int MdReaderActivity::measureSpans(const std::vector& spans) const { + return std::accumulate(spans.begin(), spans.end(), 0, [this](int acc, const MdParser::Span& span) { + return acc + (span.text.empty() ? 0 : renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style)); + }); +} + +bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, int indent, + std::vector& outLines, int maxLines) { + const size_t startSize = outLines.size(); + + if (parsed.spans.empty()) { + RenderedLine rl; + rl.indent = indent; + rl.isHR = (parsed.blockType == MdParser::BlockType::HorizontalRule); + outLines.push_back(std::move(rl)); + return true; + } + + const int availableWidth = viewportWidth - indent; + if (availableWidth <= 0) return true; + + // Build a flat list of all spans, prepending the list prefix if present + std::vector allSpans; + if (!parsed.listPrefix.empty()) { + allSpans.push_back({parsed.listPrefix, EpdFontFamily::REGULAR}); + } + allSpans.insert(allSpans.end(), parsed.spans.begin(), parsed.spans.end()); + + // Check if everything fits on one line + int totalWidth = measureSpans(allSpans); + if (totalWidth <= availableWidth) { + RenderedLine rl; + rl.spans = std::move(allSpans); + rl.indent = indent; + outLines.push_back(std::move(rl)); + return true; + } + + // Word-wrap across spans + RenderedLine currentLine; + currentLine.indent = indent; + int currentWidth = 0; + bool fullyConsumed = true; + + for (size_t si = 0; si < allSpans.size(); si++) { + const auto& span = allSpans[si]; + if (span.text.empty()) continue; + + int spanWidth = renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style); + + if (currentWidth + spanWidth <= availableWidth) { + currentLine.spans.push_back(span); + currentWidth += spanWidth; + continue; + } + + // Need to break within this span + std::string remaining = span.text; + auto style = span.style; + + while (!remaining.empty()) { + // Check line limit (using lines added, not total size) + if (static_cast(outLines.size() - startSize) >= maxLines) { + fullyConsumed = false; + goto done; + } + + int remWidth = renderer.getTextAdvanceX(cachedFontId, remaining.c_str(), style); + + if (currentWidth + remWidth <= availableWidth) { + currentLine.spans.push_back({remaining, style}); + currentWidth += remWidth; + remaining.clear(); + break; + } + + size_t breakPos = remaining.size(); + + while (breakPos > 0 && renderer.getTextAdvanceX(cachedFontId, remaining.substr(0, breakPos).c_str(), style) > + availableWidth - currentWidth) { + size_t spacePos = remaining.rfind(' ', breakPos - 1); + if (spacePos != std::string::npos && spacePos > 0) { + breakPos = spacePos; + } else { + breakPos--; + while (breakPos > 0 && (remaining[breakPos] & 0xC0) == 0x80) { + breakPos--; + } + } + } + + if (breakPos == 0) { + if (currentLine.spans.empty()) { + breakPos = 1; + while (breakPos < remaining.size() && (remaining[breakPos] & 0xC0) == 0x80) { + breakPos++; + } + } else { + outLines.push_back(std::move(currentLine)); + currentLine = RenderedLine(); + currentLine.indent = indent; + currentWidth = 0; + continue; + } + } + + currentLine.spans.push_back({remaining.substr(0, breakPos), style}); + outLines.push_back(std::move(currentLine)); + currentLine = RenderedLine(); + currentLine.indent = indent; + currentWidth = 0; + + size_t skip = breakPos; + if (skip < remaining.size() && remaining[skip] == ' ') { + skip++; + } + remaining = remaining.substr(skip); + } + } + +done: + if (!currentLine.spans.empty()) { + outLines.push_back(std::move(currentLine)); + } + return fullyConsumed; +} + +bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, std::vector& outLines, + size_t& nextOffset, bool& endInCodeBlock) { + outLines.clear(); + endInCodeBlock = startInCodeBlock; + const size_t fileSize = txt->getFileSize(); + + if (offset >= fileSize) { + 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); + return false; + } + + if (!txt->readContent(buffer, offset, chunkSize)) { + free(buffer); + return false; + } + buffer[chunkSize] = '\0'; + + bool inCodeBlock = startInCodeBlock; + size_t pos = 0; + + while (pos < chunkSize && static_cast(outLines.size()) < linesPerPage) { + // Find end of line + size_t lineEnd = pos; + while (lineEnd < chunkSize && buffer[lineEnd] != '\n') { + lineEnd++; + } + + // Check if we have a complete line + bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize); + + 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'); + size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen; + + std::string rawLine(reinterpret_cast(buffer + pos), displayLen); + + // Check for code fence toggle + bool wasFence = false; + if (MdParser::isCodeFence(rawLine)) { + inCodeBlock = !inCodeBlock; + wasFence = true; + } + + // Parse the markdown line + MdParser::ParsedLine parsed; + if (wasFence) { + // Fence lines produce no visible output + parsed.blockType = MdParser::BlockType::CodeBlock; + } else { + parsed = MdParser::parseLine(rawLine, inCodeBlock); + } + + // Determine indent (base + nesting level) + int indent = 0; + switch (parsed.blockType) { + case MdParser::BlockType::UnorderedList: + case MdParser::BlockType::OrderedList: + indent = LIST_INDENT + parsed.indentLevel * LIST_INDENT; + break; + case MdParser::BlockType::Blockquote: + indent = BLOCKQUOTE_INDENT; + break; + case MdParser::BlockType::CodeBlock: + if (!wasFence) indent = CODE_INDENT; + break; + default: + break; + } + + // Word-wrap and add to output (skip fence lines) + if (!wasFence) { + size_t linesBefore = outLines.size(); + int remainingLines = linesPerPage - static_cast(outLines.size()); + bool fullyConsumed = wordWrapParsedLine(parsed, indent, outLines, remainingLines); + + if (!fullyConsumed) { + if (linesBefore > 0) { + // Page was partially filled — rollback this line and save it for next page + outLines.resize(linesBefore); + // Don't advance pos — next page re-processes this source line + } else { + // First line on page is longer than a full page — accept truncation, advance past it + pos = lineComplete ? lineEnd + 1 : lineEnd; + } + break; + } + } + + // Advance past the newline (only if source line was fully consumed) + pos = lineEnd + 1; + } + + // Ensure progress + if (pos == 0 && !outLines.empty()) { + pos = 1; + } + + nextOffset = offset + pos; + if (nextOffset > fileSize) { + nextOffset = fileSize; + } + + endInCodeBlock = inCodeBlock; + free(buffer); + + return !outLines.empty(); +} + +void MdReaderActivity::buildPageIndex() { + pageOffsets.clear(); + pageCodeBlockState.clear(); + pageOffsets.push_back(0); + pageCodeBlockState.push_back(0); + + size_t offset = 0; + const size_t fileSize = txt->getFileSize(); + bool inCodeBlock = false; + + LOG_DBG("MDR", "Building page index for %zu bytes...", fileSize); + + GUI.drawPopup(renderer, tr(STR_INDEXING)); + + while (offset < fileSize) { + std::vector tempLines; + size_t nextOffset = offset; + bool nextCodeBlock = inCodeBlock; + + if (!loadPageAtOffset(offset, inCodeBlock, tempLines, nextOffset, nextCodeBlock)) { + break; + } + + if (nextOffset <= offset) { + break; + } + + offset = nextOffset; + inCodeBlock = nextCodeBlock; + + if (offset < fileSize) { + pageOffsets.push_back(offset); + pageCodeBlockState.push_back(inCodeBlock ? 1 : 0); + } + + if (pageOffsets.size() % 20 == 0) { + vTaskDelay(1); + } + } + + totalPages = pageOffsets.size(); + LOG_DBG("MDR", "Built page index: %d pages", totalPages); +} + +void MdReaderActivity::render(RenderLock&&) { + if (!txt) { + return; + } + + if (!initialized) { + initializeReader(); + } + + if (pageOffsets.empty()) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_FILE), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + if (currentPage < 0) currentPage = 0; + if (currentPage >= totalPages) currentPage = totalPages - 1; + + // Load current page + size_t offset = pageOffsets[currentPage]; + bool startCodeBlock = + (currentPage < static_cast(pageCodeBlockState.size())) ? pageCodeBlockState[currentPage] : false; + size_t nextOffset; + bool endCodeBlock; + currentPageLines.clear(); + loadPageAtOffset(offset, startCodeBlock, currentPageLines, nextOffset, endCodeBlock); + + renderer.clearScreen(); + renderPage(); + + saveProgress(); +} + +void MdReaderActivity::renderPage() { + const int lineHeight = renderer.getLineHeight(cachedFontId); + + auto renderLines = [&]() { + int y = cachedOrientedMarginTop; + for (const auto& line : currentPageLines) { + if (line.isHR) { + // Draw horizontal rule as a thin line + int hrY = y + lineHeight / 2; + renderer.drawLine(cachedOrientedMarginLeft + line.indent, hrY, cachedOrientedMarginLeft + viewportWidth, hrY); + } else if (!line.spans.empty()) { + int x = cachedOrientedMarginLeft + line.indent; + + // Apply text alignment for non-indented lines + if (line.indent == 0) { + int contentWidth = viewportWidth; + switch (cachedParagraphAlignment) { + case CrossPointSettings::CENTER_ALIGN: { + x = cachedOrientedMarginLeft + (contentWidth - measureSpans(line.spans)) / 2; + break; + } + case CrossPointSettings::RIGHT_ALIGN: { + x = cachedOrientedMarginLeft + contentWidth - measureSpans(line.spans); + break; + } + default: + break; + } + } + + // Render each span + for (const auto& span : line.spans) { + if (!span.text.empty()) { + renderer.drawText(cachedFontId, x, y, span.text.c_str(), true, span.style); + x += renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style); + } + } + } + y += lineHeight; + } + }; + + // Font prewarm: scan pass accumulates text, then prewarm, then real render + auto* fcm = renderer.getFontCacheManager(); + auto scope = fcm->createPrewarmScope(); + renderLines(); + scope.endScanAndPrewarm(); + + // BW rendering + renderLines(); + renderStatusBar(); + + ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); + + if (SETTINGS.textAntiAliasing) { + ReaderUtils::renderAntiAliased(renderer, [&renderLines]() { renderLines(); }); + } +} + +void MdReaderActivity::renderStatusBar() const { + const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0; + std::string title; + if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { + title = txt->getTitle(); + } + GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title); +} + +void MdReaderActivity::saveProgress() const { + FsFile f; + if (Storage.openFileForWrite("MDR", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + data[0] = currentPage & 0xFF; + data[1] = (currentPage >> 8) & 0xFF; + data[2] = 0; + data[3] = 0; + f.write(data, 4); + } +} + +void MdReaderActivity::loadProgress() { + FsFile f; + if (Storage.openFileForRead("MDR", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + currentPage = data[0] + (data[1] << 8); + if (currentPage >= totalPages) { + currentPage = totalPages - 1; + } + if (currentPage < 0) { + currentPage = 0; + } + LOG_DBG("MDR", "Loaded progress: page %d/%d", currentPage, totalPages); + } + } +} + +bool MdReaderActivity::loadPageIndexCache() { + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!Storage.openFileForRead("MDR", cachePath, f)) { + LOG_DBG("MDR", "No page index cache found"); + return false; + } + + uint32_t magic; + serialization::readPod(f, magic); + if (magic != CACHE_MAGIC) { + LOG_DBG("MDR", "Cache magic mismatch, rebuilding"); + return false; + } + + uint8_t version; + serialization::readPod(f, version); + if (version != CACHE_VERSION) { + LOG_DBG("MDR", "Cache version mismatch (%d != %d), rebuilding", version, CACHE_VERSION); + return false; + } + + uint32_t fileSize; + serialization::readPod(f, fileSize); + if (fileSize != txt->getFileSize()) { + LOG_DBG("MDR", "Cache file size mismatch, rebuilding"); + return false; + } + + int32_t cachedWidth; + serialization::readPod(f, cachedWidth); + if (cachedWidth != viewportWidth) { + LOG_DBG("MDR", "Cache viewport width mismatch, rebuilding"); + return false; + } + + int32_t cachedLines; + serialization::readPod(f, cachedLines); + if (cachedLines != linesPerPage) { + LOG_DBG("MDR", "Cache lines per page mismatch, rebuilding"); + return false; + } + + int32_t fontId; + serialization::readPod(f, fontId); + if (fontId != cachedFontId) { + LOG_DBG("MDR", "Cache font ID mismatch, rebuilding"); + return false; + } + + int32_t margin; + serialization::readPod(f, margin); + if (margin != cachedScreenMargin) { + LOG_DBG("MDR", "Cache screen margin mismatch, rebuilding"); + return false; + } + + uint8_t alignment; + serialization::readPod(f, alignment); + if (alignment != cachedParagraphAlignment) { + LOG_DBG("MDR", "Cache paragraph alignment mismatch, rebuilding"); + return false; + } + + uint32_t numPages; + serialization::readPod(f, numPages); + + pageOffsets.clear(); + // Sanity check: reject corrupt cache with absurd page count + if (numPages == 0 || numPages > 100000) { + LOG_DBG("MDR", "Cache page count out of range (%u), rebuilding", numPages); + return false; + } + + pageOffsets.reserve(numPages); + pageCodeBlockState.clear(); + pageCodeBlockState.reserve(numPages); + + for (uint32_t i = 0; i < numPages; i++) { + uint32_t pageOffset; + serialization::readPod(f, pageOffset); + uint8_t codeState; + serialization::readPod(f, codeState); + pageOffsets.push_back(pageOffset); + pageCodeBlockState.push_back(codeState); + } + + totalPages = pageOffsets.size(); + LOG_DBG("MDR", "Loaded page index cache: %d pages", totalPages); + return true; +} + +void MdReaderActivity::savePageIndexCache() const { + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!Storage.openFileForWrite("MDR", cachePath, f)) { + LOG_ERR("MDR", "Failed to save page index cache"); + return; + } + + serialization::writePod(f, CACHE_MAGIC); + serialization::writePod(f, CACHE_VERSION); + serialization::writePod(f, static_cast(txt->getFileSize())); + serialization::writePod(f, static_cast(viewportWidth)); + serialization::writePod(f, static_cast(linesPerPage)); + serialization::writePod(f, static_cast(cachedFontId)); + serialization::writePod(f, static_cast(cachedScreenMargin)); + serialization::writePod(f, cachedParagraphAlignment); + serialization::writePod(f, static_cast(pageOffsets.size())); + + for (size_t i = 0; i < pageOffsets.size(); i++) { + serialization::writePod(f, static_cast(pageOffsets[i])); + serialization::writePod(f, pageCodeBlockState[i]); + } + + LOG_DBG("MDR", "Saved page index cache: %d pages", totalPages); +} \ No newline at end of file diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 69f572a0..fa976a0e 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -11,6 +11,7 @@ #include "CrossPointState.h" #include "Epub.h" #include "EpubReaderActivity.h" +#include "MdReaderActivity.h" #include "Txt.h" #include "TxtReaderActivity.h" #include "Xtc.h" @@ -46,10 +47,9 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) { bool ReaderActivity::isXtcFile(const std::string& path) { return FsHelpers::hasXtcExtension(path); } -bool ReaderActivity::isTxtFile(const std::string& path) { - return FsHelpers::hasTxtExtension(path) || - FsHelpers::hasMarkdownExtension(path); // Treat .md as txt files (until we have a markdown reader) -} +bool ReaderActivity::isTxtFile(const std::string& path) { return FsHelpers::hasTxtExtension(path); } + +bool ReaderActivity::isMDFile(const std::string& path) { return FsHelpers::hasMarkdownExtension(path); } bool ReaderActivity::isImageFile(const std::string& path) { return FsHelpers::hasBmpExtension(path) || FsHelpers::hasJpgExtension(path) || FsHelpers::hasPngExtension(path); @@ -130,6 +130,12 @@ void ReaderActivity::onGoToTxtReader(std::unique_ptr txt) { activityManager.replaceActivity(std::make_unique(renderer, mappedInput, std::move(txt))); } +void ReaderActivity::onGoToMdReader(std::unique_ptr txt) { + const auto txtPath = txt->getPath(); + currentBookPath = txtPath; + activityManager.replaceActivity(std::make_unique(renderer, mappedInput, std::move(txt))); +} + void ReaderActivity::onEnter() { Activity::onEnter(); logReaderLaunchMemSnapshot("onEnter_begin"); @@ -161,6 +167,13 @@ void ReaderActivity::onEnter() { return; } onGoToXtcReader(std::move(xtc)); + } else if (isMdFile(initialBookPath)) { + auto txt = loadTxt(initialBookPath); + if (!txt) { + onGoBack(); + return; + } + onGoToMdReader(std::move(txt)); } else if (isTxtFile(initialBookPath)) { auto txt = loadTxt(initialBookPath); if (!txt) { diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index efdfd201..51258ad1 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -16,6 +16,7 @@ class ReaderActivity final : public Activity { static std::unique_ptr loadTxt(const std::string& path); static bool isXtcFile(const std::string& path); static bool isTxtFile(const std::string& path); + static bool isMDFile(const std::string& path); static bool isImageFile(const std::string& path); static std::string extractFolderPath(const std::string& filePath); @@ -23,6 +24,7 @@ class ReaderActivity final : public Activity { void onGoToEpubReader(std::unique_ptr epub); void onGoToXtcReader(std::unique_ptr xtc); void onGoToTxtReader(std::unique_ptr txt); + void onGoToMdReader(std::unique_ptr txt); void onGoToBmpViewer(const std::string& path); void onGoBack();