从零开发一个功能强大的 Markdown 预览器

本文将带你从零开始,使用 Python + PyQt5 开发一个支持图片、公式、代码高亮的现代化 Markdown 预览器。

一、软件介绍

1.1 项目概述

你是否遇到过这些痛点?

  • 想用 Markdown 写文档,但编辑器太重,纯文本又看不清效果
  • 图片、数学公式、代码混排时,没有预览完全不知道最终效果
  • 同时打开多个 Markdown 文件时,频繁切换太麻烦
  • 晚上看文档眼睛疲劳,需要深色模式

如果你也有这些困惑,那么 Markdown 预览器 就是为你而生的!

这是一个用 Python + PyQt5 开发的轻量级但功能强大的 Markdown 文件查看器,支持文字、图片、数学公式、代码高亮的完整可视化展现,让你的 Markdown 文档"所见即所得"。

1.2 功能亮点

功能 描述
📝 完整 Markdown 支持 标题、段落、列表、表格、引用等全部支持
🖼️ 图片渲染 本地图片、网络图片自动识别和加载
📐 数学公式 支持 LaTeX 公式(行内 $...$ 和块级 $$...$$
💻 代码高亮 Python、JavaScript、Java 等多种编程语言语法高亮
📁 多文件管理 左侧文件列表,同时打开多个文件,点击切换
🎨 双主题支持 浅色主题(护眼)、深色主题(夜间模式)
🔍 缩放功能 50%-300% 自由缩放,适配不同屏幕
⌨️ 快捷键 完整的快捷键支持,提高效率
📂 目录批量导入 一键导入整个目录下的所有 Markdown 文件
🕐 历史记录 最近打开的文件自动记录
🖱️ 拖拽支持 拖拽文件或目录到窗口即可打开

1.3 为什么选择这个方案?

  • 纯 Python 开发:无需学习复杂的前端技术
  • PyQt5 WebEngine:基于 Chromium 内核,渲染效果媲美浏览器
  • 开源免费:所有依赖都是开源的,可自由定制
  • 跨平台:Windows/Mac/Linux 都能运行

二、需求描述

2.1 核心需求

用户需要一个 Markdown 文件预览工具,主要用于:

  1. 查看 Markdown 文件 :快速打开并查看 .md 格式的文档
  2. 可视化预览:将 Markdown 语法渲染成美观的网页
  3. 多元素支持
    • 文字内容:标题、段落、列表、表格、引用
    • 图片:本地图片、网络图片都能显示
    • 数学公式:支持 LaTeX 语法的数学公式
    • 代码块:语法高亮显示
  4. 多文件管理:同时管理多个文件,方便切换查看
  5. 良好的用户体验:现代、简洁的界面设计

2.2 技术要求

技术点 要求
开发语言 Python 3.8+
GUI 框架 PyQt5(含 WebEngine)
Markdown 解析 高性能、可扩展的解析库
公式渲染 支持 LaTeX 语法
代码高亮 主流编程语言全覆盖

三、整体设计实现

3.1 技术架构

方案对比

在开发前,我们面临两种方案的选择:

方案 A:Tkinter + tkhtmlview(轻量级)

  • ✅ 轻量级,纯 Python 实现
  • ❌ 对复杂 CSS/JavaScript 支持有限
  • ❌ 无法渲染复杂的数学公式

方案 B:PyQt5 + WebEngine(功能完整)

  • ✅ 完整的 Web 渲染能力(Chromium 内核)
  • ✅ 支持复杂 CSS、JavaScript
  • ✅ 可以使用 KaTeX/MathJax 渲染公式
  • ✅ 图片渲染更稳定

最终选择:方案 B

虽然方案 B 体积稍大,但考虑到需要支持图片、公式等复杂内容,完整的 Web 渲染能力是必要的。

技术栈

3.2 核心模块设计

3.2.1 模块划分
模块 文件 职责
主程序入口 main.py 应用程序启动、参数解析
主窗口 ui/main_window.py UI 布局、事件处理
文件管理器 core/file_manager.py 文件读取、历史记录
Markdown 解析器 core/markdown_parser.py Markdown → HTML 转换
HTML 生成器 core/html_generator.py 完整 HTML 页面组装
3.2.2 文件结构
复制代码
markdown-preview/
├── main.py                           # 主程序入口
├── requirements.txt                  # 依赖列表
├── ui/
│   ├── __init__.py
│   └── main_window.py               # 主窗口 UI
├── core/
│   ├── __init__.py
│   ├── file_manager.py              # 文件管理器
│   ├── markdown_parser.py           # Markdown 解析器
│   └── html_generator.py            # HTML 生成器
├── templates/
│   └── base_template.html           # HTML 模板
└── assets/
    └── themes/
        ├── light.css                # 浅色主题
        └── dark.css                 # 深色主题
3.2.3 核心处理流程

四、实现过程与版本迭代

4.1 版本迭代概览

版本 功能 修复/优化
v1.0 基础 Markdown 预览 单文件支持
v1.1 图片路径修复 支持 images/ 子目录
v1.2 编码兼容性 支持 UTF-8、GBK 等多种编码
v1.3 依赖修复 安装缺失的 linkify-it-py
v2.0 多文件管理 左侧文件列表、批量导入
v2.1 UI 美化 现代化主题、Emoji 图标

4.2 逐步实现

4.2.1 第一步:搭建基础框架(v1.0)

目标:创建一个能打开并显示 Markdown 文件的基础程序

核心思路

  1. 使用 markdown-it-py 将 Markdown 转换为 HTML
  2. 使用 PyQt5.QtWebEngineWidgets.QWebEngineView 渲染 HTML
  3. 在 HTML 模板中嵌入 KaTeX 用于公式渲染

依赖安装

bash 复制代码
pip install PyQt5 PyQtWebEngine markdown-it-py mdit-py-plugins Pygments Pillow

基础代码结构

python 复制代码
# core/file_manager.py - 文件管理器
class FileManager:
    def read_file(self, file_path: str) -> str:
        with open(file_path, "r", encoding="utf-8") as f:
            return f.read()

# core/markdown_parser.py - Markdown 解析器
from markdown_it import MarkdownIt

class MarkdownParser:
    def __init__(self):
        self.md = MarkdownIt("gfm-like", {
            "html": True,
            "linkify": True,
            "typographer": True,
        })
    
    def parse(self, markdown_text: str) -> str:
        return self.md.render(markdown_text)

# core/html_generator.py - HTML 生成器
class HTMLGenerator:
    def generate_html(self, content: str, theme: str = "light") -> str:
        # 加载模板和主题,返回完整 HTML
        pass

# ui/main_window.py - 主窗口
from PyQt5.QtWidgets import QMainWindow
from PyQt5.QtWebEngineWidgets import QWebEngineView

class MarkdownPreviewWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self._web_view = QWebEngineView()
        self.setCentralWidget(self._web_view)

HTML 模板

html 复制代码
<!-- templates/base_template.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Markdown Preview</title>
    <style>{css_styles}</style>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
</head>
<body>
    <div class="container">{content}</div>
    
    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
    <script>
        renderMathInElement(document.body, {
            delimiters: [
                {left: '$$', right: '$$', display: true},
                {left: '$', right: '$', display: false}
            ]
        });
    </script>
</body>
</html>
4.2.2 第二步:图片路径修复(v1.1)

问题 :Markdown 中的图片相对路径(如 ![图](image.png))在 WebEngine 中无法正常加载

原因分析

  • Markdown 文件在 D:\docs\article.md
  • 图片在 D:\docs\images\image.png
  • Markdown 中引用写的是 ![图](image.png)
  • WebEngine 解析时会相对于页面 URL 查找,找不到图片

解决方案

python 复制代码
# core/markdown_parser.py
import re
from pathlib import Path

class MarkdownParser:
    def _process_image_paths(self, html: str) -> str:
        file_dir = Path(self._current_file_path).parent
        
        def find_image_in_subdirs(base_dir: Path, src: str) -> Path:
            # 1. 先检查常见的图片子目录
            candidates = [
                base_dir / src,
                base_dir / "images" / src,
                base_dir / "img" / src,
                base_dir / "assets" / src,
                base_dir / "media" / src,
            ]
            
            for candidate in candidates:
                if candidate.exists():
                    return candidate
            
            # 2. 递归搜索整个目录
            for root, dirs, files in os.walk(base_dir):
                for name in files:
                    if name.lower() == src.lower():
                        return Path(root) / name
            
            return None
        
        def fix_image_path(match):
            src = match.group(1)
            if src.startswith("http://") or src.startswith("https://"):
                return match.group(0)
            
            img_path = find_image_in_subdirs(file_dir, src)
            if img_path:
                abs_path = img_path.resolve()
                file_url = abs_path.as_uri()  # 转换为 file:// 协议
                return f'<img src="{file_url}"'
            return match.group(0)
        
        pattern = r'<img src="([^"]+)"'
        return re.sub(pattern, fix_image_path, html)

关键技术点

  • 检查常见子目录:images/img/assets/media/
  • 递归搜索整个目录
  • 不区分文件名大小写
  • 使用 as_uri() 转换为 file:///D:/... 格式
4.2.3 第三步:编码兼容性(v1.2)

问题 :使用 GBK 编码的文件(中文 Windows 常见)无法打开,报错 UnicodeDecodeError

解决方案

python 复制代码
# core/file_manager.py
class FileManager:
    def __init__(self):
        self._encodings = ['utf-8', 'gbk', 'gb2312', 'gb18030', 'cp1252']
    
    def read_file(self, file_path: str) -> str:
        last_error = None
        
        for encoding in self._encodings:
            try:
                with open(file_path, "r", encoding=encoding) as f:
                    return f.read()
            except UnicodeDecodeError as e:
                last_error = e
        
        raise ValueError(f"无法读取文件: {file_path}")

尝试顺序

  1. utf-8 - 最常用
  2. gbk - Windows 中文常见
  3. gb2312 - 中文简化
  4. gb18030 - 中文国家标准
  5. cp1252 - 西欧编码
4.2.4 第四步:依赖修复(v1.3)

问题 :运行时报错 Linkify enabled but not installed

原因markdown-it-pylinkify 插件需要额外安装 linkify-it-py

解决方案

bash 复制代码
pip install linkify-it-py

更新 requirements.txt

复制代码
PyQt5>=5.15.0
PyQtWebEngine>=5.15.0
markdown-it-py>=3.0.0
mdit-py-plugins>=0.4.0
linkify-it-py>=2.0.0
Pygments>=2.15.0
Pillow>=10.0.0
4.2.5 第五步:多文件管理(v2.0)

目标:添加左侧文件列表,支持同时管理多个文件

新增功能

  1. 左侧 QListWidget 显示已打开的文件
  2. 点击列表项切换预览
  3. 支持多选打开文件
  4. 支持从目录批量导入
  5. 文件内容缓存,切换更快

核心实现

python 复制代码
# ui/main_window.py
class MarkdownPreviewWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self._open_files = []              # 已打开文件路径列表
        self._current_file_path = None     # 当前文件
        self._file_contents_cache = {}     # 文件内容缓存
        
        # 创建分割器
        self._splitter = QSplitter(Qt.Horizontal)
        self._splitter.setSizes([280, 1120])
        
        # 左侧面板
        self._left_panel = self._create_left_panel()
        self._splitter.addWidget(self._left_panel)
        
        # 右侧预览区
        self._web_view = QWebEngineView()
        self._splitter.addWidget(self._web_view)
    
    def _create_left_panel(self):
        panel = QWidget()
        layout = QVBoxLayout(panel)
        
        # 文件列表
        self._file_list = QListWidget()
        self._file_list.itemClicked.connect(self._on_file_selected)
        layout.addWidget(self._file_list, 1)
        
        # 按钮
        add_btn = QPushButton("添加")
        add_btn.clicked.connect(self._open_file)
        
        remove_btn = QPushButton("移除")
        remove_btn.clicked.connect(self._remove_current_file)
        
        return panel
    
    def _add_file_to_list(self, file_path: str):
        if file_path in self._open_files:
            return  # 防重复
        
        self._open_files.append(file_path)
        
        # 添加到列表
        item = QListWidgetItem(file_name)
        item.setData(Qt.UserRole, file_path)
        self._file_list.addItem(item)
        
        # 自动切换到新文件
        self._load_file(file_path)
    
    def _on_file_selected(self, item: QListWidgetItem):
        file_path = item.data(Qt.UserRole)
        if file_path != self._current_file_path:
            self._load_file(file_path)
    
    def _load_file(self, file_path: str):
        # 优先从缓存读取
        if file_path in self._file_contents_cache:
            content = self._file_contents_cache[file_path]
        else:
            content, file_name = self._file_manager.open_file(file_path)
            self._file_contents_cache[file_path] = content
        
        # 渲染...

目录批量导入

python 复制代码
def _open_files_from_dir(self):
    dir_path = QFileDialog.getExistingDirectory(self, "选择目录")
    
    if dir_path:
        md_files = []
        for root, dirs, files in os.walk(dir_path):
            for file in files:
                if file.lower().endswith(('.md', '.markdown')):
                    md_files.append(os.path.join(root, file))
        
        for file_path in md_files:
            self._add_file_to_list(file_path)
4.2.6 第六步:UI 美化(v2.1)

目标:打造现代化、美观的界面

设计风格:Catppuccin(流行的柔和配色主题)

浅色主题效果

深色主题效果

实现方式:使用 Qt Style Sheets (QSS)

python 复制代码
# 浅色主题 QSS
LIGHT_THEME_QSS = """
QMainWindow, QWidget {
    background-color: #ffffff;
    color: #4c4f69;
}

QMenuBar {
    background-color: #eff1f5;
    border-bottom: 1px solid #dce0e8;
}

QListWidget {
    background-color: #ffffff;
    border: 1px solid #dce0e8;
    border-radius: 8px;
}

QListWidget::item:selected {
    background-color: #8839ef;  /* 紫色主色 */
    color: #ffffff;
    border-radius: 6px;
    margin: 2px 4px;
}

QPushButton {
    border: none;
    border-radius: 6px;
    padding: 8px 16px;
    font-weight: 600;
}
"""

# 深色主题 QSS
DARK_THEME_QSS = """
QMainWindow, QWidget {
    background-color: #1e1e2e;
    color: #cdd6f4;
}

QListWidget::item:selected {
    background-color: #89b4fa;  /* 蓝色主色 */
    color: #1e1e2e;
}
"""

主题切换

python 复制代码
def _switch_theme(self, theme: str):
    self._current_theme = theme
    if theme == "dark":
        self.setStyleSheet(DARK_THEME_QSS)
    else:
        self.setStyleSheet(LIGHT_THEME_QSS)
    
    # 同时更新预览区的主题
    self._refresh_view()

Emoji 图标装饰

python 复制代码
# 菜单项
QAction("📂 打开(&O)...", self)
QAction("🕐 最近打开的文件(&R)", self)
QAction("🎨 主题(&T)", self)

# 状态栏
self._file_label = QLabel("  📄 未打开文件")
self._zoom_label = QLabel(f"  🔍 缩放: 100%  ")
self._theme_label = QLabel("  🎨 主题: 浅色  ")

4.3 关键技术点详解

4.3.1 WebEngine 加载本地 HTML

方法 1:setHtml(推荐,适用于动态内容)

python 复制代码
html_content = "<html>...</html>"
self._web_view.setHtml(html_content)

方法 2:设置 baseUrl 用于解析相对路径

python 复制代码
file_dir = Path(file_path).parent
base_url = QUrl.fromLocalFile(str(file_dir) + "/")
self._web_view.setHtml(html_content, base_url)

重要设置

python 复制代码
settings = self._web_view.settings()
settings.setAttribute(QWebEngineSettings.JavascriptEnabled, True)
settings.setAttribute(QWebEngineSettings.LocalContentCanAccessRemoteUrls, True)
settings.setAttribute(QWebEngineSettings.LocalContentCanAccessFileUrls, True)
4.3.2 KaTeX 公式渲染

支持的公式格式

  • 行内公式:$E = mc^2$
  • 块级公式:$$\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}$$

HTML 模板中的实现

html 复制代码
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script>
    renderMathInElement(document.body, {
        delimiters: [
            {left: '$$', right: '$$', display: true},
            {left: '$', right: '$', display: false}
        ],
        throwOnError: false
    });
</script>

优势

  • 轻量级,渲染速度快
  • 支持大部分 LaTeX 数学语法
  • 通过 CDN 加载,无需本地安装
4.3.3 Pygments 代码高亮

支持的语言:Python、JavaScript、Java、C++、Go、Rust、HTML、CSS、SQL 等 300+ 种

实现

python 复制代码
from pygments import highlight
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.formatters import HtmlFormatter
from pygments.util import ClassNotFound

def _process_code_blocks(self, html: str) -> str:
    pattern = r'<pre><code class="language-(\w+)">([\s\S]*?)</code></pre>'
    
    def replace_code_block(match):
        language = match.group(1)
        code_content = match.group(2)
        
        try:
            lexer = get_lexer_by_name(language, stripall=True)
        except ClassNotFound:
            try:
                lexer = guess_lexer(code_content)  # 自动识别语言
            except ClassNotFound:
                lexer = get_lexer_by_name("text", stripall=True)
        
        formatter = HtmlFormatter(style="default", cssclass="highlight")
        highlighted = highlight(code_content, lexer, formatter)
        return highlighted
    
    return re.sub(pattern, replace_code_block, html)

支持的高亮风格

  • 浅色:defaultcolorfulmanni
  • 深色:monokainativefruity

五、完整代码

5.1 项目结构

复制代码
markdown-preview/
├── main.py
├── requirements.txt
├── ui/
│   ├── __init__.py
│   └── main_window.py
├── core/
│   ├── __init__.py
│   ├── file_manager.py
│   ├── markdown_parser.py
│   └── html_generator.py
├── templates/
│   └── base_template.html
└── assets/
    └── themes/
        ├── light.css
        └── dark.css

5.2 完整代码文件

main.py
python 复制代码
import sys
import os
from pathlib import Path

from PyQt5.QtWidgets import QApplication
from PyQt5.QtCore import Qt

from ui.main_window import MarkdownPreviewWindow


def main():
    QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
    QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
    
    app = QApplication(sys.argv)
    app.setApplicationName("Markdown 预览器")
    app.setApplicationVersion("2.0.0")
    
    window = MarkdownPreviewWindow()
    window.show()
    
    if len(sys.argv) > 1:
        file_path = sys.argv[1]
        if os.path.exists(file_path):
            window._load_file(file_path)
    
    sys.exit(app.exec_())


if __name__ == "__main__":
    main()
requirements.txt
复制代码
PyQt5>=5.15.0
PyQtWebEngine>=5.15.0
markdown-it-py>=3.0.0
mdit-py-plugins>=0.4.0
linkify-it-py>=2.0.0
Pygments>=2.15.0
Pillow>=10.0.0
core/init.py
python 复制代码
# Core modules
ui/init.py
python 复制代码
# UI modules
templates/base_template.html
html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Markdown Preview</title>
    <style>
        {css_styles}
    </style>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
</head>
<body>
    <div class="container">
        {content}
    </div>
    
    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
    <script>
        document.addEventListener('DOMContentLoaded', function() {
            renderMathInElement(document.body, {
                delimiters: [
                    {left: '$$', right: '$$', display: true},
                    {left: '$', right: '$', display: false}
                ],
                throwOnError: false,
                macros: {
                    "\\f": "#1f(#2)"
                }
            });
        });
    </script>
</body>
</html>
core/file_manager.py
python 复制代码
import os
from pathlib import Path
from typing import List, Optional


class FileManager:
    def __init__(self, max_history: int = 10):
        self._current_file: Optional[Path] = None
        self._file_history: List[Path] = []
        self._max_history = max_history
        self._encodings = ['utf-8', 'gbk', 'gb2312', 'gb18030', 'cp1252']
    
    def read_file(self, file_path: str) -> str:
        path = Path(file_path)
        
        if not path.exists():
            raise FileNotFoundError(f"文件不存在: {file_path}")
        
        if not path.is_file():
            raise ValueError(f"不是文件: {file_path}")
        
        content = None
        last_error = None
        
        for encoding in self._encodings:
            try:
                with open(path, "r", encoding=encoding) as f:
                    content = f.read()
                break
            except UnicodeDecodeError as e:
                last_error = e
        
        if content is None:
            raise ValueError(f"无法读取文件: {file_path}\n\n编码错误: {str(last_error)}")
        
        self._current_file = path
        self._add_to_history(path)
        
        return content
    
    def open_file(self, file_path: str) -> tuple[str, str]:
        content = self.read_file(file_path)
        file_name = self.get_file_name()
        return content, file_name
    
    def _add_to_history(self, file_path: Path):
        if file_path in self._file_history:
            self._file_history.remove(file_path)
        
        self._file_history.insert(0, file_path)
        
        if len(self._file_history) > self._max_history:
            self._file_history = self._file_history[:self._max_history]
    
    def get_current_file(self) -> Optional[Path]:
        return self._current_file
    
    def get_file_name(self) -> str:
        if self._current_file:
            return self._current_file.name
        return "未命名"
    
    def get_file_path(self) -> str:
        if self._current_file:
            return str(self._current_file)
        return ""
    
    def get_file_dir(self) -> Optional[str]:
        if self._current_file:
            return str(self._current_file.parent)
        return None
    
    def get_history(self) -> List[str]:
        return [str(path) for path in self._file_history]
    
    def is_markdown_file(self, file_path: str) -> bool:
        path = Path(file_path)
        ext = path.suffix.lower()
        return ext in [".md", ".markdown", ".mdown", ".mkdn", ".mkd"]
    
    def get_supported_extensions(self) -> list:
        return ["md", "markdown", "mdown", "mkdn", "mkd"]
    
    def file_exists(self, file_path: str) -> bool:
        return Path(file_path).exists()
    
    def clear_history(self):
        self._file_history.clear()
    
    def get_max_history(self) -> int:
        return self._max_history
    
    def set_max_history(self, max_history: int):
        self._max_history = max_history
        if len(self._file_history) > self._max_history:
            self._file_history = self._file_history[:self._max_history]
core/markdown_parser.py
python 复制代码
import os
import re
from pathlib import Path
from markdown_it import MarkdownIt
from mdit_py_plugins.front_matter import front_matter_plugin
from mdit_py_plugins.footnote import footnote_plugin
from mdit_py_plugins.tasklists import tasklists_plugin
from mdit_py_plugins.dollarmath import dollarmath_plugin
from pygments import highlight
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.formatters import HtmlFormatter
from pygments.util import ClassNotFound


class MarkdownParser:
    def __init__(self):
        self.md = MarkdownIt("gfm-like", {
            "html": True,
            "linkify": True,
            "typographer": True,
        })
        
        self.md.use(front_matter_plugin)
        self.md.use(footnote_plugin)
        self.md.use(tasklists_plugin)
        
        self.formatter_light = HtmlFormatter(style="default", cssclass="highlight")
        self.formatter_dark = HtmlFormatter(style="monokai", cssclass="highlight")
        
        self._current_file_path = None
    
    def set_current_file(self, file_path):
        self._current_file_path = file_path
    
    def parse(self, markdown_text: str) -> str:
        html = self.md.render(markdown_text)
        html = self._process_code_blocks(html)
        if self._current_file_path:
            html = self._process_image_paths(html)
        return html
    
    def _process_code_blocks(self, html: str) -> str:
        pattern = r'<pre><code class="language-(\w+)">([\s\S]*?)</code></pre>'
        
        def replace_code_block(match):
            language = match.group(1)
            code_content = match.group(2)
            
            try:
                lexer = get_lexer_by_name(language, stripall=True)
            except ClassNotFound:
                try:
                    lexer = guess_lexer(code_content)  # 自动识别语言
                except ClassNotFound:
                    lexer = get_lexer_by_name("text", stripall=True)
            
            highlighted = highlight(code_content, lexer, self.formatter_light)
            return highlighted
        
        return re.sub(pattern, replace_code_block, html)
    
    def _process_image_paths(self, html: str) -> str:
        if not self._current_file_path:
            return html
        
        file_dir = Path(self._current_file_path).parent
        
        def find_image_in_subdirs(base_dir: Path, src: str) -> Path:
            candidates = []
            candidates.append(base_dir / src)
            candidates.append(base_dir / "images" / src)
            candidates.append(base_dir / "img" / src)
            candidates.append(base_dir / "assets" / src)
            candidates.append(base_dir / "media" / src)
            candidates.append(base_dir / "figure" / src)
            candidates.append(base_dir / "figures" / src)
            
            for candidate in candidates:
                if candidate.exists():
                    return candidate
            
            for root, dirs, files in os.walk(base_dir):
                for name in files:
                    if name.lower() == src.lower():
                        return Path(root) / name
                    if os.path.splitext(name)[0].lower() == os.path.splitext(src)[0].lower():
                        return Path(root) / name
            
            return None
        
        def fix_image_path(match):
            src = match.group(1)
            
            if src.startswith("http://") or src.startswith("https://") or src.startswith("data:"):
                return match.group(0)
            
            if os.path.isabs(src):
                img_path = Path(src)
                if img_path.exists():
                    abs_path = img_path.resolve()
                    file_url = abs_path.as_uri()
                    return f'<img src="{file_url}"'
                else:
                    return match.group(0)
            else:
                img_path = find_image_in_subdirs(file_dir, src)
                if img_path:
                    abs_path = img_path.resolve()
                    file_url = abs_path.as_uri()
                    return f'<img src="{file_url}"'
                else:
                    return match.group(0)
        
        pattern = r'<img src="([^"]+)"'
        return re.sub(pattern, fix_image_path, html)
core/html_generator.py
python 复制代码
import os
from pathlib import Path
from typing import Optional


class HTMLGenerator:
    def __init__(self):
        self._current_dir = Path(__file__).parent.parent
        self._templates_dir = self._current_dir / "templates"
        self._themes_dir = self._current_dir / "assets" / "themes"
        
        self._template_cache = {}
        self._theme_cache = {}
    
    def load_template(self) -> str:
        template_path = self._templates_dir / "base_template.html"
        
        if template_path in self._template_cache:
            return self._template_cache[template_path]
        
        with open(template_path, "r", encoding="utf-8") as f:
            template = f.read()
        
        self._template_cache[template_path] = template
        return template
    
    def load_theme(self, theme_name: str = "light") -> str:
        theme_path = self._themes_dir / f"{theme_name}.css"
        
        if not theme_path.exists():
            theme_path = self._themes_dir / "light.css"
        
        cache_key = str(theme_path)
        if cache_key in self._theme_cache:
            return self._theme_cache[cache_key]
        
        with open(theme_path, "r", encoding="utf-8") as f:
            theme_css = f.read()
        
        self._theme_cache[cache_key] = theme_css
        return theme_css
    
    def generate_html(
        self,
        content: str,
        theme: str = "light",
        title: Optional[str] = None,
        base_url: Optional[str] = None,
    ) -> str:
        template = self.load_template()
        theme_css = self.load_theme(theme)
        
        html = template.replace("{css_styles}", theme_css)
        html = html.replace("{content}", content)
        
        if base_url:
            base_tag = f'<base href="{base_url}">'
            head_end = html.find("</head>")
            if head_end != -1:
                html = html[:head_end] + base_tag + html[head_end:]
        
        if title:
            title_tag = f"<title>{title}</title>"
            old_title = "<title>Markdown Preview</title>"
            html = html.replace(old_title, title_tag)
        
        return html
    
    def clear_cache(self):
        self._template_cache.clear()
        self._theme_cache.clear()
    
    def get_available_themes(self) -> list:
        themes = []
        if self._themes_dir.exists():
            for file in self._themes_dir.glob("*.css"):
                themes.append(file.stem)
        return themes
assets/themes/light.css
css 复制代码
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
    line-height: 1.8;
    color: #24292e;
    background-color: #ffffff;
    padding: 40px 20px;
    -webkit-font-smoothing: antialiased;
}

.container {
    max-width: 900px;
    margin: 0 auto;
    padding: 0 20px;
}

h1, h2, h3, h4, h5, h6 {
    margin-top: 24px;
    margin-bottom: 16px;
    font-weight: 600;
    line-height: 1.25;
}

h1 {
    font-size: 2em;
    padding-bottom: 0.3em;
    border-bottom: 1px solid #eaecef;
}

h2 {
    font-size: 1.5em;
    padding-bottom: 0.3em;
    border-bottom: 1px solid #eaecef;
}

h3 {
    font-size: 1.25em;
}

p {
    margin-bottom: 16px;
}

a {
    color: #0366d6;
    text-decoration: none;
}

a:hover {
    text-decoration: underline;
}

img {
    max-width: 100%;
    height: auto;
    border-radius: 4px;
    margin: 16px 0;
}

pre {
    background-color: #f6f8fa;
    border-radius: 6px;
    padding: 16px;
    overflow-x: auto;
    margin-bottom: 16px;
    font-size: 14px;
}

code {
    font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
    font-size: 14px;
}

p code, li code {
    background-color: #f6f8fa;
    padding: 0.2em 0.4em;
    border-radius: 4px;
}

ul, ol {
    margin-bottom: 16px;
    padding-left: 2em;
}

li {
    margin-bottom: 8px;
}

blockquote {
    margin: 16px 0;
    padding: 0 1em;
    color: #6a737d;
    border-left: 0.25em solid #dfe2e5;
}

table {
    border-collapse: collapse;
    margin: 16px 0;
    width: 100%;
    overflow-x: auto;
    display: block;
}

table th, table td {
    border: 1px solid #dfe2e5;
    padding: 12px 16px;
}

table th {
    background-color: #f6f8fa;
    font-weight: 600;
}

table tr:nth-child(2n) {
    background-color: #f6f8fa;
}

hr {
    height: 0.25em;
    padding: 0;
    margin: 24px 0;
    background-color: #e1e4e8;
    border: 0;
}

.katex {
    font-size: 1.1em;
}

.katex-display {
    margin: 16px 0;
    overflow-x: auto;
}
assets/themes/dark.css
css 复制代码
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
    line-height: 1.8;
    color: #c9d1d9;
    background-color: #0d1117;
    padding: 40px 20px;
    -webkit-font-smoothing: antialiased;
}

.container {
    max-width: 900px;
    margin: 0 auto;
    padding: 0 20px;
}

h1, h2, h3, h4, h5, h6 {
    margin-top: 24px;
    margin-bottom: 16px;
    font-weight: 600;
    line-height: 1.25;
    color: #e6edf3;
}

h1 {
    font-size: 2em;
    padding-bottom: 0.3em;
    border-bottom: 1px solid #30363d;
}

h2 {
    font-size: 1.5em;
    padding-bottom: 0.3em;
    border-bottom: 1px solid #30363d;
}

p {
    margin-bottom: 16px;
}

a {
    color: #58a6ff;
    text-decoration: none;
}

a:hover {
    text-decoration: underline;
}

img {
    max-width: 100%;
    height: auto;
    border-radius: 4px;
    margin: 16px 0;
}

pre {
    background-color: #161b22;
    border-radius: 6px;
    padding: 16px;
    overflow-x: auto;
    margin-bottom: 16px;
}

p code, li code {
    background-color: #161b22;
    color: #ff7b72;
    padding: 0.2em 0.4em;
    border-radius: 4px;
}

blockquote {
    margin: 16px 0;
    padding: 0 1em;
    color: #8b949e;
    border-left: 0.25em solid #30363d;
}

table th, table td {
    border: 1px solid #30363d;
    padding: 12px 16px;
}

table th {
    background-color: #161b22;
}

table tr:nth-child(2n) {
    background-color: #161b22;
}

hr {
    height: 0.25em;
    background-color: #30363d;
    border: 0;
}

.katex {
    font-size: 1.1em;
    color: #e6edf3;
}

/* Pygments 暗色主题 */
.highlight .hll { background-color: #272822 }
.highlight  { background: #161b22; color: #f8f8f2 }
.highlight .c { color: #75715e }
.highlight .k { color: #66d9ef }
.highlight .n { color: #f8f8f2 }
.highlight .o { color: #f92672 }
.highlight .cm { color: #75715e }
.highlight .cp { color: #75715e }
.highlight .c1 { color: #75715e }
.highlight .cs { color: #75715e }
.highlight .ge { font-style: italic }
.highlight .gs { font-weight: bold }
.highlight .kc { color: #66d9ef }
.highlight .kd { color: #66d9ef }
.highlight .kn { color: #f92672 }
.highlight .kp { color: #66d9ef }
.highlight .kr { color: #66d9ef }
.highlight .kt { color: #66d9ef }
.highlight .ld { color: #e6db74 }
.highlight .m { color: #ae81ff }
.highlight .s { color: #e6db74 }
.highlight .na { color: #a6e22e }
.highlight .nb { color: #f8f8f2 }
.highlight .nc { color: #a6e22e }
.highlight .no { color: #66d9ef }
.highlight .nd { color: #a6e22e }
.highlight .ni { color: #f8f8f2 }
.highlight .ne { color: #a6e22e }
.highlight .nf { color: #a6e22e }
.highlight .nl { color: #f8f8f2 }
.highlight .nn { color: #f8f8f2 }
.highlight .nx { color: #a6e22e }
.highlight .py { color: #f8f8f2 }
.highlight .nt { color: #f92672 }
.highlight .nv { color: #f8f8f2 }
.highlight .ow { color: #f92672 }
.highlight .w { color: #f8f8f2 }
.highlight .mf { color: #ae81ff }
.highlight .mh { color: #ae81ff }
.highlight .mi { color: #ae81ff }
.highlight .mo { color: #ae81ff }
.highlight .sb { color: #e6db74 }
.highlight .sc { color: #e6db74 }
.highlight .sd { color: #e6db74 }
.highlight .s2 { color: #e6db74 }
.highlight .se { color: #ae81ff }
.highlight .sh { color: #e6db74 }
.highlight .si { color: #e6db74 }
.highlight .sx { color: #e6db74 }
.highlight .sr { color: #e6db74 }
.highlight .s1 { color: #e6db74 }
.highlight .ss { color: #e6db74 }
.highlight .bp { color: #f8f8f2 }
.highlight .vc { color: #f8f8f2 }
.highlight .vg { color: #f8f8f2 }
.highlight .vi { color: #f8f8f2 }
.highlight .il { color: #ae81ff }
ui/main_window.py(简化版,完整版本见前文)
python 复制代码
import os
from pathlib import Path
from PyQt5.QtWidgets import (
    QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QToolBar, QStatusBar, QFileDialog, QMessageBox,
    QAction, QLabel, QSplitter, QMenu, QListWidget,
    QListWidgetItem, QPushButton, QFrame
)
from PyQt5.QtCore import Qt, QUrl, QSize
from PyQt5.QtGui import QKeySequence
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineSettings

from core.markdown_parser import MarkdownParser
from core.html_generator import HTMLGenerator
from core.file_manager import FileManager


DARK_THEME_QSS = """..."""  # 完整 QSS 见前文
LIGHT_THEME_QSS = """..."""  # 完整 QSS 见前文


class MarkdownPreviewWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        
        self._parser = MarkdownParser()
        self._html_generator = HTMLGenerator()
        self._file_manager = FileManager()
        
        self._current_theme = "light"
        self._zoom_factor = 1.0
        self._open_files = []
        self._current_file_path = None
        self._file_contents_cache = {}
        
        self._init_ui()
        self._init_menu()
        self._init_toolbar()
        self._init_statusbar()
        
        self._apply_theme()
        self._show_welcome()
    
    # 完整实现见前文...

六、运行方式

6.1 安装依赖

bash 复制代码
pip install -r requirements.txt

6.2 启动程序

bash 复制代码
python main.py

6.3 直接打开文件

bash 复制代码
python main.py your_file.md

6.4 快捷键

快捷键 功能
Ctrl+O 打开文件(支持多选)
Ctrl+Shift+O 从目录批量添加
Ctrl+R / F5 刷新
Ctrl+W 关闭当前文件
Ctrl+Shift+W 关闭所有文件
Ctrl+L 显示/隐藏文件列表
Ctrl++ 放大
Ctrl+- 缩小
Ctrl+0 重置缩放
Ctrl+Alt+L 浅色主题
Ctrl+Alt+D 深色主题
Ctrl+Q 退出

七、开发小结

7.1 收获与感悟

通过这个项目,我深刻体会到了以下几点:

1. 架构设计的重要性

在项目开始前,花时间规划架构是非常值得的。我们选择了模块化的设计:

  • 文件管理器:专注于文件读写和历史记录
  • Markdown 解析器:专注于内容转换
  • HTML 生成器:专注于页面组装
  • 主窗口:专注于 UI 和交互

这种解耦让后续的迭代变得非常容易,例如添加多文件管理时,主要只修改主窗口,其他模块几乎不用改。

2. 问题驱动的开发

这个项目是一步步迭代完善的:

阶段 问题 解决方案
v1.0 基础功能 搭建最小可用版本
v1.1 图片不显示 智能路径搜索
v1.2 编码报错 多编码尝试
v1.3 依赖缺失 补充依赖
v2.0 单文件不够用 添加多文件列表
v2.1 界面太朴素 UI 美化

这种方式让开发过程可控,每个阶段都有明确的目标和验证标准。

3. 用户体验细节

一些看似微小的细节,却能大幅提升体验:

  • Emoji 图标:让界面更生动,信息更直观
  • 缓存机制:文件切换无需重新读取,流畅如丝
  • 多编码支持:解决中文用户的常见痛点
  • 深色主题:保护眼睛,现代软件的标配
4. PyQt5 WebEngine 的威力

使用 WebEngine 是正确的选择:

  • 渲染效果:等同于 Chrome 浏览器
  • 公式渲染:KaTeX 可以完美运行
  • 扩展性:未来可以添加打印、导出 PDF 等功能

7.2 未来扩展方向

这个项目还有很大的扩展空间:

功能 描述 难度
实时编辑 左侧编辑,右侧预览
导出 PDF/HTML 一键导出
搜索功能 在文档内搜索
目录导航 自动生成大纲
打印支持 打印文档
自动刷新 文件变化自动刷新
分屏对比 原文和预览对比
插件系统 支持自定义插件

7.3 总结

从零开始开发一个功能完整的 Markdown 预览器,让我对 Python GUI 开发、Web 渲染、模块化设计有了更深的理解。

这个项目的核心价值不在于代码的复杂性,而在于:

  • 实用:解决了真实的痛点
  • 可扩展:模块化设计便于后续迭代
  • 学习价值:涵盖了 GUI、Web、正则、缓存等多个领域

希望这篇文章能给你带来启发,也欢迎你基于这个项目继续探索和扩展!

相关推荐
前端若水9 小时前
使用 IndexedDB 在客户端存储对话记录
java·前端·人工智能·python·机器学习
小许同学记录成长9 小时前
原始 IQ 数据时频图生成
python·算法
苦逼的猿宝9 小时前
仓储管理系统设计与实现
python·word·markdown
BU摆烂会噶9 小时前
【LangGraph】House_Agent 实战(一):架构与环境配置
人工智能·vscode·python·架构·langchain·人机交互
测试员周周10 小时前
【Appium 系列】第15节-视觉测试 — 截图、对比、视觉回归
人工智能·python·数据挖掘·回归·appium·测试用例·测试覆盖率
BU摆烂会噶10 小时前
【LangGraph】House_Agent 实战(五):持久化、流式输出与部署
人工智能·python·架构·langchain·人机交互
少年强则国强10 小时前
安装配置Claude
python
机汇五金_10 小时前
深圳电脑机箱厂家
python
WL_Aurora10 小时前
Python爬虫实战(七):Selenium自动化采集苏宁易购商品数据
爬虫·python·selenium