2026-08-17 13:05:19 +03:00
|
|
|
|
#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>
|
2026-09-01 19:25:07 +03:00
|
|
|
|
#include <QClipboard>
|
|
|
|
|
|
#include <QCoreApplication>
|
2026-08-17 13:05:19 +03:00
|
|
|
|
#include <QDir>
|
2026-09-01 19:25:07 +03:00
|
|
|
|
#include <QDirIterator>
|
2026-08-17 13:05:19 +03:00
|
|
|
|
#include <QFile>
|
|
|
|
|
|
#include <QFileInfo>
|
2026-09-01 19:25:07 +03:00
|
|
|
|
#include <QGuiApplication>
|
2026-08-17 13:05:19 +03:00
|
|
|
|
#include <QImageReader>
|
2026-09-01 19:25:07 +03:00
|
|
|
|
#include <QSettings>
|
2026-08-17 13:05:19 +03:00
|
|
|
|
#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 += "&"; break;
|
|
|
|
|
|
case '<': out += "<"; break;
|
|
|
|
|
|
case '>': out += ">"; break;
|
|
|
|
|
|
case '"': out += """; break;
|
2026-09-01 19:25:07 +03:00
|
|
|
|
case '\'': out += "'"; break;
|
2026-08-17 13:05:19 +03:00
|
|
|
|
default: out += c; break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 13:05:19 +03:00
|
|
|
|
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();
|
2026-09-01 19:25:07 +03:00
|
|
|
|
// Пытаемся определить формат через QImageReader, fallback — по расширению
|
2026-08-17 13:05:19 +03:00
|
|
|
|
QImageReader reader(path);
|
2026-09-01 19:25:07 +03:00
|
|
|
|
QByteArray fmt = reader.format().toLower();
|
|
|
|
|
|
if (fmt.isEmpty()) {
|
|
|
|
|
|
const QString ext = QFileInfo(path).suffix().toLower();
|
|
|
|
|
|
fmt = ext.toUtf8();
|
|
|
|
|
|
}
|
2026-08-17 13:05:19 +03:00
|
|
|
|
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")
|
2026-09-01 19:25:07 +03:00
|
|
|
|
: fmt == "svgz" ? QStringLiteral("image/svg+xml")
|
2026-08-17 13:05:19 +03:00
|
|
|
|
: fmt == "webp" ? QStringLiteral("image/webp")
|
|
|
|
|
|
: fmt == "bmp" ? QStringLiteral("image/bmp")
|
2026-09-01 19:25:07 +03:00
|
|
|
|
: fmt == "ico" ? QStringLiteral("image/x-icon")
|
2026-08-17 13:05:19 +03:00
|
|
|
|
: QString();
|
|
|
|
|
|
if (mime.isEmpty())
|
|
|
|
|
|
return QString();
|
2026-09-01 19:25:07 +03:00
|
|
|
|
// Базовое определение содержимого SVG — должен содержать <svg
|
|
|
|
|
|
if (mime == "image/svg+xml" && !data.contains("<svg"))
|
|
|
|
|
|
return QString();
|
2026-08-17 13:05:19 +03:00
|
|
|
|
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;
|
2026-09-01 19:25:07 +03:00
|
|
|
|
const QString lower = l.toLower();
|
2026-08-17 13:05:19 +03:00
|
|
|
|
struct Map { const char *from; const char *to; };
|
|
|
|
|
|
static const Map maps[] = {
|
2026-09-01 19:25:07 +03:00
|
|
|
|
{"с", "C"}, {"с++", "C++"},
|
|
|
|
|
|
{"c", "C"}, {"c++", "C++"}, {"cpp", "C++"}, {"cc", "C++"}, {"cxx", "C++"}, {"h", "C++"}, {"hpp", "C++"},
|
2026-08-17 13:05:19 +03:00
|
|
|
|
{"python", "Python"}, {"py", "Python"},
|
2026-09-01 19:25:07 +03:00
|
|
|
|
{"javascript", "JavaScript"}, {"js", "JavaScript"}, {"ts", "TypeScript"}, {"typescript", "TypeScript"},
|
2026-08-17 13:05:19 +03:00
|
|
|
|
{"golang", "Go"}, {"go", "Go"}, {"rust", "Rust"},
|
2026-09-01 19:25:07 +03:00
|
|
|
|
{"shell", "Bash"}, {"bash", "Bash"}, {"sh", "Bash"}, {"zsh", "Bash"},
|
|
|
|
|
|
{"csharp", "C#"}, {"c#", "C#"}, {"cs", "C#"},
|
|
|
|
|
|
{"jav", "Java"}, {"java", "Java"}, {"json", "JSON"}, {"yaml", "YAML"}, {"yml", "YAML"},
|
2026-08-17 13:05:19 +03:00
|
|
|
|
{"html", "HTML"}, {"css", "CSS"}, {"xml", "XML"}, {"sql", "SQL"},
|
|
|
|
|
|
{"markdown", "Markdown"}, {"md", "Markdown"},
|
2026-09-01 19:25:07 +03:00
|
|
|
|
{"text", "Plain Text"}, {"txt", "Plain Text"}, {"plain", "Plain Text"}
|
2026-08-17 13:05:19 +03:00
|
|
|
|
};
|
2026-09-01 19:25:07 +03:00
|
|
|
|
// Сначала точное совпадение (регистрозависимо для кириллицы)
|
2026-08-17 13:05:19 +03:00
|
|
|
|
for (const Map &m : maps) {
|
|
|
|
|
|
if (l == QString::fromUtf8(m.from))
|
|
|
|
|
|
return QString::fromUtf8(m.to);
|
|
|
|
|
|
}
|
2026-09-01 19:25:07 +03:00
|
|
|
|
// Затем lower-case
|
|
|
|
|
|
for (const Map &m : maps) {
|
|
|
|
|
|
if (lower == QString::fromUtf8(m.from))
|
|
|
|
|
|
return QString::fromUtf8(m.to);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Для неизвестных возвращаем исходный но с первой заглавной? Оставляем как есть
|
2026-08-17 13:05:19 +03:00
|
|
|
|
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);
|
2026-09-01 19:25:07 +03:00
|
|
|
|
// Выбираем тему подсветки в зависимости от темы приложения (тёмная/светлая)
|
|
|
|
|
|
Theme chosen = m_repo->defaultTheme();
|
|
|
|
|
|
// Попытка получить тему из настроек (ui/theme)
|
|
|
|
|
|
{
|
|
|
|
|
|
QSettings s;
|
|
|
|
|
|
if (s.value("ui/theme").toString() == "dark") {
|
|
|
|
|
|
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);
|
2026-08-17 13:05:19 +03:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-09-01 19:25:07 +03:00
|
|
|
|
QHash<QString, QString> codeBlocks;
|
|
|
|
|
|
int codeCounter = 0;
|
2026-08-17 13:05:19 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
MdParser::MdParser(QObject *parent)
|
|
|
|
|
|
: QObject(parent)
|
|
|
|
|
|
, m(new Impl)
|
|
|
|
|
|
{
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 13:05:19 +03:00
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
|
|
class Renderer
|
|
|
|
|
|
{
|
|
|
|
|
|
public:
|
2026-09-01 19:25:07 +03:00
|
|
|
|
Renderer(MdParser::Impl *impl, const QString &vaultRoot, const QString &fileDir)
|
|
|
|
|
|
: m_impl(impl)
|
|
|
|
|
|
, m_repo(impl ? impl->m_repo : nullptr)
|
2026-08-17 13:05:19 +03:00
|
|
|
|
, m_vaultRoot(vaultRoot)
|
|
|
|
|
|
, m_fileDir(fileDir)
|
|
|
|
|
|
{
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QString renderTree(cmark_node *node)
|
|
|
|
|
|
{
|
|
|
|
|
|
outer = node;
|
|
|
|
|
|
return block(node);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private:
|
2026-09-01 19:25:07 +03:00
|
|
|
|
MdParser::Impl *m_impl = nullptr;
|
|
|
|
|
|
Repository *m_repo = nullptr;
|
2026-08-17 13:05:19 +03:00
|
|
|
|
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>");
|
2026-09-01 19:25:07 +03:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-08-17 13:05:19 +03:00
|
|
|
|
default:
|
|
|
|
|
|
return QString();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QString list(cmark_node *n)
|
|
|
|
|
|
{
|
|
|
|
|
|
const cmark_node_type t = cmark_node_get_type(n);
|
|
|
|
|
|
if (t == CMARK_NODE_TABLE) {
|
2026-09-01 19:25:07 +03:00
|
|
|
|
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;
|
2026-08-17 13:05:19 +03:00
|
|
|
|
int i = 0;
|
|
|
|
|
|
for (cmark_node *row = cmark_node_first_child(n); row; row = cmark_node_next(row), ++i) {
|
2026-09-01 19:25:07 +03:00
|
|
|
|
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);
|
2026-08-17 13:05:19 +03:00
|
|
|
|
h += childrenHtml(cell, false);
|
|
|
|
|
|
h += QStringLiteral("</%1>").arg(tag);
|
|
|
|
|
|
}
|
|
|
|
|
|
h += QStringLiteral("</tr>");
|
|
|
|
|
|
}
|
2026-09-01 19:25:07 +03:00
|
|
|
|
h += QStringLiteral("</table></div>");
|
2026-08-17 13:05:19 +03:00
|
|
|
|
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));
|
|
|
|
|
|
CodeHighlighter hl(m_repo);
|
|
|
|
|
|
const QString highlighted = hl.highlight(code, lang);
|
2026-09-01 19:25:07 +03:00
|
|
|
|
|
|
|
|
|
|
// сохраняем оригинал для копирования
|
|
|
|
|
|
QString codeId;
|
|
|
|
|
|
if (m_impl) {
|
|
|
|
|
|
codeId = QStringLiteral("code_%1").arg(m_impl->codeCounter++);
|
|
|
|
|
|
m_impl->codeBlocks.insert(codeId, code);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// мессенджер-стиль: отдельный блок-рамка с закруглениями, без внешней границы, фон на весь блок
|
|
|
|
|
|
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 headerText = isDark ? "#8b949e" : "#656d76";
|
|
|
|
|
|
const QString langDisplay = lang.isEmpty() ? "text" : esc(lang);
|
2026-08-17 13:05:19 +03:00
|
|
|
|
const QString cls = lang.isEmpty() ? QString() : QStringLiteral(" class=\"language-%1\"").arg(esc(lang));
|
2026-09-01 19:25:07 +03:00
|
|
|
|
const QString copyLink = codeId.isEmpty() ? QString()
|
|
|
|
|
|
: QStringLiteral("<a href=\"flora://copycode/%1\" style=\"color:#8b949e;text-decoration:none;font-size:14px\">📋</a>").arg(codeId);
|
|
|
|
|
|
|
|
|
|
|
|
// рамка как сообщение: фон на весь блок, закругления 12px, без бордера, overflow:hidden
|
|
|
|
|
|
return QStringLiteral(
|
|
|
|
|
|
"<div style=\"background:%1;border-radius:12px;overflow:hidden;margin:16px 0\">"
|
|
|
|
|
|
"<div style=\"background:%2;padding:8px 14px\">"
|
|
|
|
|
|
"<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\"><tr>"
|
|
|
|
|
|
"<td style=\"text-align:left;color:%3;font-size:11px\">%4</td>"
|
|
|
|
|
|
"<td style=\"text-align:right\">%5</td>"
|
|
|
|
|
|
"</tr></table>"
|
|
|
|
|
|
"</div>"
|
|
|
|
|
|
"<div style=\"background:%1;padding:14px\"><pre style=\"margin:0\"><code%7>%6</code></pre></div>"
|
|
|
|
|
|
"</div>"
|
|
|
|
|
|
).arg(bg, headerBg, headerText, langDisplay, copyLink, highlighted, cls);
|
2026-08-17 13:05:19 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-09-01 19:25:07 +03:00
|
|
|
|
case CMARK_NODE_HTML_INLINE: {
|
|
|
|
|
|
const QString html = QString::fromUtf8(cmark_node_get_literal(n));
|
|
|
|
|
|
return safeHtmlOrEscaped(html);
|
|
|
|
|
|
}
|
2026-08-17 13:05:19 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
|
QString wikiLink(const QString &rawTarget)
|
2026-08-17 13:05:19 +03:00
|
|
|
|
{
|
2026-09-01 19:25:07 +03:00
|
|
|
|
// Поддержка алиаса [[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;
|
2026-08-17 13:05:19 +03:00
|
|
|
|
// ищем файл target или target.md в каталоге файла / корне хранилища
|
|
|
|
|
|
QStringList cands;
|
2026-09-01 19:25:07 +03:00
|
|
|
|
cands << QDir::cleanPath(m_fileDir + '/' + cleanTarget)
|
|
|
|
|
|
<< QDir::cleanPath(m_fileDir + '/' + cleanTarget + ".md")
|
|
|
|
|
|
<< QDir::cleanPath(m_vaultRoot + '/' + cleanTarget)
|
|
|
|
|
|
<< QDir::cleanPath(m_vaultRoot + '/' + cleanTarget + ".md");
|
2026-08-17 13:05:19 +03:00
|
|
|
|
for (const QString &c : cands) {
|
2026-09-01 19:25:07 +03:00
|
|
|
|
if (QFileInfo::exists(c) && QFileInfo(c).isFile())
|
2026-08-17 13:05:19 +03:00
|
|
|
|
return QStringLiteral("<a href=\"%1\">%2</a>")
|
|
|
|
|
|
.arg(fileHrefFor(c), esc(target));
|
|
|
|
|
|
}
|
2026-09-01 19:25:07 +03:00
|
|
|
|
// Глобальный поиск по имени файла в хранилище (поддержка [[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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-17 13:05:19 +03:00
|
|
|
|
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")
|
2026-09-01 19:25:07 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-08-17 13:05:19 +03:00
|
|
|
|
|
|
|
|
|
|
// Локальные пути: файл относительно текущего документа
|
|
|
|
|
|
QString resolved;
|
2026-09-01 19:25:07 +03:00
|
|
|
|
// Отсекаем якорь и 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);
|
2026-08-17 13:05:19 +03:00
|
|
|
|
else
|
2026-09-01 19:25:07 +03:00
|
|
|
|
resolved = cleanUrl;
|
|
|
|
|
|
resolved = QDir::cleanPath(resolved);
|
2026-08-17 13:05:19 +03:00
|
|
|
|
|
|
|
|
|
|
if (QFileInfo::exists(resolved))
|
|
|
|
|
|
return QStringLiteral("<a href=\"%1\">%2</a>")
|
|
|
|
|
|
.arg(fileHrefFor(resolved), label);
|
2026-09-01 19:25:07 +03:00
|
|
|
|
// Если не найдено но есть якорь — оставляем якорную ссылку
|
|
|
|
|
|
if (url.contains('#'))
|
|
|
|
|
|
return QStringLiteral("<a href=\"%1\">%2</a>").arg(esc(url), label);
|
2026-08-17 13:05:19 +03:00
|
|
|
|
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;
|
2026-09-01 19:25:07 +03:00
|
|
|
|
resolved = QDir::cleanPath(resolved);
|
2026-08-17 13:05:19 +03:00
|
|
|
|
QString src = imageDataUri(resolved);
|
|
|
|
|
|
if (src.isEmpty()) {
|
|
|
|
|
|
if (QFileInfo::exists(resolved))
|
2026-09-01 19:25:07 +03:00
|
|
|
|
src = QUrl::fromLocalFile(resolved).toString();
|
2026-08-17 13:05:19 +03:00
|
|
|
|
else
|
|
|
|
|
|
src = QStringLiteral("");
|
|
|
|
|
|
}
|
|
|
|
|
|
return src.isEmpty()
|
|
|
|
|
|
? QStringLiteral("<i>[изображение: %1]</i>").arg(esc(alt))
|
2026-09-01 19:25:07 +03:00
|
|
|
|
: QStringLiteral("<img src=\"%1\" alt=\"%2\"/>").arg(esc(src), esc(alt));
|
2026-08-17 13:05:19 +03:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
} // 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
|
const QByteArray utf8 = markdown.toUtf8();
|
|
|
|
|
|
cmark_parser_feed(parser, utf8.constData(), static_cast<size_t>(utf8.size()));
|
2026-08-17 13:05:19 +03:00
|
|
|
|
cmark_node *doc = cmark_parser_finish(parser);
|
|
|
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
|
if (m) {
|
|
|
|
|
|
m->codeBlocks.clear();
|
|
|
|
|
|
m->codeCounter = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
Renderer r(m, vaultRoot, fileDir);
|
2026-08-17 13:05:19 +03:00
|
|
|
|
QString html = r.renderTree(doc);
|
|
|
|
|
|
|
|
|
|
|
|
cmark_node_free(doc);
|
|
|
|
|
|
cmark_parser_free(parser);
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|