thumbnail
编写一个基于Markdown的长微博生成器

前言

起因是我想整理一下今年看的长片,发个长微博,准备稍微上点格式,于是研究了一下在线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转图片工具。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇