Фаза 6: просмотр Markdown — MdParser (cmark-gfm AST→HTML: таблицы/ссылки/вики/картинки data-URI/задачи-чекбоксы), подсветка кода KSyntaxHighlighting (алиасы «С»/«С++»/python), RichText-просмотр в EditorPane (onLinkActivated); тест mdparser (15/15)

This commit is contained in:
Flora (opencode)
2026-08-17 13:05:19 +03:00
parent 3317aa169a
commit c4708b6be6
8 changed files with 765 additions and 6 deletions
+497
View File
@@ -0,0 +1,497 @@
#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 <QDir>
#include <QFile>
#include <QFileInfo>
#include <QImageReader>
#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;
default: out += c; break;
}
}
return out;
}
QString fileHrefFor(const QString &path)
{
const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path));
return QStringLiteral("flora://open/") + QString::fromLatin1(enc);
}
QString wikiHrefFor(const QString &name)
{
const QByteArray enc = QUrl::toPercentEncoding(name);
return QStringLiteral("flora://wiki/") + QString::fromLatin1(enc);
}
// Картинка → data: URI (если получилось прочитать), иначе "".
QString imageDataUri(const QString &path)
{
QFile f(path);
if (!f.open(QIODevice::ReadOnly) || f.size() > 3 * 1024 * 1024)
return QString();
const QByteArray data = f.readAll();
QImageReader reader(path);
const QByteArray fmt = reader.format().toLower();
const QString mime = fmt == "jpg" || fmt == "jpeg" ? QStringLiteral("image/jpeg")
: fmt == "png" ? QStringLiteral("image/png")
: fmt == "gif" ? QStringLiteral("image/gif")
: fmt == "svg" ? QStringLiteral("image/svg+xml")
: fmt == "webp" ? QStringLiteral("image/webp")
: fmt == "bmp" ? QStringLiteral("image/bmp")
: QString();
if (mime.isEmpty())
return QString();
return QStringLiteral("data:%1;base64,%2")
.arg(mime, QString::fromLatin1(data.toBase64()));
}
// Сопоставление имени из ограждения с языками KSyntaxHighlighting.
QString normalizeLang(QString l)
{
l = l.trimmed();
if (l.isEmpty())
return l;
struct Map { const char *from; const char *to; };
static const Map maps[] = {
{"С", "C"}, {"С++", "C++"}, {"с", "C"}, {"с++", "C++"},
{"python", "Python"}, {"py", "Python"},
{"javascript", "JavaScript"}, {"js", "JavaScript"}, {"ts", "TypeScript"},
{"golang", "Go"}, {"go", "Go"}, {"rust", "Rust"},
{"shell", "Bash"}, {"bash", "Bash"}, {"sh", "Bash"},
{"csharp", "C#"}, {"c#", "C#"},
{"jav", "Java"}, {"json", "JSON"}, {"yaml", "YAML"}, {"yml", "YAML"},
{"html", "HTML"}, {"css", "CSS"}, {"xml", "XML"}, {"sql", "SQL"},
{"markdown", "Markdown"}, {"md", "Markdown"},
{"text", "Plain Text"}, {"txt", "Plain Text"}
};
for (const Map &m : maps) {
if (l == QString::fromUtf8(m.from))
return QString::fromUtf8(m.to);
}
return l;
}
// Накапливает подсвеченные фрагменты кода.
class CodeHighlighter : public AbstractHighlighter
{
public:
explicit CodeHighlighter(Repository *repo)
: m_repo(repo)
{
}
void applyFormat(int offset, int length, const Format &format) override
{
if (offset < 0 || length <= 0)
return;
Segment seg;
seg.start = offset;
seg.length = length;
const Theme th = theme();
seg.color = format.hasTextColor(th)
? format.textColor(th).name() : QString();
seg.bg = format.hasBackgroundColor(th)
? format.backgroundColor(th).name() : QString();
seg.bold = format.isBold(th);
m_segments.append(seg);
}
QString highlight(const QString &code, const QString &lang)
{
m_segments.clear();
Definition def = m_repo->definitionForName(lang);
if (!def.isValid())
return esc(code);
setDefinition(def);
setTheme(m_repo->defaultTheme());
QStringList outLines;
const QStringList lines = code.split(QLatin1Char('\n'));
State state;
for (const QString &line : lines) {
m_segments.clear();
state = highlightLine(QStringView(line), state);
outLines.append(buildLine(line));
}
return outLines.join(QLatin1Char('\n'));
}
private:
struct Segment {
int start = 0;
int length = 0;
QString color;
QString bg;
bool bold = false;
};
QVector<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;
};
MdParser::MdParser(QObject *parent)
: QObject(parent)
, m(new Impl)
{
}
namespace {
class Renderer
{
public:
Renderer(Repository *repo, const QString &vaultRoot, const QString &fileDir)
: m_repo(repo)
, m_vaultRoot(vaultRoot)
, m_fileDir(fileDir)
{
}
QString renderTree(cmark_node *node)
{
outer = node;
return block(node);
}
private:
Repository *m_repo;
QString m_vaultRoot;
QString m_fileDir;
cmark_node *outer = nullptr;
QString childrenHtml(cmark_node *parent, bool blockMode = true)
{
QString out;
for (cmark_node *c = cmark_node_first_child(parent); c; c = cmark_node_next(c)) {
if (blockMode) {
// мягкий разрыв внутри списков не плодим
if (cmark_node_get_type(c) == CMARK_NODE_ITEM)
out += list_item(c);
else
out += block(c);
} else {
out += inlineAll(c);
}
}
return out;
}
QString block(cmark_node *n)
{
const cmark_node_type t = cmark_node_get_type(n);
// Таблицы — extern-переменные (нельзя в case)
if (t == CMARK_NODE_TABLE || t == CMARK_NODE_TABLE_ROW
|| t == CMARK_NODE_TABLE_CELL)
return list(n);
switch (t) {
case CMARK_NODE_DOCUMENT:
return childrenHtml(n);
case CMARK_NODE_BLOCK_QUOTE:
return QStringLiteral("<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:
return QStringLiteral("<p>%1</p>").arg(esc(QString::fromUtf8(cmark_node_get_literal(n))));
default:
return QString();
}
}
QString list(cmark_node *n)
{
const cmark_node_type t = cmark_node_get_type(n);
if (t == CMARK_NODE_TABLE) {
QString h = QStringLiteral("<table cellspacing=\"0\" style=\"border-collapse:collapse\">");
int i = 0;
for (cmark_node *row = cmark_node_first_child(n); row; row = cmark_node_next(row), ++i) {
h += QStringLiteral("<tr>");
for (cmark_node *cell = cmark_node_first_child(row); cell; cell = cmark_node_next(cell)) {
const QString tag = i == 0 ? QStringLiteral("th") : QStringLiteral("td");
h += QStringLiteral("<%1 style=\"border:1px solid #ccc;padding:2px 8px\">")
.arg(tag);
h += childrenHtml(cell, false);
h += QStringLiteral("</%1>").arg(tag);
}
h += QStringLiteral("</tr>");
}
h += QStringLiteral("</table>");
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);
const QString cls = lang.isEmpty() ? QString() : QStringLiteral(" class=\"language-%1\"").arg(esc(lang));
return QStringLiteral("<pre><code%1>%2</code></pre>").arg(cls, highlighted);
}
QString inlineAll(cmark_node *n)
{
const cmark_node_type t = cmark_node_get_type(n);
if (t == CMARK_NODE_STRIKETHROUGH) // extern-переменная — через if, не case
return QStringLiteral("<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:
return esc(QString::fromUtf8(cmark_node_get_literal(n)));
case CMARK_NODE_CUSTOM_INLINE:
return QString();
default:
break;
}
return children(n);
}
// Специальная обработка текста: вики-ссылки [[ ]].
QString inlineText(cmark_node *n)
{
QString text = QString::fromUtf8(cmark_node_get_literal(n));
if (!text.contains(QStringLiteral("[[")))
return esc(text);
QString out;
int i = 0;
while (i < text.size()) {
const int a = text.indexOf(QStringLiteral("[["), i);
if (a < 0) {
out += esc(text.mid(i));
break;
}
out += esc(text.mid(i, a - i));
const int b = text.indexOf(QStringLiteral("]]"), a + 2);
if (b < 0) {
out += esc(text.mid(a));
break;
}
const QString target = text.mid(a + 2, b - a - 2);
out += wikiLink(target);
i = b + 2;
}
return out;
}
QString wikiLink(const QString &target)
{
// ищем файл target или target.md в каталоге файла / корне хранилища
QStringList cands;
cands << m_fileDir + '/' + target << m_fileDir + '/' + target + ".md"
<< m_vaultRoot + '/' + target << m_vaultRoot + '/' + target + ".md";
for (const QString &c : cands) {
if (QFileInfo::exists(c))
return QStringLiteral("<a href=\"%1\">%2</a>")
.arg(fileHrefFor(c), 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(url, label);
// Локальные пути: файл относительно текущего документа
QString resolved;
if (QFileInfo(url).isRelative())
resolved = QDir(m_fileDir).filePath(url);
else
resolved = url;
if (QFileInfo::exists(resolved))
return QStringLiteral("<a href=\"%1\">%2</a>")
.arg(fileHrefFor(resolved), label);
return label; // цель не найдена — просто текст
}
QString imageNode(cmark_node *n)
{
const QString url = QString::fromUtf8(cmark_node_get_url(n));
const QString alt = children(n);
QString resolved = QFileInfo(url).isRelative()
? QDir(m_fileDir).filePath(url)
: url;
QString src = imageDataUri(resolved);
if (src.isEmpty()) {
if (QFileInfo::exists(resolved))
src = QStringLiteral("file:%1").arg(QDir::toNativeSeparators(resolved));
else
src = QStringLiteral("");
}
return src.isEmpty()
? QStringLiteral("<i>[изображение: %1]</i>").arg(esc(alt))
: QStringLiteral("<img src=\"%1\" alt=\"%2\"/>").arg(src, esc(alt));
}
};
} // namespace
QString MdParser::render(const QString &markdown, const QString &vaultRoot,
const QString &fileDir)
{
cmark_gfm_core_extensions_ensure_registered();
cmark_parser *parser = cmark_parser_new(CMARK_OPT_DEFAULT);
for (const char *name : {"table", "strikethrough", "autolink", "tasklist"}) {
if (cmark_syntax_extension *ext = cmark_find_syntax_extension(name))
cmark_parser_attach_syntax_extension(parser, ext);
}
cmark_parser_feed(parser, markdown.toUtf8().constData(),
static_cast<size_t>(markdown.toUtf8().size()));
cmark_node *doc = cmark_parser_finish(parser);
Renderer r(m->m_repo, vaultRoot, fileDir);
QString html = r.renderTree(doc);
cmark_node_free(doc);
cmark_parser_free(parser);
return html;
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <QObject>
#include <QString>
// Рендер Markdown (CommonMark + GFM) в HTML для режима просмотра.
// Задачи/ссылки/переходы кодируются ссылками schemes:
// flora://task/N — переключить задачу на строке N (1-based)
// flora://open/<path> — открыть файл хранилища
// flora://wiki/<name> — вики-ссылка без найденного файла
class MdParser : public QObject
{
Q_OBJECT
public:
explicit MdParser(QObject *parent = nullptr);
// markdown → HTML. vaultRoot/vaultDir — для разрешения относительных путей.
Q_INVOKABLE QString render(const QString &markdown,
const QString &vaultRoot,
const QString &fileDir);
private:
struct Impl;
Impl *m = nullptr;
};