add Markdown (.md) file reader with formatting support #1698 by dcherrera

This commit is contained in:
jpirnay
2026-04-23 21:00:19 +02:00
parent e299318851
commit a90290a596
8 changed files with 1112 additions and 6 deletions
+306
View File
@@ -0,0 +1,306 @@
#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 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<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();
}
};
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<uint8_t>(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<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;
std::vector<Span> 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<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 .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;
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include <MdParser.h>
#include <Txt.h>
#include <vector>
#include "CrossPointSettings.h"
#include "activities/Activity.h"
class MdReaderActivity final : public Activity {
std::unique_ptr<Txt> txt;
int currentPage = 0;
int totalPages = 1;
int pagesUntilFullRefresh = 0;
// A single rendered line on screen (after word-wrapping)
struct RenderedLine {
std::vector<MdParser::Span> spans;
int indent = 0; // left indent in pixels
bool isHR = false; // draw as horizontal rule
};
// Streaming reader state
std::vector<size_t> pageOffsets;
std::vector<uint8_t> pageCodeBlockState; // 1 if page starts inside a code block
std::vector<RenderedLine> 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<RenderedLine>& 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<RenderedLine>& outLines,
int maxLines);
// Measure total pixel width of a span list
int measureSpans(const std::vector<MdParser::Span>& spans) const;
public:
explicit MdReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Txt> 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; }
};
+662
View File
@@ -0,0 +1,662 @@
#include "MdReaderActivity.h"
#include <FontCacheManager.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Serialization.h>
#include <Utf8.h>
#include <numeric>
#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<uint8_t>(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<MdParser::Span>& 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<RenderedLine>& 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<MdParser::Span> 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<int>(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<RenderedLine>& 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<uint8_t*>(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<int>(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<char*>(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<int>(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<RenderedLine> 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<int>(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<uint32_t>(txt->getFileSize()));
serialization::writePod(f, static_cast<int32_t>(viewportWidth));
serialization::writePod(f, static_cast<int32_t>(linesPerPage));
serialization::writePod(f, static_cast<int32_t>(cachedFontId));
serialization::writePod(f, static_cast<int32_t>(cachedScreenMargin));
serialization::writePod(f, cachedParagraphAlignment);
serialization::writePod(f, static_cast<uint32_t>(pageOffsets.size()));
for (size_t i = 0; i < pageOffsets.size(); i++) {
serialization::writePod(f, static_cast<uint32_t>(pageOffsets[i]));
serialization::writePod(f, pageCodeBlockState[i]);
}
LOG_DBG("MDR", "Saved page index cache: %d pages", totalPages);
}
+17 -4
View File
@@ -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> txt) {
activityManager.replaceActivity(std::make_unique<TxtReaderActivity>(renderer, mappedInput, std::move(txt)));
}
void ReaderActivity::onGoToMdReader(std::unique_ptr<Txt> txt) {
const auto txtPath = txt->getPath();
currentBookPath = txtPath;
activityManager.replaceActivity(std::make_unique<MdReaderActivity>(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) {
+2
View File
@@ -16,6 +16,7 @@ class ReaderActivity final : public Activity {
static std::unique_ptr<Txt> 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> epub);
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
void onGoToTxtReader(std::unique_ptr<Txt> txt);
void onGoToMdReader(std::unique_ptr<Txt> txt);
void onGoToBmpViewer(const std::string& path);
void onGoBack();