Markdown转Html文件带目录可合并

1.需求

1.1、将markdown文件,转换为带目录大纲的html文件

1.2、将多个html文件合并为一个文件

2. 设计

2.1 参考

VSCode+Markdown Preview Enhanced插件导出HTML侧边栏目录_vscode markdown导出html-CSDN博客

2.2 参考

怎样将以保存下来多个.html文件合并在一起生成1个.html文件?

百度安全验证

2.3 设计程序

采用Cursor+Claude 编程

3. 运行效果

4.程序

https://nnoomm.lanzouu.com/iQPXZ2rwrzod

5.代码

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# 脚本名称: merge_html.py
# 功能描述:
#   本脚本用于合并用户选择的多个 HTML 或 Markdown 文件到一个单独的 HTML 文件中。
#   用户可以通过图形界面选择 HTML 或 Markdown 文件,并调整文件顺序,
#   脚本会先将 Markdown 文件转换为 HTML,然后将所有 HTML 内容合并到一个文件中,
#   并生成一个包含所有文件标题和子标题的目录。
#   合并后的 HTML 文件具有可交互的侧边栏目录,支持展开/折叠和目录点击跳转功能。
#
# 主要特点:
#   1. 支持 HTML 和 Markdown 文件的混合合并
#   2. 支持调整文件顺序(上移/下移)
#   3. 自动生成侧边栏目录,包含所有文件的标题和子标题
#   4. 目录支持展开/折叠功能,方便浏览
#   5. 目录点击跳转到对应内容,并提供高亮效果
#   6. 侧边栏宽度可通过拖动调整
#   7. 自动检测文件编码,处理各种编码格式的文件
#
# 注意事项:
#   1. 脚本依赖于 `BeautifulSoup4`、`chardet`、`tkinter` 和 `markdown` 库,请确保已安装这些库。
#      您可以使用 `pip install beautifulsoup4 chardet markdown` 命令来安装。
#   2. 合并后的文件名为 `merged_output.html`,如果已存在同名文件,将会被覆盖。
#   3. 脚本会尝试自动检测文件的编码格式,但建议所有文件使用 UTF-8 编码,以避免乱码问题。
#   4. 目录是基于 HTML 文件中的 h1-h6 标签生成的,请确保您的文件使用了这些标题标签来组织内容。
#   5. 侧边栏的宽度可以通过拖动调整,默认宽度为 280px,最小 200px,最大 400px。
#   6. 如果在处理文件过程中出现错误,会在控制台输出错误信息,并继续处理下一个文件。
#   7. 对于Markdown文件,脚本会尝试模拟Markdown Preview Enhanced插件的样式和目录功能。
#   8. 点击左侧目录中的条目,会自动跳转到对应的内容位置,并短暂高亮显示。
#
# 使用方法:
#   1. 运行脚本后,通过"选择Markdown文件"或"选择HTML文件"按钮添加要合并的文件
#   2. 使用"上移"和"下移"按钮调整文件顺序
#   3. 点击"合并生成HTML文件"按钮,选择保存位置后生成合并后的HTML文件
#   4. 合并完成后,脚本会自动打开生成的HTML文件
#
# 打包方法:
#   pyinstaller --onedir --windowed --name "HTML合并工具" merge_html.py

import os
import glob
import chardet
import markdown
import re
from bs4 import BeautifulSoup
import tkinter as tk
from tkinter import filedialog, Listbox, Scrollbar, Button, END, MULTIPLE, messagebox

def detect_encoding(file_path):
    """检测文件编码"""
    with open(file_path, 'rb') as file:
        raw_data = file.read()
        result = chardet.detect(raw_data)
        return result['encoding'] or 'utf-8'

def convert_markdown_to_html(file_path):
    """
    将Markdown文件转换为HTML
    
    参数:
        file_path (str): Markdown文件路径
    
    返回:
        str: 转换后的HTML内容
    """
    try:
        # 检测文件编码
        file_encoding = detect_encoding(file_path)
        
        # 读取Markdown内容
        with open(file_path, 'r', encoding=file_encoding, errors='replace') as f:
            md_content = f.read()
        
        # 检查是否包含YAML front matter
        yaml_pattern = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL)
        yaml_match = yaml_pattern.search(md_content)
        
        toc_enabled = False
        if yaml_match:
            yaml_content = yaml_match.group(1)
            # 从YAML中提取toc设置
            if 'toc: true' in yaml_content:
                toc_enabled = True
            # 移除YAML front matter
            md_content = md_content[yaml_match.end():]
        
        # 使用Python-Markdown转换为HTML
        html_content = markdown.markdown(
            md_content, 
            extensions=[
                'markdown.extensions.tables',
                'markdown.extensions.fenced_code',
                'markdown.extensions.codehilite',
                'markdown.extensions.toc',
                'markdown.extensions.nl2br'
            ]
        )
        
        # 创建完整的HTML结构
        full_html = f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>{os.path.basename(file_path)}</title>
    <style>
        .markdown-body {{
            box-sizing: border-box;
            min-width: 200px;
            max-width: 980px;
            margin: 0 auto;
            padding: 45px;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
            font-size: 16px;
            line-height: 1.5;
            word-wrap: break-word;
        }}
        pre {{
            background-color: #f6f8fa;
            border-radius: 3px;
            padding: 16px;
            overflow: auto;
        }}
        code {{
            background-color: #f6f8fa;
            padding: 0.2em 0.4em;
            border-radius: 3px;
        }}
        table {{
            border-collapse: collapse;
            width: 100%;
        }}
        table, th, td {{
            border: 1px solid #dfe2e5;
            padding: 6px 13px;
        }}
        th {{
            background-color: #f6f8fa;
        }}
        tr:nth-child(even) {{
            background-color: #f6f8fa;
        }}
    </style>
</head>
<body>
    <div class="markdown-preview">
        {html_content}
    </div>
</body>
</html>"""
        
        return full_html
        
    except Exception as e:
        print(f"转换Markdown文件 {file_path} 时出错: {e}")
        print(f"错误详情: {str(e)}")
        return f"<html><body><h1>Error converting {os.path.basename(file_path)}</h1><p>{str(e)}</p></body></html>"

def merge_html_files(file_paths, output_file='merged_output.html'):
    """
    合并指定的 HTML 或 Markdown 文件列表到一个文件中。

    参数:
        file_paths (list): HTML 或 Markdown 文件路径列表。
        output_file (str): 输出文件名,默认为 'merged_output.html'。
    """
    # 生成目录和内容
    table_of_contents = []
    file_contents = []
    
    for idx, file_path in enumerate(file_paths, 1):
        try:
            # 判断文件类型(Markdown或HTML)
            is_markdown = file_path.lower().endswith(('.md', '.markdown'))
            
            # 如果是Markdown文件,先转换为HTML
            if is_markdown:
                print(f"处理Markdown文件 {file_path}")
                content = convert_markdown_to_html(file_path)
            else:
                # 检测文件编码
                file_encoding = detect_encoding(file_path)
                print(f"处理HTML文件 {file_path},编码: {file_encoding}")
                with open(file_path, 'r', encoding=file_encoding, errors='replace') as f:
                    content = f.read()
            
            soup = BeautifulSoup(content, 'html.parser')
                
            # 创建每个文件的目录部分
            file_toc = []
            file_toc.append(f'<div class="file-section">')
            file_toc.append(f'<div class="file-section-title">{os.path.basename(file_path)}</div>')
            file_toc.append(f'<ul class="custom-toc">')
            
            # 提取内容
            content_body = soup.find('div', class_='markdown-preview')
            if not content_body:
                content_body = soup.find('body') or soup
            
            # 处理目录 - 提取标题元素并创建目录
            headings = content_body.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
            
            # 修正所有标题的ID 
            for heading_idx, heading in enumerate(headings):
                # 给每个标题生成一个唯一ID
                heading_id = f"heading-file{idx}-{heading_idx}"
                heading['id'] = heading_id
                
                # 确定标题级别
                level = int(heading.name[1])
                
                # 添加目录项
                file_toc.append(
                    f'<li class="custom-toc-item level-{level}">'
                    f'<a href="#{heading_id}" class="custom-toc-link">{heading.get_text().strip()}</a>'
                    f'</li>'
                )
            
            file_toc.append('</ul>')
            file_toc.append('</div>')
            table_of_contents.append('\n'.join(file_toc))
            
            # 添加文件内容
            file_contents.append(
                f'<div class="file-content" id="file{idx}">'
                f'<div class="file-header">{os.path.basename(file_path)}</div>'
                f'{str(content_body)}'
                f'</div>'
            )
            
        except Exception as e:
            print(f"处理文件 {file_path} 时出错: {e}")
            print(f"错误详情: {str(e)}")
            continue
    
    # HTML模板
    html_template = '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>合并的文档</title>
    <style>
        /* 基础样式 */
        body, html {{
            margin: 0; 
            padding: 0; 
            height: 100%;
            overflow: hidden;
            font-family: Arial, sans-serif;
        }}
        .container {{
            display: flex;
            height: 100vh;
            position: relative;
        }}
        .sidebar {{
            width: 280px;
            min-width: 200px;
            max-width: 400px;
            background-color: #f4f4f4;
            padding: 20px;
            overflow-y: auto;
            border-right: 1px solid #ddd;
            box-sizing: border-box;
        }}
        .content {{
            flex: 1;
            overflow-y: auto;
            padding: 20px;
            box-sizing: border-box;
        }}
        .resizable-handle {{
            width: 5px;
            background-color: #ddd;
            cursor: col-resize;
            position: absolute;
            left: 280px;
            top: 0;
            bottom: 0;
            z-index: 10;
        }}
        
        /* 文件标题 */
        .file-section-title {{
            font-size: 16px;
            font-weight: bold;
            margin: 15px 0 5px 0;
            padding: 5px 0;
            border-bottom: 1px solid #ddd;
            color: #333;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: space-between;
        }}
        .file-section-title:hover {{
            background-color: #e8e8e8;
        }}
        .file-section-title::after {{
            content: "▼";
            font-size: 12px;
            margin-left: 5px;
            transition: transform 0.3s;
        }}
        .file-section-title.collapsed::after {{
            transform: rotate(-90deg);
        }}
        
        /* 目录样式 */
        .custom-toc {{
            margin: 0;
            padding: 0;
            list-style-type: none;
            font-size: 14px;
            line-height: 1.2;
            overflow: hidden;
            max-height: 2000px;
            transition: max-height 0.3s ease-in-out;
        }}
        .custom-toc.collapsed {{
            max-height: 0;
        }}
        .custom-toc-item {{
            margin: 2px 0;
        }}
        .custom-toc-link {{
            text-decoration: none;
            color: #333;
            display: block;
            padding: 2px 0;
            cursor: pointer;
        }}
        .custom-toc-link:hover {{
            color: #0066cc;
            text-decoration: underline;
        }}
        .level-1 {{ margin-left: 0; }}
        .level-2 {{ margin-left: 12px; }}
        .level-3 {{ margin-left: 24px; }}
        .level-4 {{ margin-left: 36px; }}
        .level-5 {{ margin-left: 48px; }}
        
        /* 工具栏 */
        .toolbar {{
            display: flex;
            justify-content: space-between;
            padding: 10px 0;
            border-bottom: 1px solid #ddd;
            margin-bottom: 15px;
        }}
        .toolbar button {{
            background-color: #f0f0f0;
            border: 1px solid #ccc;
            padding: 5px 10px;
            border-radius: 3px;
            cursor: pointer;
        }}
        .toolbar button:hover {{
            background-color: #e0e0e0;
        }}
        
        /* 内容样式 */
        .file-content {{
            margin-bottom: 30px;
            padding: 20px;
            border: 1px solid #eee;
            border-radius: 5px;
        }}
        .file-header {{
            font-size: 1.5em;
            font-weight: bold;
            margin-bottom: 15px;
            padding-bottom: 10px;
            border-bottom: 2px solid #eee;
        }}
        
        /* 高亮效果 */
        .highlight {{
            background-color: #ffffd0;
            transition: background-color 2s;
        }}
        
        /* 滚动条样式 */
        ::-webkit-scrollbar {{
            width: 8px;
        }}
        ::-webkit-scrollbar-track {{
            background: #f1f1f1;
        }}
        ::-webkit-scrollbar-thumb {{
            background: #888;
            border-radius: 4px;
        }}
        ::-webkit-scrollbar-thumb:hover {{
            background: #555;
        }}
        
        /* Markdown样式 */
        .markdown-preview {{
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
            font-size: 16px;
            line-height: 1.5;
            word-wrap: break-word;
        }}
        .markdown-preview pre {{
            background-color: #f6f8fa;
            border-radius: 3px;
            padding: 16px;
            overflow: auto;
        }}
        .markdown-preview code {{
            background-color: #f6f8fa;
            padding: 0.2em 0.4em;
            border-radius: 3px;
        }}
        .markdown-preview table {{
            border-collapse: collapse;
            width: 100%;
        }}
        .markdown-preview table, .markdown-preview th, .markdown-preview td {{
            border: 1px solid #dfe2e5;
            padding: 6px 13px;
        }}
        .markdown-preview th {{
            background-color: #f6f8fa;
        }}
        .markdown-preview tr:nth-child(even) {{
            background-color: #f6f8fa;
        }}
    </style>
</head>
<body>
<div class="container">
    <div class="resizable-handle" id="resizeHandle"></div>
    <div class="sidebar" id="sidebar">
        <h2>目录</h2>
        <div class="toolbar">
            <button id="expandAll">展开全部</button>
            <button id="collapseAll">折叠全部</button>
        </div>
        {table_of_contents}
    </div>
    <div class="content" id="content">
        {file_contents}
    </div>
</div>

<script>
document.addEventListener('DOMContentLoaded', function() {{
    // 侧边栏拖动功能
    let isResizing = false;
    let startX = 0;
    let startWidth = 0;
    const sidebar = document.getElementById('sidebar');
    const resizeHandle = document.getElementById('resizeHandle');
    const contentArea = document.getElementById('content');

    resizeHandle.addEventListener('mousedown', function(e) {{
        isResizing = true;
        startX = e.clientX;
        startWidth = sidebar.offsetWidth;
        document.body.style.cursor = 'col-resize';
    }});

    document.addEventListener('mousemove', function(e) {{
        if (!isResizing) return;
        const deltaX = e.clientX - startX;
        const newWidth = Math.max(200, Math.min(400, startWidth + deltaX));
        sidebar.style.width = newWidth + 'px';
        resizeHandle.style.left = newWidth + 'px';
    }});

    document.addEventListener('mouseup', function() {{
        isResizing = false;
        document.body.style.cursor = 'default';
    }});

    // 目录点击跳转事件
    const tocLinks = document.querySelectorAll('.custom-toc-link');
    tocLinks.forEach(link => {{
        link.addEventListener('click', function(e) {{
            e.preventDefault();
            
            // 获取目标ID
            const targetId = this.getAttribute('href').substring(1);
            const targetElement = document.getElementById(targetId);
            
            if (targetElement) {{
                // 移除所有高亮
                document.querySelectorAll('.highlight').forEach(el => {{
                    el.classList.remove('highlight');
                }});
                
                // 添加高亮
                targetElement.classList.add('highlight');
                
                // 滚动到目标元素
                contentArea.scrollTop = targetElement.offsetTop - contentArea.offsetTop - 20;
                
                // 2秒后移除高亮
                setTimeout(() => {{
                    targetElement.classList.remove('highlight');
                }}, 2000);
            }}
        }});
    }});

    // 折叠/展开目录功能
    const sectionTitles = document.querySelectorAll('.file-section-title');
    sectionTitles.forEach(title => {{
        title.addEventListener('click', function() {{
            const tocList = this.nextElementSibling;
            this.classList.toggle('collapsed');
            tocList.classList.toggle('collapsed');
        }});
    }});

    // 全部展开/折叠按钮
    document.getElementById('expandAll').addEventListener('click', function() {{
        sectionTitles.forEach(title => {{
            title.classList.remove('collapsed');
        }});
        document.querySelectorAll('.custom-toc').forEach(toc => {{
            toc.classList.remove('collapsed');
        }});
    }});

    document.getElementById('collapseAll').addEventListener('click', function() {{
        sectionTitles.forEach(title => {{
            title.classList.add('collapsed');
        }});
        document.querySelectorAll('.custom-toc').forEach(toc => {{
            toc.classList.add('collapsed');
        }});
    }});

    // 初始化 - 默认展开所有目录
    document.getElementById('expandAll').click();
    
    // 支持通过URL hash直接跳转到指定位置
    if (window.location.hash) {{
        const targetId = window.location.hash.substring(1);
        const targetElement = document.getElementById(targetId);
        if (targetElement) {{
            setTimeout(() => {{
                contentArea.scrollTop = targetElement.offsetTop - contentArea.offsetTop - 20;
                targetElement.classList.add('highlight');
                setTimeout(() => {{
                    targetElement.classList.remove('highlight');
                }}, 2000);
            }}, 300);
        }}
    }}
}});
</script>
</body>
</html>'''
    
    # 组装最终的HTML
    final_html = html_template.format(
        table_of_contents='\n'.join(table_of_contents),
        file_contents='\n'.join(file_contents)
    )
    
    # 写入输出文件
    with open(output_file, 'w', encoding='utf-8') as f:
        f.write(final_html)
    
    print(f'文件合并完成。输出文件:{output_file}')
    
    # 尝试打开文件
    import platform
    import subprocess
    
    if platform.system() == 'Windows':
        os.startfile(output_file)
    elif platform.system() == 'Darwin':  # macOS
        subprocess.Popen(['open', output_file])
    else:  # Linux
        subprocess.Popen(['xdg-open', output_file])

# GUI 部分
class HTMLMergeGUI:
    def __init__(self, master):
        self.master = master
        master.title("HTML/Markdown 文件合并工具")

        self.file_paths = [] # 存储选择的文件路径列表

        # 文件列表框和滚动条
        self.listbox_frame = tk.Frame(master) 
        self.listbox_frame.pack(pady=10, padx=10, fill=tk.BOTH, expand=True, side=tk.LEFT)

        self.scrollbar = Scrollbar(self.listbox_frame)
        self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y)

        self.listbox = Listbox(self.listbox_frame, selectmode=MULTIPLE, yscrollcommand=self.scrollbar.set, exportselection=False, width=50)
        self.listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        self.scrollbar.config(command=self.listbox.yview)

        # 操作按钮区域
        self.button_frame = tk.Frame(master)
        self.button_frame.pack(pady=10, padx=10, fill=tk.Y, side=tk.RIGHT)

        # 按钮:选择Markdown文件
        self.select_md_button = Button(self.button_frame, text="选择 Markdown 文件", 
                                    command=self.select_markdown_files,
                                    bg="#e6ffe6", font=("Arial", 10), width=20)
        self.select_md_button.pack(pady=5, fill=tk.X)

        # 按钮:合并文件
        self.merge_button = Button(self.button_frame, text="合并生成HTML文件", 
                               command=self.merge_files,
                               bg="#ffe6e6", font=("Arial", 10, "bold"), width=20)
        self.merge_button.pack(pady=5, fill=tk.X)

        # 按钮:选择HTML文件
        self.select_html_button = Button(self.button_frame, text="选择 HTML 文件", 
                                     command=self.select_html_files, 
                                     bg="#e6f2ff", font=("Arial", 10), width=20)
        self.select_html_button.pack(pady=5, fill=tk.X)

        # 按钮:上移
        self.up_button = Button(self.button_frame, text="上移", command=self.move_up, 
                             bg="#f0f0f0", font=("Arial", 10), width=20)
        self.up_button.pack(pady=5, fill=tk.X)

        # 按钮:下移
        self.down_button = Button(self.button_frame, text="下移", command=self.move_down, 
                               bg="#f0f0f0", font=("Arial", 10), width=20)
        self.down_button.pack(pady=5, fill=tk.X)

    def select_html_files(self):
        """打开文件对话框,选择 HTML 文件"""
        self._select_files(
            title="选择 HTML 文件",
            filetypes=(("HTML 文件", "*.html"), ("所有文件", "*.*"))
        )

    def select_markdown_files(self):
        """打开文件对话框,选择 Markdown 文件"""
        self._select_files(
            title="选择 Markdown 文件",
            filetypes=(("Markdown 文件", "*.md *.markdown"), ("所有文件", "*.*"))
        )

    def _select_files(self, title, filetypes):
        """选择文件的通用方法"""
        selected_files = filedialog.askopenfilenames(
            initialdir=".",
            title=title,
            filetypes=filetypes
        )
        if selected_files:
            for file_path in selected_files:
                if file_path not in self.file_paths: # 避免重复添加
                    self.file_paths.append(file_path)
                    # 显示文件名和类型
                    ext = os.path.splitext(file_path)[1].lower()
                    file_type = "[MD]" if ext in ['.md', '.markdown'] else "[HTML]"
                    self.listbox.insert(END, f"{file_type} {os.path.basename(file_path)}")

    def merge_files(self):
        """获取列表框中的文件路径,并执行合并操作"""
        if not self.file_paths:
            messagebox.showinfo("提示", "请先选择要合并的文件。")
            return

        # 获取文件列表,保持用户在 Listbox 中看到的顺序
        files_to_merge = [self.file_paths[i] for i in range(len(self.file_paths))]

        output_file_path = filedialog.asksaveasfilename(
            defaultextension=".html",
            filetypes=(("HTML 文件", "*.html"), ("所有文件", "*.*")),
            title="保存合并后的 HTML 文件",
            initialfile="merged_output.html" # 默认文件名
        )
        if output_file_path:
            merge_html_files(files_to_merge, output_file_path)
            messagebox.showinfo("完成", f"文件已合并并保存到: {output_file_path}")

    def move_up(self):
        """将选中的文件上移"""
        selected_indices = self.listbox.curselection() # 获取选中的索引
        if not selected_indices: # 没有选中任何项
            return

        for index in selected_indices:
            if index > 0: # 如果不是第一项,则可以上移
                file_path = self.file_paths[index] # 获取文件路径
                text = self.listbox.get(index) # 获取列表项文本

                # 删除当前位置的项
                self.listbox.delete(index)
                self.file_paths.pop(index)

                # 在新位置插入项
                insert_index = index - 1
                self.listbox.insert(insert_index, text)
                self.file_paths.insert(insert_index, file_path)

                # 保持选中状态
                self.listbox.selection_set(insert_index)
                self.listbox.see(insert_index) # 确保移动到的项可见
        return 'break' # 阻止默认的 Listbox 行为

    def move_down(self):
        """将选中的文件下移"""
        selected_indices = self.listbox.curselection() # 获取选中的索引
        if not selected_indices: # 没有选中任何项
            return

        last_index = self.listbox.size() - 1 # 最后一项的索引
        for index in reversed(selected_indices): # 反向遍历,避免索引错乱
            if index < last_index: # 如果不是最后一项,则可以下移
                file_path = self.file_paths[index] # 获取文件路径
                text = self.listbox.get(index) # 获取列表项文本

                # 删除当前位置的项
                self.listbox.delete(index)
                self.file_paths.pop(index)

                # 在新位置插入项
                insert_index = index + 1
                self.listbox.insert(insert_index, text)
                self.file_paths.insert(insert_index, file_path)

                # 保持选中状态
                self.listbox.selection_set(insert_index)
                self.listbox.see(insert_index) # 确保移动到的项可见
        return 'break' # 阻止默认的 Listbox 行为

if __name__ == '__main__':
    root = tk.Tk()
    gui = HTMLMergeGUI(root)
    root.geometry("600x400") # 调整窗口大小以适应新按钮
    root.mainloop()

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值