From b6c190345769393ce3744185d2fed0fefe0a3ee4 Mon Sep 17 00:00:00 2001 From: John Preston Date: Fri, 1 May 2026 00:03:24 +0700 Subject: [PATCH] Add some missing features. --- .../iv/markdown/iv_markdown_common.h | 7 + .../iv/markdown/iv_markdown_controller.cpp | 131 +++++- .../iv/markdown/iv_markdown_document.cpp | 26 ++ .../iv/markdown/iv_markdown_document.h | 16 + .../iv/markdown/iv_markdown_parse.cpp | 370 +++++++++++++++- .../iv/markdown/iv_markdown_prepare.cpp | 417 +++++++++++++++++- .../iv/markdown/iv_markdown_prepare.h | 16 + .../iv/markdown/iv_markdown_view.cpp | 332 +++++++++++++- .../SourceFiles/tests/test_markdown_iv.cpp | 185 ++++++++ 9 files changed, 1469 insertions(+), 31 deletions(-) diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h index d7e0e931a4..433b7c4945 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h @@ -2,10 +2,17 @@ #include +namespace Iv { +class Delegate; +} // namespace Iv + namespace Iv::Markdown { struct OpenOptions { QString sourceName; + QString sourcePath; + QString initialFragment; + Iv::Delegate *delegate = nullptr; }; struct ParseOptions { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp index b372fa9f08..191602ffac 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp @@ -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 _window; std::unique_ptr _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( 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(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; } diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_document.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_document.cpp index ef6df62b63..b5266a0500 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_document.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_document.cpp @@ -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, diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_document.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_document.h index ecca8fc024..511741f36a 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_document.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_document.h @@ -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 children; std::vector 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 { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_parse.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_parse.cpp index fd7d8a93e7..7e9deabfea 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_parse.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_parse.cpp @@ -2,6 +2,8 @@ #include "iv/markdown/iv_markdown_math.h" +#include "base/basic_types.h" + #include #include @@ -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(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""_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""_q, Qt::CaseInsensitive)) { + return result; + } + const auto summaryClosing = inner.indexOf( + u""_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)) { + out->htmlBlockKind = HtmlBlockKind::Comment; + } else if (trimmed.startsWith(u"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> *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> &entries, + const QString &key) { + for (const auto &entry : entries) { + if (entry.first == key) { + return entry.second; + } + } + return 0; +} + +[[nodiscard]] bool ContainsAnchorId( + const std::vector &anchors, + const QString &value) { + return std::find(anchors.begin(), anchors.end(), value) != anchors.end(); +} + +void AssignHeadingAnchors( + MarkdownNode *node, + std::vector> *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> *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> &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 *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 &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>(); + AssignHeadingAnchors( + &document->document, + &headingCounts, + &document->warnings); + + auto footnoteDefinitions = std::vector>(); + auto nextFootnoteOrdinal = 1; + AssignFootnoteDefinitionOrdinals( + &document->document, + &footnoteDefinitions, + &nextFootnoteOrdinal, + &document->warnings); + AssignFootnoteReferenceOrdinals( + &document->document, + footnoteDefinitions, + &document->warnings); + + auto anchors = std::vector(); + 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(); diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp index fd7d51d8b7..380a5cc404 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp @@ -1,8 +1,12 @@ #include "iv/markdown/iv_markdown_prepare.h" +#include "iv/markdown/iv_markdown_parse.h" + #include "base/call_delayed.h" #include +#include +#include #include #include @@ -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 footnoteDefinitions; + std::vector> 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 *formulas = nullptr; std::vector *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::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::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(remembered); + } + } + } break; case NodeKind::HtmlInline: case NodeKind::Unsupported: if (!node.raw.isEmpty()) { @@ -941,6 +1174,8 @@ void AppendRichBlock( TextWithEntities text, std::vector links, std::vector 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 *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 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::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 *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 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(); + AppendRichBlock( + &fallback, + PreparedBlockKind::Paragraph, + 0, + TextWithEntities::Simple(node.detailsBody), + std::vector(), + std::vector()); + return fallback; + } + auto nestedRequest = PrepareRequest{ + .document = std::make_shared(parsed.document), + .style = state->result.style, + .sourcePath = state->request->sourcePath, + }; + auto nested = PrepareSynchronously(std::move(nestedRequest)); + return nested.cancelled + ? std::vector() + : std::move(nested.blocks.blocks); +} + +[[nodiscard]] std::vector 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 PrepareDocumentBlocks( + const MarkdownNode &node, + PrepareState *state) { + auto result = PrepareChildren(node, {}, state); + if (!state->result.cancelled) { + AppendFootnotes(&result, state); + } + return result; +} + [[nodiscard]] std::vector PrepareFlowBlock( const MarkdownNode &node, PreparedBlockKind kind, PrepareState *state) { auto result = std::vector(); + auto anchorId = (kind == PreparedBlockKind::Heading) + ? node.anchorId + : QString(); auto text = TextWithEntities(); auto links = std::vector(); auto inlineObjects = std::vector(); @@ -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; } diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h index b3faab5909..8f3dc74776 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h @@ -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 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 document; MarkdownStyleSnapshot style; PrepareGeneration generation = 0; + QString sourcePath; std::shared_ptr cancelled; }; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp index 1046461fdb..f75f1665f0 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp @@ -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 @@ -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 #include #include +#include +#include #include #include @@ -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 &links) { for (const auto &link : links) { leaf->setLink( link.index, - std::make_shared(link.target)); + std::make_shared(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 &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 &blocks() const; private: int _width = -1; int _height = 0; std::vector _blocks; + std::vector> _anchors; }; @@ -1109,7 +1203,12 @@ class MarkdownDocumentWidget final public: explicit MarkdownDocumentWidget(QWidget *parent); + void setLinkActivationCallback( + std::function 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 _textPalette; + std::function _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 &blocks, + std::vector> *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 &DocumentLayout::blocks() const { return _blocks; } +[[nodiscard]] bool ToggleDetailsBlock( + std::vector *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 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( + 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 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 _document; Ui::ScrollArea *_scroll = nullptr; MarkdownDocumentWidget *_body = nullptr; Ui::FlatLabel *_loading = nullptr; PrepareGeneration _generation = 0; int _requestedDevicePixelRatio = 0; + QString _pendingFragment; std::shared_ptr _cancelled; }; @@ -1744,10 +1981,10 @@ MarkdownPreviewRoot::MarkdownPreviewRoot( const OpenOptions &options, QWidget *parent) : Ui::RpWidget(parent) +, _options(options) , _document(std::make_shared(document)) +, _pendingFragment(options.initialFragment) , _cancelled(std::make_shared(false)) { - (void)options; - _scroll = Ui::CreateChild(this, st::boxScroll); _body = _scroll->setOwnedWidget(object_ptr(_scroll)); _loading = Ui::CreateChild( @@ -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(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) { diff --git a/Telegram/SourceFiles/tests/test_markdown_iv.cpp b/Telegram/SourceFiles/tests/test_markdown_iv.cpp index 5df52a2dd4..9b09b83056 100644 --- a/Telegram/SourceFiles/tests/test_markdown_iv.cpp +++ b/Telegram/SourceFiles/tests/test_markdown_iv.cpp @@ -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 *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(); + 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(); + 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(); + 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"),