Van♂Python | 🐰给Markdown转换工具套个 "壳"

2,155 阅读8分钟

我正在参加「兔了个兔」创意投稿大赛,详情请看:「兔了个兔」创意投稿大赛

引言

上节笔者带着大家写了一个Markdown转换工具:

支持将Markdown中的图片下载到 本地或上传到自己的图床,并自动替换原始原始链接

有读者反馈:工具 对于普通用户不太友好,为了用这个工具还得另外安装Python运行环境啥的。

想想也是,每次转换都得跑下代码,呆不说,也不方便跟亲朋好友 Share(装逼),上来就一堆代码甩人家脸上?

人靠衣装马靠鞍,工具再好,用起来不方便,也是瞎掰扯。

所以本节要做的事情就是给我们的 Markdown转换工具 加个图形化界面打包成exe文件


1、加个图形化界面

Python中的GUI库还挺多,具体有哪些及比较,可自行翻阅《你心中评分最高的 python gui 库是什么?

哪个好用 见仁见智,笔者个人观点:复杂点页面 → PyQt5 (资料多,可定制性强),简单点的页面 → PySimpleGUI (使用简单、文档和Demo较多)。这里我们写的工具比较简单,直接上 PySimpleGUI

PySimpleGUI官方文档PySimpleGUI User's Manual

pip装下库:

pip install pysimplegui

Copy下官方Demo,简单体验下:

import PySimpleGUI as sg

if __name__ == '__main__':
    # 设置主题
    sg.theme('DarkAmber')
    # 定义窗口内容
    layout = [
        [sg.Text('Some text on Row 1')],
        [sg.Text('Enter something on Row 2'), sg.InputText()],
        [sg.Button('Ok'), sg.Button('Cancel')]
    ]
    # 创建窗口
    window = sg.Window('Window Title', layout)
    # 开始事件循环,处理Event,获得input输入的值
    while True:
        event, values = window.read()
        # 如果点击窗口关闭或者取消按钮
        if event == sg.WIN_CLOSED or event == 'Cancel':
            break
        print('You entered ', values[0])
    # 关闭窗口
    window.close()

运行看下效果:

不难看出基本玩法:定义布局创建窗口开始事件循环事件判断关闭窗口,限于篇幅,直接跳过熟悉控件的环节,具体控件API读者自己翻文档哈,直接开始DIY我们的工具页面~


① 主题

上面调用 sg.theme('DarkAmber') 设置了黄黑色的主题,不是很喜欢,可以调用 sg.theme_list() 打印出可选主题列表,也可以调用 sg.theme_previewer() 预览所有内置主题:

新年新气象,今年还是兔年,不得整个红色的!DarkRedDarkRed1 两个色值看着不太行,自己配下色?直接抄:

这两个红看着不戳,取色值拿下色值,代码里设置一下:

# 主题初始化
def theme_init():
    sg.theme_background_color("#CC2529")  # 窗口背景色
    sg.theme_text_color("#EFF0DB")  # 文本框字体颜色
    sg.theme_text_element_background_color("#CC2529")  # 文本框背景颜色
    sg.theme_button_color("#E35439")  # 按钮背景颜色
    sg.theme_element_background_color("#CC2529")    # 设置结点背景颜色

运行看下效果:

这logo也不行,整个兔子的:

sg.set_global_icon("rabbit.ico")    # 设置窗口图标

顺带改下窗口标题,运行看下效果:


② 功能面板

主题设置完,开始搞页面,怎么简单怎么来,主页的话,直接:一个文本 + 四个按钮,代码如下:

# 程序主页面
def main_gui():
    layout_control = [
        [sg.Text('功能面板', size=[50, 1])],  # 宽度设置50个字符,把窗口撑开,不然会缩成一团
        [sg.Button('转换为本地图片', key='md_to_local')],
        [sg.Button('转换为自己的图床', key='md_to_server')],
        [sg.Button('应用设置', key='app_settings')],
        [sg.Button('退出程序', key='exit_app')],
    ]
    window_main = sg.Window('兔了个兔MD 转换工具', layout_control)
    while True:
        event, value = window_main.read()
        if event == 'exit_app' or event == sg.WIN_CLOSED:
            break
        if event == 'md_to_local':
            md_to_local()
        if event == 'md_to_server':
            md_to_server()
        if event == 'app_settings':
            app_settings()
    window_main.close()

这里笔者为每个按钮单独设置了一个 key,方便后续event判定执行对应逻辑。不设置也可以,匹配的话就改成 匹配按钮文本 (如event == "转换为本地图片")。运行看下效果:


③ 转换为本地图片

界面同样简洁:文件选择器 + 显示路径的文本 + 转换按钮 + 输出信息的多行文本,使用 Frame 划分区域,布局代码如下:

def md_to_local():
    layout_control = [
        [
            sg.Frame(layout=[
                [sg.FileBrowse("选择文件", key='file_browser', enable_events=True)]
            ], title="请选择要转换的.md文件"),
            sg.Frame(layout=[
                [sg.Text("", key="file_path")]
            ], title="当前选中文件"),
            sg.Button("开始转换", key="transform")
        ],
        [
            sg.Frame(layout=[[sg.Multiline("", key='console_output', size=(80, 16))]], title="输出信息"),
        ]
    ]
    window_panel = sg.Window('转换为本地图片', layout_control)

运行看下效果:

还行,接着添加具体的事件处理,先是 选择文件:判断选中的文件是否以.md结尾,不是弹窗告知,是回显文件地址

    while True:
        event, value = window_panel.read()
        if event == sg.WIN_CLOSED:
            break
        if event == 'file_browser':
            # 判断选择的是否为md文件,不是弹窗
            file_path = value[event]
            if file_path.endswith('.md'):
                window_panel['file_path'].update(file_path)
            else:
                sg.popup("错误", "请选择.md结尾的文件")

运行效果如下:

这里整了个 输出信息的多行文本Multiline,因为最后会把工具打包成exe,就没地方看输出日志了。

另外,瞄了一眼它好像没提供追加文字的方法,只有一个 update(),调用它新的输出信息会直接覆盖旧的信息。为了避免丢失,简单封装个方法,update前获取文本追加:

# 输出日志信息的方法
def console_output(multiline, new_content):
    old_content = multiline.DefaultText
    multiline.update("{}\n{}".format(old_content, new_content))

然后上节写的:转换及下载图片的方法,涉及到日志打印部分的代码要稍微改动下:

def pic_to_local(match_result, pic_save_dir, multiline):
    console_output(multiline, "替换前的图片路径:" + match_result[2])
    # 生成新的图片名
    img_file_name = "{}_{}.{}".format(int(round(time.time())), order_set.pop(), match_result[3])
    console_output(multiline, "新的文件名:" + img_file_name)
    # 拼接图片相对路径(Markdown用到的)
    relative_path = 'images/{}'.format(img_file_name)
    # 拼接图片绝对路径,下载到本地
    absolute_path = os.path.join(pic_save_dir, img_file_name)
    # 顺带下载图片
    loop.run_until_complete(download_pic(absolute_path, match_result[2], multiline))
    # 还需要拼接前后括号()
    return "{}{}{}".format(match_result[1], relative_path, match_result[4])


# 下载图片的方法
async def download_pic(pic_path, url, multiline, headers=None):
    try:
        if headers is None:
            headers = default_headers
        if url.startswith("http") | url.startswith("https"):
            if os.path.exists(pic_path):
                console_output(multiline, "图片已存在,跳过下载:%s" % pic_path)
            else:
                resp = await requests.get(url, headers=headers)
                console_output(multiline, "下载图片:%s" % resp.url)
                if resp is not None:
                    if resp.status != 404:
                        async with aiofiles.open(pic_path, "wb+") as f:
                            await f.write(await resp.read())
                            console_output(multiline, "图片下载完毕:%s" % pic_path)
                    else:
                        console_output(multiline, "图片不存在:{}".format(url))
        else:
            console_output(multiline, "图片链接格式不正确:%s - %s" % (pic_path, url))
    except Exception as e:
        console_output(multiline, "下载异常:{}\n{}".format(url, e))

可以,最后就是点击开始转换时,把MD文件本地化了:

if event == 'transform':
    # 清空多行文本框输出
    window_panel['console_output'].update("")
    file_path = window_panel['file_path'].DisplayText
    if len(file_path) == 0 or not file_path.endswith(".md"):
        sg.popup("错误", "请选择.md结尾的文件")
    else:
        # 获取文件名,构建新文件夹
        file_name = os.path.basename(file_path)
        new_file_dir = os.path.join(output_dir, file_name[:-3])
        new_file_path = os.path.join(new_file_dir, file_name)
        images_dir = os.path.join(new_file_dir, "images")
        if not os.path.exists(new_file_dir):
            os.makedirs(new_file_dir)
        if not os.path.exists(images_dir):
            os.makedirs(images_dir)
        # 读取旧md文件内容,执行批量替换
        old_content = read_file_text_content(file_path)
        new_content = pic_match_pattern.sub(
            partial(partial(pic_to_local, pic_save_dir=images_dir), multiline=window_panel['console_output']),
            old_content)
        write_text_to_file(new_content, new_file_path)
        console_output(window_panel['console_output'], "转换成功,输出文件:%s" % new_file_path)
        # 输出完毕滚动到底部
        window_panel['console_output'].TKText.see("end")

运行看下效果:

文件已经保存到本地:

打开MD文件看自动转换结果是否正确:

完美转换!!!

④ 转换为自己的图床

em... 图床这块暂时没想好要做成什么样,毕竟图床有那么多,读者可以评论区留言下自己在的图床吧,到时统计一波。

另外,有部分读者连图床是什么都不知道,本地化已经够它们用了,后续再说吧,这里皮一下,简单弹个窗~

def md_to_server():
    sg.popup("非常抱歉", "这个功能正在做了 (进度0%)")

④ 应用设置

em...好像没啥好设置的,意思意思弄个版本号 + 点击跳转笔者Github吧~

import webbrowser

def app_settings():
    layout_control = [
        [
            sg.Text('应用设置', size=[40, 1])  # 宽度设置50个字符,把窗口撑开
        ],
        [
            sg.Frame(layout=[
                [sg.Text("v0.0.1")]
            ], title="版本号"),
            sg.Frame(layout=[
                [sg.Text("https://github.com/coder-pig", enable_events=True, text_color="#0000FF", key="github")]
            ], title="Github地址")
        ]
    ]
    window_panel = sg.Window('兔了个兔 MD转换工具', layout_control)
    while True:
        event, value = window_panel.read()
        if event == sg.WIN_CLOSED:
            break
        if event == "github":
            webbrowser.open("https://github.com/coder-pig")
    window_panel.close()

运行效果如下:

基本的界面写得差不多了,接着折腾下打包~


2、打包成exe

使用 pyinstaller 库来打包,pip装下库:

pip install pyinstaller

pyinstaller的 常用参数如下

  • –i:图标路径
  • -F:把程序和所有依赖的库打包成一个exe文件,启动较慢
  • -D:创建一个目录,里面包含exe及依赖的库对应的文件,启动较快
  • -w:使用窗口,无控制台
  • -c:使用控制台,无窗口
  • -h:查看参数

这里因为我们把代码全写到一个py文件中,所以只需要单独打包这个py文件,直接执行命令:

pyinstaller -F -i rabbit.ico -w app_gui.py

静待打包成功,来到 dist 目录下可以看到生成的exe文件:

卧槽,小小工具近27mb?是的,因为打了很多没用到的库进去。尝试 切到虚拟环境下打包

大小缩减一半,另外 减少非必要引用也能减少体积,能from xxx import xxx,就不要import xxx。

双击执行exe文件,遇到下述错误:

ImportError: The 'jaraco' package is required; normally this is bundled with xxx

解决方法

将pyinstaller升级到5.1后的版本,使用 pip show pyinstaller 可以查看库版本,使用 pip install pyinstaller==5.1.0 安装指定版本即可。

解决完,赶紧发给没有安装Python环境的胖友验证下(秀一下),启动有点慢,字体显示有些问题,但功能正常。

嫌启动慢的话,可以打包成一个目录:

pyinstaller -D -i rabbit.ico -w app_gui.py

产物如下:

最后顺带说下 如何打包多个py文件,假设我们这个app_gui.py是主文件,还有其他两个文件test1.py和test2.py。

先打包主文件,生成主函数对应的spec文件

pyi-makespec app_gui.py

接着修改生成的spec文件,确保以下两项包含该项目的路径:

接着对spec文件进行pyinstaller安装即可:

pyinstaller app_gui.spec

行吧,就说到这里,想体验这个工具的可自行下载:兔了个兔MD文件转换工具.7z,我把py源码也放里面了,怕exe有毒啥的可以自行编译哈,感谢~