Add some missing features.

This commit is contained in:
John Preston
2026-05-01 00:03:24 +07:00
parent 24ed0da073
commit b6c1903457
9 changed files with 1469 additions and 31 deletions
@@ -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(&paragraph, 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"),