前言
起因是我想整理一下今年看的长片,发个长微博,准备稍微上点格式,于是研究了一下在线md编辑器(本着折腾的精神,当然是要部署在家庭服务器上的),最后部署了StackEdit的中文版。它可以直接用github同步,而github可能是唯一支持非https oauth2和callback的服务,为什么啊?
但随即我发现网络上并没有特别好用的,能把MD文件直接渲染成长图的工具——为什么?一些仅有的选项效果非常丑陋,功能也很残缺。所以我决定自己动手,写一个Docker镜像,能一键部署、一键转换,而且要支持以下特性:
其中后两个的优先级不高,就先这样吧。
结构和依赖
和以前一样,整个应用是用Python写的,前端使用Flask。使用mistune来将md文件解析为html(比markdown好用了十倍甚至九倍),然后用imgkit库将其渲染为图片,imgkit库在渲染时需要命令行工具wkhtmltopdf。最后,使用Pillow库进行图片压缩,提供给用户下载。除此之外,还需要使用命令行工具fontconfig来配置字体,防止wkhtmltopdf在陌生的系统上六亲不认。
最终,整个文件结构如下:
MD2IMG
├─ app.py
├─ Dockerfile
├─ requirements.txt
├─ templates
│ └─ index.html
└─ static
├─ default_light.css
├─ default_night.css
├─ logo.ico
├─ logo.png
├─ script.js
├─ style.css
└─ fonts
└─ 微软雅黑.ttf
其中requirements.txt包括:
Flask mistune imgkit Pillow
Dockerfile:
FROM python:3.8-slim
# 安装 wkhtmltopdf 和 fontconfig
RUN apt-get update && \
apt-get install -y wkhtmltopdf fontconfig && \
apt-get clean
# 设置工作目录
WORKDIR /app
# 复制应用文件
COPY . /app
# 安装 Python 依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制字体文件到系统字体目录并更新字体缓存
COPY static/fonts/*.ttf /usr/share/fonts/
RUN fc-cache -fv
# 暴露端口
EXPOSE 5000
# 运行应用
CMD ["python", "app.py"]
构建基本功能
我们要实现的最基础的功能是:用户提交一个md文件,点一下按钮,获得一张图片。一旦我们实现了这个功能,其他部分不过是修修补补罢了。
为了实现这一点,index.html可以非常简洁:
<!DOCTYPE html> <html> <head> <title>MD转图片</title> </head> <body> <h1>上传MD文件</h1> <form action="/upload" method="post" enctype="multipart/form-data"> <input type="file" name="file" required> <input type="text" name="width" placeholder="宽度"> <input type="submit" value="开始转换"> </form> </body> </html>
app.py:
from flask import Flask, request, render_template, send_file
import mistune
import imgkit
app = Flask(__name__)
@app.route('/', methods=['GET'])
def index():
return render_template('index.html')
@app.route('/upload', methods=['POST'])
def upload_file():
file = request.files['file']
width = request.form.get('width', '800') # 默认宽度为800
if file:
content = file.read().decode('utf-8')
markdown = mistune.create_markdown(plugins=['strikethrough','footnotes','table','url','task_lists','def_list','abbr','mark','insert','superscript','subscript','math','ruby','spoiler'])
html = markdown(content)
options = {
'format': 'png',
'quality': '100',
'width' : int(width)
}
img = imgkit.from_string(html, False, options=options)
with open("output.png", "wb") as f:
f.write(img)
return send_file("output.png", as_attachment=True)
return 'No file provided', 400
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')
需要注意的是,width参数只接受int类型。wkhtmltopdf还有一个参数是“disable-smart-width”,正常不用管即可,即使这样会让最终生成的图片略宽一些。因为wkhtmltopdf会智能判断渲染内容需要多宽的距离,或多或少都会添一点避免出界。如果设置为空字符串禁用智能宽度的话,一般都不会效果更好。
图片压缩/自动删除
直接让imgkit就这么输出图片,会得到一个巨大的无损png。以我用来测试的不太长的影评MD文件而言,生成了20多M的图片,这不仅不太适合发微博,对网络和服务器存储也都不太友好。所以我们要趁着应用结构还很简单,先加入两个功能:使用Pillow压缩文件,不要让生成的东西太吓人;生成图片一段时间后,把文件删除。
对于前者,我们在生成了img实例后,将其保存到/tmp文件夹,然后调用Pillow的Image库将其压缩:
from PIL import Image
# ...已经生成了img实例
# 确保 tmp 目录存在
os.makedirs('tmp', exist_ok=True)
# 将图片保存到磁盘,打一个时间戳
output_path = f'tmp/output_{int(time.time())}.png'
with open(output_path, 'wb') as f:
f.write(img)
# 读取图片并压缩
image = Image.open(output_path)
compress_png_path = output_path.replace('.png', '_compressed.png')
image.save(compress_png_path, 'PNG', optimize=True) # 使用optimize选项压缩PNG
# 返回文件
return send_file(compress_png_path, mimetype='image/png', as_attachment=True)
对于后者,可以调用Python的threading模块,在生成图片后发起一个线程,以删除图片。由于我们之前的操作,应用事实上生成了两个图片文件,两个都要删除。
import threading
def delete_file(path):
"""等待指定时间后删除文件"""
def delay_delete():
os.remove(path)
timer = threading.Timer(1800, delay_delete) # 设置定时器,1800秒后执行delay_delete函数
timer.start()
# 在返回文件前调用
delete_file(output_path) # 调用delete_file函数安排删除
delete_file(compress_png_path)
页面美化,添加功能
接下来我们就可以考虑一点美化的事了。
首先,把index.html修一修,加入一些样式,加入调节字体大小的文本框、黑夜模式开关以及输入附加CSS的文本框。还要加入拖拽md文件的功能,避免每次都打开那个弱智一样的文件对话框。
<!DOCTYPE html>
<html>
<head>
<title>MD2IMG</title>
<link rel="stylesheet" type="text/css" href="/static/style.css">
<link rel="icon" type="image/x-ico" href="/static/logo.ico" />
</head>
<body>
<div class="container">
<h1>Markdown to IMG</h1>
<form action="/upload" method="post" enctype="multipart/form-data">
<div class="file-drop-area">
<span class="custom-file-button">选择/拖拽.md文件</span>
<input type="file" name="file" class="file-input" accept=".md" required>
</div>
<input type="text" name="width" placeholder="宽度(默认600px)">
<input type="text" name="font_size" placeholder="字号(默认24px)">
<div class="switch-container">
<label class="switch-label">黑夜模式</label>
<label class="switch">
<input type="checkbox" name="mode" checked>
<span class="slider round"></span>
</label>
</div>
<textarea name="custom_css" placeholder="自定义CSS"></textarea>
<input type="submit" value="导出图片">
</form>
</div>
</body>
</html>
<script>
const fileInput = document.querySelector('.file-input');
const dropArea = document.querySelector('.file-drop-area');
const fileButton = dropArea.querySelector('.custom-file-button');
function updateDropArea(file) {
fileButton.textContent = file.name; // 更新文本为文件名
dropArea.style.backgroundColor = '#cce5ff'; // 更改为亮蓝色背景
}
dropArea.addEventListener('dragover', (event) => {
event.stopPropagation();
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
});
dropArea.addEventListener('dragleave', (event) => {
dropArea.style.backgroundColor = '#f8f9fa'; // 恢复原始背景色
});
dropArea.addEventListener('drop', (event) => {
event.stopPropagation();
event.preventDefault();
const files = event.dataTransfer.files;
fileInput.files = files;
if (files.length > 0) {
updateDropArea(files[0]);
}
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length > 0) {
updateDropArea(fileInput.files[0]);
}
});
</script>
加上一个能看的样式表:
/* static/style.css */
body {
font-family: 'Arial', sans-serif;
background-color: #f4f4f9;
margin: 0;
padding: 0px 30px 0px;
display: flex;
justify-content: flex-start;
align-items: center;
height: 100vh;
flex-direction: column;
}
.container {
background: white;
padding: 10px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
width: 60%; /* 设置宽度为屏幕的60% */
max-width: 800px;
min-width: 400px;
margin-top: 2%; /* 在顶部添加5%的间距 */
}
h1 {
color: #333;
text-align: center;
}
form {
display: flex;
flex-direction: column;
}
input[type="file"] {
border: 2px solid #ddd;
padding: 10px;
border-radius: 5px;
margin-bottom: 20px;
cursor: pointer;
}
input[type="text"] {
border: 2px solid #ddd;
padding: 10px;
border-radius: 5px;
margin-bottom: 20px;
}
input[type="submit"] {
background-color: #007BFF;
color: white;
border: none;
padding: 12px 20px;
font-size: 16px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
}
input[type="submit"]:hover {
background-color: #0056b3;
}
.file-drop-area {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 50px;
border: 2px dashed #007BFF;
border-radius: 5px;
margin-bottom: 20px;
background-color: #f8f9fa;
transition: background-color 0.3s;
}
.file-drop-area:hover {
background-color: #e2e6ea;
}
.file-input {
position: absolute;
width: 100%;
height: 100%;
cursor: pointer;
opacity: 0;
}
.custom-file-button {
text-align: center;
pointer-events: none;
user-select: none;
padding: 10px;
font-weight: bold;
color: #007BFF;
}
/* 开关样式 */
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #2196F3;
}
input:checked + .slider:before {
transform: translateX(26px);
}
textarea {
width: 100%; /* 设置宽度为100%以填满容器 */
height: 200px; /* 设置一个固定高度 */
padding: 10px; /* 增加内边距 */
border: 2px solid #ddd; /* 设置边框 */
border-radius: 5px; /* 圆角边框 */
box-sizing: border-box; /* 包含边框和内边距在内的总宽高 */
margin-bottom: 20px; /* 底部外边距 */
transition: border-color 0.3s; /* 平滑过渡效果 */
resize:none;
}
textarea:focus {
border-color: #007BFF; /* 聚焦时边框颜色变化 */
}
.switch-container {
display: flex;
align-items: center; /* 垂直居中 */
margin-bottom: 20px; /* 底部外边距 */
}
/* 为开关增加标签样式 */
.switch-label {
margin-right: 10px;
font-size: 16px;
color: #666;
}
/* 调整开关位置使之垂直居中 */
.switch {
vertical-align: middle;
margin-left: 5px;
}
这样一来,我们就有了一个比较能看的页面,可以在上面修改字号、打开黑夜模式,甚至为渲染.md文件添加自定义CSS。当然,实现这些功能的后端还没有完成。
为渲染.md添加样式
字号、黑夜模式、自定义CSS本质上都是一件事:如何在渲染.md文件时添加一个样式表。
基本的思路是这样的:准备两个CSS文件,分别对应白天和黑夜模式,通过获取开关的状态,选择其中一个成为基础css。然后分别将自定义CSS和字体大小附加在其后,形成一个整体的样式表,将其嵌入生成的html结构中,即可实现对.md文件渲染样式的控制。
@app.route('/upload', methods=['POST'])
file = request.files['file']
width = request.form.get('width', '600') # 获取用户指定的宽度
if width == "":
width = "600"
font_size = request.form.get('font_size', '24') # 默认字体大小为24px
if font_size == "":
font_size = "24"
mode = 'night' if request.form.get('mode') == 'on' else 'day' # 根据复选框状态设定模式
custom_css = request.form.get('custom_css', '') # 获取用户自定义CSS
if file:
content = file.read().decode('utf-8')
if not content.startswith('\n'):
content = "\n"+content
if mode == 'day':
css = get_css("default_light.css")
else:
css = get_css("default_night.css")
combined_css = css + custom_css + f"body {{font-size:{font_size}px;}}"
markdown = mistune.create_markdown(plugins=['strikethrough','footnotes','table','url','task_lists','def_list','abbr','mark','insert','superscript','subscript','math','ruby','spoiler'])
html = markdown(content)
# HTML添加样式
html = f'<html><head><style>{combined_css}</style></head><body><div style="width: {width}px;">{html}</div></body></html>'
/* 渲染生成图片 */
添加.md实时预览
为了隔离页面CSS对md预览的影响,在页面中加入一个iframe框架,用于盛放根据md生成的html内容:
<div style="padding-top:40px" id="preview-area">
<iframe id="markdown-preview"></iframe>
</div>
同时加入相应的js代码,当文件/宽度/字号/黑夜模式/自定义CSS等内容发生变动的时候,将一应信息提交到后端,再将返回的html嵌入到iframe元素中,同时,调整iframe元素的宽度。
// 渲染预览
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.querySelector('input[type="file"]');
const inputs = document.querySelectorAll('input, textarea');
const previewFrame = document.getElementById('markdown-preview'); // iframe元素
let markdownText = ''; // 存储读取的Markdown文本
fileInput.addEventListener('change', function() {
const file = fileInput.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
markdownText = e.target.result;
console.log("Markdown text loaded:", markdownText); // 调试输出
updatePreview(); // 更新预览
};
reader.readAsText(file);
}
});
async function updatePreview() {
const formData = new FormData();
formData.append('markdown_text', markdownText);
formData.append('width', document.querySelector('input[name="width"]').value);
formData.append('font_size', document.querySelector('input[name="font_size"]').value);
formData.append('mode', document.querySelector('input[name="mode"]').checked ? 'on' : 'off');
formData.append('custom_css', document.querySelector('textarea[name="custom_css"]').value);
console.log("Attempting to send data to server."); // 确认发送前的调试输出
const response = await fetch('/preview', {
method: 'POST',
body: formData
});
if (response.ok) {
const text = await response.text();
const blob = new Blob([text], {type: 'text/html'});
const url = URL.createObjectURL(blob);
previewFrame.style.width = '0px'; // 重置宽度
previewFrame.style.height = '0px'; // 重置高度
previewFrame.src = url; // 设置iframe的src属性
previewFrame.onload = function() {
setTimeout(() => {
try {
const body = previewFrame.contentWindow.document.body;
const html = previewFrame.contentWindow.document.documentElement;
const height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight);
const width = Math.max(body.scrollWidth, body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth);
previewFrame.style.height = height + 'px';
previewFrame.style.width = width + 'px'; // 设置宽度
} catch (e) {
console.error('Error adjusting iframe dimensions:', e);
}
}, 0);
};
} else {
previewFrame.src = "data:text/html,Error loading preview";
console.error("Failed to fetch preview:", response.statusText); // 调试输出错误
}
}
inputs.forEach(input => input.addEventListener('change', updatePreview));
});
在后端app.py中加入相应的路由,处理提交的数据,返回html。
@app.route('/preview', methods=['POST'])
def preview():
markdown_text = request.form.get('markdown_text', 'Default markdown text if not received')
width = request.form.get('width', '600')
if width == "":
width = "600"
font_size = request.form.get('font_size', '24')
if font_size == "":
font_size = "24"
mode = 'night' if request.form.get('mode') == 'on' else 'day'
custom_css = request.form.get('custom_css', '')
if mode == 'day':
css = get_css("default_light.css")
else:
css = get_css("default_night.css")
combined_css = css + custom_css + f"body {{font-size:{font_size}px;}}"
# 渲染Markdown为HTML
markdown = mistune.create_markdown(plugins=['strikethrough','footnotes','table','url','task_lists','def_list','abbr','mark','insert','superscript','subscript','math','ruby','spoiler'])
html = markdown(markdown_text)
# 返回完整的HTML页面内容
return f'<html><head><style>{combined_css}</style></head><body><div style="width: {width}px;">{html}</div></body></html>'
打包运行,即得到了一个丝滑的MD转图片工具。




