Merge pull request #124 from jpirnay/feat-markdown
feat: add + extend Markdown (.md) file reader #1698 by dcherrera
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
#include "MdParser.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
namespace MdParser {
|
||||
|
||||
static EpdFontFamily::Style combineFlags(bool bold, bool italic) {
|
||||
if (bold && italic) return EpdFontFamily::BOLD_ITALIC;
|
||||
if (bold) return EpdFontFamily::BOLD;
|
||||
if (italic) return EpdFontFamily::ITALIC;
|
||||
return EpdFontFamily::REGULAR;
|
||||
}
|
||||
|
||||
static constexpr int TAB_WIDTH = 4;
|
||||
|
||||
static std::string trimLeft(const std::string& s) {
|
||||
size_t i = 0;
|
||||
while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) i++;
|
||||
return s.substr(i);
|
||||
}
|
||||
|
||||
static uint8_t parseListIndentLevel(size_t leadingSpaces) {
|
||||
// Top-level list markers may be preceded by up to 3 spaces.
|
||||
// Nested list items require at least 4 spaces before the marker.
|
||||
if (leadingSpaces < TAB_WIDTH) {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<uint8_t>((leadingSpaces - TAB_WIDTH) / TAB_WIDTH + 1);
|
||||
}
|
||||
|
||||
static bool isWordChar(char c) { return std::isalnum(static_cast<unsigned char>(c)) || c == '_'; }
|
||||
|
||||
static bool isUnderscoreEmphasis(const std::string& text, size_t pos, size_t count) {
|
||||
if (pos == 0 || pos + count >= text.size()) {
|
||||
return true;
|
||||
}
|
||||
const char before = text[pos - 1];
|
||||
const char after = text[pos + count];
|
||||
return !(isWordChar(before) && isWordChar(after));
|
||||
}
|
||||
|
||||
static bool isHorizontalRuleLine(const std::string& line) {
|
||||
// Must be at least 3 chars of the same marker (-, *, _) with optional spaces
|
||||
if (line.size() < 3) return false;
|
||||
|
||||
char marker = 0;
|
||||
int count = 0;
|
||||
for (char c : line) {
|
||||
if (c == ' ' || c == '\t') continue;
|
||||
if (c == '-' || c == '*' || c == '_') {
|
||||
if (marker == 0) marker = c;
|
||||
if (c != marker) return false;
|
||||
count++;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return count >= 3;
|
||||
}
|
||||
|
||||
std::vector<Span> parseInline(const std::string& text) {
|
||||
std::vector<Span> spans;
|
||||
std::string current;
|
||||
bool bold = false;
|
||||
bool italic = false;
|
||||
size_t i = 0;
|
||||
|
||||
auto emitSpan = [&]() {
|
||||
if (!current.empty()) {
|
||||
spans.push_back({std::move(current), combineFlags(bold, italic)});
|
||||
current.clear();
|
||||
}
|
||||
};
|
||||
|
||||
char boldMarker = 0;
|
||||
char italicMarker = 0;
|
||||
|
||||
while (i < text.size()) {
|
||||
char c = text[i];
|
||||
|
||||
// Escaped character
|
||||
if (c == '\\' && i + 1 < text.size()) {
|
||||
char next = text[i + 1];
|
||||
if (next == '*' || next == '_' || next == '`' || next == '[' || next == '!' || next == '\\') {
|
||||
current += next;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// *** or ___ — toggle both bold and italic when marker matches the current open markers
|
||||
if ((c == '*' || c == '_') && i + 2 < text.size() && text[i + 1] == c && text[i + 2] == c) {
|
||||
if (c == '_' && !isUnderscoreEmphasis(text, i, 3)) {
|
||||
current.append(3, c);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
emitSpan();
|
||||
if (bold && italic && boldMarker == c && italicMarker == c) {
|
||||
bold = false;
|
||||
italic = false;
|
||||
boldMarker = 0;
|
||||
italicMarker = 0;
|
||||
} else {
|
||||
bold = true;
|
||||
italic = true;
|
||||
boldMarker = c;
|
||||
italicMarker = c;
|
||||
}
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ** or __ — toggle bold
|
||||
if ((c == '*' || c == '_') && i + 1 < text.size() && text[i + 1] == c) {
|
||||
if (c == '_' && !isUnderscoreEmphasis(text, i, 2)) {
|
||||
current.append(2, c);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
emitSpan();
|
||||
if (bold && boldMarker == c) {
|
||||
bold = false;
|
||||
boldMarker = 0;
|
||||
} else {
|
||||
bold = true;
|
||||
boldMarker = c;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// * or _ — toggle italic
|
||||
if (c == '*' || c == '_') {
|
||||
if (c == '_' && !isUnderscoreEmphasis(text, i, 1)) {
|
||||
current.push_back(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
emitSpan();
|
||||
if (italic && italicMarker == c) {
|
||||
italic = false;
|
||||
italicMarker = 0;
|
||||
} else {
|
||||
italic = true;
|
||||
italicMarker = c;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Backtick code span — strip backticks, render as regular
|
||||
if (c == '`') {
|
||||
size_t end = text.find('`', i + 1);
|
||||
if (end != std::string::npos) {
|
||||
emitSpan();
|
||||
spans.push_back({text.substr(i + 1, end - i - 1), EpdFontFamily::REGULAR});
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
current += c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Image  — show [alt]
|
||||
if (c == '!' && i + 1 < text.size() && text[i + 1] == '[') {
|
||||
size_t closeBracket = text.find(']', i + 2);
|
||||
if (closeBracket != std::string::npos && closeBracket + 1 < text.size() && text[closeBracket + 1] == '(') {
|
||||
size_t closeParen = text.find(')', closeBracket + 2);
|
||||
if (closeParen != std::string::npos) {
|
||||
std::string alt = text.substr(i + 2, closeBracket - i - 2);
|
||||
current += "[";
|
||||
current += alt;
|
||||
current += "]";
|
||||
i = closeParen + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
current += c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Link [text](url) — show text only
|
||||
if (c == '[') {
|
||||
size_t closeBracket = text.find(']', i + 1);
|
||||
if (closeBracket != std::string::npos && closeBracket + 1 < text.size() && text[closeBracket + 1] == '(') {
|
||||
size_t closeParen = text.find(')', closeBracket + 2);
|
||||
if (closeParen != std::string::npos) {
|
||||
current += text.substr(i + 1, closeBracket - i - 1);
|
||||
i = closeParen + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
current += c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
current += c;
|
||||
i++;
|
||||
}
|
||||
|
||||
emitSpan();
|
||||
|
||||
// If bold/italic were left open, the text had unmatched markers.
|
||||
// The spans are still usable — the trailing text just keeps the toggled style.
|
||||
return spans;
|
||||
}
|
||||
|
||||
bool isCodeFence(const std::string& line) {
|
||||
auto trimmed = trimLeft(line);
|
||||
if (trimmed.size() < 3) return false;
|
||||
// Must start with ``` (with optional language tag after)
|
||||
if (trimmed[0] == '`' && trimmed[1] == '`' && trimmed[2] == '`') return true;
|
||||
// Also support ~~~ fences
|
||||
if (trimmed[0] == '~' && trimmed[1] == '~' && trimmed[2] == '~') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Detect task list checkbox at start of list content, update prefix accordingly.
|
||||
// Returns content with the checkbox marker stripped.
|
||||
static std::string handleTaskList(const std::string& content, std::string& listPrefix) {
|
||||
if (content.size() >= 3 && content[0] == '[' && content[2] == ']') {
|
||||
char mark = content[1];
|
||||
if (mark == 'x' || mark == 'X') {
|
||||
listPrefix = "☑ ";
|
||||
} else if (mark == ' ') {
|
||||
listPrefix = "☐ ";
|
||||
} else {
|
||||
return content; // Not a checkbox — keep content as-is
|
||||
}
|
||||
size_t skip = 3;
|
||||
if (skip < content.size() && content[skip] == ' ') skip++;
|
||||
return content.substr(skip);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock) {
|
||||
ParsedLine result;
|
||||
|
||||
// Inside a code block: either closing fence or verbatim text
|
||||
if (inCodeBlock) {
|
||||
if (isCodeFence(rawLine)) {
|
||||
result.blockType = BlockType::CodeBlock;
|
||||
return result;
|
||||
}
|
||||
result.blockType = BlockType::CodeBlock;
|
||||
result.spans.push_back({rawLine, EpdFontFamily::REGULAR});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Opening code fence
|
||||
if (isCodeFence(rawLine)) {
|
||||
result.blockType = BlockType::CodeBlock;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Count leading whitespace for nesting level before trimming.
|
||||
// Up to 3 spaces before a list marker are still top-level in CommonMark.
|
||||
size_t leadingSpaces = 0;
|
||||
for (size_t i = 0; i < rawLine.size(); i++) {
|
||||
if (rawLine[i] == ' ')
|
||||
leadingSpaces++;
|
||||
else if (rawLine[i] == '\t')
|
||||
leadingSpaces += TAB_WIDTH; // Treat tab as 4 spaces to match CommonMark nesting rules
|
||||
else
|
||||
break;
|
||||
}
|
||||
result.indentLevel = parseListIndentLevel(leadingSpaces);
|
||||
|
||||
std::string trimmed = trimLeft(rawLine);
|
||||
|
||||
// Blank line
|
||||
if (trimmed.empty()) {
|
||||
result.blockType = BlockType::BlankLine;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Horizontal rule (must check BEFORE unordered list since --- and *** overlap)
|
||||
if (isHorizontalRuleLine(trimmed)) {
|
||||
result.blockType = BlockType::HorizontalRule;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ATX headers: # H1, ## H2, ### H3+
|
||||
if (trimmed[0] == '#') {
|
||||
int level = 0;
|
||||
size_t pos = 0;
|
||||
while (pos < trimmed.size() && trimmed[pos] == '#') {
|
||||
level++;
|
||||
pos++;
|
||||
}
|
||||
if (pos < trimmed.size() && trimmed[pos] == ' ') {
|
||||
std::string content = trimmed.substr(pos + 1);
|
||||
// Strip optional trailing # sequence
|
||||
size_t trail = content.size();
|
||||
while (trail > 0 && content[trail - 1] == '#') trail--;
|
||||
while (trail > 0 && content[trail - 1] == ' ') trail--;
|
||||
if (trail < content.size()) content = content.substr(0, trail);
|
||||
|
||||
if (level <= 1)
|
||||
result.blockType = BlockType::Header1;
|
||||
else if (level == 2)
|
||||
result.blockType = BlockType::Header2;
|
||||
else
|
||||
result.blockType = BlockType::Header3;
|
||||
|
||||
result.spans = parseInline(content);
|
||||
// Force bold on all header spans
|
||||
for (auto& span : result.spans) {
|
||||
if (span.style == EpdFontFamily::REGULAR)
|
||||
span.style = EpdFontFamily::BOLD;
|
||||
else if (span.style == EpdFontFamily::ITALIC)
|
||||
span.style = EpdFontFamily::BOLD_ITALIC;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Unordered list: - , * , + (marker followed by space)
|
||||
if (trimmed.size() > 1 && trimmed[1] == ' ' && (trimmed[0] == '-' || trimmed[0] == '*' || trimmed[0] == '+')) {
|
||||
result.blockType = BlockType::UnorderedList;
|
||||
result.listPrefix = "\xe2\x80\xa2 "; // "• "
|
||||
std::string content = handleTaskList(trimmed.substr(2), result.listPrefix);
|
||||
result.spans = parseInline(content);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Ordered list: 1. , 2. , etc. (up to 3-digit number)
|
||||
{
|
||||
size_t dotPos = trimmed.find(". ");
|
||||
if (dotPos != std::string::npos && dotPos <= 3 && dotPos > 0) {
|
||||
bool allDigits = true;
|
||||
for (size_t j = 0; j < dotPos; j++) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(trimmed[j]))) {
|
||||
allDigits = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allDigits) {
|
||||
result.blockType = BlockType::OrderedList;
|
||||
result.listPrefix = trimmed.substr(0, dotPos + 2); // e.g. "1. "
|
||||
std::string content = handleTaskList(trimmed.substr(dotPos + 2), result.listPrefix);
|
||||
result.spans = parseInline(content);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blockquote: > text
|
||||
if (trimmed[0] == '>') {
|
||||
result.blockType = BlockType::Blockquote;
|
||||
std::string content = trimmed.substr(1);
|
||||
if (!content.empty() && content[0] == ' ') content = content.substr(1);
|
||||
result.spans = parseInline(content);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Default: paragraph
|
||||
result.blockType = BlockType::Paragraph;
|
||||
result.spans = parseInline(trimmed);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace MdParser
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <EpdFontFamily.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace MdParser {
|
||||
|
||||
struct Span {
|
||||
std::string text;
|
||||
EpdFontFamily::Style style;
|
||||
};
|
||||
|
||||
enum class BlockType : uint8_t {
|
||||
Paragraph,
|
||||
Header1,
|
||||
Header2,
|
||||
Header3,
|
||||
UnorderedList,
|
||||
OrderedList,
|
||||
Blockquote,
|
||||
CodeBlock,
|
||||
HorizontalRule,
|
||||
BlankLine
|
||||
};
|
||||
|
||||
struct ParsedLine {
|
||||
BlockType blockType = BlockType::Paragraph;
|
||||
std::vector<Span> spans;
|
||||
std::string listPrefix; // "• " or "1. " etc.
|
||||
uint8_t indentLevel = 0; // Nesting depth (each 4 spaces = 1 level)
|
||||
};
|
||||
|
||||
// Parse a single raw line of markdown into block type and styled spans.
|
||||
// |inCodeBlock| indicates whether the line is inside a fenced code block.
|
||||
ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock);
|
||||
|
||||
// Returns true if the line is a code fence (``` with optional language tag).
|
||||
bool isCodeFence(const std::string& line);
|
||||
|
||||
// Parse inline markdown formatting (bold, italic, code spans, links, images).
|
||||
std::vector<Span> parseInline(const std::string& text);
|
||||
|
||||
} // namespace MdParser
|
||||
+3
-1
@@ -40,9 +40,11 @@ std::string Txt::getTitle() const {
|
||||
size_t lastSlash = filepath.find_last_of('/');
|
||||
std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath;
|
||||
|
||||
// Remove .txt extension
|
||||
// Remove .txt or .md extension
|
||||
if (FsHelpers::hasTxtExtension(filename)) {
|
||||
filename = filename.substr(0, filename.length() - 4);
|
||||
} else if (FsHelpers::hasMarkdownExtension(filename)) {
|
||||
filename = filename.substr(0, filename.length() - 3);
|
||||
}
|
||||
|
||||
return filename;
|
||||
|
||||
+1
-1
Submodule open-x4-sdk updated: a931d452d4...ed5cb2f99d
@@ -0,0 +1,895 @@
|
||||
#include "MdReaderActivity.h"
|
||||
|
||||
#include <FontCacheManager.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Serialization.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <numeric>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "MdReaderTocSelectionActivity.h"
|
||||
#include "ReaderUtils.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
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() {
|
||||
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::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();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
pageBuffer.reserve(CHUNK_SIZE + 1);
|
||||
scanHeadings();
|
||||
|
||||
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();
|
||||
}
|
||||
assignHeadingPageNumbers();
|
||||
|
||||
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, bool isCodeBlock) {
|
||||
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());
|
||||
|
||||
const int listPrefixIndent =
|
||||
!parsed.listPrefix.empty()
|
||||
? renderer.getTextAdvanceX(cachedFontId, parsed.listPrefix.c_str(), EpdFontFamily::REGULAR)
|
||||
: 0;
|
||||
|
||||
// Check if everything fits on one line
|
||||
int totalWidth = measureSpans(allSpans);
|
||||
if (totalWidth <= availableWidth) {
|
||||
RenderedLine rl;
|
||||
rl.spans = std::move(allSpans);
|
||||
rl.indent = indent;
|
||||
rl.isCodeBlock = isCodeBlock;
|
||||
outLines.push_back(std::move(rl));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Word-wrap across spans
|
||||
const int continuationIndent = indent + listPrefixIndent;
|
||||
RenderedLine currentLine;
|
||||
currentLine.indent = indent;
|
||||
currentLine.isCodeBlock = isCodeBlock;
|
||||
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 = continuationIndent;
|
||||
currentLine.isCodeBlock = isCodeBlock;
|
||||
currentWidth = 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
currentLine.spans.push_back({remaining.substr(0, breakPos), style});
|
||||
outLines.push_back(std::move(currentLine));
|
||||
currentLine = RenderedLine();
|
||||
currentLine.indent = continuationIndent;
|
||||
currentLine.isCodeBlock = isCodeBlock;
|
||||
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 bufferSize = std::min(CHUNK_SIZE, fileSize - offset);
|
||||
pageBuffer.resize(bufferSize + 1);
|
||||
if (!txt->readContent(pageBuffer.data(), offset, bufferSize)) {
|
||||
return false;
|
||||
}
|
||||
pageBuffer[bufferSize] = '\0';
|
||||
|
||||
bool inCodeBlock = startInCodeBlock;
|
||||
size_t pos = 0;
|
||||
|
||||
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 < bufferSize && pageBuffer[lineEnd] != '\n') {
|
||||
lineEnd++;
|
||||
}
|
||||
|
||||
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 && pageBuffer[pos + lineContentLen - 1] == '\r');
|
||||
size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen;
|
||||
|
||||
std::string rawLine(reinterpret_cast<char*>(pageBuffer.data() + 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,
|
||||
parsed.blockType == MdParser::BlockType::CodeBlock);
|
||||
|
||||
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;
|
||||
return !outLines.empty();
|
||||
}
|
||||
|
||||
void MdReaderActivity::buildPageIndex() {
|
||||
pageOffsets.clear();
|
||||
pageCodeBlockState.clear();
|
||||
|
||||
const size_t fileSize = txt->getFileSize();
|
||||
if (fileSize == 0) {
|
||||
totalPages = 0;
|
||||
LOG_DBG("MDR", "Empty markdown file, no pages");
|
||||
return;
|
||||
}
|
||||
|
||||
pageOffsets.push_back(0);
|
||||
pageCodeBlockState.push_back(0);
|
||||
|
||||
size_t offset = 0;
|
||||
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);
|
||||
|
||||
std::function<void()> 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);
|
||||
y += lineHeight;
|
||||
} else {
|
||||
if (line.isCodeBlock) {
|
||||
const int barX = cachedOrientedMarginLeft + std::max(line.indent - 6, 0);
|
||||
renderer.drawLine(barX, y + 2, barX, y + lineHeight - 2);
|
||||
}
|
||||
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(); });
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
uint32_t page = static_cast<uint32_t>(currentPage < 0 ? 0 : currentPage);
|
||||
uint8_t data[4];
|
||||
data[0] = page & 0xFF;
|
||||
data[1] = (page >> 8) & 0xFF;
|
||||
data[2] = (page >> 16) & 0xFF;
|
||||
data[3] = (page >> 24) & 0xFF;
|
||||
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) {
|
||||
uint32_t loadedPage = static_cast<uint32_t>(data[0]) | (static_cast<uint32_t>(data[1]) << 8) |
|
||||
(static_cast<uint32_t>(data[2]) << 16) | (static_cast<uint32_t>(data[3]) << 24);
|
||||
if (totalPages == 0) {
|
||||
currentPage = 0;
|
||||
} else if (loadedPage >= static_cast<uint32_t>(totalPages)) {
|
||||
currentPage = totalPages - 1;
|
||||
} else {
|
||||
currentPage = static_cast<int>(loadedPage);
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#pragma once
|
||||
|
||||
#include <MdParser.h>
|
||||
#include <Txt.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#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;
|
||||
|
||||
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
|
||||
bool isCodeBlock = false;
|
||||
};
|
||||
|
||||
// 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;
|
||||
std::vector<uint8_t> pageBuffer;
|
||||
|
||||
std::vector<MdHeading> headings;
|
||||
int currentHeadingIndex = -1;
|
||||
|
||||
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();
|
||||
void scanHeadings();
|
||||
void assignHeadingPageNumbers();
|
||||
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.
|
||||
bool wordWrapParsedLine(const MdParser::ParsedLine& parsed, int indent, std::vector<RenderedLine>& outLines,
|
||||
int maxLines, bool isCodeBlock = false);
|
||||
|
||||
// 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; }
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
#include "MdReaderTocSelectionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#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 indentRelative = 20 + (heading.level - 1) * 10;
|
||||
const int drawX = contentRect.x + indentRelative;
|
||||
const std::string title =
|
||||
renderer.truncatedText(UI_10_FONT_ID, heading.title.c_str(), contentRect.width - 40 - indentRelative);
|
||||
renderer.drawText(UI_10_FONT_ID, drawX, 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 "../Activity.h"
|
||||
#include "MdReaderActivity.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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -2278,9 +2278,10 @@
|
||||
if (file.isDirectory) return 0;
|
||||
const ext = file.name.includes('.') ? file.name.split('.').pop().toLowerCase() : '';
|
||||
if (ext === 'epub') return 1;
|
||||
if (ext === 'xtc') return 2;
|
||||
if (ext === 'xtc' || ext === 'xtch') return 2;
|
||||
if (ext === 'txt') return 3;
|
||||
return 4;
|
||||
if (ext === 'md') return 4;
|
||||
return 5;
|
||||
}
|
||||
|
||||
function sortFiles(files) {
|
||||
@@ -2355,11 +2356,18 @@
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += file.name;
|
||||
|
||||
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
|
||||
const ext = file.name.includes('.') ? file.name.split('.').pop().toLowerCase() : '';
|
||||
const isXtc = ext === 'xtc' || ext === 'xtch';
|
||||
const isMd = ext === 'md';
|
||||
const fileIcon = file.isEpub ? '📗' : isXtc ? '📘' : isMd ? '📝' : '📄';
|
||||
const fileBadge = file.isEpub ? 'EPUB' : isXtc ? 'XTC' : ext === 'txt' ? 'TXT' : isMd ? 'MD' : '';
|
||||
const rowClass = file.isEpub ? 'epub-file' : isXtc ? 'xtc-file' : '';
|
||||
|
||||
fileTableContent += `<tr class="${rowClass}">`;
|
||||
fileTableContent += `<td style="text-align:center"><input type="checkbox" class="select-item" data-path="${encodeURIComponent(filePath)}" data-name="${escapeHtml(file.name)}" data-type="file"></td>`;
|
||||
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>`;
|
||||
fileTableContent += `<td><span class="file-icon">${fileIcon}</span>`;
|
||||
fileTableContent += `<a rel="noopener noreferrer" target="_blank" href="/download?path=${encodeURIComponent(filePath)}" class="file-link">${escapeHtml(displayFileName(file.name))}</a>`;
|
||||
if (file.isEpub) fileTableContent += '<span class="epub-badge">EPUB</span>';
|
||||
if (fileBadge) fileTableContent += `<span class="epub-badge">${fileBadge}</span>`;
|
||||
fileTableContent += '</td>';
|
||||
fileTableContent += `<td>${file.name.includes('.') ? file.name.split('.').pop().toUpperCase() : '-'}</td>`;
|
||||
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#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);
|
||||
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);
|
||||
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);
|
||||
PASS();
|
||||
}
|
||||
|
||||
void testNestedUnorderedListIndentLevel() {
|
||||
printf("testNestedUnorderedListIndentLevel...\n");
|
||||
auto parsed = MdParser::parseLine(" - nested item", false);
|
||||
ASSERT_EQ(parsed.blockType, MdParser::BlockType::UnorderedList);
|
||||
ASSERT_EQ(parsed.listPrefix, "\xe2\x80\xa2 ");
|
||||
ASSERT_EQ(parsed.indentLevel, 1);
|
||||
ASSERT_EQ(flattenText(parsed.spans), "nested item");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void testNestedOrderedListIndentLevel() {
|
||||
printf("testNestedOrderedListIndentLevel...\n");
|
||||
auto parsed = MdParser::parseLine(" 1. nested ordered", false);
|
||||
ASSERT_EQ(parsed.blockType, MdParser::BlockType::OrderedList);
|
||||
ASSERT_EQ(parsed.listPrefix, "1. ");
|
||||
ASSERT_EQ(parsed.indentLevel, 2);
|
||||
ASSERT_EQ(flattenText(parsed.spans), "nested ordered");
|
||||
PASS();
|
||||
}
|
||||
|
||||
int main() {
|
||||
printf("=== Markdown Parser Tests ===\n\n");
|
||||
|
||||
testSnakeCaseUnderscoresRemainLiteral();
|
||||
testUnderscoreWithinExpressionRemainsLiteral();
|
||||
testUnderscoreEmphasisStillWorks();
|
||||
testAsteriskEmphasisStillWorks();
|
||||
testUnderscoreBoldWorks();
|
||||
testNestedUnorderedListIndentLevel();
|
||||
testNestedOrderedListIndentLevel();
|
||||
|
||||
printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed);
|
||||
return testsFailed > 0 ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user