diff --git a/README.md b/README.md index bbd2bbd..037f9b4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。 -当前版本 **0.4.0**。 +当前版本 **0.5.0**。 ## 界面 @@ -14,6 +14,10 @@ ![源代码模式](docs/screenshot-source.png) +导出 PDF 前先给一个预览窗口,确认无误再选路径保存: + +![导出 PDF](docs/screenshot-export.png) + ## 功能 ### 编辑 @@ -56,6 +60,16 @@ 按钮在源码模式下会变成 ``。 +### 导出 PDF + +**文件 → 导出 PDF…**(`Ctrl+E`)会先弹出预览窗口,里面就是最终排版; +点「保存 PDF…」选个位置即可。A4 纸、16mm 页边距,预览和导出用的是同一份 +排版文档,所见即所得。 + +导出用的是 Qt 自带的 `QPrinter` + `QTextDocument`,不需要额外的 PDF 库。 +支持标题、列表(含任务列表)、引用、代码块、行内代码、粗体 / 斜体 / 删除线、 +链接;图片只输出 alt 文字,表格暂时按原文输出。 + ### 文件 - 新建、打开、保存、另存为、最近打开(最多 8 条) @@ -152,6 +166,7 @@ VoiletMark --screenshot a.png # 渲染窗口截图后退出(调试 / 视觉 | `Ctrl+N` / `Ctrl+O` / `Ctrl+S` | 新建 / 打开 / 保存 | | `Ctrl+Shift+S` | 另存为 | | `Ctrl+Shift+O` | 打开文件夹 | +| `Ctrl+E` | 导出 PDF(先预览) | | `Ctrl+B` | 显示 / 隐藏侧栏 | | `Ctrl+Shift+M` | 源代码模式 | | `Ctrl+1` ~ `Ctrl+6` | 设为 1–6 级标题(再按取消) | @@ -170,7 +185,9 @@ src/ ├── ThemeManager.{h,cpp} 四种主题模式 + 系统检测 + 调色板 / QSS 生成 ├── MainWindow.{h,cpp} 菜单、文件读写、文件夹侧栏、状态栏、设置 ├── MarkdownEditor.{h,cpp} 编辑器控件:行号、缩进、标题 / 代码块命令、源码模式 -└── MarkdownHighlighter.{h,cpp} Markdown 着色与就地渲染(隐藏标记、标题缩放) +├── MarkdownHighlighter.{h,cpp} Markdown 着色与就地渲染(隐藏标记、标题缩放) +├── MarkdownRenderer.{h,cpp} 导出用的 Markdown → 富文本文档转换 +└── ExportDialog.{h,cpp} PDF 预览与保存对话框 logo.png 图标源文件 resources.qrc 窗口图标资源 diff --git a/VoiletMark.pro b/VoiletMark.pro index 2d0bb45..88188fd 100644 --- a/VoiletMark.pro +++ b/VoiletMark.pro @@ -1,8 +1,8 @@ TEMPLATE = app TARGET = VoiletMark -VERSION = 0.4.0 +VERSION = 0.5.0 -QT += core gui widgets +QT += core gui widgets printsupport CONFIG += c++17 warn_on @@ -22,12 +22,16 @@ SOURCES += \ src/MainWindow.cpp \ src/MarkdownEditor.cpp \ src/MarkdownHighlighter.cpp \ + src/MarkdownRenderer.cpp \ + src/ExportDialog.cpp \ src/ThemeManager.cpp HEADERS += \ src/MainWindow.h \ src/MarkdownEditor.h \ src/MarkdownHighlighter.h \ + src/MarkdownRenderer.h \ + src/ExportDialog.h \ src/ThemeManager.h # Linux 桌面集成:make install 之后能在应用菜单里找到 diff --git a/docs/screenshot-export.png b/docs/screenshot-export.png new file mode 100644 index 0000000..1e3c4c0 Binary files /dev/null and b/docs/screenshot-export.png differ diff --git a/logo.png b/logo.png old mode 100644 new mode 100755 index d0374c9..6b8e0ff Binary files a/logo.png and b/logo.png differ diff --git a/packaging/voiletmark.icns b/packaging/voiletmark.icns index 9ee87c3..619fe5a 100644 Binary files a/packaging/voiletmark.icns and b/packaging/voiletmark.icns differ diff --git a/packaging/voiletmark.ico b/packaging/voiletmark.ico index b5eff53..cb28243 100644 Binary files a/packaging/voiletmark.ico and b/packaging/voiletmark.ico differ diff --git a/packaging/voiletmark.png b/packaging/voiletmark.png index 099c01f..2d36fe1 100644 Binary files a/packaging/voiletmark.png and b/packaging/voiletmark.png differ diff --git a/src/ExportDialog.cpp b/src/ExportDialog.cpp new file mode 100644 index 0000000..3772ac3 --- /dev/null +++ b/src/ExportDialog.cpp @@ -0,0 +1,107 @@ +#include "ExportDialog.h" + +#include "MarkdownRenderer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +ExportDialog::ExportDialog(const QString &markdown, const QString &documentName, + const QString &startDirectory, QWidget *parent) + : QDialog(parent) + , m_documentName(documentName) + , m_startDirectory(startDirectory) +{ + setWindowTitle(tr("导出 PDF — %1") + .arg(documentName.isEmpty() ? tr("未命名") : documentName)); + resize(900, 840); + + QVBoxLayout *layout = new QVBoxLayout(this); + layout->setContentsMargins(16, 16, 16, 12); + layout->setSpacing(10); + + m_document = new QTextDocument(this); + MarkdownRenderer::render(markdown, m_document); + // A4 去掉 16mm 页边距后的正文宽度(96dpi 下的像素): + // 预览和打印用同一个页宽,看到的就是导出的样子。 + m_document->setPageSize(QSizeF(672.76, 1001.57)); + + m_preview = new QTextBrowser(this); + m_preview->setFrameShape(QFrame::NoFrame); + m_preview->setOpenExternalLinks(true); + m_preview->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_preview->setFixedWidth(700); + m_preview->setDocument(m_document); + m_preview->setStyleSheet(QStringLiteral( + "QTextBrowser { background: #ffffff; color: #1f2328;" + " border: 1px solid #d8dde3; border-radius: 4px; }")); + + QHBoxLayout *center = new QHBoxLayout; + center->addStretch(1); + center->addWidget(m_preview); + center->addStretch(1); + layout->addLayout(center, 1); + + QLabel *hint = new QLabel(tr("A4 纸、16mm 页边距,内容和预览一致。"), this); + hint->setForegroundRole(QPalette::PlaceholderText); + + QPushButton *saveButton = new QPushButton(tr("保存 PDF…"), this); + saveButton->setDefault(true); + QPushButton *closeButton = new QPushButton(tr("关闭"), this); + + QHBoxLayout *buttons = new QHBoxLayout; + buttons->addWidget(hint); + buttons->addStretch(1); + buttons->addWidget(closeButton); + buttons->addWidget(saveButton); + layout->addLayout(buttons); + + connect(closeButton, &QPushButton::clicked, this, &QDialog::reject); + connect(saveButton, &QPushButton::clicked, this, &ExportDialog::savePdf); +} + +void ExportDialog::savePdf() +{ + QString suggested = m_documentName.isEmpty() + ? tr("未命名") + : QFileInfo(m_documentName).completeBaseName(); + suggested += QStringLiteral(".pdf"); + + QString target = QFileDialog::getSaveFileName( + this, tr("保存 PDF"), QDir(m_startDirectory).filePath(suggested), + tr("PDF 文件 (*.pdf)")); + if (target.isEmpty()) + return; + + if (!target.endsWith(QStringLiteral(".pdf"), Qt::CaseInsensitive)) + target += QStringLiteral(".pdf"); + + QPrinter printer(QPrinter::HighResolution); + printer.setOutputFormat(QPrinter::PdfFormat); + printer.setOutputFileName(target); + printer.setPageSize(QPageSize(QPageSize::A4)); + printer.setPageMargins(QMarginsF(16, 16, 16, 16), QPageLayout::Millimeter); + printer.setDocName(QFileInfo(target).completeBaseName()); + + m_document->print(&printer); + + if (!QFileInfo::exists(target) || QFileInfo(target).size() <= 0) { + QMessageBox::warning(this, tr("导出失败"), + tr("没能写出「%1」,换个位置再试一次。") + .arg(QDir::toNativeSeparators(target))); + return; + } + + QMessageBox::information(this, tr("导出完成"), + tr("已保存到\n%1").arg(QDir::toNativeSeparators(target))); + accept(); +} diff --git a/src/ExportDialog.h b/src/ExportDialog.h new file mode 100644 index 0000000..cc6e2d2 --- /dev/null +++ b/src/ExportDialog.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +class QTextBrowser; +class QTextDocument; + +/// “导出 PDF”窗口:上方是排版后的预览,下方保存为 PDF。 +class ExportDialog : public QDialog +{ + Q_OBJECT + +public: + ExportDialog(const QString &markdown, const QString &documentName, + const QString &startDirectory, QWidget *parent = nullptr); + +private: + void savePdf(); + + QTextBrowser *m_preview = nullptr; + QTextDocument *m_document = nullptr; + QString m_documentName; + QString m_startDirectory; +}; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index ab24f91..af24b1e 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -1,5 +1,6 @@ #include "MainWindow.h" +#include "ExportDialog.h" #include "MarkdownEditor.h" #include "ThemeManager.h" @@ -133,6 +134,10 @@ void MainWindow::createActions() m_saveAsAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+S"))); connect(m_saveAsAction, &QAction::triggered, this, [this] { saveFileAs(); }); + m_exportPdfAction = new QAction(tr("导出 PDF…"), this); + m_exportPdfAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+E"))); + connect(m_exportPdfAction, &QAction::triggered, this, &MainWindow::exportPdf); + m_quitAction = new QAction(tr("退出"), this); m_quitAction->setShortcut(QKeySequence::Quit); connect(m_quitAction, &QAction::triggered, this, &QWidget::close); @@ -269,6 +274,7 @@ void MainWindow::createMenus() fileMenu->addSeparator(); fileMenu->addAction(m_saveAction); fileMenu->addAction(m_saveAsAction); + fileMenu->addAction(m_exportPdfAction); fileMenu->addSeparator(); fileMenu->addAction(m_quitAction); @@ -536,6 +542,12 @@ bool MainWindow::saveFileAs() return writeToFile(path); } +void MainWindow::exportPdf() +{ + ExportDialog dialog(m_editor->toPlainText(), displayName(), startDirectory(), this); + dialog.exec(); +} + bool MainWindow::writeToFile(const QString &path) { QString text = m_editor->toPlainText(); diff --git a/src/MainWindow.h b/src/MainWindow.h index f6f8ac7..361c68d 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -56,6 +56,7 @@ private: void selectInSidebar(const QString &path); QStringList scanFolder() const; void setSourceModeEnabled(bool enabled); + void exportPdf(); void changeZoom(int direction); void resetZoom(); @@ -92,6 +93,7 @@ private: QAction *m_openAction = nullptr; QAction *m_saveAction = nullptr; QAction *m_saveAsAction = nullptr; + QAction *m_exportPdfAction = nullptr; QAction *m_quitAction = nullptr; QAction *m_undoAction = nullptr; diff --git a/src/MarkdownRenderer.cpp b/src/MarkdownRenderer.cpp new file mode 100644 index 0000000..175bfda --- /dev/null +++ b/src/MarkdownRenderer.cpp @@ -0,0 +1,405 @@ +#include "MarkdownRenderer.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QStringList proseFamilies() +{ + return {QStringLiteral("Noto Sans CJK SC"), QStringLiteral("Source Han Sans SC"), + QStringLiteral("WenQuanYi Micro Hei"), QStringLiteral("Microsoft YaHei"), + QStringLiteral("PingFang SC"), QStringLiteral("Segoe UI"), + QStringLiteral("DejaVu Sans")}; +} + +QStringList monoFamilies() +{ + return {QStringLiteral("JetBrains Mono"), QStringLiteral("Cascadia Code"), + QStringLiteral("Source Han Mono SC"), QStringLiteral("Noto Sans Mono CJK SC"), + QStringLiteral("Sarasa Mono SC"), QStringLiteral("Consolas"), + QStringLiteral("DejaVu Sans Mono")}; +} + +QColor hex(const char *value) +{ + return QColor(QString::fromLatin1(value)); +} + +struct Style +{ + QColor text = hex("#1f2328"); + QColor heading = hex("#11151a"); + QColor muted = hex("#5b6570"); + QColor rule = hex("#d0d7de"); + QColor codeText = hex("#b4295a"); + QColor codeBg = hex("#f3f4f6"); + QColor link = hex("#0969da"); + QColor marker = hex("#0969da"); +}; + +constexpr qreal kBodySize = 11.5; +constexpr qreal kCodeSize = 10.5; + +class Renderer +{ +public: + explicit Renderer(QTextDocument *document) + : m_document(document) + , m_cursor(document) + { + } + + void run(const QString &markdown); + +private: + void beginBlock(const QTextBlockFormat &blockFormat, const QTextCharFormat &charFormat); + void renderHeading(int level, const QString &text); + void renderParagraph(const QString &text, int quoteDepth); + void renderListItem(const QString &marker, const QString &text, int indentLevel); + void renderCodeBlock(const QStringList &lines); + void renderRule(); + void renderInline(const QString &text, const QTextCharFormat &base); + + QTextCharFormat bodyFormat(const QColor &color = QColor()) const; + QTextCharFormat codeFormat() const; + + QTextDocument *m_document = nullptr; + QTextCursor m_cursor; + bool m_firstBlock = true; + Style m_style; +}; + +QTextCharFormat Renderer::bodyFormat(const QColor &color) const +{ + QTextCharFormat format; + format.setFontFamilies(proseFamilies()); + format.setFontPointSize(kBodySize); + format.setForeground(color.isValid() ? color : m_style.text); + return format; +} + +QTextCharFormat Renderer::codeFormat() const +{ + QTextCharFormat format; + format.setFontFamilies(monoFamilies()); + format.setFontFixedPitch(true); + format.setFontPointSize(kCodeSize); + format.setForeground(m_style.codeText); + format.setBackground(m_style.codeBg); + return format; +} + +void Renderer::beginBlock(const QTextBlockFormat &blockFormat, const QTextCharFormat &charFormat) +{ + if (m_firstBlock) { + m_cursor.setBlockFormat(blockFormat); + m_cursor.setBlockCharFormat(charFormat); + m_firstBlock = false; + } else { + m_cursor.insertBlock(blockFormat, charFormat); + } + m_cursor.setCharFormat(charFormat); +} + +void Renderer::renderHeading(int level, const QString &text) +{ + static const qreal scales[6] = {2.0, 1.55, 1.3, 1.15, 1.05, 1.0}; + QTextBlockFormat blockFormat; + blockFormat.setLineHeight(135, QTextBlockFormat::ProportionalHeight); + blockFormat.setTopMargin(level <= 2 ? 20 : 14); + blockFormat.setBottomMargin(8); + + QTextCharFormat format; + format.setFontFamilies(proseFamilies()); + format.setFontPointSize(kBodySize * scales[qBound(1, level, 6) - 1]); + format.setFontWeight(QFont::Bold); + format.setForeground(m_style.heading); + + beginBlock(blockFormat, format); + m_cursor.insertText(text, format); +} + +void Renderer::renderParagraph(const QString &text, int quoteDepth) +{ + QTextBlockFormat blockFormat; + blockFormat.setLineHeight(150, QTextBlockFormat::ProportionalHeight); + blockFormat.setBottomMargin(8); + if (quoteDepth > 0) { + blockFormat.setLeftMargin(18 + (quoteDepth - 1) * 16); + } + beginBlock(blockFormat, bodyFormat(quoteDepth > 0 ? m_style.muted : QColor())); + + if (quoteDepth > 0) { + QTextCharFormat bar = bodyFormat(m_style.rule); + bar.setFontWeight(QFont::Bold); + m_cursor.insertText(QStringLiteral("│ "), bar); + } + renderInline(text, bodyFormat(quoteDepth > 0 ? m_style.muted : QColor())); +} + +void Renderer::renderListItem(const QString &marker, const QString &text, int indentLevel) +{ + QTextBlockFormat blockFormat; + blockFormat.setLineHeight(150, QTextBlockFormat::ProportionalHeight); + blockFormat.setBottomMargin(6); + blockFormat.setLeftMargin(18 + indentLevel * 18); + blockFormat.setTextIndent(-14); + beginBlock(blockFormat, bodyFormat()); + + QTextCharFormat markerFormat = bodyFormat(m_style.marker); + markerFormat.setFontWeight(QFont::DemiBold); + m_cursor.insertText(marker + QLatin1Char(' '), markerFormat); + renderInline(text, bodyFormat()); +} + +void Renderer::renderCodeBlock(const QStringList &lines) +{ + QTextBlockFormat blockFormat; + blockFormat.setLineHeight(135, QTextBlockFormat::ProportionalHeight); + blockFormat.setTopMargin(6); + blockFormat.setBottomMargin(10); + blockFormat.setLeftMargin(14); + blockFormat.setRightMargin(14); + blockFormat.setBackground(m_style.codeBg); + + const QTextCharFormat format = codeFormat(); + QStringList content = lines; + if (content.isEmpty()) + content << QString(); + + for (int index = 0; index < content.size(); ++index) { + QTextBlockFormat lineFormat = blockFormat; + lineFormat.setTopMargin(index == 0 ? 6 : 0); + lineFormat.setBottomMargin(index == content.size() - 1 ? 10 : 0); + beginBlock(lineFormat, format); + const QString line = content.at(index); + m_cursor.insertText(line.isEmpty() ? QStringLiteral(" ") : line, format); + } +} + +void Renderer::renderRule() +{ + QTextBlockFormat blockFormat; + blockFormat.setLineHeight(140, QTextBlockFormat::ProportionalHeight); + blockFormat.setTopMargin(6); + blockFormat.setBottomMargin(12); + beginBlock(blockFormat, bodyFormat(m_style.rule)); + + QTextCharFormat format = bodyFormat(m_style.rule); + format.setFontPointSize(8); + m_cursor.insertText(QString(60, QChar(0x2500)), format); +} + +void Renderer::renderInline(const QString &text, const QTextCharFormat &base) +{ + struct Rule { + QRegularExpression pattern; + int kind; + }; + + static const QVector rules = { + {QRegularExpression(QStringLiteral("`([^`\\n]+)`")), 0}, + {QRegularExpression(QStringLiteral("!\\[([^\\]]*)\\]\\(([^)]*)\\)")), 1}, + {QRegularExpression(QStringLiteral("\\[([^\\]]*)\\]\\(([^)]*)\\)")), 2}, + {QRegularExpression(QStringLiteral("\\*\\*\\*([^*]+)\\*\\*\\*")), 3}, + {QRegularExpression(QStringLiteral("\\*\\*([^*]+)\\*\\*")), 4}, + {QRegularExpression(QStringLiteral("__([^_]+)__")), 4}, + {QRegularExpression(QStringLiteral("\\*([^*]+)\\*")), 5}, + {QRegularExpression(QStringLiteral("(?]+)>")), 7}, + }; + + int position = 0; + while (position < text.size()) { + int bestStart = -1; + int bestKind = -1; + QRegularExpressionMatch best; + for (const Rule &rule : rules) { + const QRegularExpressionMatch match = rule.pattern.match(text, position); + if (!match.hasMatch()) + continue; + if (bestStart < 0 || match.capturedStart() < bestStart) { + bestStart = match.capturedStart(); + bestKind = rule.kind; + best = match; + } + } + + if (bestStart < 0) { + m_cursor.insertText(text.mid(position), base); + break; + } + if (bestStart > position) + m_cursor.insertText(text.mid(position, bestStart - position), base); + + const QString content = best.capturedLength(1) > 0 ? best.captured(1) : best.captured(0); + switch (bestKind) { + case 0: { // 行内代码 + m_cursor.insertText(content, codeFormat()); + break; + } + case 1: { // 图片:显示 alt + QTextCharFormat format = base; + format.setForeground(m_style.muted); + format.setFontItalic(true); + m_cursor.insertText(content.isEmpty() ? QStringLiteral("[图片]") : content, format); + break; + } + case 2: { // 链接 + QTextCharFormat format = base; + format.setForeground(m_style.link); + format.setFontUnderline(true); + format.setAnchor(true); + format.setAnchorHref(best.captured(2)); + m_cursor.insertText(best.captured(1), format); + break; + } + case 3: { // 粗斜体 + QTextCharFormat format = base; + format.setFontWeight(QFont::Bold); + format.setFontItalic(true); + m_cursor.insertText(content, format); + break; + } + case 4: { // 粗体 + QTextCharFormat format = base; + format.setFontWeight(QFont::Bold); + format.setForeground(m_style.heading); + m_cursor.insertText(content, format); + break; + } + case 5: { // 斜体 + QTextCharFormat format = base; + format.setFontItalic(true); + m_cursor.insertText(content, format); + break; + } + case 6: { // 删除线 + QTextCharFormat format = base; + format.setFontStrikeOut(true); + format.setForeground(m_style.muted); + m_cursor.insertText(content, format); + break; + } + default: { // 裸链接 + QTextCharFormat format = base; + format.setForeground(m_style.link); + format.setFontUnderline(true); + format.setAnchor(true); + format.setAnchorHref(best.captured(1)); + m_cursor.insertText(best.captured(1), format); + break; + } + } + + position = bestStart + best.capturedLength(); + } +} + +void Renderer::run(const QString &markdown) +{ + const QStringList lines = markdown.split(QLatin1Char('\n')); + + static const QRegularExpression headingPattern(QStringLiteral("^(#{1,6})\\s+(.*)$")); + static const QRegularExpression fencePattern(QStringLiteral("^\\s{0,3}(?:```|~~~)(.*)$")); + static const QRegularExpression quotePattern(QStringLiteral("^\\s{0,3}(>+)\\s?(.*)$")); + static const QRegularExpression bulletPattern( + QStringLiteral("^(\\s*)[-+*]\\s+\\[([ xX])\\]\\s?(.*)$")); + static const QRegularExpression listPattern( + QStringLiteral("^(\\s*)([-+*]|\\d{1,9}[.)])\\s+(.*)$")); + static const QRegularExpression rulePattern( + QStringLiteral("^\\s{0,3}(?:(?:\\*\\s*){3,}|(?:-\\s*){3,}|(?:_\\s*){3,})$")); + + int index = 0; + while (index < lines.size()) { + const QString line = lines.at(index); + + const QRegularExpressionMatch fence = fencePattern.match(line); + if (fence.hasMatch()) { + QStringList code; + ++index; + while (index < lines.size() && !fencePattern.match(lines.at(index)).hasMatch()) + code << lines.at(index++); + if (index < lines.size()) + ++index; // 跳过闭合围栏 + renderCodeBlock(code); + continue; + } + + if (line.trimmed().isEmpty()) { + ++index; + continue; + } + + const QRegularExpressionMatch heading = headingPattern.match(line); + if (heading.hasMatch()) { + renderHeading(heading.capturedLength(1), heading.captured(2).trimmed()); + ++index; + continue; + } + + if (rulePattern.match(line).hasMatch()) { + renderRule(); + ++index; + continue; + } + + const QRegularExpressionMatch quote = quotePattern.match(line); + if (quote.hasMatch()) { + renderParagraph(quote.captured(2).trimmed(), quote.capturedLength(1)); + ++index; + continue; + } + + const QRegularExpressionMatch task = bulletPattern.match(line); + if (task.hasMatch()) { + const int indent = task.captured(1).size() / 2; + const bool done = task.captured(2).compare(QLatin1String("x"), Qt::CaseInsensitive) == 0; + renderListItem(done ? QStringLiteral("☑") : QStringLiteral("☐"), + task.captured(3), indent); + ++index; + continue; + } + + const QRegularExpressionMatch list = listPattern.match(line); + if (list.hasMatch()) { + const int indent = list.captured(1).size() / 2; + QString marker = list.captured(2); + if (marker.size() == 1 && (marker == QLatin1String("-") || marker == QLatin1String("+") + || marker == QLatin1String("*"))) + marker = QStringLiteral("•"); + renderListItem(marker, list.captured(3), indent); + ++index; + continue; + } + + renderParagraph(line, 0); + ++index; + } +} + +} // namespace + +void MarkdownRenderer::render(const QString &markdown, QTextDocument *document) +{ + if (!document) + return; + + document->clear(); + + QFont body; + body.setFamilies(proseFamilies()); + body.setPointSizeF(kBodySize); + document->setDefaultFont(body); + document->setDocumentMargin(0); + + Renderer renderer(document); + renderer.run(markdown); +} diff --git a/src/MarkdownRenderer.h b/src/MarkdownRenderer.h new file mode 100644 index 0000000..3772903 --- /dev/null +++ b/src/MarkdownRenderer.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +class QTextDocument; + +/// 把 Markdown 渲染成排版好的 QTextDocument,供导出预览和打印 PDF 共用。 +/// 不依赖任何外部库,只是在这里做一遍块级 / 行内解析。 +namespace MarkdownRenderer { + +void render(const QString &markdown, QTextDocument *document); + +} // namespace MarkdownRenderer