feat: v0.5.0 —— 导出 PDF(带预览窗口)
导出 - 文件 → 导出 PDF…(Ctrl+E)先弹出预览窗口,确认后点「保存 PDF…」选路径 - A4 纸、16mm 页边距;预览和导出共用同一个 QTextDocument,页宽一致 - 只用 Qt 自带的 QPrinter / QTextDocument,没有引入外部 PDF 库 排版 - 新增 MarkdownRenderer:把 Markdown 解析成排版好的富文本文档 - 支持标题、列表 / 任务列表、引用、围栏代码块、行内代码、 粗体 / 斜体 / 删除线、链接;图片输出 alt 文字,表格暂按原文 - 代码块带底色、引用带竖线、分隔线画成横线 其它 - 用用户更新后的 logo.png 重新生成了 ico / icns / png - docs/screenshot-export.png 与 README 同步
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。
|
一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。
|
||||||
|
|
||||||
当前版本 **0.4.0**。
|
当前版本 **0.5.0**。
|
||||||
|
|
||||||
## 界面
|
## 界面
|
||||||
|
|
||||||
@@ -14,6 +14,10 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
导出 PDF 前先给一个预览窗口,确认无误再选路径保存:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
### 编辑
|
### 编辑
|
||||||
@@ -56,6 +60,16 @@
|
|||||||
|
|
||||||
按钮在源码模式下会变成 `</src>`。
|
按钮在源码模式下会变成 `</src>`。
|
||||||
|
|
||||||
|
### 导出 PDF
|
||||||
|
|
||||||
|
**文件 → 导出 PDF…**(`Ctrl+E`)会先弹出预览窗口,里面就是最终排版;
|
||||||
|
点「保存 PDF…」选个位置即可。A4 纸、16mm 页边距,预览和导出用的是同一份
|
||||||
|
排版文档,所见即所得。
|
||||||
|
|
||||||
|
导出用的是 Qt 自带的 `QPrinter` + `QTextDocument`,不需要额外的 PDF 库。
|
||||||
|
支持标题、列表(含任务列表)、引用、代码块、行内代码、粗体 / 斜体 / 删除线、
|
||||||
|
链接;图片只输出 alt 文字,表格暂时按原文输出。
|
||||||
|
|
||||||
### 文件
|
### 文件
|
||||||
|
|
||||||
- 新建、打开、保存、另存为、最近打开(最多 8 条)
|
- 新建、打开、保存、另存为、最近打开(最多 8 条)
|
||||||
@@ -152,6 +166,7 @@ VoiletMark --screenshot a.png # 渲染窗口截图后退出(调试 / 视觉
|
|||||||
| `Ctrl+N` / `Ctrl+O` / `Ctrl+S` | 新建 / 打开 / 保存 |
|
| `Ctrl+N` / `Ctrl+O` / `Ctrl+S` | 新建 / 打开 / 保存 |
|
||||||
| `Ctrl+Shift+S` | 另存为 |
|
| `Ctrl+Shift+S` | 另存为 |
|
||||||
| `Ctrl+Shift+O` | 打开文件夹 |
|
| `Ctrl+Shift+O` | 打开文件夹 |
|
||||||
|
| `Ctrl+E` | 导出 PDF(先预览) |
|
||||||
| `Ctrl+B` | 显示 / 隐藏侧栏 |
|
| `Ctrl+B` | 显示 / 隐藏侧栏 |
|
||||||
| `Ctrl+Shift+M` | 源代码模式 |
|
| `Ctrl+Shift+M` | 源代码模式 |
|
||||||
| `Ctrl+1` ~ `Ctrl+6` | 设为 1–6 级标题(再按取消) |
|
| `Ctrl+1` ~ `Ctrl+6` | 设为 1–6 级标题(再按取消) |
|
||||||
@@ -170,7 +185,9 @@ src/
|
|||||||
├── ThemeManager.{h,cpp} 四种主题模式 + 系统检测 + 调色板 / QSS 生成
|
├── ThemeManager.{h,cpp} 四种主题模式 + 系统检测 + 调色板 / QSS 生成
|
||||||
├── MainWindow.{h,cpp} 菜单、文件读写、文件夹侧栏、状态栏、设置
|
├── MainWindow.{h,cpp} 菜单、文件读写、文件夹侧栏、状态栏、设置
|
||||||
├── MarkdownEditor.{h,cpp} 编辑器控件:行号、缩进、标题 / 代码块命令、源码模式
|
├── MarkdownEditor.{h,cpp} 编辑器控件:行号、缩进、标题 / 代码块命令、源码模式
|
||||||
└── MarkdownHighlighter.{h,cpp} Markdown 着色与就地渲染(隐藏标记、标题缩放)
|
├── MarkdownHighlighter.{h,cpp} Markdown 着色与就地渲染(隐藏标记、标题缩放)
|
||||||
|
├── MarkdownRenderer.{h,cpp} 导出用的 Markdown → 富文本文档转换
|
||||||
|
└── ExportDialog.{h,cpp} PDF 预览与保存对话框
|
||||||
|
|
||||||
logo.png 图标源文件
|
logo.png 图标源文件
|
||||||
resources.qrc 窗口图标资源
|
resources.qrc 窗口图标资源
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
TEMPLATE = app
|
TEMPLATE = app
|
||||||
TARGET = VoiletMark
|
TARGET = VoiletMark
|
||||||
VERSION = 0.4.0
|
VERSION = 0.5.0
|
||||||
|
|
||||||
QT += core gui widgets
|
QT += core gui widgets printsupport
|
||||||
|
|
||||||
CONFIG += c++17 warn_on
|
CONFIG += c++17 warn_on
|
||||||
|
|
||||||
@@ -22,12 +22,16 @@ SOURCES += \
|
|||||||
src/MainWindow.cpp \
|
src/MainWindow.cpp \
|
||||||
src/MarkdownEditor.cpp \
|
src/MarkdownEditor.cpp \
|
||||||
src/MarkdownHighlighter.cpp \
|
src/MarkdownHighlighter.cpp \
|
||||||
|
src/MarkdownRenderer.cpp \
|
||||||
|
src/ExportDialog.cpp \
|
||||||
src/ThemeManager.cpp
|
src/ThemeManager.cpp
|
||||||
|
|
||||||
HEADERS += \
|
HEADERS += \
|
||||||
src/MainWindow.h \
|
src/MainWindow.h \
|
||||||
src/MarkdownEditor.h \
|
src/MarkdownEditor.h \
|
||||||
src/MarkdownHighlighter.h \
|
src/MarkdownHighlighter.h \
|
||||||
|
src/MarkdownRenderer.h \
|
||||||
|
src/ExportDialog.h \
|
||||||
src/ThemeManager.h
|
src/ThemeManager.h
|
||||||
|
|
||||||
# Linux 桌面集成:make install 之后能在应用菜单里找到
|
# Linux 桌面集成:make install 之后能在应用菜单里找到
|
||||||
|
|||||||
|
After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,107 @@
|
|||||||
|
#include "ExportDialog.h"
|
||||||
|
|
||||||
|
#include "MarkdownRenderer.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QPageSize>
|
||||||
|
#include <QPrinter>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QTextBrowser>
|
||||||
|
#include <QTextDocument>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "MainWindow.h"
|
#include "MainWindow.h"
|
||||||
|
|
||||||
|
#include "ExportDialog.h"
|
||||||
#include "MarkdownEditor.h"
|
#include "MarkdownEditor.h"
|
||||||
#include "ThemeManager.h"
|
#include "ThemeManager.h"
|
||||||
|
|
||||||
@@ -133,6 +134,10 @@ void MainWindow::createActions()
|
|||||||
m_saveAsAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+S")));
|
m_saveAsAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+S")));
|
||||||
connect(m_saveAsAction, &QAction::triggered, this, [this] { saveFileAs(); });
|
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 = new QAction(tr("退出"), this);
|
||||||
m_quitAction->setShortcut(QKeySequence::Quit);
|
m_quitAction->setShortcut(QKeySequence::Quit);
|
||||||
connect(m_quitAction, &QAction::triggered, this, &QWidget::close);
|
connect(m_quitAction, &QAction::triggered, this, &QWidget::close);
|
||||||
@@ -269,6 +274,7 @@ void MainWindow::createMenus()
|
|||||||
fileMenu->addSeparator();
|
fileMenu->addSeparator();
|
||||||
fileMenu->addAction(m_saveAction);
|
fileMenu->addAction(m_saveAction);
|
||||||
fileMenu->addAction(m_saveAsAction);
|
fileMenu->addAction(m_saveAsAction);
|
||||||
|
fileMenu->addAction(m_exportPdfAction);
|
||||||
fileMenu->addSeparator();
|
fileMenu->addSeparator();
|
||||||
fileMenu->addAction(m_quitAction);
|
fileMenu->addAction(m_quitAction);
|
||||||
|
|
||||||
@@ -536,6 +542,12 @@ bool MainWindow::saveFileAs()
|
|||||||
return writeToFile(path);
|
return writeToFile(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::exportPdf()
|
||||||
|
{
|
||||||
|
ExportDialog dialog(m_editor->toPlainText(), displayName(), startDirectory(), this);
|
||||||
|
dialog.exec();
|
||||||
|
}
|
||||||
|
|
||||||
bool MainWindow::writeToFile(const QString &path)
|
bool MainWindow::writeToFile(const QString &path)
|
||||||
{
|
{
|
||||||
QString text = m_editor->toPlainText();
|
QString text = m_editor->toPlainText();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ private:
|
|||||||
void selectInSidebar(const QString &path);
|
void selectInSidebar(const QString &path);
|
||||||
QStringList scanFolder() const;
|
QStringList scanFolder() const;
|
||||||
void setSourceModeEnabled(bool enabled);
|
void setSourceModeEnabled(bool enabled);
|
||||||
|
void exportPdf();
|
||||||
|
|
||||||
void changeZoom(int direction);
|
void changeZoom(int direction);
|
||||||
void resetZoom();
|
void resetZoom();
|
||||||
@@ -92,6 +93,7 @@ private:
|
|||||||
QAction *m_openAction = nullptr;
|
QAction *m_openAction = nullptr;
|
||||||
QAction *m_saveAction = nullptr;
|
QAction *m_saveAction = nullptr;
|
||||||
QAction *m_saveAsAction = nullptr;
|
QAction *m_saveAsAction = nullptr;
|
||||||
|
QAction *m_exportPdfAction = nullptr;
|
||||||
QAction *m_quitAction = nullptr;
|
QAction *m_quitAction = nullptr;
|
||||||
|
|
||||||
QAction *m_undoAction = nullptr;
|
QAction *m_undoAction = nullptr;
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
#include "MarkdownRenderer.h"
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QTextBlockFormat>
|
||||||
|
#include <QTextCharFormat>
|
||||||
|
#include <QTextCursor>
|
||||||
|
#include <QTextDocument>
|
||||||
|
|
||||||
|
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<Rule> 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("(?<![_\\w])_([^_]+)_(?![_\\w])")), 5},
|
||||||
|
{QRegularExpression(QStringLiteral("~~([^~]+)~~")), 6},
|
||||||
|
{QRegularExpression(QStringLiteral("<(https?://[^>]+)>")), 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class QTextDocument;
|
||||||
|
|
||||||
|
/// 把 Markdown 渲染成排版好的 QTextDocument,供导出预览和打印 PDF 共用。
|
||||||
|
/// 不依赖任何外部库,只是在这里做一遍块级 / 行内解析。
|
||||||
|
namespace MarkdownRenderer {
|
||||||
|
|
||||||
|
void render(const QString &markdown, QTextDocument *document);
|
||||||
|
|
||||||
|
} // namespace MarkdownRenderer
|
||||||