diff --git a/CMakeLists.txt b/CMakeLists.txt index 9107521..feecbbb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,6 +41,7 @@ set(FLORA_HEADERS src/core/FileStore.h src/core/SettingsStore.h src/git/GitManager.h + src/markdown/MdParser.h src/sync/SyncManager.h ) @@ -51,6 +52,7 @@ set(FLORA_SOURCES src/core/FileStore.cpp src/core/SettingsStore.cpp src/git/GitManager.cpp + src/markdown/MdParser.cpp src/sync/SyncManager.cpp ) @@ -61,6 +63,9 @@ add_library(flora_core STATIC ) target_include_directories(flora_core PUBLIC src) target_compile_features(flora_core PUBLIC cxx_std_17) +target_compile_definitions(flora_core PRIVATE + CMARK_GFM_STATIC_DEFINE + CMARK_GFM_EXTENSIONS_STATIC_DEFINE) target_link_libraries(flora_core PUBLIC Qt6::Quick Qt6::QuickControls2 @@ -73,6 +78,9 @@ target_link_libraries(flora_core PUBLIC Qt6::Svg cmark::cmark ) +if(TARGET cmark::cmark_extensions) + target_link_libraries(flora_core PUBLIC cmark::cmark_extensions) +endif() if(TARGET Flora_libgit2_static) target_link_libraries(flora_core PUBLIC Flora_libgit2_static) else() @@ -81,6 +89,9 @@ endif() if(TARGET libssh2_static) target_link_libraries(flora_core PUBLIC libssh2_static) endif() +if(TARGET KF6SyntaxHighlighting) + target_link_libraries(flora_core PUBLIC KF6SyntaxHighlighting) +endif() # --- Приложение (QML) --- qt_add_executable(flora src/main.cpp) diff --git a/qml/panels/EditorPane.qml b/qml/panels/EditorPane.qml index 025d8fd..e0a419b 100644 --- a/qml/panels/EditorPane.qml +++ b/qml/panels/EditorPane.qml @@ -237,18 +237,61 @@ Item { id: viewer anchors.fill: parent visible: !pane.editMode - text: documentManager.contentAt(pane.docIndex) + textFormat: TextEdit.RichText + text: "" readOnly: true selectByMouse: true - font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace" - font.pixelSize: 14 wrapMode: TextEdit.Wrap padding: 12 + onLinkActivated: (href) => pane.handleViewLink(href) placeholderText: qsTr("Документ не выбран") } } } + // ---------- Просмотр (режим просмотра) ---------- + function pathDir(p) { + const idx = p.lastIndexOf("/") + return idx >= 0 ? p.substring(0, idx) : p + } + function renderView() { + if (pane.editMode || pane.docIndex < 0) + return + viewer.text = mdParser.render(documentManager.contentAt(pane.docIndex), + fileStore.vault, + pathDir(documentManager.pathAt(pane.docIndex))) + } + function handleViewLink(href) { + if (href.startsWith("flora://task/")) { + const line = Number(href.substring("flora://task/".length)) + if (line > 0) pane.toggleTaskLine(line) + return + } + if (href.startsWith("flora://open/")) { + const path = decodeURIComponent(href.substring("flora://open/".length)) + if (path.length > 0) documentManager.openDocument(path) + return + } + if (href.startsWith("flora://wiki/")) { + // Фаза 8: создание через базу связей; пока — статус. + console.log("wiki:", decodeURIComponent(href)) + return + } + console.log("внешняя ссылка:", href) + } + function toggleTaskLine(line) { + const text = documentManager.contentAt(pane.docIndex) + const lines = text.split("\n") + const ln = lines[line - 1] + if (ln === undefined) return + if (/\[ \]/.test(ln)) + lines[line - 1] = ln.replace("[ ]", "[x]") + else if (/\[x\]/.test(ln)) + lines[line - 1] = ln.replace("[x]", "[ ]") + documentManager.updateContent(pane.docIndex, lines.join("\n")) + pane.renderView() + } + // Свежий заголовок при смене признака «изменено» Connections { target: documentManager @@ -262,10 +305,17 @@ Item { target: documentManager function onContentChanged(i) { if (i === pane.docIndex && !pane.editMode) - viewer.text = documentManager.contentAt(i) + pane.renderView() } } - onDocIndexChanged: pane.refreshHeader() + onDocIndexChanged: { + pane.refreshHeader() + pane.renderView() + } + onEditModeChanged: { + if (!pane.editMode) + pane.renderView() + } Component.onCompleted: pane.refreshHeader() } \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 18d963d..900d243 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,6 +8,7 @@ #include "core/DocumentManager.h" #include "core/SettingsStore.h" #include "git/GitManager.h" +#include "markdown/MdParser.h" #include "sync/SyncManager.h" #include @@ -39,6 +40,7 @@ int main(int argc, char *argv[]) SyncManager syncManager; VaultManager vaultManager(&settingsStore, &gitManager); DocumentManager documentManager; + MdParser mdParser; QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("settingsStore", &settingsStore); @@ -46,6 +48,7 @@ int main(int argc, char *argv[]) engine.rootContext()->setContextProperty("syncManager", &syncManager); engine.rootContext()->setContextProperty("vaultManager", &vaultManager); engine.rootContext()->setContextProperty("documentManager", &documentManager); + engine.rootContext()->setContextProperty("mdParser", &mdParser); WindowController windowController( &engine, QStringLiteral("qrc:/qt/qml/Flora/qml/MainWindow.qml")); diff --git a/src/markdown/MdParser.cpp b/src/markdown/MdParser.cpp new file mode 100644 index 0000000..dd07c96 --- /dev/null +++ b/src/markdown/MdParser.cpp @@ -0,0 +1,497 @@ +#include "markdown/MdParser.h" + +#include +#include +#include +#include "table.h" +#include "strikethrough.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using KSyntaxHighlighting::AbstractHighlighter; +using KSyntaxHighlighting::Definition; +using KSyntaxHighlighting::Format; +using KSyntaxHighlighting::Repository; +using KSyntaxHighlighting::State; +using KSyntaxHighlighting::Theme; + +namespace { + +QString esc(const QString &s) +{ + QString out; + out.reserve(s.size()); + for (const QChar &c : s) { + switch (c.unicode()) { + case '&': out += "&"; break; + case '<': out += "<"; break; + case '>': out += ">"; break; + case '"': out += """; break; + default: out += c; break; + } + } + return out; +} + +QString fileHrefFor(const QString &path) +{ + const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path)); + return QStringLiteral("flora://open/") + QString::fromLatin1(enc); +} + +QString wikiHrefFor(const QString &name) +{ + const QByteArray enc = QUrl::toPercentEncoding(name); + return QStringLiteral("flora://wiki/") + QString::fromLatin1(enc); +} + +// Картинка → data: URI (если получилось прочитать), иначе "". +QString imageDataUri(const QString &path) +{ + QFile f(path); + if (!f.open(QIODevice::ReadOnly) || f.size() > 3 * 1024 * 1024) + return QString(); + const QByteArray data = f.readAll(); + QImageReader reader(path); + const QByteArray fmt = reader.format().toLower(); + const QString mime = fmt == "jpg" || fmt == "jpeg" ? QStringLiteral("image/jpeg") + : fmt == "png" ? QStringLiteral("image/png") + : fmt == "gif" ? QStringLiteral("image/gif") + : fmt == "svg" ? QStringLiteral("image/svg+xml") + : fmt == "webp" ? QStringLiteral("image/webp") + : fmt == "bmp" ? QStringLiteral("image/bmp") + : QString(); + if (mime.isEmpty()) + return QString(); + return QStringLiteral("data:%1;base64,%2") + .arg(mime, QString::fromLatin1(data.toBase64())); +} + +// Сопоставление имени из ограждения с языками KSyntaxHighlighting. +QString normalizeLang(QString l) +{ + l = l.trimmed(); + if (l.isEmpty()) + return l; + struct Map { const char *from; const char *to; }; + static const Map maps[] = { + {"С", "C"}, {"С++", "C++"}, {"с", "C"}, {"с++", "C++"}, + {"python", "Python"}, {"py", "Python"}, + {"javascript", "JavaScript"}, {"js", "JavaScript"}, {"ts", "TypeScript"}, + {"golang", "Go"}, {"go", "Go"}, {"rust", "Rust"}, + {"shell", "Bash"}, {"bash", "Bash"}, {"sh", "Bash"}, + {"csharp", "C#"}, {"c#", "C#"}, + {"jav", "Java"}, {"json", "JSON"}, {"yaml", "YAML"}, {"yml", "YAML"}, + {"html", "HTML"}, {"css", "CSS"}, {"xml", "XML"}, {"sql", "SQL"}, + {"markdown", "Markdown"}, {"md", "Markdown"}, + {"text", "Plain Text"}, {"txt", "Plain Text"} + }; + for (const Map &m : maps) { + if (l == QString::fromUtf8(m.from)) + return QString::fromUtf8(m.to); + } + return l; +} + +// Накапливает подсвеченные фрагменты кода. +class CodeHighlighter : public AbstractHighlighter +{ +public: + explicit CodeHighlighter(Repository *repo) + : m_repo(repo) + { + } + + void applyFormat(int offset, int length, const Format &format) override + { + if (offset < 0 || length <= 0) + return; + Segment seg; + seg.start = offset; + seg.length = length; + const Theme th = theme(); + seg.color = format.hasTextColor(th) + ? format.textColor(th).name() : QString(); + seg.bg = format.hasBackgroundColor(th) + ? format.backgroundColor(th).name() : QString(); + seg.bold = format.isBold(th); + m_segments.append(seg); + } + + QString highlight(const QString &code, const QString &lang) + { + m_segments.clear(); + Definition def = m_repo->definitionForName(lang); + if (!def.isValid()) + return esc(code); + setDefinition(def); + setTheme(m_repo->defaultTheme()); + + QStringList outLines; + const QStringList lines = code.split(QLatin1Char('\n')); + State state; + for (const QString &line : lines) { + m_segments.clear(); + state = highlightLine(QStringView(line), state); + outLines.append(buildLine(line)); + } + return outLines.join(QLatin1Char('\n')); + } + +private: + struct Segment { + int start = 0; + int length = 0; + QString color; + QString bg; + bool bold = false; + }; + QVector m_segments; + Repository *m_repo; + + QString buildLine(const QString &line) + { + if (m_segments.isEmpty()) + return esc(line); + QString html; + int pos = 0; + for (const Segment &seg : m_segments) { + if (seg.start > pos) + html += esc(line.mid(pos, seg.start - pos)); + QString style; + if (!seg.color.isEmpty()) + style += QStringLiteral("color:%1;").arg(seg.color); + if (!seg.bg.isEmpty()) + style += QStringLiteral("background-color:%1;").arg(seg.bg); + if (seg.bold) + style += QStringLiteral("font-weight:bold;"); + const QString body = esc(line.mid(seg.start, seg.length)); + html += QStringLiteral("%2").arg(style, body); + pos = seg.start + seg.length; + } + if (pos < line.size()) + html += esc(line.mid(pos)); + return html; + } +}; + +} // namespace + +struct MdParser::Impl +{ + Impl() + { + // Реестр языков и тем KSyntaxHighlighting + m_repo = new Repository; + } + ~Impl() { delete m_repo; } + + Repository *m_repo = nullptr; +}; + +MdParser::MdParser(QObject *parent) + : QObject(parent) + , m(new Impl) +{ +} + +namespace { + +class Renderer +{ +public: + Renderer(Repository *repo, const QString &vaultRoot, const QString &fileDir) + : m_repo(repo) + , m_vaultRoot(vaultRoot) + , m_fileDir(fileDir) + { + } + + QString renderTree(cmark_node *node) + { + outer = node; + return block(node); + } + +private: + Repository *m_repo; + QString m_vaultRoot; + QString m_fileDir; + cmark_node *outer = nullptr; + + QString childrenHtml(cmark_node *parent, bool blockMode = true) + { + QString out; + for (cmark_node *c = cmark_node_first_child(parent); c; c = cmark_node_next(c)) { + if (blockMode) { + // мягкий разрыв внутри списков не плодим + if (cmark_node_get_type(c) == CMARK_NODE_ITEM) + out += list_item(c); + else + out += block(c); + } else { + out += inlineAll(c); + } + } + return out; + } + + QString block(cmark_node *n) + { + const cmark_node_type t = cmark_node_get_type(n); + // Таблицы — extern-переменные (нельзя в case) + if (t == CMARK_NODE_TABLE || t == CMARK_NODE_TABLE_ROW + || t == CMARK_NODE_TABLE_CELL) + return list(n); + + switch (t) { + case CMARK_NODE_DOCUMENT: + return childrenHtml(n); + case CMARK_NODE_BLOCK_QUOTE: + return QStringLiteral("
") + childrenHtml(n) + QStringLiteral("
"); + case CMARK_NODE_LIST: + return list(n); + case CMARK_NODE_ITEM: + return list_item(n); + case CMARK_NODE_HEADING: { + const int lvl = cmark_node_get_heading_level(n); + return QStringLiteral("").arg(lvl) + + childrenHtml(n, false) + + QStringLiteral("").arg(lvl); + } + case CMARK_NODE_CODE_BLOCK: + return code_block(n); + case CMARK_NODE_THEMATIC_BREAK: + return QStringLiteral("
"); + case CMARK_NODE_PARAGRAPH: + return QStringLiteral("

") + childrenHtml(n, false) + QStringLiteral("

"); + case CMARK_NODE_HTML_BLOCK: + return QStringLiteral("

%1

").arg(esc(QString::fromUtf8(cmark_node_get_literal(n)))); + default: + return QString(); + } + } + + QString list(cmark_node *n) + { + const cmark_node_type t = cmark_node_get_type(n); + if (t == CMARK_NODE_TABLE) { + QString h = QStringLiteral(""); + int i = 0; + for (cmark_node *row = cmark_node_first_child(n); row; row = cmark_node_next(row), ++i) { + h += QStringLiteral(""); + for (cmark_node *cell = cmark_node_first_child(row); cell; cell = cmark_node_next(cell)) { + const QString tag = i == 0 ? QStringLiteral("th") : QStringLiteral("td"); + h += QStringLiteral("<%1 style=\"border:1px solid #ccc;padding:2px 8px\">") + .arg(tag); + h += childrenHtml(cell, false); + h += QStringLiteral("").arg(tag); + } + h += QStringLiteral(""); + } + h += QStringLiteral("
"); + return h; + } + if (t == CMARK_NODE_TABLE_ROW || t == CMARK_NODE_TABLE_CELL) + return childrenHtml(n, false); + + const cmark_list_type lt = cmark_node_get_list_type(n); + const char *tag = (lt == CMARK_ORDERED_LIST) ? "ol" : "ul"; + QString h = QStringLiteral("<%1>").arg(tag); + h += childrenHtml(n); + h += QStringLiteral("").arg(tag); + return h; + } + + QString list_item(cmark_node *n) + { + const bool isTask = QString::fromUtf8(cmark_node_get_type_string(n)) + == QLatin1String("tasklist"); + QString body; + if (isTask) { + const bool checked = cmark_gfm_extensions_get_tasklist_item_checked(n); + const int startLine = int(cmark_node_get_start_line(n)); + const QString box = checked + ? QStringLiteral("\u2611").arg(startLine) + : QStringLiteral("\u2610").arg(startLine); + body = box + ' ' + childrenHtml(n); + } else { + body = childrenHtml(n); + } + return QStringLiteral("
  • ") + body + QStringLiteral("
  • "); + } + + QString code_block(cmark_node *n) + { + const char *info = cmark_node_get_fence_info(n); + const QString lang = normalizeLang(QString::fromUtf8(info ? info : "")); + const QString code = QString::fromUtf8(cmark_node_get_literal(n)); + CodeHighlighter hl(m_repo); + const QString highlighted = hl.highlight(code, lang); + const QString cls = lang.isEmpty() ? QString() : QStringLiteral(" class=\"language-%1\"").arg(esc(lang)); + return QStringLiteral("
    %2
    ").arg(cls, highlighted); + } + + QString inlineAll(cmark_node *n) + { + const cmark_node_type t = cmark_node_get_type(n); + if (t == CMARK_NODE_STRIKETHROUGH) // extern-переменная — через if, не case + return QStringLiteral("") + children(n) + QStringLiteral(""); + + switch (t) { + case CMARK_NODE_TEXT: + return inlineText(n); + case CMARK_NODE_SOFTBREAK: + return QStringLiteral("\n"); + case CMARK_NODE_LINEBREAK: + return QStringLiteral("
    "); + case CMARK_NODE_CODE: + return QStringLiteral("%1") + .arg(esc(QString::fromUtf8(cmark_node_get_literal(n)))); + case CMARK_NODE_EMPH: + return QStringLiteral("") + children(n) + QStringLiteral(""); + case CMARK_NODE_STRONG: + return QStringLiteral("") + children(n) + QStringLiteral(""); + case CMARK_NODE_LINK: + return linkNode(n); + case CMARK_NODE_IMAGE: + return imageNode(n); + case CMARK_NODE_HTML_INLINE: + return esc(QString::fromUtf8(cmark_node_get_literal(n))); + case CMARK_NODE_CUSTOM_INLINE: + return QString(); + default: + break; + } + return children(n); + } + + // Специальная обработка текста: вики-ссылки [[ ]]. + QString inlineText(cmark_node *n) + { + QString text = QString::fromUtf8(cmark_node_get_literal(n)); + if (!text.contains(QStringLiteral("[["))) + return esc(text); + QString out; + int i = 0; + while (i < text.size()) { + const int a = text.indexOf(QStringLiteral("[["), i); + if (a < 0) { + out += esc(text.mid(i)); + break; + } + out += esc(text.mid(i, a - i)); + const int b = text.indexOf(QStringLiteral("]]"), a + 2); + if (b < 0) { + out += esc(text.mid(a)); + break; + } + const QString target = text.mid(a + 2, b - a - 2); + out += wikiLink(target); + i = b + 2; + } + return out; + } + + QString wikiLink(const QString &target) + { + // ищем файл target или target.md в каталоге файла / корне хранилища + QStringList cands; + cands << m_fileDir + '/' + target << m_fileDir + '/' + target + ".md" + << m_vaultRoot + '/' + target << m_vaultRoot + '/' + target + ".md"; + for (const QString &c : cands) { + if (QFileInfo::exists(c)) + return QStringLiteral("%2") + .arg(fileHrefFor(c), esc(target)); + } + return QStringLiteral("%2") + .arg(wikiHrefFor(target), esc(target)); + } + + QString children(cmark_node *n) + { + QString out; + for (cmark_node *c = cmark_node_first_child(n); c; c = cmark_node_next(c)) + out += inlineAll(c); + return out; + } + + QString linkNode(cmark_node *n) + { + const QString url = QString::fromUtf8(cmark_node_get_url(n)); + const QString label = children(n); + const QByteArray scheme = QUrl(url).scheme().toUtf8(); + + // Внешние схемы — оставляем напрямую + if (scheme == "http" || scheme == "https" || scheme == "mailto" + || scheme == "ftp" || scheme == "flora") + return QStringLiteral("%2").arg(url, label); + + // Локальные пути: файл относительно текущего документа + QString resolved; + if (QFileInfo(url).isRelative()) + resolved = QDir(m_fileDir).filePath(url); + else + resolved = url; + + if (QFileInfo::exists(resolved)) + return QStringLiteral("%2") + .arg(fileHrefFor(resolved), label); + return label; // цель не найдена — просто текст + } + + QString imageNode(cmark_node *n) + { + const QString url = QString::fromUtf8(cmark_node_get_url(n)); + const QString alt = children(n); + QString resolved = QFileInfo(url).isRelative() + ? QDir(m_fileDir).filePath(url) + : url; + QString src = imageDataUri(resolved); + if (src.isEmpty()) { + if (QFileInfo::exists(resolved)) + src = QStringLiteral("file:%1").arg(QDir::toNativeSeparators(resolved)); + else + src = QStringLiteral(""); + } + return src.isEmpty() + ? QStringLiteral("[изображение: %1]").arg(esc(alt)) + : QStringLiteral("\"%2\"/").arg(src, esc(alt)); + } +}; + +} // namespace + +QString MdParser::render(const QString &markdown, const QString &vaultRoot, + const QString &fileDir) +{ + cmark_gfm_core_extensions_ensure_registered(); + cmark_parser *parser = cmark_parser_new(CMARK_OPT_DEFAULT); + for (const char *name : {"table", "strikethrough", "autolink", "tasklist"}) { + if (cmark_syntax_extension *ext = cmark_find_syntax_extension(name)) + cmark_parser_attach_syntax_extension(parser, ext); + } + + cmark_parser_feed(parser, markdown.toUtf8().constData(), + static_cast(markdown.toUtf8().size())); + cmark_node *doc = cmark_parser_finish(parser); + + Renderer r(m->m_repo, vaultRoot, fileDir); + QString html = r.renderTree(doc); + + cmark_node_free(doc); + cmark_parser_free(parser); + return html; +} \ No newline at end of file diff --git a/src/markdown/MdParser.h b/src/markdown/MdParser.h new file mode 100644 index 0000000..b660fc0 --- /dev/null +++ b/src/markdown/MdParser.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +// Рендер Markdown (CommonMark + GFM) в HTML для режима просмотра. +// Задачи/ссылки/переходы кодируются ссылками schemes: +// flora://task/N — переключить задачу на строке N (1-based) +// flora://open/ — открыть файл хранилища +// flora://wiki/ — вики-ссылка без найденного файла +class MdParser : public QObject +{ + Q_OBJECT + +public: + explicit MdParser(QObject *parent = nullptr); + + // markdown → HTML. vaultRoot/vaultDir — для разрешения относительных путей. + Q_INVOKABLE QString render(const QString &markdown, + const QString &vaultRoot, + const QString &fileDir); + +private: + struct Impl; + Impl *m = nullptr; +}; \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 95e9610..32bd0b4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -54,4 +54,24 @@ add_executable(flora_docmgr_test documentmanager_test.cpp target_include_directories(flora_docmgr_test PRIVATE ../src) target_link_libraries(flora_docmgr_test PRIVATE Qt6::Core) -add_test(NAME flora_docmgr_test COMMAND flora_docmgr_test) \ No newline at end of file +add_test(NAME flora_docmgr_test COMMAND flora_docmgr_test) + +# Тест MdParser (рендер Markdown → HTML). +add_executable(flora_mdparser_test mdparser_test.cpp + ../src/markdown/MdParser.cpp + ../src/markdown/MdParser.h +) +target_include_directories(flora_mdparser_test PRIVATE ../src) +target_compile_definitions(flora_mdparser_test PRIVATE + CMARK_GFM_STATIC_DEFINE + CMARK_GFM_EXTENSIONS_STATIC_DEFINE) +target_link_libraries(flora_mdparser_test PRIVATE + Qt6::Core + Qt6::Gui + cmark::cmark + cmark::cmark_extensions +) +if(TARGET KF6SyntaxHighlighting) + target_link_libraries(flora_mdparser_test PRIVATE KF6SyntaxHighlighting) +endif() +add_test(NAME flora_mdparser_test COMMAND flora_mdparser_test) \ No newline at end of file diff --git a/tests/mdparser_test.cpp b/tests/mdparser_test.cpp new file mode 100644 index 0000000..fd871f9 --- /dev/null +++ b/tests/mdparser_test.cpp @@ -0,0 +1,101 @@ +// Тест MdParser (Фаза 6): рендер Markdown → HTML +// (заголовки, жирный/курсив/зачёркнутый, таблицы, ссылки, картинки, +// задачи с переключаемыми ссылками, подсветка кода, вики-ссылки). +#include +#include +#include +#include +#include + +#include "markdown/MdParser.h" + +static int fail(const char *msg, const QString &detail = QString()) +{ + std::fprintf(stderr, "FAIL: %s | %s\n", msg, qPrintable(detail)); + return 1; +} + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + + QTemporaryDir dir; + if (!dir.isValid()) + return fail("temp dir"); + + const QString vault = dir.path(); + QDir(vault).mkpath("sub"); + { + QFile f(vault + "/sub/b.md"); + f.open(QIODevice::WriteOnly); + f.write("b"); + f.close(); + QFile g(vault + "/sub/pic.png"); + g.open(QIODevice::WriteOnly); + g.write("\x89PNG fake"); + g.close(); + } + + MdParser p; + const QString fileDir = vault + "/sub"; + + auto probe = [](const QString &html, const char *sub) { + return html.contains(QString::fromUtf8(sub)); + }; + + // Заголовок + QString h = p.render("# Заголовок\n", vault, fileDir); + if (!probe(h, "

    Заголовок

    ")) + return fail("заголовок", h); + + // Жирный / курсив / зачёркнутый + QString f = p.render("**ж** *к* ~~з~~\n", vault, fileDir); + if (!probe(f, "ж") || !probe(f, "к") || !probe(f, "з")) + return fail("инлайн-разметка", f); + + // Таблица GFM + QString t = p.render("| A | B |\n|---|---|\n| 1 | 2 |\n", vault, fileDir); + if (!probe(t, "б")) + return fail("ссылка", lk); + + // Вики-ссылка [[b]] + QString wk = p.render("[[b]]\n", vault, fileDir); + if (!probe(wk, "flora://open/") || !probe(wk, ">b")) + return fail("вики-ссылка", wk); + + // Подсветка кода python (должны появиться цветные span'ы) + QString c = p.render("```python\nprint(\"hi\")\n```\n", vault, fileDir); + if (!probe(c, "language-Python") || !probe(c, "style=\"color:")) + return fail("подсветка кода", c); + + // Простой код без языка — не должен падать + p.render("```\nпросто\n```\n", vault, fileDir); + + // Язык «С++» из кириллицы должен мапиться на C++ (подсветка сработает) + QString cpp = p.render("```С++\nint x = 1;\n```\n", vault, fileDir); + if (!probe(cpp, "language-C++")) + return fail("алиас С++", cpp); + + // Экранирование HTML + QString e = p.render("\n", vault, fileDir); + if (probe(e, "