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()