История: diff прямо в Просмотре версии + код не в одну строку

- Подсветка: highlight() отдаёт <br/> вместо сырых \n (RichText их схлопывал — код был одной строкой); plainToRich для fallback-путей
- MdParser.parseBlocksWithDiff(old,new): поблочный LCS-diff с fence-aware разбивкой, change same/del/add, изменённые блоки в цветном div (красный/зелёный как в diff), codeId для копирования сохранены
- MarkdownView.compareMarkdown: при задании рендерит версию с подсветкой отличий
- История: Просмотр версии сравнивается со свежим содержимым с диска (workdirContent), легенда на обеих вкладках
- Тесты: <br/>, порядок del/add, тинты, identical без подсветки
This commit is contained in:
2026-09-06 19:26:53 +03:00
parent 3d04ac0095
commit beb1eb2956
7 changed files with 307 additions and 12 deletions
+15 -6
View File
@@ -30,6 +30,7 @@ Dialog {
property string selectedHash: "" property string selectedHash: ""
property string diffHtml: "" property string diffHtml: ""
property string versionContent: "" property string versionContent: ""
property string currentContent: ""
property bool busy: false property bool busy: false
function fileDirOf(p) { function fileDirOf(p) {
@@ -59,6 +60,8 @@ Dialog {
function reload() { function reload() {
if (vaultPath.length===0 || filePath.length===0) { if (vaultPath.length===0 || filePath.length===0) {
historyModel = [] historyModel = []
versionContent = ""
currentContent = ""
diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Файл не выбран") + "</p>" diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Файл не выбран") + "</p>"
return return
} }
@@ -75,6 +78,7 @@ Dialog {
selectedIndex = -1 selectedIndex = -1
selectedHash = "" selectedHash = ""
versionContent = "" versionContent = ""
currentContent = ""
const hasFile = filePath.length>0 const hasFile = filePath.length>0
if (hasFile) { if (hasFile) {
const st = gitManager.fileGitStatus(vaultPath, filePath) const st = gitManager.fileGitStatus(vaultPath, filePath)
@@ -94,12 +98,16 @@ Dialog {
if (selectedHash.length===0) { if (selectedHash.length===0) {
diffHtml = "" diffHtml = ""
versionContent = "" versionContent = ""
currentContent = ""
return return
} }
// свежее содержимое с диска — diff всегда актуален, даже если файл правили при открытом диалоге
currentContent = gitManager.workdirContent(filePath)
const html = gitManager.fileDiffHtml(vaultPath, filePath, selectedHash) const html = gitManager.fileDiffHtml(vaultPath, filePath, selectedHash)
if (html.length===0) diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Нет изменений") + "</p>" if (html.length===0) diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Нет изменений") + "</p>"
else diffHtml = html else diffHtml = html
// содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре» // содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре»,
// с подсветкой отличий от текущего файла прямо там
versionContent = gitManager.fileContentAt(vaultPath, filePath, selectedHash) versionContent = gitManager.fileContentAt(vaultPath, filePath, selectedHash)
} }
@@ -342,6 +350,7 @@ Dialog {
id: versionPreview id: versionPreview
width: parent.width width: parent.width
markdown: root.versionContent markdown: root.versionContent
compareMarkdown: root.currentContent
vaultRoot: root.vaultPath vaultRoot: root.vaultPath
fileDir: root.fileDirOf(root.filePath) fileDir: root.fileDirOf(root.filePath)
emptyText: qsTr("Нет версии для просмотра") emptyText: qsTr("Нет версии для просмотра")
@@ -352,18 +361,18 @@ Dialog {
} }
} }
// Легенда — только для вкладки «Изменения» // Легенда — цвета отличий от текущего файла (обе вкладки)
Row { Row {
visible: tabBar.currentIndex === 0
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: tabBar.currentIndex === 0 ? implicitHeight : 0
Layout.margins: 6 Layout.margins: 6
spacing: 12 spacing: 12
Row { spacing: 4; Rectangle { width:12; height:12; color:"#e6ffec"; border.color:"#1a7f37"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("добавлено"); color: Theme.textMuted; font.pixelSize:10 } } Row { spacing: 4; Rectangle { width:12; height:12; color:"#e6ffec"; border.color:"#1a7f37"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("добавлено"); color: Theme.textMuted; font.pixelSize:10 } }
Row { spacing: 4; Rectangle { width:12; height:12; color:"#ffebe9"; border.color:"#cf222e"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("удалено"); color: Theme.textMuted; font.pixelSize:10 } } Row { spacing: 4; Rectangle { width:12; height:12; color:"#ffebe9"; border.color:"#cf222e"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("удалено"); color: Theme.textMuted; font.pixelSize:10 } }
Row { spacing: 4; Rectangle { width:12; height:12; color:"#ddf4ff"; border.color:"#0969da"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("заголовок"); color: Theme.textMuted; font.pixelSize:10 } } Row { visible: tabBar.currentIndex === 0; spacing: 4; Rectangle { width:12; height:12; color:"#ddf4ff"; border.color:"#0969da"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("заголовок"); color: Theme.textMuted; font.pixelSize:10 } }
Label { Label {
text: qsTr("Сравнение выбранной версии с текущим файлом") text: tabBar.currentIndex === 0
? qsTr("Сравнение выбранной версии с текущим файлом")
: qsTr("Версия с подсветкой отличий от текущего файла")
color: Theme.textFaint color: Theme.textFaint
font.pixelSize: 10 font.pixelSize: 10
Layout.fillWidth: true Layout.fillWidth: true
+7 -1
View File
@@ -9,6 +9,9 @@ Item {
property string vaultRoot: "" property string vaultRoot: ""
property string fileDir: "" property string fileDir: ""
property string emptyText: qsTr("Пустой файл — начните писать в режиме редактирования") property string emptyText: qsTr("Пустой файл — начните писать в режиме редактирования")
// Если задан — markdown считается старой версией и подсвечиваются отличия от него
// (блоки del — только в markdown, add — только в compareMarkdown)
property string compareMarkdown: ""
implicitHeight: column.implicitHeight + 24 implicitHeight: column.implicitHeight + 24
signal linkActivated(string href) signal linkActivated(string href)
@@ -17,7 +20,9 @@ Item {
function update() { function update() {
// Триггерим пересборку блоков // Триггерим пересборку блоков
blocksModel.clear() blocksModel.clear()
const blocks = mdParser.parseBlocks(markdown, vaultRoot, fileDir) const blocks = compareMarkdown.length > 0
? mdParser.parseBlocksWithDiff(markdown, compareMarkdown, vaultRoot, fileDir)
: mdParser.parseBlocks(markdown, vaultRoot, fileDir)
for (let i = 0; i < blocks.length; i++) { for (let i = 0; i < blocks.length; i++) {
blocksModel.append(blocks[i]) blocksModel.append(blocks[i])
} }
@@ -26,6 +31,7 @@ Item {
} }
onMarkdownChanged: update() onMarkdownChanged: update()
onCompareMarkdownChanged: update()
onVaultRootChanged: update() onVaultRootChanged: update()
onFileDirChanged: update() onFileDirChanged: update()
+8
View File
@@ -627,6 +627,14 @@ QString GitManager::restoreFile(const QString &vaultPath, const QString &filePat
return QString(); return QString();
} }
QString GitManager::workdirContent(const QString &filePath) const
{
QFile f(filePath);
if (!f.open(QIODevice::ReadOnly) || f.size() > 2 * 1024 * 1024)
return QString();
return QString::fromUtf8(f.readAll());
}
QDateTime GitManager::lastModifiedTime(const QString &vaultPath, const QString &filePath) const QDateTime GitManager::lastModifiedTime(const QString &vaultPath, const QString &filePath) const
{ {
const QVariantList hist = fileHistory(vaultPath, filePath); const QVariantList hist = fileHistory(vaultPath, filePath);
+3
View File
@@ -38,6 +38,9 @@ public:
// Содержимое файла в конкретном коммите (hash). Пусто если файл не существовал. // Содержимое файла в конкретном коммите (hash). Пусто если файл не существовал.
Q_INVOKABLE QString fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const; Q_INVOKABLE QString fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
// Текущее содержимое файла на диске (для сравнения с версией). Пусто при ошибке/лимите 2 МБ.
Q_INVOKABLE QString workdirContent(const QString &filePath) const;
// Diff между версией в коммите и текущим файлом (рабочее дерево). Возвращает HTML с цветами. // Diff между версией в коммите и текущим файлом (рабочее дерево). Возвращает HTML с цветами.
Q_INVOKABLE QString fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const; Q_INVOKABLE QString fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
// Текстовый diff (unified) // Текстовый diff (unified)
+217 -5
View File
@@ -92,6 +92,130 @@ QString safeHtmlOrEscaped(const QString &html)
return isSafeHtmlTag(html) ? html : esc(html); return isSafeHtmlTag(html) ? html : esc(html);
} }
// Плоский текст → RichText с явными переносами (иначе всё склеится в одну строку)
QString plainToRich(const QString &s)
{
QString e = s.toHtmlEscaped();
e.replace(QLatin1Char('\n'), QStringLiteral("<br/>"));
return e;
}
QString normalizeLang(QString l); // определена ниже
// Разбивка markdown на блоки: абзацы/заголовки/списки/таблицы — по пустым строкам,
// fenced-блоки (```/~~~) — целиком, не разрывая.
QStringList splitMdBlocks(const QString &md)
{
QStringList blocks;
QStringList cur;
bool inFence = false;
auto flush = [&]() {
while (!cur.isEmpty() && cur.first().trimmed().isEmpty())
cur.removeFirst();
while (!cur.isEmpty() && cur.last().trimmed().isEmpty())
cur.removeLast();
if (!cur.isEmpty())
blocks.append(cur.join(QLatin1Char('\n')));
cur.clear();
};
const QStringList lines = md.split(QLatin1Char('\n'));
for (const QString &ln : lines) {
const QString t = ln.trimmed();
const bool fence = t.startsWith(QLatin1String("```")) || t.startsWith(QLatin1String("~~~"));
if (fence && !inFence) { flush(); cur.append(ln); inFence = true; continue; }
if (fence && inFence) { cur.append(ln); inFence = false; flush(); continue; }
if (inFence) { cur.append(ln); continue; }
if (t.isEmpty()) { flush(); continue; }
cur.append(ln);
}
flush();
return blocks;
}
bool isFenceBlock(const QString &block)
{
const QString t = block.trimmed();
return t.startsWith(QLatin1String("```")) || t.startsWith(QLatin1String("~~~"));
}
// Разбор fenced-блока на язык и код. Возвращает false если это не fence.
bool parseFenceBlock(const QString &block, QString *langOut, QString *codeOut)
{
const QStringList lines = block.split(QLatin1Char('\n'));
if (lines.size() < 2)
return false;
const QString first = lines.first().trimmed();
QString fence;
if (first.startsWith(QLatin1String("```")))
fence = QStringLiteral("```");
else if (first.startsWith(QLatin1String("~~~")))
fence = QStringLiteral("~~~");
else
return false;
if (langOut)
*langOut = normalizeLang(first.mid(fence.length()).trimmed());
int end = lines.size();
if (lines.last().trimmed().startsWith(fence))
end = lines.size() - 1;
if (codeOut)
*codeOut = lines.mid(1, end - 1).join(QLatin1Char('\n'));
return true;
}
struct DiffOp { char kind; /* '=' same, '-' del (только в old), '+' add (только в new) */ QString text; };
// Построчный (поблочный) LCS-diff. Для огромных документов — дешёвый префикс/суффикс.
QVector<DiffOp> diffMdBlocks(const QStringList &a, const QStringList &b)
{
const int n = a.size(), m = b.size();
QVector<DiffOp> ops;
if (n == 0 && m == 0)
return ops;
auto emitFallback = [&]() {
int pre = 0;
while (pre < n && pre < m && a[pre] == b[pre])
++pre;
int suf = 0;
while (suf < n - pre && suf < m - pre && a[n - 1 - suf] == b[m - 1 - suf])
++suf;
for (int i = 0; i < pre; ++i)
ops.append({'=', a[i]});
for (int i = pre; i < n - suf; ++i)
ops.append({'-', a[i]});
for (int j = pre; j < m - suf; ++j)
ops.append({'+', b[j]});
for (int i = n - suf; i < n; ++i)
ops.append({'=', a[i]});
};
if ((long long)(n + 1) * (m + 1) > 250000) {
emitFallback();
return ops;
}
QVector<int> dp((n + 1) * (m + 1), 0);
auto IDX = [&](int i, int j) { return i * (m + 1) + j; };
for (int i = n - 1; i >= 0; --i)
for (int j = m - 1; j >= 0; --j)
dp[IDX(i, j)] = (a[i] == b[j]) ? dp[IDX(i + 1, j + 1)] + 1
: qMax(dp[IDX(i + 1, j)], dp[IDX(i, j + 1)]);
int i = 0, j = 0;
while (i < n && j < m) {
if (a[i] == b[j]) { ops.append({'=', a[i]}); ++i; ++j; }
else if (dp[IDX(i + 1, j)] >= dp[IDX(i, j + 1)]) { ops.append({'-', a[i]}); ++i; }
else { ops.append({'+', b[j]}); ++j; }
}
while (i < n) { ops.append({'-', a[i]}); ++i; }
while (j < m) { ops.append({'+', b[j]}); ++j; }
return ops;
}
// Цветная обёртка изменённого блока (те же цвета, что в fileDiffHtml)
QString tintDiffHtml(const QString &inner, bool isDel)
{
if (isDel)
return QStringLiteral("<div style=\"background-color:#ffebe9;border-left:3px solid #cf222e;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").arg(inner);
return QStringLiteral("<div style=\"background-color:#e6ffec;border-left:3px solid #1a7f37;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").arg(inner);
}
QString fileHrefFor(const QString &path) QString fileHrefFor(const QString &path)
{ {
const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path)); const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path));
@@ -200,8 +324,12 @@ public:
{ {
m_segments.clear(); m_segments.clear();
Definition def = m_repo->definitionForName(lang); Definition def = m_repo->definitionForName(lang);
if (!def.isValid()) if (!def.isValid()) {
return esc(code); // RichText схлопывает \n в пробел — отдаём явные переносы
QString e = esc(code);
e.replace(QLatin1Char('\n'), QStringLiteral("<br/>"));
return e;
}
setDefinition(def); setDefinition(def);
// Выбираем тему подсветки в зависимости от темы приложения (тёмная/светлая) // Выбираем тему подсветки в зависимости от темы приложения (тёмная/светлая)
Theme chosen = m_repo->defaultTheme(); Theme chosen = m_repo->defaultTheme();
@@ -233,7 +361,8 @@ public:
state = highlightLine(QStringView(line), state); state = highlightLine(QStringView(line), state);
outLines.append(buildLine(line)); outLines.append(buildLine(line));
} }
return outLines.join(QLatin1Char('\n')); // Явные <br/>: RichText иначе склеит строки в одну длинную
return outLines.join(QStringLiteral("<br/>"));
} }
private: private:
@@ -793,7 +922,7 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
if (m && m->m_repo) { if (m && m->m_repo) {
highlighted = hl.highlight(code, lang, true); highlighted = hl.highlight(code, lang, true);
} else { } else {
highlighted = code.toHtmlEscaped(); highlighted = plainToRich(code);
} }
QVariantMap block; QVariantMap block;
block["type"] = "code"; block["type"] = "code";
@@ -848,7 +977,90 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
QString MdParser::highlightedCode(const QString &code, const QString &language) const QString MdParser::highlightedCode(const QString &code, const QString &language) const
{ {
QString lang = language.trimmed(); QString lang = language.trimmed();
if (!m || !m->m_repo) return code.toHtmlEscaped(); if (!m || !m->m_repo) return plainToRich(code);
CodeHighlighter hl(m->m_repo); CodeHighlighter hl(m->m_repo);
return hl.highlight(code, lang, true); return hl.highlight(code, lang, true);
}
QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &newMd,
const QString &vaultRoot, const QString &fileDir)
{
QVariantList blocks;
if (m) {
m->codeBlocks.clear();
m->codeCounter = 0;
}
if (oldMd.isEmpty() && newMd.isEmpty())
return blocks;
// render() внутри очищает codeBlocks — сохраняем/восстанавливаем вокруг вызовов
auto renderKeep = [&](const QString &part) -> QString {
QHash<QString, QString> savedBlocks;
int savedCounter = 0;
if (m) { savedBlocks = m->codeBlocks; savedCounter = m->codeCounter; }
QString html = render(part, vaultRoot, fileDir);
if (m) { m->codeBlocks = savedBlocks; m->codeCounter = savedCounter; }
return html;
};
auto appendHtmlBlock = [&](const QString &html, const char *change) {
QVariantMap block;
block["type"] = "html";
block["html"] = html;
block["codeText"] = "";
block["language"] = "";
block["codeId"] = "";
block["highlightedText"] = "";
block["change"] = QString::fromLatin1(change);
blocks.append(block);
};
auto appendCodeBlock = [&](const QString &lang, const QString &code, const char *change, bool tint) {
QString codeId;
if (m) {
codeId = QStringLiteral("code_%1").arg(m->codeCounter++);
m->codeBlocks.insert(codeId, code);
}
CodeHighlighter hl(m ? m->m_repo : nullptr);
QString highlighted = (m && m->m_repo) ? hl.highlight(code, lang, true)
: plainToRich(code);
if (tint)
highlighted = tintDiffHtml(highlighted, change[0] == 'd');
QVariantMap block;
block["type"] = "code";
block["codeText"] = code;
block["language"] = lang.isEmpty() ? "text" : lang;
block["codeId"] = codeId;
block["highlightedText"] = highlighted;
block["html"] = "";
block["change"] = QString::fromLatin1(change);
blocks.append(block);
};
const QVector<DiffOp> ops = diffMdBlocks(splitMdBlocks(oldMd), splitMdBlocks(newMd));
for (const DiffOp &op : ops) {
const bool changed = op.kind != '=';
const bool isDel = op.kind == '-';
const char *change = op.kind == '=' ? "same" : (isDel ? "del" : "add");
QString lang, code;
if (isFenceBlock(op.text) && parseFenceBlock(op.text, &lang, &code)) {
appendCodeBlock(lang, code, change, changed);
} else {
QString html = renderKeep(op.text);
if (html.trimmed().isEmpty())
continue;
if (changed)
html = tintDiffHtml(html, isDel);
appendHtmlBlock(html, change);
}
}
// Пустой результат при непустом вводе — отдать как один html-блок
if (blocks.isEmpty() && (!oldMd.trimmed().isEmpty() || !newMd.trimmed().isEmpty())) {
const QString src = !oldMd.trimmed().isEmpty() ? oldMd : newMd;
const QString html = renderKeep(src);
if (!html.trimmed().isEmpty())
appendHtmlBlock(html, "same");
}
return blocks;
} }
+8
View File
@@ -28,6 +28,14 @@ public:
const QString &vaultRoot, const QString &vaultRoot,
const QString &fileDir); const QString &fileDir);
// Те же блоки, но версия oldMd с подсветкой отличий от newMd (для «Истории»).
// Каждый блок дополнительно имеет change:"same"/"del"/"add" (del — только в old,
// add — только в new); изменённые блоки обёрнуты в цветной div прямо в html/highlighted.
Q_INVOKABLE QVariantList parseBlocksWithDiff(const QString &oldMd,
const QString &newMd,
const QString &vaultRoot,
const QString &fileDir);
Q_INVOKABLE QString getCodeBlock(const QString &id) const; Q_INVOKABLE QString getCodeBlock(const QString &id) const;
Q_INVOKABLE void copyToClipboard(const QString &text) const; Q_INVOKABLE void copyToClipboard(const QString &text) const;
Q_INVOKABLE QString highlightedCode(const QString &code, const QString &language) const; Q_INVOKABLE QString highlightedCode(const QString &code, const QString &language) const;
+49
View File
@@ -134,6 +134,55 @@ int main(int argc, char *argv[])
if (!hl2.contains("span")) return fail("hl2 no span", hl2.left(200)); if (!hl2.contains("span")) return fail("hl2 no span", hl2.left(200));
} }
// Подсветка: переносы строк — явные <br/>, иначе RichText склеит в одну строку
{
QString hl = p.highlightedCode("int a = 1;\nint b = 2;\n", "cpp");
if (!hl.contains("<br/>")) return fail("highlighted без <br/>", hl.left(200));
if (hl.contains("\n")) return fail("highlighted с сырым \\n", hl.left(200));
QString plain = p.highlightedCode("a\nb", "неизвестный-язык-xyz");
if (!plain.contains("<br/>")) return fail("plain highlight без <br/>", plain);
}
// parseBlocksWithDiff: версия old vs текущее new
{
const QString oldMd = "# Заголовок\n\nСтарая строка\n\n```cpp\nint x = 1;\n```\n";
const QString newMd = "# Заголовок\n\nНовая строка\n\n```cpp\nint x = 2;\n```\n";
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
if (d.isEmpty()) return fail("diff пуст");
// порядок как у стандартного diff: сначала все del изменённого куска, потом все add
auto changeOf = [&](int i) { return d[i].toMap().value("change").toString(); };
if (d.size() != 5) return fail("diff размер", QString::number(d.size()));
if (changeOf(0) != "same") return fail("diff[0] должен быть same", changeOf(0));
if (d[0].toMap().value("type").toString() != "html") return fail("diff[0] тип");
if (changeOf(1) != "del") return fail("diff[1] должен быть del", changeOf(1));
if (!d[1].toMap().value("html").toString().contains("ffebe9")) return fail("diff[1] без красной подсветки");
if (changeOf(2) != "del" || d[2].toMap().value("type").toString() != "code")
return fail("diff[2] код del", changeOf(2));
if (!d[2].toMap().value("highlightedText").toString().contains("ffebe9"))
return fail("diff[2] код без красной подсветки");
if (changeOf(3) != "add") return fail("diff[3] должен быть add", changeOf(3));
if (!d[3].toMap().value("html").toString().contains("e6ffec")) return fail("diff[3] без зелёной подсветки");
if (changeOf(4) != "add" || d[4].toMap().value("type").toString() != "code")
return fail("diff[4] код add", changeOf(4));
// кодовые блоки сохранили id для копирования
if (d[2].toMap().value("codeId").toString().isEmpty()) return fail("diff codeId пуст");
if (p.getCodeBlock(d[2].toMap().value("codeId").toString()) != "int x = 1;")
return fail("getCodeBlock после diff", p.getCodeBlock(d[2].toMap().value("codeId").toString()));
}
// parseBlocksWithDiff: идентичные тексты — всё same, без подсветки
{
const QString md = "# A\n\ntext\n";
auto d = p.parseBlocksWithDiff(md, md, vault, fileDir);
for (const auto &v : d) {
if (v.toMap().value("change").toString() != "same")
return fail("identical должен быть same");
if (v.toMap().value("html").toString().contains("ffebe9")
|| v.toMap().value("html").toString().contains("e6ffec"))
return fail("identical с подсветкой");
}
}
std::printf("MDPARSER TEST OK\n"); std::printf("MDPARSER TEST OK\n");
return 0; return 0;
} }