#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 #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; case '\'': out += "'"; break; default: out += c; break; } } return out; } bool isSafeHtmlTag(const QString &html) { QString t = html.trimmed(); if (!t.startsWith(QLatin1Char('<'))) return false; // извлекаем имя тега: 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 — должен содержать 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 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; QHash 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(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("
") + 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: { const QString html = QString::fromUtf8(cmark_node_get_literal(n)); if (isSafeHtmlTag(html)) return html; return QStringLiteral("

%1

").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("
").arg(bg, border); h += QStringLiteral(""); // считаем строки для последней границы 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("").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("").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)); // 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("📋").arg(codeId); // Пузырь Telegram: используем таблицу для совместимости с QTextDocument (не поддерживает position:absolute) // Верхняя строка — язык слева и кнопка справа, средняя — код, нижняя — время и галочки справа return QStringLiteral( "
    " "" "" "" "" "
    %2%3
    " "
    " "
    "
                "%5
    " "
    " "
    %7 ✓✓
    " "
    " ).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("") + 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: { 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("%2") .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("%2") .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("%2") .arg(fileHrefFor(it2.filePath()), 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(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("%2").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("%2") .arg(fileHrefFor(resolved), label); // Если не найдено но есть якорь — оставляем якорную ссылку if (url.contains('#')) return QStringLiteral("%2").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("[изображение: %1]").arg(esc(alt)) : QStringLiteral("\"%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(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; }