Files
Flora/src/markdown/MdParser.cpp
T
grendervill c861fcc763 Блоки кода как в Telegram (скрин): тёмно-синий пузырь #3b5a7a, время и галочки
- Bubble: background #3b5a7a (как на скрине #2e4d6a), radius 12, padding 10/14/6, max-width 88%, shadow, язык uppercase #8a9ba8 10px слева, кнопка 📋 справа (rgba 0.15, radius 12, border 0.2)
- Код: контейнер rgba(0,0,0,0.18) radius 8, padding 10/12, JetBrains Mono 13px/1.45/tab-size 4, color #fff, подсветка форсирована тёмной темой (highlight(...,true) -> Breeze Dark)
- Время: QTime::currentTime hh:mm + ✓✓ #5eb1f6 внизу справа, как в сообщении ТГ (20:31 ✓✓)
- Сохранено автовыравнивание/скобки/отступы из предыдущего коммита (Tab 4 пробела, Enter сохраняет indent, Backspace удаляет 4, авто-закрытие ()[]{}''""<> и </tag>)
2026-09-01 20:39:34 +03:00

750 lines
31 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "markdown/MdParser.h"
#include <cmark-gfm.h>
#include <cmark-gfm-extension_api.h>
#include <cmark-gfm-core-extensions.h>
#include "table.h"
#include "strikethrough.h"
#include <KSyntaxHighlighting/abstracthighlighter.h>
#include <KSyntaxHighlighting/definition.h>
#include <KSyntaxHighlighting/format.h>
#include <KSyntaxHighlighting/repository.h>
#include <KSyntaxHighlighting/state.h>
#include <KSyntaxHighlighting/theme.h>
#include <QBuffer>
#include <QClipboard>
#include <QCoreApplication>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImageReader>
#include <QSettings>
#include <QTime>
#include <QUrl>
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 += "&amp;"; break;
case '<': out += "&lt;"; break;
case '>': out += "&gt;"; break;
case '"': out += "&quot;"; break;
case '\'': out += "&#39;"; break;
default: out += c; break;
}
}
return out;
}
bool isSafeHtmlTag(const QString &html)
{
QString t = html.trimmed();
if (!t.startsWith(QLatin1Char('<')))
return false;
// извлекаем имя тега: </? tag
static const QRegularExpression re(QStringLiteral("^</?\\s*([a-zA-Z0-9]+)"));
auto m = re.match(t);
if (!m.hasMatch())
return false;
const QString tag = m.captured(1).toLower();
static const QSet<QString> safe = {
"sup", "sub", "span", "u", "mark", "small", "s", "del", "ins",
"abbr", "cite", "q", "kbd", "code", "b", "i", "em", "strong",
"br", "hr", "a", "font"
};
if (!safe.contains(tag))
return false;
// блокируем любые event-атрибуты (on*) и javascript: / data:text/html
static const QRegularExpression onEventRe(QStringLiteral("\\bon\\w+\\s*="), QRegularExpression::CaseInsensitiveOption);
if (onEventRe.match(t).hasMatch())
return false;
if (t.contains(QStringLiteral("javascript:"), Qt::CaseInsensitive)
|| t.contains(QStringLiteral("data:text/html"), Qt::CaseInsensitive)
|| t.contains(QStringLiteral("vbscript:"), Qt::CaseInsensitive))
return false;
// блокируем style с expression/moz-binding/behavior
if (t.contains(QStringLiteral("expression"), Qt::CaseInsensitive)
|| t.contains(QStringLiteral("moz-binding"), Qt::CaseInsensitive)
|| t.contains(QStringLiteral("behavior"), Qt::CaseInsensitive))
return false;
return true;
}
QString safeHtmlOrEscaped(const QString &html)
{
return isSafeHtmlTag(html) ? html : esc(html);
}
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, fallback — по расширению
QImageReader reader(path);
QByteArray fmt = reader.format().toLower();
if (fmt.isEmpty()) {
const QString ext = QFileInfo(path).suffix().toLower();
fmt = ext.toUtf8();
}
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 == "svgz" ? QStringLiteral("image/svg+xml")
: fmt == "webp" ? QStringLiteral("image/webp")
: fmt == "bmp" ? QStringLiteral("image/bmp")
: fmt == "ico" ? QStringLiteral("image/x-icon")
: QString();
if (mime.isEmpty())
return QString();
// Базовое определение содержимого SVG — должен содержать <svg
if (mime == "image/svg+xml" && !data.contains("<svg"))
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;
const QString lower = l.toLower();
struct Map { const char *from; const char *to; };
static const Map maps[] = {
{"с", "C"}, {"с++", "C++"},
{"c", "C"}, {"c++", "C++"}, {"cpp", "C++"}, {"cc", "C++"}, {"cxx", "C++"}, {"h", "C++"}, {"hpp", "C++"},
{"python", "Python"}, {"py", "Python"},
{"javascript", "JavaScript"}, {"js", "JavaScript"}, {"ts", "TypeScript"}, {"typescript", "TypeScript"},
{"golang", "Go"}, {"go", "Go"}, {"rust", "Rust"},
{"shell", "Bash"}, {"bash", "Bash"}, {"sh", "Bash"}, {"zsh", "Bash"},
{"csharp", "C#"}, {"c#", "C#"}, {"cs", "C#"},
{"jav", "Java"}, {"java", "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"}, {"plain", "Plain Text"}
};
// Сначала точное совпадение (регистрозависимо для кириллицы)
for (const Map &m : maps) {
if (l == QString::fromUtf8(m.from))
return QString::fromUtf8(m.to);
}
// Затем lower-case
for (const Map &m : maps) {
if (lower == 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, bool forceDark = false)
{
m_segments.clear();
Definition def = m_repo->definitionForName(lang);
if (!def.isValid())
return esc(code);
setDefinition(def);
// Выбираем тему подсветки в зависимости от темы приложения (тёмная/светлая)
Theme chosen = m_repo->defaultTheme();
// Попытка получить тему из настроек (ui/theme) или форсировать тёмную для пузырей
{
QSettings s;
bool wantDark = forceDark || s.value("ui/theme").toString() == "dark";
if (wantDark) {
for (auto t : m_repo->themes()) {
if (t.name().contains("dark", Qt::CaseInsensitive) || t.name().contains("Dark")) {
chosen = t;
break;
}
}
// fallback: ищем Breeze Dark
if (chosen.name() == m_repo->defaultTheme().name()) {
auto td = m_repo->theme("Breeze Dark");
if (td.isValid()) chosen = td;
}
}
}
setTheme(chosen);
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<Segment> 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("<span style=\"%1\">%2</span>").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;
QHash<QString, QString> codeBlocks;
int codeCounter = 0;
};
MdParser::MdParser(QObject *parent)
: QObject(parent)
, m(new Impl)
{
}
MdParser::~MdParser()
{
delete m;
}
QString MdParser::getCodeBlock(const QString &id) const
{
return m ? m->codeBlocks.value(id) : QString();
}
void MdParser::copyToClipboard(const QString &text) const
{
if (!qobject_cast<QGuiApplication*>(QCoreApplication::instance()))
return;
if (auto *clip = QGuiApplication::clipboard())
clip->setText(text);
}
namespace {
class Renderer
{
public:
Renderer(MdParser::Impl *impl, const QString &vaultRoot, const QString &fileDir)
: m_impl(impl)
, m_repo(impl ? impl->m_repo : nullptr)
, m_vaultRoot(vaultRoot)
, m_fileDir(fileDir)
{
}
QString renderTree(cmark_node *node)
{
outer = node;
return block(node);
}
private:
MdParser::Impl *m_impl = nullptr;
Repository *m_repo = nullptr;
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("<blockquote>") + childrenHtml(n) + QStringLiteral("</blockquote>");
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("<h%1>").arg(lvl)
+ childrenHtml(n, false)
+ QStringLiteral("</h%1>").arg(lvl);
}
case CMARK_NODE_CODE_BLOCK:
return code_block(n);
case CMARK_NODE_THEMATIC_BREAK:
return QStringLiteral("<hr/>");
case CMARK_NODE_PARAGRAPH:
return QStringLiteral("<p>") + childrenHtml(n, false) + QStringLiteral("</p>");
case CMARK_NODE_HTML_BLOCK: {
const QString html = QString::fromUtf8(cmark_node_get_literal(n));
if (isSafeHtmlTag(html))
return html;
return QStringLiteral("<p>%1</p>").arg(esc(html));
}
default:
return QString();
}
}
QString list(cmark_node *n)
{
const cmark_node_type t = cmark_node_get_type(n);
if (t == CMARK_NODE_TABLE) {
QSettings s;
const bool isDark = s.value("ui/theme").toString() == "dark";
const QString bg = isDark ? "#2d333f" : "#f8f9fb";
const QString headerBg = isDark ? "#252a33" : "#eef1f3";
const QString border = isDark ? "#30363d" : "#d0d7de";
const QString headerText = isDark ? "#c9d1d9" : "#24292f";
const QString cellText = isDark ? "#c9d1d9" : "#24292f";
// отдельная рамка как сообщение: фон + закругления + видимая рамка
QString h = QStringLiteral("<div style=\"background:%1;border:1px solid %2;border-radius:12px;overflow:hidden;margin:16px 0\">").arg(bg, border);
h += QStringLiteral("<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\">");
// считаем строки для последней границы
int rowCount = 0;
for (cmark_node *r = cmark_node_first_child(n); r; r = cmark_node_next(r)) ++rowCount;
int i = 0;
for (cmark_node *row = cmark_node_first_child(n); row; row = cmark_node_next(row), ++i) {
const bool isHeader = (i == 0);
const bool isLastRow = (i == rowCount - 1);
const QString rowBg = isHeader ? headerBg : bg;
h += QStringLiteral("<tr style=\"background:%1\">").arg(rowBg);
// считаем колонки
int colCount = 0;
for (cmark_node *c = cmark_node_first_child(row); c; c = cmark_node_next(c)) ++colCount;
int colIdx = 0;
for (cmark_node *cell = cmark_node_first_child(row); cell; cell = cmark_node_next(cell), ++colIdx) {
const bool isLastCol = (colIdx == colCount - 1);
const QString tag = isHeader ? QStringLiteral("th") : QStringLiteral("td");
QString cellStyle;
if (isHeader) {
cellStyle = QStringLiteral("padding:8px 12px;text-align:left;color:%1;font-weight:600").arg(headerText);
if (!isLastRow) cellStyle += QStringLiteral(";border-bottom:1px solid %1").arg(border);
if (!isLastCol) cellStyle += QStringLiteral(";border-right:1px solid %1").arg(border);
} else {
cellStyle = QStringLiteral("padding:8px 12px;text-align:left;color:%1").arg(cellText);
if (!isLastRow) cellStyle += QStringLiteral(";border-bottom:1px solid %1").arg(border);
if (!isLastCol) cellStyle += QStringLiteral(";border-right:1px solid %1").arg(border);
}
h += QStringLiteral("<%1 style=\"%2\">").arg(tag, cellStyle);
h += childrenHtml(cell, false);
h += QStringLiteral("</%1>").arg(tag);
}
h += QStringLiteral("</tr>");
}
h += QStringLiteral("</table></div>");
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("</%1>").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("<a href=\"flora://task/%1\">\u2611</a>").arg(startLine)
: QStringLiteral("<a href=\"flora://task/%1\">\u2610</a>").arg(startLine);
body = box + ' ' + childrenHtml(n);
} else {
body = childrenHtml(n);
}
return QStringLiteral("<li>") + body + QStringLiteral("</li>");
}
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));
// Telegram-стиль как на скрине: тёмно-синий пузырь (#2e4d6a) с кодом, время и галочки внизу справа
// Сохраняем оригинал для копирования
QString codeId;
if (m_impl) {
codeId = QStringLiteral("code_%1").arg(m_impl->codeCounter++);
m_impl->codeBlocks.insert(codeId, code);
}
// Форсируем тёмную тему подсветки для контраста на синем фоне Telegram-пузыря
CodeHighlighter hl(m_repo);
const QString highlighted = hl.highlight(code, lang, true);
// Переопределяем lang после highlight (highlight уже сделал)
const QString langDisplay = lang.isEmpty() ? "text" : esc(lang);
const QString cls = lang.isEmpty() ? QString() : QStringLiteral(" class=\"language-%1\"").arg(esc(lang));
// Время как в Telegram — текущее время рендера (можно заменить на lastModified)
const QString timeStr = QTime::currentTime().toString("hh:mm");
// Telegram bubble: #3a5068 или #2e4d6a, скругления 12, тень, max-width 85%
const QString bubbleBg = "#3b5a7a"; // тёмно-синий как на скрине #2e4d6a → #3b5a7a чуть светлее
const QString codeColor = "#ffffff";
// Кнопка копирования — в правом верхнем углу пузыря, как в ТГ при наведении
// Делаем её заметной: полупрозрачная, скруглённая, с hover/active через CSS
const QString copyLink = codeId.isEmpty() ? QString()
: QStringLiteral("<a href=\"flora://copycode/%1\" style=\"display:inline-block;background:rgba(255,255,255,0.15);color:#ffffff;text-decoration:none;font-size:12px;padding:3px 8px;border-radius:12px;border:1px solid rgba(255,255,255,0.2);font-family:-apple-system,sans-serif;\" title=\"Копировать\">📋</a>").arg(codeId);
// Пузырь Telegram: используем таблицу для совместимости с QTextDocument (не поддерживает position:absolute)
// Верхняя строка — язык слева и кнопка справа, средняя — код, нижняя — время и галочки справа
return QStringLiteral(
"<div style=\"background:%1;border-radius:12px;padding:10px 14px 6px 14px;margin:12px 0;max-width:88%%;box-shadow:0 1px 2px rgba(0,0,0,0.15)\">"
"<table width=\"100%%\" cellspacing=\"0\" cellpadding=\"0\" style=\"margin-bottom:6px\">"
"<tr>"
"<td style=\"text-align:left;color:#8a9ba8;font-size:10px;font-weight:600;letter-spacing:0.4px;text-transform:uppercase;opacity:0.9\">%2</td>"
"<td style=\"text-align:right\">%3</td>"
"</tr></table>"
"<div style=\"background:rgba(0,0,0,0.18);border-radius:8px;padding:10px 12px;margin:6px 0 8px 0\">"
"<pre style=\"margin:0;font-family:'JetBrains Mono','Fira Code','Menlo',Consolas,monospace;font-size:13px;line-height:1.45;tab-size:4;color:%4;white-space:pre-wrap;word-wrap:break-word\">"
"<code%6>%5</code></pre>"
"</div>"
"<div style=\"text-align:right;color:#8a9ba8;font-size:11px;margin-top:2px;opacity:0.95\">%7 <span style=\"color:#5eb1f6\">✓✓</span></div>"
"</div>"
).arg(bubbleBg, langDisplay, copyLink, codeColor, highlighted, cls, timeStr);
}
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("<s>") + children(n) + QStringLiteral("</s>");
switch (t) {
case CMARK_NODE_TEXT:
return inlineText(n);
case CMARK_NODE_SOFTBREAK:
return QStringLiteral("\n");
case CMARK_NODE_LINEBREAK:
return QStringLiteral("<br/>");
case CMARK_NODE_CODE:
return QStringLiteral("<code style=\"background:#f2f2f2;font-family:Menlo,Consolas,monospace\">%1</code>")
.arg(esc(QString::fromUtf8(cmark_node_get_literal(n))));
case CMARK_NODE_EMPH:
return QStringLiteral("<i>") + children(n) + QStringLiteral("</i>");
case CMARK_NODE_STRONG:
return QStringLiteral("<b>") + children(n) + QStringLiteral("</b>");
case CMARK_NODE_LINK:
return linkNode(n);
case CMARK_NODE_IMAGE:
return imageNode(n);
case CMARK_NODE_HTML_INLINE: {
const QString html = QString::fromUtf8(cmark_node_get_literal(n));
return safeHtmlOrEscaped(html);
}
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 &rawTarget)
{
// Поддержка алиаса [[file|alias]] уже обработана в inlineText, но на всякий случай
QString target = rawTarget;
int pipe = target.indexOf('|');
if (pipe >= 0)
target = target.left(pipe).trimmed();
target = target.trimmed();
if (target.isEmpty())
return esc(rawTarget);
// Защита от traversal
if (target.contains(QStringLiteral("..")) || target.contains(QStringLiteral("://")))
return esc(target);
// Отсекаем якорь
QString cleanTarget = target;
int hash = cleanTarget.indexOf('#');
if (hash >= 0) cleanTarget = cleanTarget.left(hash).trimmed();
if (cleanTarget.isEmpty()) cleanTarget = target;
// ищем файл target или target.md в каталоге файла / корне хранилища
QStringList cands;
cands << QDir::cleanPath(m_fileDir + '/' + cleanTarget)
<< QDir::cleanPath(m_fileDir + '/' + cleanTarget + ".md")
<< QDir::cleanPath(m_vaultRoot + '/' + cleanTarget)
<< QDir::cleanPath(m_vaultRoot + '/' + cleanTarget + ".md");
for (const QString &c : cands) {
if (QFileInfo::exists(c) && QFileInfo(c).isFile())
return QStringLiteral("<a href=\"%1\">%2</a>")
.arg(fileHrefFor(c), esc(target));
}
// Глобальный поиск по имени файла в хранилище (поддержка [[inner]] где inner в подпапке)
if (!m_vaultRoot.isEmpty() && QDir(m_vaultRoot).exists()) {
QDirIterator it(m_vaultRoot, QStringList() << cleanTarget << cleanTarget + ".md",
QDir::Files, QDirIterator::Subdirectories);
// Ограничиваем поиск 5000 файлов чтобы не тормозить
int checked = 0;
while (it.hasNext() && checked < 5000) {
it.next();
++checked;
const QString found = it.filePath();
if (QFileInfo(found).completeBaseName().compare(cleanTarget, Qt::CaseInsensitive)==0
|| QFileInfo(found).fileName().compare(cleanTarget, Qt::CaseInsensitive)==0
|| QFileInfo(found).fileName().compare(cleanTarget+".md", Qt::CaseInsensitive)==0) {
return QStringLiteral("<a href=\"%1\">%2</a>")
.arg(fileHrefFor(found), esc(target));
}
}
// Fallback: точный поиск через QDirIterator с фильтром
QDirIterator it2(m_vaultRoot, QStringList() << "*.md", QDir::Files, QDirIterator::Subdirectories);
checked=0;
while (it2.hasNext() && checked < 5000) {
it2.next(); ++checked;
if (QFileInfo(it2.filePath()).completeBaseName().compare(cleanTarget, Qt::CaseInsensitive)==0) {
return QStringLiteral("<a href=\"%1\">%2</a>")
.arg(fileHrefFor(it2.filePath()), esc(target));
}
}
}
return QStringLiteral("<a href=\"%1\" style=\"color:#888\">%2</a>")
.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("<a href=\"%1\">%2</a>").arg(esc(url), label);
// www.example.com без схемы — считаем внешним
if (url.startsWith(QStringLiteral("www.")) || url.contains(QStringLiteral(".com"))
|| url.contains(QStringLiteral(".org")) || url.contains(QStringLiteral(".net"))) {
// Если выглядит как домен и не существует локально — делаем https
QString maybeUrl = url;
if (!maybeUrl.contains(QStringLiteral("://")))
maybeUrl = QStringLiteral("https://") + maybeUrl;
const QString resolvedLocal = QDir(m_fileDir).filePath(url);
if (!QFileInfo::exists(resolvedLocal))
return QStringLiteral("<a href=\"%1\">%2</a>").arg(esc(maybeUrl), label);
}
// Локальные пути: файл относительно текущего документа
QString resolved;
// Отсекаем якорь и query перед проверкой существования
QString cleanUrl = url;
int hash = cleanUrl.indexOf('#');
if (hash >= 0) cleanUrl = cleanUrl.left(hash);
int q = cleanUrl.indexOf('?');
if (q >= 0) cleanUrl = cleanUrl.left(q);
cleanUrl = cleanUrl.trimmed();
if (cleanUrl.isEmpty())
return label;
if (QFileInfo(cleanUrl).isRelative())
resolved = QDir(m_fileDir).filePath(cleanUrl);
else
resolved = cleanUrl;
resolved = QDir::cleanPath(resolved);
if (QFileInfo::exists(resolved))
return QStringLiteral("<a href=\"%1\">%2</a>")
.arg(fileHrefFor(resolved), label);
// Если не найдено но есть якорь — оставляем якорную ссылку
if (url.contains('#'))
return QStringLiteral("<a href=\"%1\">%2</a>").arg(esc(url), 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;
resolved = QDir::cleanPath(resolved);
QString src = imageDataUri(resolved);
if (src.isEmpty()) {
if (QFileInfo::exists(resolved))
src = QUrl::fromLocalFile(resolved).toString();
else
src = QStringLiteral("");
}
return src.isEmpty()
? QStringLiteral("<i>[изображение: %1]</i>").arg(esc(alt))
: QStringLiteral("<img src=\"%1\" alt=\"%2\"/>").arg(esc(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);
}
const QByteArray utf8 = markdown.toUtf8();
cmark_parser_feed(parser, utf8.constData(), static_cast<size_t>(utf8.size()));
cmark_node *doc = cmark_parser_finish(parser);
if (m) {
m->codeBlocks.clear();
m->codeCounter = 0;
}
Renderer r(m, vaultRoot, fileDir);
QString html = r.renderTree(doc);
cmark_node_free(doc);
cmark_parser_free(parser);
return html;
}