mirror of
https://github.com/AyuGram/AyuGramDesktop.git
synced 2026-08-06 20:03:12 +00:00
Add some missing features.
This commit is contained in:
@@ -2,10 +2,17 @@
|
||||
|
||||
#include <QtCore/QString>
|
||||
|
||||
namespace Iv {
|
||||
class Delegate;
|
||||
} // namespace Iv
|
||||
|
||||
namespace Iv::Markdown {
|
||||
|
||||
struct OpenOptions {
|
||||
QString sourceName;
|
||||
QString sourcePath;
|
||||
QString initialFragment;
|
||||
Iv::Delegate *delegate = nullptr;
|
||||
};
|
||||
|
||||
struct ParseOptions {
|
||||
|
||||
@@ -23,6 +23,12 @@ namespace Iv::Markdown {
|
||||
namespace {
|
||||
|
||||
constexpr auto kMaxSourceBytes = 4 * 1024 * 1024;
|
||||
constexpr auto kZoomStep = int(10);
|
||||
|
||||
struct OpenTarget {
|
||||
QString path;
|
||||
QString fragment;
|
||||
};
|
||||
|
||||
[[nodiscard]] bool IsReadableLocalFile(const QFileInfo &info) {
|
||||
return info.exists() && info.isFile() && info.isReadable();
|
||||
@@ -47,6 +53,35 @@ constexpr auto kMaxSourceBytes = 4 * 1024 * 1024;
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] QString NormalizeFragmentId(QString fragment) {
|
||||
fragment = QString::fromUtf8(
|
||||
QByteArray::fromPercentEncoding(fragment.toUtf8()));
|
||||
fragment = fragment.trimmed().toLower();
|
||||
while (fragment.startsWith(QChar('#'))) {
|
||||
fragment.remove(0, 1);
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
[[nodiscard]] OpenTarget ParseOpenTarget(QString path) {
|
||||
const auto direct = QFileInfo(path);
|
||||
if (direct.exists()) {
|
||||
return { path, QString() };
|
||||
}
|
||||
const auto hash = path.lastIndexOf(QChar('#'));
|
||||
if (hash <= 0) {
|
||||
return { path, QString() };
|
||||
}
|
||||
const auto candidate = path.mid(0, hash);
|
||||
if (candidate.isEmpty()) {
|
||||
return { path, QString() };
|
||||
}
|
||||
const auto info = QFileInfo(candidate);
|
||||
return info.exists()
|
||||
? OpenTarget{ candidate, NormalizeFragmentId(path.mid(hash + 1)) }
|
||||
: OpenTarget{ path, QString() };
|
||||
}
|
||||
|
||||
[[nodiscard]] bool HasPreviewableContent(const MarkdownNode &node) {
|
||||
switch (node.kind) {
|
||||
case NodeKind::Document:
|
||||
@@ -68,9 +103,23 @@ constexpr auto kMaxSourceBytes = 4 * 1024 * 1024;
|
||||
|| !document.formulas.empty();
|
||||
}
|
||||
|
||||
void LogDocumentWarnings(
|
||||
const PreparedDocument &document,
|
||||
const QString &path) {
|
||||
for (const auto &warning : document.warnings) {
|
||||
DEBUG_LOG(("Native Markdown IV: warning (%1): %2"
|
||||
).arg(warning
|
||||
).arg(path));
|
||||
}
|
||||
}
|
||||
|
||||
class Controller final {
|
||||
public:
|
||||
Controller(PreparedDocument document, QString title);
|
||||
Controller(
|
||||
PreparedDocument document,
|
||||
QString title,
|
||||
QString sourcePath,
|
||||
QString initialFragment);
|
||||
|
||||
void activate();
|
||||
|
||||
@@ -81,6 +130,8 @@ private:
|
||||
|
||||
PreparedDocument _document;
|
||||
QString _title;
|
||||
QString _sourcePath;
|
||||
QString _initialFragment;
|
||||
Iv::DelegateImpl _delegate;
|
||||
std::unique_ptr<Ui::RpWindow> _window;
|
||||
std::unique_ptr<Ui::RpWidget> _preview;
|
||||
@@ -106,18 +157,30 @@ void RemoveController(Controller *controller) {
|
||||
}
|
||||
}
|
||||
|
||||
void OpenDocumentWindow(PreparedDocument document, QString title) {
|
||||
void OpenDocumentWindow(
|
||||
PreparedDocument document,
|
||||
QString title,
|
||||
QString sourcePath,
|
||||
QString initialFragment) {
|
||||
auto controller = std::make_unique<Controller>(
|
||||
std::move(document),
|
||||
std::move(title));
|
||||
std::move(title),
|
||||
std::move(sourcePath),
|
||||
std::move(initialFragment));
|
||||
const auto raw = controller.get();
|
||||
ActiveControllers().push_back(std::move(controller));
|
||||
raw->activate();
|
||||
}
|
||||
|
||||
Controller::Controller(PreparedDocument document, QString title)
|
||||
Controller::Controller(
|
||||
PreparedDocument document,
|
||||
QString title,
|
||||
QString sourcePath,
|
||||
QString initialFragment)
|
||||
: _document(std::move(document))
|
||||
, _title(std::move(title)) {
|
||||
, _title(std::move(title))
|
||||
, _sourcePath(std::move(sourcePath))
|
||||
, _initialFragment(std::move(initialFragment)) {
|
||||
createWindow();
|
||||
}
|
||||
|
||||
@@ -159,7 +222,12 @@ void Controller::createWindow() {
|
||||
|
||||
_preview = CreateMarkdownPreviewWidget(
|
||||
_document,
|
||||
OpenOptions{ .sourceName = _title });
|
||||
OpenOptions{
|
||||
.sourceName = _title,
|
||||
.sourcePath = _sourcePath,
|
||||
.initialFragment = _initialFragment,
|
||||
.delegate = &_delegate,
|
||||
});
|
||||
_preview->setParent(window->body().get());
|
||||
_preview->setGeometry(QRect(QPoint(), window->body()->size()));
|
||||
window->body()->sizeValue() | rpl::on_next([=](QSize size) {
|
||||
@@ -172,6 +240,22 @@ void Controller::createWindow() {
|
||||
finishClose();
|
||||
} else if (e->type() == QEvent::KeyPress) {
|
||||
const auto event = static_cast<QKeyEvent*>(e.get());
|
||||
if (event->modifiers() & Qt::ControlModifier) {
|
||||
if (event->key() == Qt::Key_Plus
|
||||
|| event->key() == Qt::Key_Equal) {
|
||||
event->accept();
|
||||
_delegate.ivSetZoom(_delegate.ivZoom() + kZoomStep);
|
||||
return;
|
||||
} else if (event->key() == Qt::Key_Minus) {
|
||||
event->accept();
|
||||
_delegate.ivSetZoom(_delegate.ivZoom() - kZoomStep);
|
||||
return;
|
||||
} else if (event->key() == Qt::Key_0) {
|
||||
event->accept();
|
||||
_delegate.ivSetZoom(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event->key() == Qt::Key_Escape) {
|
||||
event->accept();
|
||||
close();
|
||||
@@ -195,32 +279,35 @@ void Controller::finishClose() {
|
||||
} // namespace
|
||||
|
||||
bool TryOpenLocalFile(const QString &path) {
|
||||
const auto info = QFileInfo(path);
|
||||
const auto target = ParseOpenTarget(path);
|
||||
const auto info = QFileInfo(target.path);
|
||||
if (!IsReadableLocalFile(info)) {
|
||||
return false;
|
||||
}
|
||||
if (info.size() > kMaxSourceBytes) {
|
||||
DEBUG_LOG(("Native Markdown IV: rejected local file too large: %1"
|
||||
).arg(path));
|
||||
).arg(target.path));
|
||||
return false;
|
||||
}
|
||||
|
||||
auto bytes = QByteArray();
|
||||
if (!ReadLocalSource(path, &bytes)) {
|
||||
if (!ReadLocalSource(target.path, &bytes)) {
|
||||
return false;
|
||||
}
|
||||
if (bytes.size() > kMaxSourceBytes) {
|
||||
DEBUG_LOG(("Native Markdown IV: rejected local file too large: %1"
|
||||
).arg(path));
|
||||
).arg(target.path));
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto title = info.fileName();
|
||||
auto validated = ValidateMarkdownSourceForIv(bytes, ParseOptions{ title });
|
||||
const auto fallbackTitle = info.fileName();
|
||||
auto validated = ValidateMarkdownSourceForIv(
|
||||
bytes,
|
||||
ParseOptions{ fallbackTitle });
|
||||
if (!validated.ok) {
|
||||
DEBUG_LOG(("Native Markdown IV: source validation failure (%1): %2"
|
||||
).arg(validated.error
|
||||
).arg(path));
|
||||
).arg(target.path));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -230,23 +317,31 @@ bool TryOpenLocalFile(const QString &path) {
|
||||
if (error.startsWith(u"cmark-"_q)) {
|
||||
DEBUG_LOG(("Native Markdown IV: cmark parse failure (%1): %2"
|
||||
).arg(error
|
||||
).arg(path));
|
||||
).arg(target.path));
|
||||
} else {
|
||||
DEBUG_LOG(("Native Markdown IV: parse failure (%1): %2"
|
||||
).arg(error
|
||||
).arg(path));
|
||||
).arg(target.path));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!AcceptsPreview(result.document)) {
|
||||
DEBUG_LOG(("Native Markdown IV: unsupported or empty document: %1"
|
||||
).arg(path));
|
||||
).arg(target.path));
|
||||
return false;
|
||||
}
|
||||
LogDocumentWarnings(result.document, target.path);
|
||||
|
||||
OpenDocumentWindow(std::move(result.document), title);
|
||||
const auto title = result.document.title.trimmed().isEmpty()
|
||||
? fallbackTitle
|
||||
: result.document.title.trimmed();
|
||||
OpenDocumentWindow(
|
||||
std::move(result.document),
|
||||
title,
|
||||
info.absoluteFilePath(),
|
||||
target.fragment);
|
||||
DEBUG_LOG(("Native Markdown IV: opened as native Markdown IV: %1"
|
||||
).arg(path));
|
||||
).arg(target.path));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ namespace {
|
||||
case NodeKind::TableCell: return FromLatin1("TableCell");
|
||||
case NodeKind::HtmlInline: return FromLatin1("HtmlInline");
|
||||
case NodeKind::HtmlBlock: return FromLatin1("HtmlBlock");
|
||||
case NodeKind::FootnoteReference: return FromLatin1("FootnoteReference");
|
||||
case NodeKind::FootnoteDefinition: return FromLatin1("FootnoteDefinition");
|
||||
case NodeKind::DisplayMath: return FromLatin1("DisplayMath");
|
||||
case NodeKind::InlineMath: return FromLatin1("InlineMath");
|
||||
case NodeKind::SoftBreak: return FromLatin1("SoftBreak");
|
||||
@@ -68,6 +70,16 @@ namespace {
|
||||
return FromLatin1("None");
|
||||
}
|
||||
|
||||
[[nodiscard]] QString HtmlBlockKindName(HtmlBlockKind kind) {
|
||||
switch (kind) {
|
||||
case HtmlBlockKind::None: return FromLatin1("None");
|
||||
case HtmlBlockKind::Comment: return FromLatin1("Comment");
|
||||
case HtmlBlockKind::Details: return FromLatin1("Details");
|
||||
case HtmlBlockKind::Unsupported: return FromLatin1("Unsupported");
|
||||
}
|
||||
return FromLatin1("None");
|
||||
}
|
||||
|
||||
[[nodiscard]] QString TaskStateName(TaskState state) {
|
||||
switch (state) {
|
||||
case TaskState::None: return FromLatin1("None");
|
||||
@@ -171,6 +183,10 @@ void DumpNode(
|
||||
AddStringAttribute(&line, "title", node.title);
|
||||
AddStringAttribute(&line, "info", node.info);
|
||||
AddStringAttribute(&line, "raw", node.raw);
|
||||
AddStringAttribute(&line, "anchorId", node.anchorId);
|
||||
AddStringAttribute(&line, "footnoteLabel", node.footnoteLabel);
|
||||
AddStringAttribute(&line, "detailsSummary", node.detailsSummary);
|
||||
AddStringAttribute(&line, "detailsBody", node.detailsBody);
|
||||
AddStringAttribute(&line, "unsupportedKind", node.unsupportedKind);
|
||||
if (node.headingLevel != 0) {
|
||||
AddIntAttribute(&line, "headingLevel", node.headingLevel);
|
||||
@@ -184,6 +200,9 @@ void DumpNode(
|
||||
if (node.formulaIndex != -1) {
|
||||
AddIntAttribute(&line, "formulaIndex", node.formulaIndex);
|
||||
}
|
||||
if (node.footnoteOrdinal != 0) {
|
||||
AddIntAttribute(&line, "footnoteOrdinal", node.footnoteOrdinal);
|
||||
}
|
||||
if (node.kind == NodeKind::List) {
|
||||
AddStringAttribute(&line, "listKind", ListKindName(node.listKind));
|
||||
AddStringAttribute(
|
||||
@@ -191,12 +210,19 @@ void DumpNode(
|
||||
"listDelimiter",
|
||||
ListDelimiterName(node.listDelimiter));
|
||||
}
|
||||
if (node.htmlBlockKind != HtmlBlockKind::None) {
|
||||
AddStringAttribute(
|
||||
&line,
|
||||
"htmlBlockKind",
|
||||
HtmlBlockKindName(node.htmlBlockKind));
|
||||
}
|
||||
if (node.taskState != TaskState::None) {
|
||||
AddStringAttribute(&line, "taskState", TaskStateName(node.taskState));
|
||||
}
|
||||
AddBoolAttribute(&line, "tight", node.tight);
|
||||
AddBoolAttribute(&line, "autolink", node.autolink);
|
||||
AddBoolAttribute(&line, "tableHeader", node.tableHeader);
|
||||
AddBoolAttribute(&line, "detailsOpen", node.detailsOpen);
|
||||
if (!node.tableAlignments.empty()) {
|
||||
AddStringAttribute(
|
||||
&line,
|
||||
|
||||
@@ -28,6 +28,8 @@ enum class NodeKind {
|
||||
TableCell,
|
||||
HtmlInline,
|
||||
HtmlBlock,
|
||||
FootnoteReference,
|
||||
FootnoteDefinition,
|
||||
DisplayMath,
|
||||
InlineMath,
|
||||
SoftBreak,
|
||||
@@ -64,6 +66,13 @@ enum class TableAlignment {
|
||||
Right,
|
||||
};
|
||||
|
||||
enum class HtmlBlockKind {
|
||||
None,
|
||||
Comment,
|
||||
Details,
|
||||
Unsupported,
|
||||
};
|
||||
|
||||
struct SourceRange {
|
||||
bool available = false;
|
||||
int startLine = 0;
|
||||
@@ -82,6 +91,10 @@ struct MarkdownNode {
|
||||
QString title;
|
||||
QString info;
|
||||
QString raw;
|
||||
QString anchorId;
|
||||
QString footnoteLabel;
|
||||
QString detailsSummary;
|
||||
QString detailsBody;
|
||||
QString unsupportedKind;
|
||||
std::vector<MarkdownNode> children;
|
||||
std::vector<TableAlignment> tableAlignments;
|
||||
@@ -89,12 +102,15 @@ struct MarkdownNode {
|
||||
int listStart = 0;
|
||||
int tableColumn = -1;
|
||||
int formulaIndex = -1;
|
||||
int footnoteOrdinal = 0;
|
||||
ListKind listKind = ListKind::Bullet;
|
||||
ListDelimiter listDelimiter = ListDelimiter::None;
|
||||
TaskState taskState = TaskState::None;
|
||||
HtmlBlockKind htmlBlockKind = HtmlBlockKind::None;
|
||||
bool tight = false;
|
||||
bool autolink = false;
|
||||
bool tableHeader = false;
|
||||
bool detailsOpen = false;
|
||||
};
|
||||
|
||||
struct MathFormula {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "iv/markdown/iv_markdown_math.h"
|
||||
|
||||
#include "base/basic_types.h"
|
||||
|
||||
#include <QtCore/QByteArray>
|
||||
#include <QtCore/QString>
|
||||
|
||||
@@ -49,6 +51,13 @@ struct ParserState {
|
||||
bool failed = false;
|
||||
};
|
||||
|
||||
struct ParsedDetailsBlock {
|
||||
QString summary;
|
||||
QString body;
|
||||
bool open = false;
|
||||
bool ok = false;
|
||||
};
|
||||
|
||||
void ParserDeleter::operator()(cmark_parser *parser) const {
|
||||
if (parser) {
|
||||
cmark_parser_free(parser);
|
||||
@@ -71,6 +80,12 @@ void NodeDeleter::operator()(cmark_node *node) const {
|
||||
FromLatin1(name));
|
||||
}
|
||||
|
||||
void AddWarning(ParserState *state, QString warning) {
|
||||
if (state && state->warnings && !warning.isEmpty()) {
|
||||
state->warnings->push_back(std::move(warning));
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] unsigned char ByteAt(const QByteArray &source, int index) {
|
||||
return static_cast<unsigned char>(source.at(index));
|
||||
}
|
||||
@@ -645,6 +660,7 @@ void RecordCapabilities(cmark_node *node, ParserState *state) {
|
||||
case CMARK_NODE_PARAGRAPH: return NodeKind::Paragraph;
|
||||
case CMARK_NODE_HEADING: return NodeKind::Heading;
|
||||
case CMARK_NODE_THEMATIC_BREAK: return NodeKind::ThematicBreak;
|
||||
case CMARK_NODE_FOOTNOTE_DEFINITION: return NodeKind::FootnoteDefinition;
|
||||
case CMARK_NODE_TEXT: return NodeKind::Text;
|
||||
case CMARK_NODE_SOFTBREAK: return NodeKind::SoftBreak;
|
||||
case CMARK_NODE_LINEBREAK: return NodeKind::LineBreak;
|
||||
@@ -653,6 +669,7 @@ void RecordCapabilities(cmark_node *node, ParserState *state) {
|
||||
case CMARK_NODE_EMPH: return NodeKind::Emphasis;
|
||||
case CMARK_NODE_STRONG: return NodeKind::Strong;
|
||||
case CMARK_NODE_LINK: return NodeKind::Link;
|
||||
case CMARK_NODE_FOOTNOTE_REFERENCE: return NodeKind::FootnoteReference;
|
||||
default: break;
|
||||
}
|
||||
return ExtensionNodeKind(RawTypeString(node));
|
||||
@@ -732,6 +749,110 @@ void RecordCapabilities(cmark_node *node, ParserState *state) {
|
||||
return result;
|
||||
}
|
||||
|
||||
[[nodiscard]] QString NormalizeFragmentId(QString fragment) {
|
||||
fragment = QString::fromUtf8(
|
||||
QByteArray::fromPercentEncoding(fragment.toUtf8()));
|
||||
fragment = fragment.trimmed().toLower();
|
||||
while (fragment.startsWith(u"#"_q)) {
|
||||
fragment.remove(0, 1);
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
[[nodiscard]] QString AnchorIdBaseFromText(QString text) {
|
||||
text = text.trimmed().toLower();
|
||||
auto result = QString();
|
||||
auto pendingHyphen = false;
|
||||
for (const auto ch : text) {
|
||||
if (ch.isLetterOrNumber()) {
|
||||
if (pendingHyphen && !result.isEmpty()) {
|
||||
result.append(QChar('-'));
|
||||
}
|
||||
result.append(ch);
|
||||
pendingHyphen = false;
|
||||
} else if (!result.isEmpty()) {
|
||||
pendingHyphen = true;
|
||||
}
|
||||
}
|
||||
if (result.isEmpty()) {
|
||||
return u"section"_q;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[[nodiscard]] QString FootnoteDefinitionAnchorId(int ordinal) {
|
||||
return (ordinal > 0) ? (u"fn-"_q + QString::number(ordinal)) : QString();
|
||||
}
|
||||
|
||||
[[nodiscard]] QString ExtractFootnoteLabel(
|
||||
QString raw,
|
||||
bool definition) {
|
||||
raw = raw.trimmed();
|
||||
if (!raw.startsWith(u"[^"_q)) {
|
||||
return QString();
|
||||
}
|
||||
const auto closing = raw.indexOf(u']');
|
||||
if (closing <= 2) {
|
||||
return QString();
|
||||
}
|
||||
if (definition && (closing + 1 >= raw.size() || raw[closing + 1] != u':')) {
|
||||
return QString();
|
||||
}
|
||||
return raw.mid(2, closing - 2).trimmed();
|
||||
}
|
||||
|
||||
[[nodiscard]] bool ParseDetailsOpenAttribute(QString raw) {
|
||||
raw = raw.trimmed().toLower();
|
||||
if (raw.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return (raw == u"open"_q)
|
||||
|| (raw == u"open=\"\""_q)
|
||||
|| (raw == u"open=''"_q)
|
||||
|| (raw == u"open=\"open\""_q)
|
||||
|| (raw == u"open='open'"_q);
|
||||
}
|
||||
|
||||
[[nodiscard]] ParsedDetailsBlock ParseDetailsBlock(QString raw) {
|
||||
auto result = ParsedDetailsBlock();
|
||||
raw = raw.trimmed();
|
||||
if (!raw.startsWith(u"<details"_q, Qt::CaseInsensitive)
|
||||
|| !raw.endsWith(u"</details>"_q, Qt::CaseInsensitive)) {
|
||||
return result;
|
||||
}
|
||||
const auto openingEnd = raw.indexOf(QChar('>'));
|
||||
if (openingEnd < 0) {
|
||||
return result;
|
||||
}
|
||||
const auto openingAttributes = raw.mid(8, openingEnd - 8);
|
||||
if (!openingAttributes.trimmed().isEmpty()
|
||||
&& !ParseDetailsOpenAttribute(openingAttributes)) {
|
||||
return result;
|
||||
}
|
||||
result.open = ParseDetailsOpenAttribute(openingAttributes);
|
||||
auto inner = raw.mid(
|
||||
openingEnd + 1,
|
||||
raw.size() - openingEnd - 11);
|
||||
if (inner.contains(u"<details"_q, Qt::CaseInsensitive)) {
|
||||
return result;
|
||||
}
|
||||
inner = inner.trimmed();
|
||||
if (!inner.startsWith(u"<summary>"_q, Qt::CaseInsensitive)) {
|
||||
return result;
|
||||
}
|
||||
const auto summaryClosing = inner.indexOf(
|
||||
u"</summary>"_q,
|
||||
0,
|
||||
Qt::CaseInsensitive);
|
||||
if (summaryClosing < 0) {
|
||||
return result;
|
||||
}
|
||||
result.summary = inner.mid(9, summaryClosing - 9).trimmed();
|
||||
result.body = inner.mid(summaryClosing + 10).trimmed();
|
||||
result.ok = !result.summary.isEmpty();
|
||||
return result;
|
||||
}
|
||||
|
||||
[[nodiscard]] QString PlainText(const MarkdownNode &node) {
|
||||
auto result = node.text;
|
||||
for (const auto &child : node.children) {
|
||||
@@ -753,7 +874,10 @@ void RecordCapabilities(cmark_node *node, ParserState *state) {
|
||||
&& text == node.url.mid(mailto.size());
|
||||
}
|
||||
|
||||
void FillNodeAttributes(cmark_node *node, MarkdownNode *out) {
|
||||
void FillNodeAttributes(
|
||||
cmark_node *node,
|
||||
ParserState *state,
|
||||
MarkdownNode *out) {
|
||||
switch (out->kind) {
|
||||
case NodeKind::Text:
|
||||
case NodeKind::InlineCode:
|
||||
@@ -763,7 +887,37 @@ void FillNodeAttributes(cmark_node *node, MarkdownNode *out) {
|
||||
out->text = FromCmarkString(cmark_node_get_literal(node));
|
||||
out->info = FromCmarkString(cmark_node_get_fence_info(node));
|
||||
break;
|
||||
case NodeKind::HtmlBlock:
|
||||
case NodeKind::HtmlBlock: {
|
||||
out->raw = FromCmarkString(cmark_node_get_literal(node));
|
||||
const auto trimmed = out->raw.trimmed();
|
||||
if (trimmed.startsWith(u"<!--"_q) && trimmed.endsWith(u"-->"_q)) {
|
||||
out->htmlBlockKind = HtmlBlockKind::Comment;
|
||||
} else if (trimmed.startsWith(u"<details"_q, Qt::CaseInsensitive)) {
|
||||
const auto details = ParseDetailsBlock(out->raw);
|
||||
if (details.ok) {
|
||||
out->htmlBlockKind = HtmlBlockKind::Details;
|
||||
out->detailsSummary = details.summary;
|
||||
out->detailsBody = details.body;
|
||||
out->detailsOpen = details.open;
|
||||
} else {
|
||||
out->htmlBlockKind = HtmlBlockKind::Unsupported;
|
||||
AddWarning(
|
||||
state,
|
||||
FromLatin1("Malformed details block at %1:%2").arg(
|
||||
out->range.startLine
|
||||
).arg(
|
||||
out->range.startColumn));
|
||||
}
|
||||
} else if (!trimmed.isEmpty()) {
|
||||
out->htmlBlockKind = HtmlBlockKind::Unsupported;
|
||||
AddWarning(
|
||||
state,
|
||||
FromLatin1("Unsupported HTML block at %1:%2").arg(
|
||||
out->range.startLine
|
||||
).arg(
|
||||
out->range.startColumn));
|
||||
}
|
||||
} break;
|
||||
case NodeKind::HtmlInline:
|
||||
out->raw = FromCmarkString(cmark_node_get_literal(node));
|
||||
break;
|
||||
@@ -796,6 +950,22 @@ void FillNodeAttributes(cmark_node *node, MarkdownNode *out) {
|
||||
out->url = FromCmarkString(cmark_node_get_url(node));
|
||||
out->title = FromCmarkString(cmark_node_get_title(node));
|
||||
break;
|
||||
case NodeKind::FootnoteReference:
|
||||
out->raw = SourceSlice(state->normalizedSource, out->range);
|
||||
if (const auto parent = cmark_node_parent_footnote_def(node)) {
|
||||
out->footnoteLabel = FromCmarkString(cmark_node_get_literal(parent));
|
||||
}
|
||||
if (out->footnoteLabel.isEmpty()) {
|
||||
out->footnoteLabel = ExtractFootnoteLabel(out->raw, false);
|
||||
}
|
||||
break;
|
||||
case NodeKind::FootnoteDefinition:
|
||||
out->raw = SourceSlice(state->normalizedSource, out->range);
|
||||
out->footnoteLabel = FromCmarkString(cmark_node_get_literal(node));
|
||||
if (out->footnoteLabel.isEmpty()) {
|
||||
out->footnoteLabel = ExtractFootnoteLabel(out->raw, true);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -821,7 +991,7 @@ void FillNodeAttributes(cmark_node *node, MarkdownNode *out) {
|
||||
out->unsupportedKind = CmarkKind(node);
|
||||
out->raw = SourceSlice(state->normalizedSource, out->range);
|
||||
}
|
||||
FillNodeAttributes(node, out);
|
||||
FillNodeAttributes(node, state, out);
|
||||
for (auto child = cmark_node_first_child(node); child;) {
|
||||
const auto next = cmark_node_next(child);
|
||||
auto converted = MarkdownNode();
|
||||
@@ -1231,6 +1401,199 @@ void NormalizeDisplayMathChildren(
|
||||
return result;
|
||||
}
|
||||
|
||||
[[nodiscard]] int *FindNamedCounter(
|
||||
std::vector<std::pair<QString, int>> *entries,
|
||||
const QString &key) {
|
||||
if (!entries) {
|
||||
return nullptr;
|
||||
}
|
||||
for (auto &entry : *entries) {
|
||||
if (entry.first == key) {
|
||||
return &entry.second;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] int FindNamedValue(
|
||||
const std::vector<std::pair<QString, int>> &entries,
|
||||
const QString &key) {
|
||||
for (const auto &entry : entries) {
|
||||
if (entry.first == key) {
|
||||
return entry.second;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool ContainsAnchorId(
|
||||
const std::vector<QString> &anchors,
|
||||
const QString &value) {
|
||||
return std::find(anchors.begin(), anchors.end(), value) != anchors.end();
|
||||
}
|
||||
|
||||
void AssignHeadingAnchors(
|
||||
MarkdownNode *node,
|
||||
std::vector<std::pair<QString, int>> *counts,
|
||||
QStringList *warnings) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (node->kind == NodeKind::Heading) {
|
||||
const auto base = AnchorIdBaseFromText(PlainText(*node));
|
||||
auto count = FindNamedCounter(counts, base);
|
||||
if (count) {
|
||||
++(*count);
|
||||
node->anchorId = base + u"-"_q + QString::number(*count);
|
||||
if (warnings) {
|
||||
warnings->push_back(FromLatin1(
|
||||
"Duplicate heading anchor \"%1\" remapped to \"%2\"").arg(
|
||||
base
|
||||
).arg(
|
||||
node->anchorId));
|
||||
}
|
||||
} else {
|
||||
counts->push_back({ base, 1 });
|
||||
node->anchorId = base;
|
||||
}
|
||||
}
|
||||
for (auto &child : node->children) {
|
||||
AssignHeadingAnchors(&child, counts, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
void AssignFootnoteDefinitionOrdinals(
|
||||
MarkdownNode *node,
|
||||
std::vector<std::pair<QString, int>> *definitions,
|
||||
int *nextOrdinal,
|
||||
QStringList *warnings) {
|
||||
if (!node || !definitions || !nextOrdinal) {
|
||||
return;
|
||||
}
|
||||
if (node->kind == NodeKind::FootnoteDefinition) {
|
||||
if (node->footnoteLabel.isEmpty()) {
|
||||
if (warnings) {
|
||||
warnings->push_back(FromLatin1(
|
||||
"Footnote definition without label at %1:%2").arg(
|
||||
node->range.startLine
|
||||
).arg(
|
||||
node->range.startColumn));
|
||||
}
|
||||
} else if (const auto existing = FindNamedValue(
|
||||
*definitions,
|
||||
node->footnoteLabel)) {
|
||||
node->footnoteOrdinal = existing;
|
||||
node->anchorId = FootnoteDefinitionAnchorId(existing);
|
||||
if (warnings) {
|
||||
warnings->push_back(FromLatin1(
|
||||
"Duplicate footnote definition \"%1\"").arg(
|
||||
node->footnoteLabel));
|
||||
}
|
||||
} else {
|
||||
node->footnoteOrdinal = *nextOrdinal;
|
||||
node->anchorId = FootnoteDefinitionAnchorId(*nextOrdinal);
|
||||
definitions->push_back({ node->footnoteLabel, *nextOrdinal });
|
||||
++(*nextOrdinal);
|
||||
}
|
||||
}
|
||||
for (auto &child : node->children) {
|
||||
AssignFootnoteDefinitionOrdinals(&child, definitions, nextOrdinal, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
void AssignFootnoteReferenceOrdinals(
|
||||
MarkdownNode *node,
|
||||
const std::vector<std::pair<QString, int>> &definitions,
|
||||
QStringList *warnings) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (node->kind == NodeKind::FootnoteReference) {
|
||||
if (node->footnoteLabel.isEmpty()) {
|
||||
if (warnings) {
|
||||
warnings->push_back(FromLatin1(
|
||||
"Footnote reference without label at %1:%2").arg(
|
||||
node->range.startLine
|
||||
).arg(
|
||||
node->range.startColumn));
|
||||
}
|
||||
} else if (const auto ordinal = FindNamedValue(
|
||||
definitions,
|
||||
node->footnoteLabel)) {
|
||||
node->footnoteOrdinal = ordinal;
|
||||
} else if (warnings) {
|
||||
warnings->push_back(FromLatin1(
|
||||
"Unresolved footnote reference \"%1\"").arg(
|
||||
node->footnoteLabel));
|
||||
}
|
||||
}
|
||||
for (auto &child : node->children) {
|
||||
AssignFootnoteReferenceOrdinals(&child, definitions, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
void CollectAnchorIds(
|
||||
const MarkdownNode &node,
|
||||
std::vector<QString> *anchors) {
|
||||
if (!anchors) {
|
||||
return;
|
||||
}
|
||||
if (!node.anchorId.isEmpty()
|
||||
&& (node.kind == NodeKind::Heading
|
||||
|| node.kind == NodeKind::FootnoteDefinition)) {
|
||||
anchors->push_back(node.anchorId);
|
||||
}
|
||||
for (const auto &child : node.children) {
|
||||
CollectAnchorIds(child, anchors);
|
||||
}
|
||||
}
|
||||
|
||||
void ValidateLocalFragments(
|
||||
const MarkdownNode &node,
|
||||
const std::vector<QString> &anchors,
|
||||
QStringList *warnings) {
|
||||
if (node.kind == NodeKind::Link && node.url.startsWith(QChar('#'))) {
|
||||
const auto fragment = NormalizeFragmentId(node.url.mid(1));
|
||||
if (fragment.isEmpty() || !ContainsAnchorId(anchors, fragment)) {
|
||||
if (warnings) {
|
||||
warnings->push_back(FromLatin1(
|
||||
"Unresolved local fragment \"%1\"").arg(
|
||||
node.url));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const auto &child : node.children) {
|
||||
ValidateLocalFragments(child, anchors, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
void FinalizeDocumentSemantics(PreparedDocument *document) {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
auto headingCounts = std::vector<std::pair<QString, int>>();
|
||||
AssignHeadingAnchors(
|
||||
&document->document,
|
||||
&headingCounts,
|
||||
&document->warnings);
|
||||
|
||||
auto footnoteDefinitions = std::vector<std::pair<QString, int>>();
|
||||
auto nextFootnoteOrdinal = 1;
|
||||
AssignFootnoteDefinitionOrdinals(
|
||||
&document->document,
|
||||
&footnoteDefinitions,
|
||||
&nextFootnoteOrdinal,
|
||||
&document->warnings);
|
||||
AssignFootnoteReferenceOrdinals(
|
||||
&document->document,
|
||||
footnoteDefinitions,
|
||||
&document->warnings);
|
||||
|
||||
auto anchors = std::vector<QString>();
|
||||
CollectAnchorIds(document->document, &anchors);
|
||||
ValidateLocalFragments(document->document, anchors, &document->warnings);
|
||||
}
|
||||
|
||||
void NormalizeDisplayMathBlocks(
|
||||
PreparedDocument *document,
|
||||
const QByteArray &source,
|
||||
@@ -1386,6 +1749,7 @@ ParseResult ParseMarkdownForIv(ValidatedMarkdownSource source) {
|
||||
&document,
|
||||
source.normalized,
|
||||
source.lineStarts);
|
||||
FinalizeDocumentSemantics(&document);
|
||||
document.title = FirstHeadingTitle(document.document);
|
||||
document.empty = document.document.children.empty()
|
||||
&& document.formulas.empty();
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
#include "iv/markdown/iv_markdown_prepare.h"
|
||||
|
||||
#include "iv/markdown/iv_markdown_parse.h"
|
||||
|
||||
#include "base/call_delayed.h"
|
||||
|
||||
#include <QtCore/QByteArray>
|
||||
#include <QtCore/QDir>
|
||||
#include <QtCore/QFileInfo>
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
@@ -27,10 +31,17 @@ struct PrepareContext {
|
||||
int quoteDepth = 0;
|
||||
};
|
||||
|
||||
struct FootnoteDefinitionEntry {
|
||||
const MarkdownNode *node = nullptr;
|
||||
};
|
||||
|
||||
struct PrepareState {
|
||||
const PrepareRequest *request = nullptr;
|
||||
PreparedResult result;
|
||||
QByteArray sourceUtf8;
|
||||
std::vector<FootnoteDefinitionEntry> footnoteDefinitions;
|
||||
std::vector<std::pair<QString, QString>> firstFootnoteReferences;
|
||||
int nextGeneratedId = 0;
|
||||
|
||||
[[nodiscard]] bool cancelled() {
|
||||
if (!request->cancelled) {
|
||||
@@ -86,6 +97,35 @@ struct PrepareState {
|
||||
const auto till = std::clamp(range.endOffset, from, sourceUtf8.size());
|
||||
return QString::fromUtf8(sourceUtf8.constData() + from, till - from);
|
||||
}
|
||||
|
||||
[[nodiscard]] QString firstFootnoteReferenceAnchor(
|
||||
const QString &label) const {
|
||||
for (const auto &entry : firstFootnoteReferences) {
|
||||
if (entry.first == label) {
|
||||
return entry.second;
|
||||
}
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
[[nodiscard]] QString rememberFootnoteReferenceAnchor(
|
||||
const QString &label,
|
||||
QString *blockAnchorId) {
|
||||
if (label.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
if (const auto existing = firstFootnoteReferenceAnchor(label); !existing.isEmpty()) {
|
||||
return existing;
|
||||
}
|
||||
auto anchorId = (blockAnchorId && !blockAnchorId->isEmpty())
|
||||
? *blockAnchorId
|
||||
: (u"fnref-"_q + QString::number(++nextGeneratedId));
|
||||
if (blockAnchorId && blockAnchorId->isEmpty()) {
|
||||
*blockAnchorId = anchorId;
|
||||
}
|
||||
firstFootnoteReferences.push_back({ label, anchorId });
|
||||
return anchorId;
|
||||
}
|
||||
};
|
||||
|
||||
struct InlineFormulaSource {
|
||||
@@ -97,6 +137,7 @@ struct InlineFormulaSource {
|
||||
struct InlineFormulaContext {
|
||||
const std::vector<InlineFormulaSource> *formulas = nullptr;
|
||||
std::vector<PreparedInlineObject> *prepared = nullptr;
|
||||
QString *blockAnchorId = nullptr;
|
||||
int next = 0;
|
||||
int textSize = 0;
|
||||
int renderWidthCap = 0;
|
||||
@@ -122,6 +163,154 @@ void ClearPreparedOutput(PreparedResult *result) {
|
||||
return u"internal:index"_q + QChar(index);
|
||||
}
|
||||
|
||||
[[nodiscard]] QString NormalizeFragmentId(QString fragment) {
|
||||
fragment = QString::fromUtf8(
|
||||
QByteArray::fromPercentEncoding(fragment.toUtf8()));
|
||||
fragment = fragment.trimmed().toLower();
|
||||
while (fragment.startsWith(u"#"_q)) {
|
||||
fragment.remove(0, 1);
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool HasUrlScheme(const QString &target) {
|
||||
if (target.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const auto colon = target.indexOf(QChar(':'));
|
||||
if (colon <= 0) {
|
||||
return false;
|
||||
}
|
||||
const auto slash = target.indexOf(QChar('/'));
|
||||
const auto question = target.indexOf(QChar('?'));
|
||||
const auto hash = target.indexOf(QChar('#'));
|
||||
auto limit = target.size();
|
||||
for (const auto value : { slash, question, hash }) {
|
||||
if (value >= 0) {
|
||||
limit = std::min(limit, value);
|
||||
}
|
||||
}
|
||||
if (colon >= limit) {
|
||||
return false;
|
||||
}
|
||||
if (!target[0].isLetter()) {
|
||||
return false;
|
||||
}
|
||||
for (auto i = 1; i != colon; ++i) {
|
||||
const auto ch = target[i];
|
||||
if (!ch.isLetterOrNumber() && ch != QChar('+') && ch != QChar('-')
|
||||
&& ch != QChar('.')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool LooksLikeWindowsDrivePath(const QString &target) {
|
||||
return target.size() >= 2
|
||||
&& target[0].isLetter()
|
||||
&& target[1] == QChar(':');
|
||||
}
|
||||
|
||||
[[nodiscard]] bool LooksLikeFileUrl(const QString &target) {
|
||||
return target.size() >= 5
|
||||
&& target.left(5).compare(u"file:"_q, Qt::CaseInsensitive) == 0;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool LooksLikeFilesystemTarget(const QString &target) {
|
||||
return target.startsWith(u"/"_q)
|
||||
|| target.startsWith(u"\\"_q)
|
||||
|| target.startsWith(u"//"_q)
|
||||
|| target.startsWith(u"\\\\"_q)
|
||||
|| LooksLikeWindowsDrivePath(target)
|
||||
|| LooksLikeFileUrl(target);
|
||||
}
|
||||
|
||||
[[nodiscard]] QString ComparablePath(QString path) {
|
||||
path = QDir::fromNativeSeparators(QDir::cleanPath(path));
|
||||
return path.toLower();
|
||||
}
|
||||
|
||||
[[nodiscard]] bool IsContainedPath(
|
||||
const QString &baseDirectory,
|
||||
const QString &resolvedPath) {
|
||||
const auto base = ComparablePath(baseDirectory);
|
||||
const auto resolved = ComparablePath(resolvedPath);
|
||||
return (resolved == base) || resolved.startsWith(base + u"/"_q);
|
||||
}
|
||||
|
||||
[[nodiscard]] QString DetailsAnchorId(PrepareState *state) {
|
||||
return u"details-"_q + QString::number(++state->nextGeneratedId);
|
||||
}
|
||||
|
||||
[[nodiscard]] QString FootnoteDefinitionAnchor(const MarkdownNode &node) {
|
||||
return !node.anchorId.isEmpty()
|
||||
? node.anchorId
|
||||
: (node.footnoteOrdinal > 0
|
||||
? (u"fn-"_q + QString::number(node.footnoteOrdinal))
|
||||
: QString());
|
||||
}
|
||||
|
||||
[[nodiscard]] PreparedLink ClassifiedLink(
|
||||
uint16 index,
|
||||
QString target,
|
||||
const PrepareState *state) {
|
||||
auto result = PreparedLink();
|
||||
result.index = index;
|
||||
if (target.startsWith(QChar('#'))) {
|
||||
result.kind = PreparedLinkKind::Anchor;
|
||||
result.target = NormalizeFragmentId(target.mid(1));
|
||||
return result;
|
||||
}
|
||||
|
||||
auto fragmentIndex = target.indexOf(QChar('#'));
|
||||
if (fragmentIndex >= 0) {
|
||||
result.fragment = NormalizeFragmentId(target.mid(fragmentIndex + 1));
|
||||
target = target.left(fragmentIndex);
|
||||
}
|
||||
result.target = target;
|
||||
|
||||
if (target.isEmpty()) {
|
||||
result.kind = PreparedLinkKind::Anchor;
|
||||
result.target = result.fragment;
|
||||
result.fragment = QString();
|
||||
return result;
|
||||
}
|
||||
if (LooksLikeFilesystemTarget(target)) {
|
||||
result.kind = PreparedLinkKind::RejectedRelative;
|
||||
return result;
|
||||
}
|
||||
if (HasUrlScheme(target)) {
|
||||
result.kind = PreparedLinkKind::External;
|
||||
return result;
|
||||
}
|
||||
if (!state
|
||||
|| !state->request
|
||||
|| state->request->sourcePath.isEmpty()) {
|
||||
result.kind = PreparedLinkKind::RejectedRelative;
|
||||
return result;
|
||||
}
|
||||
if (target.contains(QChar('?'))) {
|
||||
result.kind = PreparedLinkKind::RejectedRelative;
|
||||
return result;
|
||||
}
|
||||
const auto baseDirectory = QFileInfo(state->request->sourcePath).absolutePath();
|
||||
if (baseDirectory.isEmpty()) {
|
||||
result.kind = PreparedLinkKind::RejectedRelative;
|
||||
return result;
|
||||
}
|
||||
const auto resolved = QDir(baseDirectory).absoluteFilePath(target);
|
||||
const auto cleanedResolved = QDir::cleanPath(resolved);
|
||||
if (!IsContainedPath(baseDirectory, cleanedResolved)
|
||||
|| !LooksLikeMarkdownFile(cleanedResolved)) {
|
||||
result.kind = PreparedLinkKind::RejectedRelative;
|
||||
return result;
|
||||
}
|
||||
result.kind = PreparedLinkKind::LocalFile;
|
||||
result.target = cleanedResolved;
|
||||
return result;
|
||||
}
|
||||
|
||||
[[nodiscard]] int CappedListDepth(int depth) {
|
||||
return std::min(depth, kMaxVisualListDepth);
|
||||
}
|
||||
@@ -659,6 +848,7 @@ void AppendInline(
|
||||
if (index > std::numeric_limits<uint16>::max()) {
|
||||
break;
|
||||
}
|
||||
const auto preparedLink = ClassifiedLink(uint16(index), node.url, state);
|
||||
text->entities.push_back(
|
||||
EntityInText(
|
||||
EntityType::CustomUrl,
|
||||
@@ -667,9 +857,52 @@ void AppendInline(
|
||||
InternalLinkData(uint16(index))));
|
||||
links->push_back({
|
||||
.index = uint16(index),
|
||||
.target = node.url,
|
||||
.kind = preparedLink.kind,
|
||||
.target = preparedLink.target,
|
||||
.fragment = preparedLink.fragment,
|
||||
});
|
||||
} break;
|
||||
case NodeKind::FootnoteReference: {
|
||||
if (node.footnoteOrdinal <= 0) {
|
||||
const auto fallback = !node.raw.isEmpty()
|
||||
? node.raw
|
||||
: (u"[^"_q + node.footnoteLabel + u"]"_q);
|
||||
AppendTextWithInlineFormulas(
|
||||
node,
|
||||
fallback,
|
||||
text,
|
||||
inlineFormulas,
|
||||
state);
|
||||
break;
|
||||
}
|
||||
const auto index = links->size() + 1;
|
||||
const auto display = QString::number(node.footnoteOrdinal);
|
||||
text->append(display);
|
||||
if (text->text.size() > from) {
|
||||
text->entities.push_back(EntityInText(
|
||||
EntityType::Superscript,
|
||||
from,
|
||||
text->text.size() - from));
|
||||
}
|
||||
if (index <= std::numeric_limits<uint16>::max()) {
|
||||
text->entities.push_back(EntityInText(
|
||||
EntityType::CustomUrl,
|
||||
from,
|
||||
display.size(),
|
||||
InternalLinkData(uint16(index))));
|
||||
links->push_back({
|
||||
.index = uint16(index),
|
||||
.kind = PreparedLinkKind::Footnote,
|
||||
.target = FootnoteDefinitionAnchor(node),
|
||||
});
|
||||
if (inlineFormulas) {
|
||||
const auto remembered = state->rememberFootnoteReferenceAnchor(
|
||||
node.footnoteLabel,
|
||||
inlineFormulas->blockAnchorId);
|
||||
static_cast<void>(remembered);
|
||||
}
|
||||
}
|
||||
} break;
|
||||
case NodeKind::HtmlInline:
|
||||
case NodeKind::Unsupported:
|
||||
if (!node.raw.isEmpty()) {
|
||||
@@ -941,6 +1174,8 @@ void AppendRichBlock(
|
||||
TextWithEntities text,
|
||||
std::vector<PreparedLink> links,
|
||||
std::vector<PreparedInlineObject> inlineObjects,
|
||||
QString anchorId = QString(),
|
||||
bool collapsed = false,
|
||||
bool allowEmpty = false) {
|
||||
SortEntities(&text);
|
||||
if (text.text.isEmpty() && !allowEmpty) {
|
||||
@@ -952,6 +1187,8 @@ void AppendRichBlock(
|
||||
block.text = std::move(text);
|
||||
block.links = std::move(links);
|
||||
block.inlineObjects = std::move(inlineObjects);
|
||||
block.anchorId = std::move(anchorId);
|
||||
block.collapsed = collapsed;
|
||||
blocks->push_back(std::move(block));
|
||||
}
|
||||
|
||||
@@ -987,6 +1224,26 @@ void AppendRichBlock(
|
||||
return block;
|
||||
}
|
||||
|
||||
void CollectFootnoteDefinitions(
|
||||
const MarkdownNode &node,
|
||||
std::vector<FootnoteDefinitionEntry> *definitions) {
|
||||
if (!definitions) {
|
||||
return;
|
||||
}
|
||||
if (node.kind == NodeKind::FootnoteDefinition && node.footnoteOrdinal > 0) {
|
||||
if (node.footnoteOrdinal > int(definitions->size())) {
|
||||
definitions->resize(node.footnoteOrdinal);
|
||||
}
|
||||
auto &entry = (*definitions)[node.footnoteOrdinal - 1];
|
||||
if (!entry.node) {
|
||||
entry.node = &node;
|
||||
}
|
||||
}
|
||||
for (const auto &child : node.children) {
|
||||
CollectFootnoteDefinitions(child, definitions);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<PreparedBlock> PrepareBlocks(
|
||||
const MarkdownNode &node,
|
||||
PrepareContext context,
|
||||
@@ -1006,11 +1263,153 @@ void AppendRichBlock(
|
||||
return result;
|
||||
}
|
||||
|
||||
void AppendFootnoteBacklink(PreparedBlock *block, const QString &target) {
|
||||
if (!block || target.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
const auto index = block->links.size() + 1;
|
||||
if (index > std::numeric_limits<uint16>::max()) {
|
||||
return;
|
||||
}
|
||||
if (!block->text.text.isEmpty()) {
|
||||
block->text.append(QChar(' '));
|
||||
}
|
||||
const auto from = block->text.text.size();
|
||||
const auto label = u"[back]"_q;
|
||||
block->text.append(label);
|
||||
block->text.entities.push_back(EntityInText(
|
||||
EntityType::CustomUrl,
|
||||
from,
|
||||
label.size(),
|
||||
InternalLinkData(uint16(index))));
|
||||
block->links.push_back({
|
||||
.index = uint16(index),
|
||||
.kind = PreparedLinkKind::FootnoteBacklink,
|
||||
.target = target,
|
||||
});
|
||||
SortEntities(&block->text);
|
||||
}
|
||||
|
||||
void AppendFootnotes(
|
||||
std::vector<PreparedBlock> *blocks,
|
||||
PrepareState *state) {
|
||||
if (!blocks || !state || state->footnoteDefinitions.empty()) {
|
||||
return;
|
||||
}
|
||||
auto list = PreparedBlock();
|
||||
list.kind = PreparedBlockKind::List;
|
||||
list.listKind = ListKind::Ordered;
|
||||
list.listDelimiter = ListDelimiter::Period;
|
||||
list.startNumber = 1;
|
||||
for (const auto &entry : state->footnoteDefinitions) {
|
||||
if (state->cancelled() || !entry.node) {
|
||||
return;
|
||||
}
|
||||
auto item = PreparedBlock();
|
||||
item.kind = PreparedBlockKind::ListItem;
|
||||
item.listKind = ListKind::Ordered;
|
||||
item.listDelimiter = ListDelimiter::Period;
|
||||
item.orderedNumber = entry.node->footnoteOrdinal;
|
||||
item.anchorId = FootnoteDefinitionAnchor(*entry.node);
|
||||
item.children = PrepareChildren(*entry.node, {}, state);
|
||||
if (item.children.empty()) {
|
||||
item.children.push_back(EmptyParagraphBlock());
|
||||
}
|
||||
const auto backlink = state->firstFootnoteReferenceAnchor(
|
||||
entry.node->footnoteLabel);
|
||||
if (!item.children.empty()
|
||||
&& item.children.back().kind == PreparedBlockKind::Paragraph) {
|
||||
AppendFootnoteBacklink(&item.children.back(), backlink);
|
||||
} else if (!backlink.isEmpty()) {
|
||||
auto paragraph = EmptyParagraphBlock();
|
||||
AppendFootnoteBacklink(¶graph, backlink);
|
||||
item.children.push_back(std::move(paragraph));
|
||||
}
|
||||
list.children.push_back(std::move(item));
|
||||
}
|
||||
if (list.children.empty()) {
|
||||
return;
|
||||
}
|
||||
blocks->push_back(PrepareRuleBlock());
|
||||
blocks->push_back(std::move(list));
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<PreparedBlock> PrepareNestedDetailsBody(
|
||||
const MarkdownNode &node,
|
||||
PrepareState *state) {
|
||||
if (node.detailsBody.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
const auto parsed = ParseMarkdownForIv(
|
||||
node.detailsBody.toUtf8(),
|
||||
ParseOptions{ state->request->document->sourceName + u"#details"_q });
|
||||
if (!parsed.ok
|
||||
|| !parsed.document.formulas.empty()
|
||||
|| parsed.document.stats.footnotesSeen) {
|
||||
auto fallback = std::vector<PreparedBlock>();
|
||||
AppendRichBlock(
|
||||
&fallback,
|
||||
PreparedBlockKind::Paragraph,
|
||||
0,
|
||||
TextWithEntities::Simple(node.detailsBody),
|
||||
std::vector<PreparedLink>(),
|
||||
std::vector<PreparedInlineObject>());
|
||||
return fallback;
|
||||
}
|
||||
auto nestedRequest = PrepareRequest{
|
||||
.document = std::make_shared<const PreparedDocument>(parsed.document),
|
||||
.style = state->result.style,
|
||||
.sourcePath = state->request->sourcePath,
|
||||
};
|
||||
auto nested = PrepareSynchronously(std::move(nestedRequest));
|
||||
return nested.cancelled
|
||||
? std::vector<PreparedBlock>()
|
||||
: std::move(nested.blocks.blocks);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<PreparedBlock> PrepareDetailsBlocks(
|
||||
const MarkdownNode &node,
|
||||
PrepareState *state) {
|
||||
auto block = PreparedBlock();
|
||||
block.kind = PreparedBlockKind::Details;
|
||||
block.anchorId = DetailsAnchorId(state);
|
||||
block.collapsed = !node.detailsOpen;
|
||||
block.text.text = (node.detailsOpen ? u"v "_q : u"> "_q)
|
||||
+ node.detailsSummary;
|
||||
if (!block.text.text.isEmpty()) {
|
||||
block.text.entities.push_back(EntityInText(
|
||||
EntityType::CustomUrl,
|
||||
0,
|
||||
block.text.text.size(),
|
||||
InternalLinkData(1)));
|
||||
block.links.push_back({
|
||||
.index = 1,
|
||||
.kind = PreparedLinkKind::ToggleDetails,
|
||||
.target = block.anchorId,
|
||||
});
|
||||
}
|
||||
block.children = PrepareNestedDetailsBody(node, state);
|
||||
return { std::move(block) };
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<PreparedBlock> PrepareDocumentBlocks(
|
||||
const MarkdownNode &node,
|
||||
PrepareState *state) {
|
||||
auto result = PrepareChildren(node, {}, state);
|
||||
if (!state->result.cancelled) {
|
||||
AppendFootnotes(&result, state);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<PreparedBlock> PrepareFlowBlock(
|
||||
const MarkdownNode &node,
|
||||
PreparedBlockKind kind,
|
||||
PrepareState *state) {
|
||||
auto result = std::vector<PreparedBlock>();
|
||||
auto anchorId = (kind == PreparedBlockKind::Heading)
|
||||
? node.anchorId
|
||||
: QString();
|
||||
auto text = TextWithEntities();
|
||||
auto links = std::vector<PreparedLink>();
|
||||
auto inlineObjects = std::vector<PreparedInlineObject>();
|
||||
@@ -1023,6 +1422,7 @@ void AppendRichBlock(
|
||||
auto inlineFormulas = InlineFormulaContext{
|
||||
.formulas = &formulas,
|
||||
.prepared = &inlineObjects,
|
||||
.blockAnchorId = &anchorId,
|
||||
.textSize = textSize,
|
||||
.renderWidthCap = ScaleFormulaCap(
|
||||
state->result.style.displayMathMaxRenderWidth,
|
||||
@@ -1059,7 +1459,8 @@ void AppendRichBlock(
|
||||
(kind == PreparedBlockKind::Heading) ? node.headingLevel : 0,
|
||||
std::move(text),
|
||||
std::move(links),
|
||||
std::move(inlineObjects));
|
||||
std::move(inlineObjects),
|
||||
std::move(anchorId));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1169,6 +1570,13 @@ void AppendRichBlock(
|
||||
if (state->cancelled()) {
|
||||
return {};
|
||||
}
|
||||
if (node.kind == NodeKind::HtmlBlock) {
|
||||
if (node.htmlBlockKind == HtmlBlockKind::Comment) {
|
||||
return {};
|
||||
} else if (node.htmlBlockKind == HtmlBlockKind::Details) {
|
||||
return PrepareDetailsBlocks(node, state);
|
||||
}
|
||||
}
|
||||
if (!node.children.empty()) {
|
||||
return PrepareChildren(node, context, state);
|
||||
}
|
||||
@@ -1197,6 +1605,7 @@ void AppendRichBlock(
|
||||
|
||||
switch (node.kind) {
|
||||
case NodeKind::Document:
|
||||
return PrepareDocumentBlocks(node, state);
|
||||
case NodeKind::TableRow:
|
||||
case NodeKind::TableCell:
|
||||
case NodeKind::HtmlBlock:
|
||||
@@ -1208,6 +1617,8 @@ void AppendRichBlock(
|
||||
return PrepareFlowBlock(node, PreparedBlockKind::Paragraph, state);
|
||||
case NodeKind::Heading:
|
||||
return PrepareFlowBlock(node, PreparedBlockKind::Heading, state);
|
||||
case NodeKind::FootnoteDefinition:
|
||||
return {};
|
||||
case NodeKind::CodeBlock:
|
||||
return { PrepareCodeBlock(node) };
|
||||
case NodeKind::ThematicBreak:
|
||||
@@ -1250,6 +1661,8 @@ void AppendRichBlock(
|
||||
const PreparedDocument &document,
|
||||
PrepareState *state) {
|
||||
auto result = PreparedRenderDocument();
|
||||
state->footnoteDefinitions.clear();
|
||||
CollectFootnoteDefinitions(document.document, &state->footnoteDefinitions);
|
||||
result.blocks = PrepareBlocks(document.document, {}, state);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -25,11 +25,24 @@ enum class PreparedBlockKind {
|
||||
Quote,
|
||||
DisplayMath,
|
||||
Table,
|
||||
Details,
|
||||
};
|
||||
|
||||
enum class PreparedLinkKind {
|
||||
External,
|
||||
Anchor,
|
||||
Footnote,
|
||||
FootnoteBacklink,
|
||||
LocalFile,
|
||||
RejectedRelative,
|
||||
ToggleDetails,
|
||||
};
|
||||
|
||||
struct PreparedLink {
|
||||
uint16 index = 0;
|
||||
PreparedLinkKind kind = PreparedLinkKind::External;
|
||||
QString target;
|
||||
QString fragment;
|
||||
};
|
||||
|
||||
struct PreparedInlineObject {
|
||||
@@ -62,6 +75,7 @@ struct PreparedBlock {
|
||||
std::vector<TableAlignment> tableAlignments;
|
||||
QString codeLanguage;
|
||||
QString formulaTex;
|
||||
QString anchorId;
|
||||
ListKind listKind = ListKind::Bullet;
|
||||
ListDelimiter listDelimiter = ListDelimiter::None;
|
||||
MathKind mathKind = MathKind::Display;
|
||||
@@ -73,6 +87,7 @@ struct PreparedBlock {
|
||||
int actualDepth = 0;
|
||||
int visualDepth = 0;
|
||||
int tableColumnCount = 0;
|
||||
bool collapsed = false;
|
||||
bool depthClamped = false;
|
||||
bool tight = false;
|
||||
};
|
||||
@@ -178,6 +193,7 @@ struct PrepareRequest {
|
||||
std::shared_ptr<const PreparedDocument> document;
|
||||
MarkdownStyleSnapshot style;
|
||||
PrepareGeneration generation = 0;
|
||||
QString sourcePath;
|
||||
std::shared_ptr<std::atomic_bool> cancelled;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "iv/markdown/iv_markdown_view.h"
|
||||
#include "iv/markdown/iv_markdown_controller.h"
|
||||
#include "iv/markdown/iv_markdown_prepare.h"
|
||||
#include "iv/iv_delegate.h"
|
||||
|
||||
#include <QtCore/QDebug>
|
||||
|
||||
@@ -7,6 +9,7 @@
|
||||
#include "core/credits_amount.h"
|
||||
#include "core/click_handler_types.h"
|
||||
#include "lang/lang_keys.h"
|
||||
#include "logs.h"
|
||||
#include "ui/click_handler.h"
|
||||
#include "ui/painter.h"
|
||||
#include "ui/rp_widget.h"
|
||||
@@ -23,6 +26,8 @@
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -108,6 +113,7 @@ struct LaidOutBlock {
|
||||
QRect tableRect;
|
||||
QRect visibleFormulaRect;
|
||||
QRect visibleTableRect;
|
||||
QString anchorId;
|
||||
int textWidth = 0;
|
||||
int markerWidth = 0;
|
||||
int headingLevel = 0;
|
||||
@@ -117,6 +123,7 @@ struct LaidOutBlock {
|
||||
int formulaIndex = -1;
|
||||
int orderedNumber = 0;
|
||||
style::align formulaAlign = style::al_left;
|
||||
bool collapsed = false;
|
||||
bool overflowed = false;
|
||||
};
|
||||
|
||||
@@ -175,6 +182,8 @@ constexpr auto kCodeTrailingGuard = 0x2060;
|
||||
return style.displayMathSkip;
|
||||
case PreparedBlockKind::Table:
|
||||
return style.tableSkip;
|
||||
case PreparedBlockKind::Details:
|
||||
return style.paragraphSkip;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -250,13 +259,35 @@ constexpr auto kCodeTrailingGuard = 0x2060;
|
||||
return result;
|
||||
}
|
||||
|
||||
class PreparedLinkClickHandler final : public ClickHandler {
|
||||
public:
|
||||
explicit PreparedLinkClickHandler(PreparedLink link)
|
||||
: _link(std::move(link)) {
|
||||
}
|
||||
|
||||
void onClick(ClickContext) const override {
|
||||
}
|
||||
|
||||
[[nodiscard]] const PreparedLink &link() const {
|
||||
return _link;
|
||||
}
|
||||
|
||||
QString url() const override {
|
||||
return _link.target;
|
||||
}
|
||||
|
||||
private:
|
||||
PreparedLink _link;
|
||||
|
||||
};
|
||||
|
||||
void BindLinks(
|
||||
Ui::Text::String *leaf,
|
||||
const std::vector<PreparedLink> &links) {
|
||||
for (const auto &link : links) {
|
||||
leaf->setLink(
|
||||
link.index,
|
||||
std::make_shared<HiddenUrlClickHandler>(link.target));
|
||||
std::make_shared<PreparedLinkClickHandler>(link));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,6 +533,7 @@ void SetTextLeaf(
|
||||
int width) {
|
||||
auto block = LaidOutBlock();
|
||||
block.kind = prepared.kind;
|
||||
block.anchorId = prepared.anchorId;
|
||||
block.headingLevel = prepared.headingLevel;
|
||||
block.textWidth = std::max(width, 1);
|
||||
|
||||
@@ -838,6 +870,7 @@ void SetTextLeaf(
|
||||
bool tight) {
|
||||
auto block = LaidOutBlock();
|
||||
block.kind = PreparedBlockKind::ListItem;
|
||||
block.anchorId = prepared.anchorId;
|
||||
block.listKind = prepared.listKind;
|
||||
block.listDelimiter = prepared.listDelimiter;
|
||||
block.taskState = prepared.taskState;
|
||||
@@ -1030,6 +1063,56 @@ void SetTextLeaf(
|
||||
return block;
|
||||
}
|
||||
|
||||
[[nodiscard]] LaidOutBlock LayoutDetailsBlock(
|
||||
const PreparedBlock &prepared,
|
||||
const MarkdownStyleSnapshot &style,
|
||||
const std::vector<PreparedFormulaSlot> &formulas,
|
||||
int left,
|
||||
int top,
|
||||
int width,
|
||||
LayoutContext context) {
|
||||
auto block = LaidOutBlock();
|
||||
block.kind = PreparedBlockKind::Details;
|
||||
block.anchorId = prepared.anchorId;
|
||||
block.collapsed = prepared.collapsed;
|
||||
block.textWidth = std::max(width, 1);
|
||||
|
||||
SetTextLeaf(
|
||||
&block.leaf,
|
||||
style.paragraphStyle,
|
||||
prepared.text,
|
||||
prepared.inlineObjects,
|
||||
style,
|
||||
formulas);
|
||||
BindLinks(&block.leaf, prepared.links);
|
||||
|
||||
const auto summaryHeight = std::max(
|
||||
block.leaf.countHeight(block.textWidth, true),
|
||||
TextLineHeight(style.paragraphStyle));
|
||||
block.textRect = QRect(left, top, block.textWidth, summaryHeight);
|
||||
|
||||
auto bottom = top + summaryHeight;
|
||||
if (!prepared.collapsed && !prepared.children.empty()) {
|
||||
const auto childLeft = left + style.listContinuationIndent;
|
||||
const auto childWidth = std::max(
|
||||
width - style.listContinuationIndent,
|
||||
1);
|
||||
const auto childTop = bottom + style.listMarkerSkip;
|
||||
bottom = LayoutBlocks(
|
||||
prepared.children,
|
||||
&block.children,
|
||||
style,
|
||||
formulas,
|
||||
childLeft,
|
||||
childTop,
|
||||
childWidth,
|
||||
context);
|
||||
}
|
||||
block.outer = QRect(left, top, std::max(width, 1), std::max(bottom - top, summaryHeight));
|
||||
block.contentRect = block.textRect;
|
||||
return block;
|
||||
}
|
||||
|
||||
[[nodiscard]] LaidOutBlock LayoutBlock(
|
||||
const PreparedBlock &prepared,
|
||||
const MarkdownStyleSnapshot &style,
|
||||
@@ -1084,6 +1167,15 @@ void SetTextLeaf(
|
||||
width);
|
||||
case PreparedBlockKind::Table:
|
||||
return LayoutTableBlock(prepared, formulas, style, left, top, width);
|
||||
case PreparedBlockKind::Details:
|
||||
return LayoutDetailsBlock(
|
||||
prepared,
|
||||
style,
|
||||
formulas,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
context);
|
||||
}
|
||||
return LayoutFlowBlock(prepared, formulas, style, left, top, width);
|
||||
}
|
||||
@@ -1094,12 +1186,14 @@ public:
|
||||
void relayout(const PreparedResult &prepared, int width);
|
||||
|
||||
[[nodiscard]] int height() const;
|
||||
[[nodiscard]] int anchorTop(const QString &anchorId) const;
|
||||
[[nodiscard]] const std::vector<LaidOutBlock> &blocks() const;
|
||||
|
||||
private:
|
||||
int _width = -1;
|
||||
int _height = 0;
|
||||
std::vector<LaidOutBlock> _blocks;
|
||||
std::vector<std::pair<QString, int>> _anchors;
|
||||
|
||||
};
|
||||
|
||||
@@ -1109,7 +1203,12 @@ class MarkdownDocumentWidget final
|
||||
public:
|
||||
explicit MarkdownDocumentWidget(QWidget *parent);
|
||||
|
||||
void setLinkActivationCallback(
|
||||
std::function<void(const PreparedLink &, Qt::MouseButton)> callback);
|
||||
void setPreparedResult(PreparedResult prepared);
|
||||
void setZoom(int value);
|
||||
[[nodiscard]] int anchorTop(const QString &anchorId) const;
|
||||
[[nodiscard]] bool toggleDetails(const QString &anchorId);
|
||||
int resizeGetHeight(int newWidth) override;
|
||||
|
||||
protected:
|
||||
@@ -1128,11 +1227,14 @@ private:
|
||||
void forceRelayoutCurrentWidth();
|
||||
void updateHover(QPoint point);
|
||||
void applyCursor(style::cursor cursor);
|
||||
[[nodiscard]] double zoomScale() const;
|
||||
|
||||
PreparedResult _prepared;
|
||||
DocumentLayout _layout;
|
||||
std::optional<SnapshotTextPalette> _textPalette;
|
||||
std::function<void(const PreparedLink &, Qt::MouseButton)> _activateLink;
|
||||
style::cursor _cursor = style::cur_default;
|
||||
int _zoom = 100;
|
||||
|
||||
};
|
||||
|
||||
@@ -1426,6 +1528,15 @@ void PaintBlock(
|
||||
case PreparedBlockKind::Table:
|
||||
PaintTableBlock(p, block, style, clip);
|
||||
break;
|
||||
case PreparedBlockKind::Details:
|
||||
PaintTextLeaf(
|
||||
p,
|
||||
block.leaf,
|
||||
block.textRect,
|
||||
block.textWidth,
|
||||
clip);
|
||||
PaintBlocks(p, block.children, prepared, clip);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1543,6 +1654,11 @@ void PaintBlocks(
|
||||
return nullptr;
|
||||
case PreparedBlockKind::Table:
|
||||
return LinkAtTableBlock(block, point);
|
||||
case PreparedBlockKind::Details:
|
||||
if (const auto result = LinkAtTextBlock(block, point)) {
|
||||
return result;
|
||||
}
|
||||
return LinkAtBlocks(block.children, point);
|
||||
case PreparedBlockKind::CodeBlock:
|
||||
case PreparedBlockKind::Rule:
|
||||
return nullptr;
|
||||
@@ -1566,6 +1682,20 @@ void PaintBlocks(
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void CollectAnchors(
|
||||
const std::vector<LaidOutBlock> &blocks,
|
||||
std::vector<std::pair<QString, int>> *anchors) {
|
||||
if (!anchors) {
|
||||
return;
|
||||
}
|
||||
for (const auto &block : blocks) {
|
||||
if (!block.anchorId.isEmpty()) {
|
||||
anchors->push_back({ block.anchorId, block.outer.top() });
|
||||
}
|
||||
CollectAnchors(block.children, anchors);
|
||||
}
|
||||
}
|
||||
|
||||
void DocumentLayout::relayout(
|
||||
const PreparedResult &prepared,
|
||||
int width) {
|
||||
@@ -1575,6 +1705,7 @@ void DocumentLayout::relayout(
|
||||
}
|
||||
_width = width;
|
||||
_blocks.clear();
|
||||
_anchors.clear();
|
||||
|
||||
const auto &page = prepared.style.pagePadding;
|
||||
const auto innerWidth = std::max(width - page.left() - page.right(), 1);
|
||||
@@ -1588,28 +1719,70 @@ void DocumentLayout::relayout(
|
||||
innerWidth,
|
||||
{});
|
||||
_height = y + page.bottom();
|
||||
CollectAnchors(_blocks, &_anchors);
|
||||
}
|
||||
|
||||
void DocumentLayout::invalidate() {
|
||||
_width = -1;
|
||||
_height = 0;
|
||||
_blocks.clear();
|
||||
_anchors.clear();
|
||||
}
|
||||
|
||||
int DocumentLayout::height() const {
|
||||
return _height;
|
||||
}
|
||||
|
||||
int DocumentLayout::anchorTop(const QString &anchorId) const {
|
||||
for (const auto &entry : _anchors) {
|
||||
if (entry.first == anchorId) {
|
||||
return entry.second;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const std::vector<LaidOutBlock> &DocumentLayout::blocks() const {
|
||||
return _blocks;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool ToggleDetailsBlock(
|
||||
std::vector<PreparedBlock> *blocks,
|
||||
const QString &anchorId) {
|
||||
if (!blocks) {
|
||||
return false;
|
||||
}
|
||||
for (auto &block : *blocks) {
|
||||
if (block.kind == PreparedBlockKind::Details
|
||||
&& block.anchorId == anchorId) {
|
||||
block.collapsed = !block.collapsed;
|
||||
if (block.text.text.startsWith(u"> "_q)
|
||||
|| block.text.text.startsWith(u"v "_q)) {
|
||||
block.text.text.replace(
|
||||
0,
|
||||
2,
|
||||
block.collapsed ? u"> "_q : u"v "_q);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (ToggleDetailsBlock(&block.children, anchorId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
MarkdownDocumentWidget::MarkdownDocumentWidget(
|
||||
QWidget *parent)
|
||||
: Ui::RpWidget(parent) {
|
||||
setMouseTracking(true);
|
||||
}
|
||||
|
||||
void MarkdownDocumentWidget::setLinkActivationCallback(
|
||||
std::function<void(const PreparedLink &, Qt::MouseButton)> callback) {
|
||||
_activateLink = std::move(callback);
|
||||
}
|
||||
|
||||
void MarkdownDocumentWidget::setPreparedResult(PreparedResult prepared) {
|
||||
ClickHandler::clearActive(this);
|
||||
applyCursor(style::cur_default);
|
||||
@@ -1619,11 +1792,39 @@ void MarkdownDocumentWidget::setPreparedResult(PreparedResult prepared) {
|
||||
forceRelayoutCurrentWidth();
|
||||
}
|
||||
|
||||
void MarkdownDocumentWidget::setZoom(int value) {
|
||||
value = (value > 0) ? value : 100;
|
||||
if (_zoom == value) {
|
||||
return;
|
||||
}
|
||||
_zoom = value;
|
||||
forceRelayoutCurrentWidth();
|
||||
}
|
||||
|
||||
int MarkdownDocumentWidget::anchorTop(const QString &anchorId) const {
|
||||
const auto top = _layout.anchorTop(anchorId);
|
||||
if (top < 0) {
|
||||
return -1;
|
||||
}
|
||||
return int(std::floor(top * zoomScale()));
|
||||
}
|
||||
|
||||
bool MarkdownDocumentWidget::toggleDetails(const QString &anchorId) {
|
||||
if (!ToggleDetailsBlock(&_prepared.blocks.blocks, anchorId)) {
|
||||
return false;
|
||||
}
|
||||
_layout.invalidate();
|
||||
forceRelayoutCurrentWidth();
|
||||
return true;
|
||||
}
|
||||
|
||||
int MarkdownDocumentWidget::resizeGetHeight(int newWidth) {
|
||||
ClickHandler::clearActive(this);
|
||||
applyCursor(style::cur_default);
|
||||
_layout.relayout(_prepared, newWidth);
|
||||
return std::max(_layout.height(), 1);
|
||||
const auto scale = zoomScale();
|
||||
const auto layoutWidth = std::max(int(std::floor(newWidth / scale)), 1);
|
||||
_layout.relayout(_prepared, layoutWidth);
|
||||
return std::max(int(std::ceil(_layout.height() * scale)), 1);
|
||||
}
|
||||
|
||||
void MarkdownDocumentWidget::paintEvent(QPaintEvent *e) {
|
||||
@@ -1632,7 +1833,20 @@ void MarkdownDocumentWidget::paintEvent(QPaintEvent *e) {
|
||||
p.setTextPalette(_textPalette->palette);
|
||||
}
|
||||
|
||||
PaintBlocks(p, _layout.blocks(), _prepared, e->rect());
|
||||
const auto scale = zoomScale();
|
||||
if (scale == 1.) {
|
||||
PaintBlocks(p, _layout.blocks(), _prepared, e->rect());
|
||||
return;
|
||||
}
|
||||
const auto clip = QRect(
|
||||
int(std::floor(e->rect().x() / scale)),
|
||||
int(std::floor(e->rect().y() / scale)),
|
||||
int(std::ceil(e->rect().width() / scale)) + 1,
|
||||
int(std::ceil(e->rect().height() / scale)) + 1);
|
||||
p.save();
|
||||
p.scale(scale, scale);
|
||||
PaintBlocks(p, _layout.blocks(), _prepared, clip);
|
||||
p.restore();
|
||||
}
|
||||
|
||||
void MarkdownDocumentWidget::mouseMoveEvent(QMouseEvent *e) {
|
||||
@@ -1651,7 +1865,14 @@ void MarkdownDocumentWidget::mouseReleaseEvent(QMouseEvent *e) {
|
||||
if (activated
|
||||
&& (e->button() == Qt::LeftButton
|
||||
|| e->button() == Qt::MiddleButton)) {
|
||||
ActivateClickHandler(window(), activated, e->button());
|
||||
if (const auto prepared = std::dynamic_pointer_cast<PreparedLinkClickHandler>(
|
||||
activated)) {
|
||||
if (_activateLink) {
|
||||
_activateLink(prepared->link(), e->button());
|
||||
}
|
||||
} else {
|
||||
ActivateClickHandler(window(), activated, e->button());
|
||||
}
|
||||
}
|
||||
if (rect().contains(e->pos())) {
|
||||
updateHover(e->pos());
|
||||
@@ -1680,11 +1901,19 @@ void MarkdownDocumentWidget::clickHandlerPressedChanged(
|
||||
}
|
||||
|
||||
ClickHandlerPtr MarkdownDocumentWidget::linkAt(QPoint point) const {
|
||||
const auto scale = zoomScale();
|
||||
if (scale != 1.) {
|
||||
point = QPoint(
|
||||
int(std::floor(point.x() / scale)),
|
||||
int(std::floor(point.y() / scale)));
|
||||
}
|
||||
return LinkAtBlocks(_layout.blocks(), point);
|
||||
}
|
||||
|
||||
void MarkdownDocumentWidget::relayoutCurrentWidth() {
|
||||
_layout.relayout(_prepared, width());
|
||||
const auto scale = zoomScale();
|
||||
const auto layoutWidth = std::max(int(std::floor(width() / scale)), 1);
|
||||
_layout.relayout(_prepared, layoutWidth);
|
||||
}
|
||||
|
||||
void MarkdownDocumentWidget::forceRelayoutCurrentWidth() {
|
||||
@@ -1706,6 +1935,10 @@ void MarkdownDocumentWidget::applyCursor(style::cursor cursor) {
|
||||
}
|
||||
}
|
||||
|
||||
double MarkdownDocumentWidget::zoomScale() const {
|
||||
return std::max(_zoom, 1) / 100.;
|
||||
}
|
||||
|
||||
constexpr auto kDeferredPreparationSourceBytes = 128 * 1024;
|
||||
constexpr auto kDeferredPreparationFormulaCount = 4;
|
||||
constexpr auto kDeferredPreparationConvertedNodes = 1200;
|
||||
@@ -1724,17 +1957,21 @@ private:
|
||||
void startPreparation(
|
||||
bool deferred,
|
||||
std::optional<MarkdownStyleSnapshot> style = std::nullopt);
|
||||
void activateLink(const PreparedLink &link, Qt::MouseButton button);
|
||||
void applyPreparedResult(PreparedResult prepared);
|
||||
[[nodiscard]] bool scrollToAnchor(const QString &anchorId);
|
||||
void updateChildrenGeometry(QSize size);
|
||||
void updateLoadingGeometry();
|
||||
void cancelInFlightRequest();
|
||||
|
||||
const OpenOptions _options;
|
||||
const std::shared_ptr<const PreparedDocument> _document;
|
||||
Ui::ScrollArea *_scroll = nullptr;
|
||||
MarkdownDocumentWidget *_body = nullptr;
|
||||
Ui::FlatLabel *_loading = nullptr;
|
||||
PrepareGeneration _generation = 0;
|
||||
int _requestedDevicePixelRatio = 0;
|
||||
QString _pendingFragment;
|
||||
std::shared_ptr<std::atomic_bool> _cancelled;
|
||||
|
||||
};
|
||||
@@ -1744,10 +1981,10 @@ MarkdownPreviewRoot::MarkdownPreviewRoot(
|
||||
const OpenOptions &options,
|
||||
QWidget *parent)
|
||||
: Ui::RpWidget(parent)
|
||||
, _options(options)
|
||||
, _document(std::make_shared<PreparedDocument>(document))
|
||||
, _pendingFragment(options.initialFragment)
|
||||
, _cancelled(std::make_shared<std::atomic_bool>(false)) {
|
||||
(void)options;
|
||||
|
||||
_scroll = Ui::CreateChild<Ui::ScrollArea>(this, st::boxScroll);
|
||||
_body = _scroll->setOwnedWidget(object_ptr<MarkdownDocumentWidget>(_scroll));
|
||||
_loading = Ui::CreateChild<Ui::FlatLabel>(
|
||||
@@ -1758,6 +1995,14 @@ MarkdownPreviewRoot::MarkdownPreviewRoot(
|
||||
_scroll->hide();
|
||||
if (_body) {
|
||||
_body->hide();
|
||||
_body->setLinkActivationCallback([=](
|
||||
const PreparedLink &link,
|
||||
Qt::MouseButton button) {
|
||||
activateLink(link, button);
|
||||
});
|
||||
if (_options.delegate) {
|
||||
_body->setZoom(_options.delegate->ivZoom());
|
||||
}
|
||||
}
|
||||
_loading->hide();
|
||||
|
||||
@@ -1780,6 +2025,15 @@ MarkdownPreviewRoot::MarkdownPreviewRoot(
|
||||
startPreparation(shouldDeferPreparation(), std::move(style));
|
||||
}, lifetime());
|
||||
|
||||
if (_options.delegate) {
|
||||
_options.delegate->ivZoomValue(
|
||||
) | rpl::on_next([=](int value) {
|
||||
if (_body) {
|
||||
_body->setZoom(value);
|
||||
}
|
||||
}, lifetime());
|
||||
}
|
||||
|
||||
startPreparation(shouldDeferPreparation(), std::move(initialStyle));
|
||||
}
|
||||
|
||||
@@ -1813,6 +2067,7 @@ void MarkdownPreviewRoot::startPreparation(
|
||||
.document = _document,
|
||||
.style = std::move(*style),
|
||||
.generation = generation,
|
||||
.sourcePath = _options.sourcePath,
|
||||
.cancelled = cancelled,
|
||||
};
|
||||
|
||||
@@ -1850,15 +2105,76 @@ void MarkdownPreviewRoot::startPreparation(
|
||||
}
|
||||
}
|
||||
|
||||
void MarkdownPreviewRoot::activateLink(
|
||||
const PreparedLink &link,
|
||||
Qt::MouseButton button) {
|
||||
if (button != Qt::LeftButton && button != Qt::MiddleButton) {
|
||||
return;
|
||||
}
|
||||
switch (link.kind) {
|
||||
case PreparedLinkKind::External:
|
||||
HiddenUrlClickHandler::Open(link.target);
|
||||
break;
|
||||
case PreparedLinkKind::Anchor:
|
||||
case PreparedLinkKind::Footnote:
|
||||
case PreparedLinkKind::FootnoteBacklink:
|
||||
if (!scrollToAnchor(link.target)) {
|
||||
DEBUG_LOG(("Native Markdown IV: unresolved anchor: %1").arg(
|
||||
link.target));
|
||||
}
|
||||
break;
|
||||
case PreparedLinkKind::LocalFile: {
|
||||
auto path = link.target;
|
||||
if (!link.fragment.isEmpty()) {
|
||||
path += u"#"_q + link.fragment;
|
||||
}
|
||||
if (!TryOpenLocalFile(path)) {
|
||||
DEBUG_LOG(("Native Markdown IV: failed local markdown link: %1").arg(
|
||||
path));
|
||||
}
|
||||
} break;
|
||||
case PreparedLinkKind::RejectedRelative:
|
||||
DEBUG_LOG(("Native Markdown IV: rejected relative markdown link: %1").arg(
|
||||
link.target));
|
||||
break;
|
||||
case PreparedLinkKind::ToggleDetails:
|
||||
if (_body && !_body->toggleDetails(link.target)) {
|
||||
DEBUG_LOG(("Native Markdown IV: failed details toggle: %1").arg(
|
||||
link.target));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void MarkdownPreviewRoot::applyPreparedResult(PreparedResult prepared) {
|
||||
if (!_body) {
|
||||
return;
|
||||
}
|
||||
_body->setPreparedResult(std::move(prepared));
|
||||
if (_options.delegate) {
|
||||
_body->setZoom(_options.delegate->ivZoom());
|
||||
}
|
||||
_body->resizeToWidth(_scroll->width());
|
||||
_scroll->show();
|
||||
_body->show();
|
||||
_loading->hide();
|
||||
if (!_pendingFragment.isEmpty()) {
|
||||
const auto scrolled = scrollToAnchor(_pendingFragment);
|
||||
static_cast<void>(scrolled);
|
||||
_pendingFragment.clear();
|
||||
}
|
||||
}
|
||||
|
||||
bool MarkdownPreviewRoot::scrollToAnchor(const QString &anchorId) {
|
||||
if (!_body || !_scroll || anchorId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const auto top = _body->anchorTop(anchorId);
|
||||
if (top < 0) {
|
||||
return false;
|
||||
}
|
||||
_scroll->scrollToY(top, top + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
void MarkdownPreviewRoot::updateChildrenGeometry(QSize size) {
|
||||
|
||||
@@ -446,6 +446,60 @@ using NodeKindPathIter = NodeKindPath::const_iterator;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] const MarkdownNode *FindHtmlBlockContaining(
|
||||
const MarkdownNode &node,
|
||||
const QString &text) {
|
||||
if (node.kind == NodeKind::HtmlBlock && node.raw.contains(text)) {
|
||||
return &node;
|
||||
}
|
||||
for (const auto &child : node.children) {
|
||||
if (const auto found = FindHtmlBlockContaining(child, text)) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void CollectNodesByKind(
|
||||
const MarkdownNode &node,
|
||||
NodeKind kind,
|
||||
std::vector<const MarkdownNode*> *out) {
|
||||
if (!out) {
|
||||
return;
|
||||
}
|
||||
if (node.kind == kind) {
|
||||
out->push_back(&node);
|
||||
}
|
||||
for (const auto &child : node.children) {
|
||||
CollectNodesByKind(child, kind, out);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] const MarkdownNode *FindLinkByTarget(
|
||||
const MarkdownNode &node,
|
||||
const QString &target) {
|
||||
if (node.kind == NodeKind::Link && node.url == target) {
|
||||
return &node;
|
||||
}
|
||||
for (const auto &child : node.children) {
|
||||
if (const auto found = FindLinkByTarget(child, target)) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool WarningContains(
|
||||
const PreparedDocument &document,
|
||||
const QString &snippet) {
|
||||
for (const auto &warning : document.warnings) {
|
||||
if (warning.contains(snippet, Qt::CaseInsensitive)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
[[nodiscard]] int CountFormulas(
|
||||
const PreparedDocument &document,
|
||||
MathKind kind) {
|
||||
@@ -954,6 +1008,136 @@ void CheckInlineHtmlCoverage(bool dump, bool *ok) {
|
||||
}
|
||||
}
|
||||
|
||||
void CheckFixtureSemanticCoverage(
|
||||
const PreparedDocument &document,
|
||||
const QString &path,
|
||||
bool *ok) {
|
||||
auto footnoteReferences = std::vector<const MarkdownNode*>();
|
||||
CollectNodesByKind(
|
||||
document.document,
|
||||
NodeKind::FootnoteReference,
|
||||
&footnoteReferences);
|
||||
Check(
|
||||
footnoteReferences.size() >= 2,
|
||||
FromLatin1("markdown-example.md footnote reference count"),
|
||||
ok);
|
||||
if (footnoteReferences.size() >= 2) {
|
||||
Check(
|
||||
footnoteReferences[0]->footnoteLabel == FromLatin1("1")
|
||||
&& footnoteReferences[0]->footnoteOrdinal == 1,
|
||||
FromLatin1("markdown-example.md first footnote reference label"),
|
||||
ok);
|
||||
Check(
|
||||
footnoteReferences[1]->footnoteLabel == FromLatin1("long-note")
|
||||
&& footnoteReferences[1]->footnoteOrdinal == 2,
|
||||
FromLatin1("markdown-example.md second footnote reference label"),
|
||||
ok);
|
||||
}
|
||||
|
||||
auto footnoteDefinitions = std::vector<const MarkdownNode*>();
|
||||
CollectNodesByKind(
|
||||
document.document,
|
||||
NodeKind::FootnoteDefinition,
|
||||
&footnoteDefinitions);
|
||||
Check(
|
||||
footnoteDefinitions.size() >= 2,
|
||||
FromLatin1("markdown-example.md footnote definition count"),
|
||||
ok);
|
||||
if (footnoteDefinitions.size() >= 2) {
|
||||
Check(
|
||||
footnoteDefinitions[0]->anchorId == FromLatin1("fn-1")
|
||||
&& footnoteDefinitions[1]->anchorId == FromLatin1("fn-2"),
|
||||
FromLatin1("markdown-example.md footnote anchors"),
|
||||
ok);
|
||||
}
|
||||
|
||||
const auto headingsLink = FindLinkByTarget(document.document, FromLatin1("#headings"));
|
||||
Check(
|
||||
headingsLink != nullptr,
|
||||
FromLatin1("markdown-example.md toc fragment link"),
|
||||
ok);
|
||||
|
||||
const auto relativeLink = FindLinkByTarget(
|
||||
document.document,
|
||||
FromLatin1("./docs/getting-started.md"));
|
||||
Check(
|
||||
relativeLink != nullptr,
|
||||
FromLatin1("markdown-example.md relative link parse"),
|
||||
ok);
|
||||
|
||||
const auto headings = FindNodeByKindAndLineRange(
|
||||
document.document,
|
||||
NodeKind::Heading,
|
||||
27,
|
||||
27);
|
||||
Check(
|
||||
headings != nullptr && headings->anchorId == FromLatin1("headings"),
|
||||
FromLatin1("markdown-example.md headings anchor id"),
|
||||
ok);
|
||||
const auto definitionLists = FindNodeByKindAndLineRange(
|
||||
document.document,
|
||||
NodeKind::Heading,
|
||||
266,
|
||||
266);
|
||||
Check(
|
||||
definitionLists != nullptr
|
||||
&& definitionLists->anchorId
|
||||
== FromLatin1("definition-lists-renderer-dependent"),
|
||||
FromLatin1("markdown-example.md punctuation heading anchor id"),
|
||||
ok);
|
||||
|
||||
const auto details = FindNodeByKindAndLineRange(
|
||||
document.document,
|
||||
NodeKind::HtmlBlock,
|
||||
261,
|
||||
264);
|
||||
Check(
|
||||
details != nullptr,
|
||||
FromLatin1("markdown-example.md details block range"),
|
||||
ok);
|
||||
if (details) {
|
||||
Check(
|
||||
details->htmlBlockKind == HtmlBlockKind::Details
|
||||
&& details->detailsSummary
|
||||
== FromLatin1("Click to expand details/summary block"),
|
||||
FromLatin1("markdown-example.md details classification"),
|
||||
ok);
|
||||
}
|
||||
const auto comment = FindHtmlBlockContaining(
|
||||
document.document,
|
||||
FromLatin1("markdown-renderer-test"));
|
||||
Check(
|
||||
comment != nullptr && comment->htmlBlockKind == HtmlBlockKind::Comment,
|
||||
FromLatin1("markdown-example.md comment classification"),
|
||||
ok);
|
||||
Check(
|
||||
WarningContains(document, FromLatin1("Unsupported HTML block")),
|
||||
FromLatin1("markdown-example.md unsupported html warning"),
|
||||
ok);
|
||||
|
||||
const auto duplicateHeadings = ParseMarkdownForIv(
|
||||
QByteArray("## Same\n## Same\n"),
|
||||
ParseOptions{ FromLatin1("generated-duplicate-headings.md") });
|
||||
Check(
|
||||
duplicateHeadings.ok,
|
||||
FromLatin1("generated duplicate headings parse failed"),
|
||||
ok);
|
||||
if (duplicateHeadings.ok) {
|
||||
auto duplicateNodes = std::vector<const MarkdownNode*>();
|
||||
CollectNodesByKind(
|
||||
duplicateHeadings.document.document,
|
||||
NodeKind::Heading,
|
||||
&duplicateNodes);
|
||||
Check(
|
||||
duplicateNodes.size() == 2
|
||||
&& duplicateNodes[0]->anchorId == FromLatin1("same")
|
||||
&& duplicateNodes[1]->anchorId == FromLatin1("same-2"),
|
||||
FromLatin1("generated duplicate headings anchors"),
|
||||
ok);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
@@ -1089,6 +1273,7 @@ int main(int argc, char **argv) {
|
||||
FromLatin1("markdown-example.md second table alignments"),
|
||||
&ok);
|
||||
}
|
||||
CheckFixtureSemanticCoverage(markdown, args.markdownPath, &ok);
|
||||
Check(
|
||||
latex.stats.cmarkNodeCount == 532,
|
||||
FromLatin1("latex-markdown-test.md cmark node count"),
|
||||
|
||||
Reference in New Issue
Block a user