From cf05de24f59bf03ae1e2bf5ef8fffbe7e6385629 Mon Sep 17 00:00:00 2001 From: liyepan Date: Thu, 22 Oct 2026 18:39:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.10.2=20=E2=80=94=E2=80=94=20PDF=20?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC=E4=B8=8E=E4=BB=A3=E7=A0=81=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=EF=BC=8CGit=20=E6=8C=89=E9=92=AE=E9=87=8D=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF 导出 - 表格用 QTextTable 渲染:表头灰底加粗、单元格边框、按 :---: 保留对齐 - 代码块接入 CodeHighlight:关键字/字符串/数字/注释/预处理指令分色 - 修掉 MarkdownRenderer 里表格分隔行正则丢失反斜杠的问题 侧栏 Git - 按钮重排:第一行 获取 / 拉取 / 添加,第二行 提交 / 推送 - 「暂存」改名为「添加」,和右键菜单用词一致 其它 - 默认欢迎文档里的示例表格确认可用(编辑模式渲染成表格控件) - README 同步,版本 0.10.2 --- README.md | 17 +- VoiletMark.pro | 2 +- src/MainWindow.cpp | 16 +- src/MarkdownRenderer.cpp | 167 +++++++++++++++- translations/voiletmark_en.ts | 365 +++++++++++++++++----------------- 5 files changed, 368 insertions(+), 199 deletions(-) diff --git a/README.md b/README.md index ecbee40..6b7c78f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 一个轻量的跨平台 Markdown 编辑器,基于 **Qt 5 / C++17** 构建。 -当前版本 **0.10.1**。 +当前版本 **0.10.2**。 ## 界面 @@ -54,11 +54,13 @@ | 按钮 | 实际执行 | | --- | --- | -| 拉取 | `git pull --ff-only` | | 获取 | `git fetch --all --prune` | -| 推送 | `git push` | -| 暂存 | `git add -A` | +| 拉取 | `git pull --ff-only` | +| 添加 | `git add -A` | | 提交 | 填说明后 `git commit -m` | +| 推送 | `git push` | + +排列是两行:第一行 获取 / 拉取 / 添加,第二行 提交 和 推送。 右键侧栏里的文件有三项: @@ -94,10 +96,11 @@ A4 纸、16mm 页边距。 导出用的是 Qt 自带的 `QPrinter` + `QTextDocument`,不需要额外的 PDF 库。 支持标题、列表(含任务列表)、引用、代码块、行内代码、粗体 / 斜体 / 删除线、 -链接;图片只输出 alt 文字,表格暂时按原文输出。 +链接、表格;图片只输出 alt 文字。 -代码块在 PDF 里是**一整块带边框的面板**(不是一行一条底色), -右上角标注语言名(C++ / Python …,取围栏上的标记)。 +- 代码块是**一整块带边框的面板**(不是一行一条底色),右上角标注语言名, + 块内按语言做语法高亮 +- 表格渲染成真正的表格:表头灰底加粗、单元格边框、按 `:---:` 保留对齐 ### 语言 diff --git a/VoiletMark.pro b/VoiletMark.pro index c117e43..090a961 100644 --- a/VoiletMark.pro +++ b/VoiletMark.pro @@ -1,6 +1,6 @@ TEMPLATE = app TARGET = VoiletMark -VERSION = 0.10.1 +VERSION = 0.10.2 QT += core gui widgets printsupport diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 96f615d..a27c215 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -340,17 +340,17 @@ void MainWindow::createSidebar() return button; }; - m_gitPullButton = makeGitButton(tr("拉取"), tr("git pull --ff-only")); m_gitFetchButton = makeGitButton(tr("获取"), tr("git fetch --all --prune")); - m_gitPushButton = makeGitButton(tr("推送"), tr("git push")); - m_gitStageButton = makeGitButton(tr("暂存"), tr("git add -A")); + m_gitPullButton = makeGitButton(tr("拉取"), tr("git pull --ff-only")); + m_gitStageButton = makeGitButton(tr("添加"), tr("git add -A")); m_gitCommitButton = makeGitButton(tr("提交"), tr("把暂存区的内容提交")); + m_gitPushButton = makeGitButton(tr("推送"), tr("git push")); - gitLayout->addWidget(m_gitPullButton, 1, 0); - gitLayout->addWidget(m_gitFetchButton, 1, 1); - gitLayout->addWidget(m_gitPushButton, 1, 2); - gitLayout->addWidget(m_gitStageButton, 2, 0); - gitLayout->addWidget(m_gitCommitButton, 2, 1, 1, 2); + gitLayout->addWidget(m_gitFetchButton, 1, 0); + gitLayout->addWidget(m_gitPullButton, 1, 1); + gitLayout->addWidget(m_gitStageButton, 1, 2); + gitLayout->addWidget(m_gitCommitButton, 2, 0, 1, 2); + gitLayout->addWidget(m_gitPushButton, 2, 2); layout->addWidget(m_gitBar); m_gitBar->setVisible(false); diff --git a/src/MarkdownRenderer.cpp b/src/MarkdownRenderer.cpp index c1fd680..42e2e8f 100644 --- a/src/MarkdownRenderer.cpp +++ b/src/MarkdownRenderer.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -43,6 +44,12 @@ struct Style QColor codeText = hex("#24292f"); QColor codeBg = hex("#f3f4f6"); QColor border = hex("#d0d7de"); + QColor tableHeader = hex("#f6f8fa"); + QColor keyword = hex("#0550ae"); + QColor string = hex("#0a7d3a"); + QColor number = hex("#953800"); + QColor comment = hex("#6e7781"); + QColor preprocessor = hex("#8250df"); QColor link = hex("#0969da"); QColor marker = hex("#0969da"); }; @@ -50,6 +57,26 @@ struct Style constexpr qreal kBodySize = 11.5; constexpr qreal kCodeSize = 10.5; +bool isTableSeparator(const QString &text) +{ + static const QRegularExpression pattern( + QStringLiteral(R"(^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)+\|?\s*$)")); + return pattern.match(text).hasMatch(); +} + +QStringList splitTableRow(const QString &line) +{ + QString text = line.trimmed(); + if (text.startsWith(QLatin1Char('|'))) + text.remove(0, 1); + if (text.endsWith(QLatin1Char('|'))) + text.chop(1); + QStringList cells = text.split(QLatin1Char('|')); + for (QString &cell : cells) + cell = cell.trimmed(); + return cells; +} + class Renderer { public: @@ -67,6 +94,8 @@ private: void renderParagraph(const QString &text, int quoteDepth); void renderListItem(const QString &marker, const QString &text, int indentLevel); void renderCodeBlock(const QStringList &lines, const QString &info); + void renderTable(const QStringList &rows, const QString &separator); + QColor tokenColor(CodeHighlight::Token token) const; void renderRule(); void renderInline(const QString &text, const QTextCharFormat &base); @@ -217,9 +246,34 @@ void Renderer::renderCodeBlock(const QStringList &lines, const QString &info) if (content.isEmpty()) content << QString(); + const CodeHighlight::Language codeLanguage = CodeHighlight::fromInfoString(info); + bool inComment = false; + for (const QString &line : content) { insideBlock(codeBlock, format); - frameCursor.insertText(line.isEmpty() ? QStringLiteral(" ") : line, format); + + bool endsInComment = inComment; + int position = 0; + + if (codeLanguage != CodeHighlight::Language::None) { + const QVector spans = + CodeHighlight::tokenize(line, codeLanguage, inComment, &endsInComment); + for (const CodeHighlight::Span &span : spans) { + if (span.start > position) + frameCursor.insertText(line.mid(position, span.start - position), format); + + QTextCharFormat tokenFormat = format; + tokenFormat.setForeground(tokenColor(span.token)); + frameCursor.insertText(line.mid(span.start, span.length), tokenFormat); + position = span.start + span.length; + } + } + inComment = endsInComment; + + if (position < line.size()) + frameCursor.insertText(line.mid(position), format); + if (line.isEmpty()) + frameCursor.insertText(QStringLiteral(" "), format); } // 光标挪到边框外面,后面的内容接着写 @@ -227,6 +281,102 @@ void Renderer::renderCodeBlock(const QStringList &lines, const QString &info) m_cursor.setPosition(frame->lastPosition()); } +QColor Renderer::tokenColor(CodeHighlight::Token token) const +{ + switch (token) { + case CodeHighlight::Token::Keyword: + case CodeHighlight::Token::Type: + return m_style.keyword; + case CodeHighlight::Token::String: + return m_style.string; + case CodeHighlight::Token::Number: + return m_style.number; + case CodeHighlight::Token::Comment: + return m_style.comment; + case CodeHighlight::Token::Preprocessor: + return m_style.preprocessor; + } + return m_style.codeText; +} + +void Renderer::renderTable(const QStringList &rows, const QString &separator) +{ + if (rows.isEmpty()) + return; + + if (m_firstBlock) { + QTextBlockFormat emptyFormat; + beginBlock(emptyFormat, bodyFormat()); + } + + const QStringList headerCells = splitTableRow(rows.first()); + const int columns = qMax(1, headerCells.size()); + + QVector alignments; + for (const QString &cell : splitTableRow(separator)) { + const bool left = cell.startsWith(QLatin1Char(':')); + const bool right = cell.endsWith(QLatin1Char(':')); + if (left && right) + alignments.append(Qt::AlignHCenter); + else if (right) + alignments.append(Qt::AlignRight); + else + alignments.append(Qt::AlignLeft); + } + + QTextTableFormat tableFormat; + tableFormat.setBorder(1); + tableFormat.setBorderBrush(m_style.border); + tableFormat.setBorderStyle(QTextFrameFormat::BorderStyle_Solid); + tableFormat.setCellSpacing(0); + tableFormat.setCellPadding(6); + tableFormat.setWidth(QTextLength(QTextLength::PercentageLength, 100)); + tableFormat.setTopMargin(6); + tableFormat.setBottomMargin(12); + + QTextTable *table = m_cursor.insertTable(rows.size(), columns, tableFormat); + for (int row = 0; row < rows.size(); ++row) { + const QStringList cells = (row == 0) ? headerCells : splitTableRow(rows.at(row)); + for (int column = 0; column < columns; ++column) { + QTextTableCell cell = table->cellAt(row, column); + + QTextTableCellFormat cellFormat = cell.format().toTableCellFormat(); + cellFormat.setBackground(row == 0 ? m_style.tableHeader : m_style.codeBg.lighter(104)); + cellFormat.setTopBorder(1); + cellFormat.setBottomBorder(1); + cellFormat.setLeftBorder(1); + cellFormat.setRightBorder(1); + cellFormat.setTopBorderBrush(m_style.border); + cellFormat.setBottomBorderBrush(m_style.border); + cellFormat.setLeftBorderBrush(m_style.border); + cellFormat.setRightBorderBrush(m_style.border); + cellFormat.setTopBorderStyle(QTextFrameFormat::BorderStyle_Solid); + cellFormat.setBottomBorderStyle(QTextFrameFormat::BorderStyle_Solid); + cellFormat.setLeftBorderStyle(QTextFrameFormat::BorderStyle_Solid); + cellFormat.setRightBorderStyle(QTextFrameFormat::BorderStyle_Solid); + cell.setFormat(cellFormat); + + QTextCursor cellCursor = cell.firstCursorPosition(); + QTextBlockFormat blockFormat; + blockFormat.setAlignment(alignments.value(column, Qt::AlignLeft)); + cellCursor.setBlockFormat(blockFormat); + + QTextCharFormat charFormat; + charFormat.setFontFamilies(proseFamilies()); + charFormat.setFontPointSize(kBodySize); + charFormat.setForeground(m_style.text); + if (row == 0) { + charFormat.setFontWeight(QFont::Bold); + charFormat.setForeground(m_style.heading); + } + cellCursor.insertText(cells.value(column), charFormat); + } + } + + m_cursor = QTextCursor(m_document); + m_cursor.setPosition(table->lastPosition()); +} + void Renderer::renderRule() { QTextBlockFormat blockFormat; @@ -365,6 +515,21 @@ void Renderer::run(const QString &markdown) while (index < lines.size()) { const QString line = lines.at(index); + // 表格:当前行有 |,下一行是 |---| + if (line.contains(QLatin1Char('|')) && index + 1 < lines.size() + && isTableSeparator(lines.at(index + 1))) { + QStringList rows{line}; + int next = index + 2; + while (next < lines.size() && !lines.at(next).trimmed().isEmpty() + && lines.at(next).contains(QLatin1Char('|'))) { + rows << lines.at(next); + ++next; + } + renderTable(rows, lines.at(index + 1)); + index = next; + continue; + } + const QRegularExpressionMatch fence = fencePattern.match(line); if (fence.hasMatch()) { QStringList code; diff --git a/translations/voiletmark_en.ts b/translations/voiletmark_en.ts index bdfcaa0..745523b 100644 --- a/translations/voiletmark_en.ts +++ b/translations/voiletmark_en.ts @@ -1,9 +1,10 @@ - + + GitRunner - + (超时,已中止) @@ -13,448 +14,447 @@ MainWindow - - + + 新建 New - - + + 打开… Open… - - + + 保存 Save - - + + 另存为… Save As… - - + + 导出 PDF… Export PDF… - - + + 退出 Quit - - + + 撤销 Undo - - + + 重做 Redo - - + + 剪切 Cut - - + + 复制 Copy - - + + 粘贴 Paste - - + + 全选 Select All - - + + 自动换行 Word Wrap - - + + 放大 Zoom In - - + + 缩小 Zoom Out - - + + 实际大小 Actual Size - - + + 打开文件夹… Open Folder… - - - - + + + + 侧栏 Sidebar - - + + 源代码模式 Source Mode - - + + 代码块 Code Block - - + + 标题 %1 Heading %1 - + 文件夹 Folder - - + + 未打开文件夹 No folder open - - + + Git 仓库 Git repository - - + + 拉取 Pull - + git pull --ff-only git pull --ff-only - - + + 获取 Fetch - + git fetch --all --prune git fetch --all --prune - - - + + + 推送 Push - + git push git push - - + 暂存 Stage - + git add -A git add -A - - - - - + + + + + 提交 Commit - + 把暂存区的内容提交 Commit the staged changes - - + + 文件 File - - + + 最近打开 Recent Files - - + + 编辑 Edit - - + + 视图 View - - + + 主题 Theme - - + + 6:00–18:00 使用浅色,其余时间使用深色 Light from 6:00 to 18:00, dark the rest of the day - - + + 语言 Language - - + + 格式 Format - - + + 标题 Heading - - + + 帮助 Help - - - + + + 关于 VoiletMark About VoiletMark - - + + 显示 / 隐藏文件夹侧栏(Ctrl+B) Show / hide the folder sidebar (Ctrl+B) - - + + 源代码模式:关闭语法高亮与行号(Ctrl+Shift+M) Source mode: turn off highlighting and line numbers (Ctrl+Shift+M) - - + + 未命名 Untitled - + 打开 Markdown 文件 Open Markdown File - + Markdown 文件 (*.md *.markdown *.mdown *.mkd);;文本文件 (*.txt);;所有文件 (*) Markdown files (*.md *.markdown *.mdown *.mkd);;Text files (*.txt);;All files (*) - + 无法打开文件 Cannot Open File - + 读取「%1」失败:%2 - Could not read "%1": %2 + Could not read "%1": %2 - + 另存为 Save As - + Markdown 文件 (*.md);;文本文件 (*.txt);;所有文件 (*) Markdown files (*.md);;Text files (*.txt);;All files (*) - + 导出 PDF Export PDF - + PDF 文件 (*.pdf) PDF files (*.pdf) - + 导出失败 Export Failed - + 没能写出「%1」,换个位置再试一次。 - Could not write "%1". Try another location. + Could not write "%1". Try another location. - + 已导出 %1 Exported to %1 - - - + + + 无法保存文件 Cannot Save File - + 写入「%1」失败:%2 - Could not write "%1": %2 + Could not write "%1": %2 - + 写入「%1」时发生错误。 - An error occurred while writing "%1". + An error occurred while writing "%1". - + 提交「%1」失败:%2 - Could not commit "%1": %2 + Could not commit "%1": %2 - + 已保存 %1 Saved to %1 - + VoiletMark VoiletMark - + 「%1」有未保存的修改,是否保存? - "%1" has unsaved changes. Save them? + "%1" has unsaved changes. Save them? - + %1[*] — VoiletMark %1[*] — VoiletMark - + 行 %1,列 %2 Line %1, Col %2 - + 已选 %1 字 %1 selected - + %1 字 %1 chars - + <h3>VoiletMark %1</h3><p>一个轻量的跨平台 Markdown 编辑器。</p><p>基于 Qt %2 与 C++17 构建。</p> <h3>VoiletMark %1</h3><p>A lightweight cross-platform Markdown editor.</p><p>Built with Qt %2 and C++17.</p> - + (暂无记录) (none) - + 清空列表 Clear List - + 打开文件夹 Open Folder - + 文件 → 打开文件夹… File → Open Folder… - + (没有 Markdown 文件) (no Markdown files) - + 正在%1… %1… - + Git 操作失败 Git Operation Failed - + %1失败: %2 @@ -463,118 +463,119 @@ %2 - + (没有输出) (no output) - + %1完成 %1 finished - + 暂存区是空的,先点「暂存」把所有改动加进去。 - Nothing is staged yet. Click "Stage" first to add all changes. + Nothing is staged yet. Click "Stage" first to add all changes. - + 提交说明: Commit message: - + 添加到提交 Add to commit - + 「%1」已经在 .gitignore 里了 - "%1" is already in .gitignore + "%1" is already in .gitignore - + 忽略文件 Ignore File - + 打不开 .gitignore:%1 Cannot open .gitignore: %1 - + 已把 %1 写进 .gitignore Added %1 to .gitignore - - - + + + 删除远端 Delete from remote - + 「%1」没有被 git 跟踪,远端本来就没有它。 - "%1" is not tracked by git, so the remote never had it. + "%1" is not tracked by git, so the remote never had it. - + 从仓库删除「%1」并推送到远端? 本地文件会保留,远端和别人 pull 下来的仓库里会删掉它。 - Remove "%1" from the repository and push? + Remove "%1" from the repository and push? -Your local file is kept, but the remote copy and everyone else's will be deleted. +Your local file is kept, but the remote copy and everyone else's will be deleted. - + 删除并推送 Delete and push - + chore: 从仓库删除 %1 chore: remove %1 from repository - + + 添加 Add - + git add 这个文件 git add this file - + 忽略 Ignore - + 把路径写进 .gitignore(不改仓库里已跟踪的文件) Add the path to .gitignore (does not untrack files already in the repo) - + git rm --cached 后提交并推送:远端删除,本地保留 git rm --cached, then commit and push: gone from the remote, kept locally - + 取消 Cancel - + 从仓库移除 Remove from repository - + 打开 Open @@ -582,40 +583,40 @@ Your local file is kept, but the remote copy and everyone else's will be deleted MarkdownEditor - - + + 已复制 Copied - - + + 复制 Copy - - + + 纯文本 Plain text - + 插入行 Insert row - + 删除行 Delete row - + 插入列 Insert column - + 删除列 Delete column @@ -623,24 +624,24 @@ Your local file is kept, but the remote copy and everyone else's will be deleted QObject - + 浅色 Light - + 深色 Dark - + 按时段自动 Scheduled - + 跟随系统 Follow System - \ No newline at end of file +