用 wxPython 打造一个「照片文字合成工具」:从 UI 交互到 SQLite 持久化的完整源码解析

前言

做过图片加水印、九宫格证件照排版、朋友圈九图文案这类小工具的同学应该都有过这样的诉求:打开一张照片,在上面随意摆放几行文字,每行文字的字体、字号、颜色、加粗都要能单独调,调完还要能一键导出合并后的大图。市面上现成的工具要么功能太重(PS/GIMP),要么在线工具不支持批量本地化处理。

这篇文章会带你逐行拆解一个用 wxPython 实现的桌面小工具——「照片文字合成工具」的完整实现,重点讲清楚三个工程上比较有意思的问题:

  1. 画布上的文字组件是怎么做到"所见即所得"地拖拽、并且导出的大图和预览完全一致的?
  2. wxPython 里 GraphicsContext 和普通 DC 绘图到底该怎么选?踩过哪些坑?
  3. PyInstaller 打包成 exe 之后,配置文件/数据库路径经常"找不到",这个工具是怎么彻底解决的?

全文代码约 680 行,单文件即可运行,也可以直接 pyinstaller -F -w 打包。文末附完整源码结构图和可扩展方向。


C:\Users\86182\Desktop\通知书生成\noticegen
在这里插入图片描述

一、项目功能与技术选型

1.1 功能清单

  • 打开本地照片作为背景(jpg / png / bmp)
  • 在照片上添加任意数量的文字组件,鼠标拖拽自由定位
  • 每个文字组件可独立设置:文字内容、字体、字号、加粗、斜体、下划线、颜色
  • 双击文字组件可弹窗快速改文字
  • 一键"保存并导出",按原图分辨率合并背景与所有文字,输出到指定文件夹
  • 所有状态(照片路径、导出目录、每个文字组件的全部属性)自动持久化到 SQLite,下次打开程序自动恢复
  • 兼容 PyInstaller 打包为单文件 exe,数据库路径始终锚定在程序所在目录

1.2 为什么选 wxPython 而不是 PyQt / Tkinter

  • 原生控件外观:wxPython 直接调用系统原生控件(Windows 下是 Win32 控件),不需要额外美化就有"像正经软件"的观感,比 Tkinter 默认皮肤要精致。
  • GraphicsContext 抗锯齿绘图:wx 自带基于 Cairo/Direct2D 的矢量绘图接口,做文字预览这种需要抗锯齿的场景比原生 DC 效果好很多。
  • License 友好:wxWidgets 是 LGPL,商业分发没有 PyQt 那样的 GPL/商业授权顾虑。
  • 打包体积:相比 PyQt5,wxPython 打包出的 exe 体积通常更小。

1.3 技术栈

GUI 框架    : wxPython (Phoenix, wx.Font / wx.GraphicsContext / wx.MemoryDC)
持久化      : sqlite3(标准库自带,无需额外依赖)
图片编解码   : wx.Image(内置支持 jpg/png/bmp,无需 Pillow)
打包        : PyInstaller

值得一提的是,整个项目没有引入 Pillow。文字渲染、图片缩放、格式转换全部用 wx 自带的 wx.Image / wx.Bitmap / wx.GraphicsContext 完成,这样做的好处是预览用的绘图 API 和最终导出用的绘图 API 是同一套,天然保证了"所见即所得",不会出现"wx 画的预览"和"PIL 画的导出图"字体渲染细节对不上的问题。


二、整体架构

代码按职责拆成四个部分,从下到上依次是:

┌─────────────────────────────────────────────┐
│  MainFrame(主窗口)                          │
│  - 工具栏按钮、属性面板、文字列表               │
│  - 负责"UI 事件 -> 修改数据 -> 触发重绘/持久化"  │
└───────────────┬─────────────────────────────┘
                │ 持有
┌───────────────▼─────────────────────────────┐
│  PhotoCanvas(自绘画布,继承 wx.Panel)         │
│  - 背景图缩放显示、文字叠加绘制                  │
│  - 鼠标拖拽 / 命中测试                          │
│  - render_full_bitmap():原分辨率合成导出        │
└───────────────┬─────────────────────────────┘
                │ 存储/渲染
┌───────────────▼─────────────────────────────┐
│  TextItem(数据模型)                          │
│  - 一个文字组件的全部属性                        │
└───────────────┬─────────────────────────────┘
                │ 读写
┌───────────────▼─────────────────────────────┐
│  ConfigDB(SQLite 封装)                       │
│  - settings 表:key-value 配置                 │
│  - text_items 表:文字组件列表                  │
└─────────────────────────────────────────────┘

这是一个非常典型的 MVC 变体TextItem 是 Model,PhotoCanvas 是 View(同时也承担了部分 Controller 职责,比如拖拽),MainFrame 是 Controller + 更外层的 View,ConfigDB 是持久化层。下面按这个顺序逐个拆解。


三、路径处理:PyInstaller 打包后不"找不到文件"的关键

这是很多新手打包 exe 后最容易踩的坑:直接跑 .py 时用 __file__ 能拿到脚本目录,但打包成 exe 后 __file__ 指向的是 PyInstaller 运行时解压的临时目录(sys._MEIPASS),程序重启一次这个临时目录就变了,之前写的数据库文件也就"丢了"

解决方式是判断 sys.frozen 标志位:

def get_app_dir():
    """返回程序所在目录。

    - 直接运行 .py 时:返回脚本文件所在目录
    - PyInstaller 打包成 exe 后运行时:sys.frozen 为 True,
      返回 exe 可执行文件所在目录(而不是 PyInstaller 解压的临时目录)
    """
    if getattr(sys, "frozen", False):
        return os.path.dirname(os.path.abspath(sys.executable))
    return os.path.dirname(os.path.abspath(__file__))


APP_DIR = get_app_dir()
DB_PATH = os.path.join(APP_DIR, "photo_text_config.db")

原理说明:

  • sys.frozen 是 PyInstaller(以及 cx_Freeze 等打包工具)运行时会自动注入的属性,普通 Python 解释器运行 .py 时是不存在这个属性的,所以用 getattr(sys, "frozen", False) 做安全判断。
  • 打包后 sys.executable 指向的是最终生成的 exe 文件本身的路径,而不是临时解压目录,这正是我们想要的"程序所在目录"。
  • 未打包时用 os.path.abspath(__file__) 拿到当前脚本的绝对路径,再取 dirname 就是脚本所在目录。

这个函数在整个程序里只在启动时被调用一次,算出的 APP_DIRDB_PATH 作为模块级常量贯穿全局,后面无论是打开对话框的默认目录,还是数据库连接路径,都统一引用这两个常量,避免了路径计算逻辑散落在各处导致不一致。

💡 踩坑提醒:网上很多教程会写成 os.path.dirname(sys.executable),在 Windows 下大多数情况没问题,但如果传入的是相对路径(某些启动方式下 sys.executable 可能不是绝对路径)就会出错,所以这里额外套了一层 os.path.abspath() 做保险。


四、数据模型:TextItem

class TextItem:
    def __init__(self, text="双击编辑文字", x=0.5, y=0.5,
                 font_face="Microsoft YaHei", font_size=48,
                 bold=False, italic=False, underline=False,
                 color=(255, 0, 0), z=0):
        self.text = text
        self.x = x            # 归一化坐标(相对原图宽度),0~1,文字中心点
        self.y = y             # 归一化坐标(相对原图高度),0~1
        self.font_face = font_face
        self.font_size = font_size   # 字号,按原图分辨率的像素大小
        ...

这个类看起来平平无奇,但有一个非常关键的设计决策xy 存的不是画布上的像素坐标,而是相对原图宽高的归一化坐标(0~1 之间的浮点数)font_size 存的也不是"预览时看到的字号",而是相对原图分辨率的真实像素字号

为什么要这样设计? 因为画布面板的大小是随窗口拖拽变化的,图片在画布里是"按比例缩放居中显示"的(后面会讲 compute_layout),如果直接存画布上的像素坐标,一旦窗口大小变了、或者预览缩放比例变了,之前存的坐标就全部对不上了。而用归一化坐标 + 真实分辨率字号,无论预览时怎么缩放,只要乘以对应的缩放系数就能换算出当前该画在哪里、画多大——这也是后面导出大图时"预览看到什么,导出就是什么"的根本保证。


五、SQLite 持久化层:ConfigDB

5.1 表结构设计

CREATE TABLE IF NOT EXISTS settings (
    key   TEXT PRIMARY KEY,
    value TEXT
);

CREATE TABLE IF NOT EXISTS text_items (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    text        TEXT,
    x           REAL,
    y           REAL,
    font_face   TEXT,
    font_size   INTEGER,
    bold        INTEGER,
    italic      INTEGER,
    underline   INTEGER,
    color_r     INTEGER,
    color_g     INTEGER,
    color_b     INTEGER,
    z_order     INTEGER
);

settings 是一张典型的 key-value 配置表,用来存"最近打开的照片路径""最近使用的导出目录"这类全局唯一的设置项。text_items 则存放当前照片上的全部文字组件,每次保存前先 DELETE FROM text_items 整表清空再重新插入——因为文字组件数量、顺序都可能变化,与其写复杂的 diff/update 逻辑,不如"全量覆盖"来得简单可靠,反正这张表的数据量最多也就几十条,性能完全不是问题。

5.2 Upsert 写法:ON CONFLICT DO UPDATE

def set_setting(self, key, value):
    self.conn.execute(
        "INSERT INTO settings(key, value) VALUES (?, ?) "
        "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
        (key, value),
    )
    self.conn.commit()

这里用了 SQLite 3.24+ 支持的 ON CONFLICT ... DO UPDATE 语法(等价于 MySQL 的 ON DUPLICATE KEY UPDATE),一句 SQL 就完成了"存在则更新、不存在则插入",比先 SELECT 判断存在再决定 INSERT/UPDATE 要简洁得多,也避免了并发场景下的竞态问题(虽然本项目是单机单进程场景用不上这个优势,但这是更规范的写法)。

5.3 读取时的对象重建

def load_text_items(self):
    cur = self.conn.cursor()
    cur.execute(
        "SELECT text, x, y, font_face, font_size, bold, italic, underline, "
        "color_r, color_g, color_b, z_order FROM text_items ORDER BY z_order"
    )
    items = []
    for row in cur.fetchall():
        items.append(TextItem(
            text=row[0], x=row[1], y=row[2],
            font_face=row[3], font_size=row[4],
            bold=bool(row[5]), italic=bool(row[6]), underline=bool(row[7]),
            color=(row[8], row[9], row[10]), z=row[11],
        ))
    return items

需要注意两个类型转换细节:

  1. SQLite 没有原生布尔类型,bold/italic/underline 存的时候是 int(item.bold)(0/1),读的时候要显式 bool(row[5]) 转回布尔值,否则 01 虽然在 if 判断里能正常工作,但类型语义不对,容易在后续比较逻辑里埋雷。
  2. color_r/g/b 三个字段读出来后重新组装成元组 (r, g, b),和 TextItemcolor 属性的存储格式保持一致,这样上层代码不用关心底层是三个字段还是一个元组,做到了数据库表结构和内存对象结构的解耦

ORDER BY z_order 保证了读出来的文字组件顺序和保存前的图层顺序一致(z_order 就是保存时的列表下标,见 save_text_items 里的 enumerate(items)),这个顺序也直接决定了后面绘制和命中测试的图层堆叠关系。


六、核心画布 PhotoCanvas:所见即所得的关键

这是整个项目里代码量最大、也最有技术含量的一个类,下面拆成几个子问题来讲。

6.1 坐标系统与自适应布局:compute_layout

def compute_layout(self):
    panel_w, panel_h = self.GetClientSize()
    if self.img_w <= 0 or self.img_h <= 0 or panel_w <= 0 or panel_h <= 0:
        self.scale = 1.0
        self.offset_x = 0.0
        self.offset_y = 0.0
        return
    scale = min(panel_w / self.img_w, panel_h / self.img_h)
    self.scale = scale
    disp_w = self.img_w * scale
    disp_h = self.img_h * scale
    self.offset_x = (panel_w - disp_w) / 2.0
    self.offset_y = (panel_h - disp_h) / 2.0

这是典型的"图片等比缩放居中显示"算法(类似 CSS 里的 object-fit: contain):

  • 分别计算"宽度方向缩放到刚好塞满面板"和"高度方向缩放到刚好塞满面板"两个缩放系数,取较小值 min(...),保证图片完整显示在面板内、不会有任何部分被裁掉。
  • 用较小的那个缩放系数算出图片实际显示的宽高 disp_w/disp_h,再用 (面板尺寸 - 显示尺寸) / 2 算出居中所需的偏移量 offset_x/offset_y

这个函数在每次 on_paint 触发时都会重新计算一遍(因为窗口可能被拖拽改变了大小),计算量很小(就几次除法乘法),不会有性能问题。

6.2 归一化坐标 ↔ 画布像素坐标的双向转换

def image_to_canvas(self, nx, ny):
    px = self.offset_x + nx * self.img_w * self.scale
    py = self.offset_y + ny * self.img_h * self.scale
    return px, py

def canvas_to_image(self, px, py):
    if self.img_w <= 0 or self.img_h <= 0 or self.scale <= 0:
        return 0.5, 0.5
    nx = (px - self.offset_x) / (self.img_w * self.scale)
    ny = (py - self.offset_y) / (self.img_h * self.scale)
    nx = min(max(nx, 0.0), 1.0)
    ny = min(max(ny, 0.0), 1.0)
    return nx, ny

这两个函数是一对严格互逆的坐标转换:image_to_canvasTextItem 里存的归一化坐标换算成当前画布上应该画在哪个像素点;canvas_to_image 反过来,把鼠标在画布上点击/拖拽的像素坐标换算回归一化坐标存回 TextItem这一对函数是拖拽功能和跨分辨率一致性的数学基础

canvas_to_image 里额外做了 min(max(nx, 0.0), 1.0)边界钳制(clamp),防止用户把文字拖出图片范围外导致坐标变成负数或大于 1,这样即使用户把鼠标拖到画布外面,文字最多也就贴着图片边缘,不会"飞出去"。

6.3 双缓冲绘制与 GraphicsContext

def on_paint(self, evt):
    dc = wx.AutoBufferedPaintDC(self)
    dc.SetBackground(wx.Brush(wx.Colour(235, 235, 235)))
    dc.Clear()
    self.compute_layout()

    if self.bg_image is not None:
        disp_w = max(1, int(self.img_w * self.scale))
        disp_h = max(1, int(self.img_h * self.scale))
        scaled = self.bg_image.Scale(disp_w, disp_h, wx.IMAGE_QUALITY_HIGH)
        bmp = wx.Bitmap(scaled)
        dc.DrawBitmap(bmp, int(self.offset_x), int(self.offset_y))
    ...
    gc = wx.GraphicsContext.Create(dc)
    if gc:
        for idx, item in enumerate(self.items):
            self.draw_item(gc, item, idx == self.selected_index)

几个关键点:

  • wx.AutoBufferedPaintDC:这是 wx 提供的"自动双缓冲" DC,绘图先画到内存中的位图,最后一次性刷到屏幕,避免了频繁重绘造成的闪烁(尤其是拖拽文字的时候,如果不用双缓冲会明显看到背景图片和文字先后刷新的撕裂感)。
  • 背景图缩放用 wx.IMAGE_QUALITY_HIGH:wx.Image 缩放支持多种插值算法,HIGH 对应双三次插值一类的高质量算法,牺牲一点缩放性能换取预览画面不会因为最近邻插值而出现锯齿。
  • 文字绘制单独用 wx.GraphicsContext,而不是复用同一个 dc:这是因为 wx.DC 的文字绘制在不同平台下抗锯齿效果参差不齐(尤其是斜体、中文字体的边缘容易出现明显锯齿),而 wx.GraphicsContext 底层是基于 Cairo(Linux/macOS)或 Direct2D/GDI+(Windows)的矢量绘图引擎,文字渲染质量明显更好,所以背景图用普通 DC 画(性能更好),文字叠加用 GraphicsContext 画(质量更好),两者可以共存在同一次 on_paint 里,因为 wx.GraphicsContext.Create(dc) 本身就是"包裹"在传入的 DC 之上工作的。

6.4 文字绘制与选中框:draw_item

def draw_item(self, gc, item, selected):
    weight = wx.FONTWEIGHT_BOLD if item.bold else wx.FONTWEIGHT_NORMAL
    style = wx.FONTSTYLE_ITALIC if item.italic else wx.FONTSTYLE_NORMAL
    size = max(1, int(item.font_size * self.scale))
    font = wx.Font(size, wx.FONTFAMILY_DEFAULT, style, weight,
                    item.underline, faceName=item.font_face)
    gc.SetFont(font, wx.Colour(*item.color))
    extent = gc.GetTextExtent(item.text)
    tw, th = extent[0], extent[1]
    cx, cy = self.image_to_canvas(item.x, item.y)
    x = cx - tw / 2.0
    y = cy - th / 2.0
    gc.DrawText(item.text, x, y)

    if selected:
        gc.SetPen(wx.Pen(wx.Colour(0, 120, 255), 2, wx.PENSTYLE_SHORT_DASH))
        gc.SetBrush(wx.TRANSPARENT_BRUSH)
        gc.DrawRectangle(x - 4, y - 4, tw + 8, th + 8)

有三个值得展开讲的细节:

① 预览字号是"真实字号乘以画布缩放系数"size = max(1, int(item.font_size * self.scale))。前面提到 font_size 存的是原图分辨率下的真实像素字号,比如用户设置 60px,但如果原图是 4000×3000 的大图、画布缩放系数只有 0.2,那预览时实际显示的字号就应该是 12px,这样才能保证"预览里字的相对大小"和"导出大图里字的相对大小"是完全一致的比例关系。max(1, ...) 是防止缩放系数极小时字号算出 0 或负数导致 wx.Font 构造失败。

wx.Font 构造函数的参数顺序wx.Font(pointSize, family, style, weight, underline, faceName=...),这个参数顺序是 wxPython Phoenix 版本的标准签名,容易和一些老教程里的旧版 API(wx.Font(pointSize, family, style, weight, underline, faceName, encoding) 位置参数写法)搞混,这里统一用 faceName= 关键字参数传递,可读性更好也不容易因为参数顺序记错而出 bug。

③ 居中定位算法TextItem 里存的 (x, y) 语义是"文字中心点",但 gc.DrawText 接受的是"文字左上角坐标",所以要用 gc.GetTextExtent(text) 先量出文字的宽高 (tw, th),再用 cx - tw/2cy - th/2 把中心点坐标换算成左上角坐标——这个"先量尺寸再居中"的模式在整个项目里出现了三次(预览绘制、命中测试、导出渲染),是贯穿全文的一个核心技巧。

⚠️ 版本兼容坑wx.GraphicsContext.GetTextExtent() 在不同 wxWidgets 版本下返回值的元素个数不完全一致(有的版本返回 (width, height) 二元组,有的返回 (width, height, descent, externalLeading) 四元组)。这份代码里统一用 extent = gc.GetTextExtent(item.text); tw, th = extent[0], extent[1] 这种"先拿到完整返回值、再按下标取前两个"的写法,无论底层返回几个元素都能正常工作,避免了直接写 tw, th = gc.GetTextExtent(...) 在某些环境下解包报错(ValueError: not enough values to unpack)——这是我在联调测试阶段真实遇到并修复的一个跨版本兼容性问题。

6.5 命中测试:判断鼠标点在哪个文字上

def get_item_rect(self, item):
    dc = wx.ClientDC(self)
    ...
    dc.SetFont(font)
    tw, th = dc.GetTextExtent(item.text)
    cx, cy = self.image_to_canvas(item.x, item.y)
    return wx.Rect(int(cx - tw / 2), int(cy - th / 2), int(tw) + 1, int(th) + 1)

def hit_test(self, pos):
    for idx in reversed(range(len(self.items))):
        if self.get_item_rect(self.items[idx]).Contains(pos):
            return idx
    return -1

get_item_rect 用普通 wx.ClientDC 而不是 GraphicsContext 来测量文字包围盒(因为这里只是做数值计算,不需要实际绘图,普通 DC 的 GetTextExtent 更轻量)。

hit_test 的遍历顺序是 reversed(range(len(self.items))),即从后往前遍历,这是因为文字组件是按 z_order(插入顺序)绘制的,后添加的文字画在最上面,如果有多个文字互相重叠,鼠标点击应该优先选中"看起来在最上层"的那个,所以命中测试也要按照"最后画的最先测"的倒序遍历,这样才和用户的视觉直觉一致。

6.6 拖拽实现:CaptureMouse 的作用

def on_left_down(self, evt):
    pos = evt.GetPosition()
    idx = self.hit_test(pos)
    self.selected_index = idx
    if idx >= 0:
        self.dragging = True
        if not self.HasCapture():
            self.CaptureMouse()
    if self.on_selection_changed:
        self.on_selection_changed(idx)
    self.Refresh()

def on_left_up(self, evt):
    if self.dragging:
        if self.HasCapture():
            self.ReleaseMouse()
        self.dragging = False

def on_motion(self, evt):
    if self.dragging and evt.Dragging() and evt.LeftIsDown() and self.selected_index >= 0:
        pos = evt.GetPosition()
        nx, ny = self.canvas_to_image(pos.x, pos.y)
        self.items[self.selected_index].x = nx
        self.items[self.selected_index].y = ny
        self.Refresh()

这是标准的"三段式拖拽"实现模式:EVT_LEFT_DOWN 选中并开始拖拽 → EVT_MOTION 持续更新位置 → EVT_LEFT_UP 结束拖拽。这里有个容易被忽略但很重要的细节:self.CaptureMouse()

如果不调用 CaptureMouse(),当用户拖拽文字拖得很快、鼠标移动到画布控件范围外时,EVT_MOTION 事件就会因为鼠标已经不在这个控件上而停止触发,导致"拖拽跟丢"的糟糕体验。CaptureMouse() 会让这个控件在鼠标按下期间独占接收所有鼠标事件,即使鼠标移出了控件边界也照样能收到 EVT_MOTION,这样快速拖拽也不会丢帧。对应的,鼠标松开时必须调用 ReleaseMouse() 释放独占,而且要用 if self.HasCapture() 做保护判断,防止重复释放抛异常(这是 wx 里一个常见的运行时错误来源)。

canvas_to_image 换算出归一化坐标后直接赋值给 self.items[self.selected_index].x/y,然后 self.Refresh() 触发重绘——这里没有做任何节流(throttle),因为 on_paint 本身足够轻量(就是画一张背景图加几个文字),在普通分辨率下每次鼠标移动都触发重绘完全不会卡顿。

6.7 导出核心:render_full_bitmap

def render_full_bitmap(self):
    """按原图分辨率生成合并后的位图(背景 + 所有文字)"""
    if self.bg_image is None:
        return None
    full_bmp = wx.Bitmap(self.bg_image)
    mdc = wx.MemoryDC(full_bmp)
    gc = wx.GraphicsContext.Create(mdc)
    for item in self.items:
        weight = wx.FONTWEIGHT_BOLD if item.bold else wx.FONTWEIGHT_NORMAL
        style = wx.FONTSTYLE_ITALIC if item.italic else wx.FONTSTYLE_NORMAL
        font = wx.Font(item.font_size, wx.FONTFAMILY_DEFAULT, style, weight,
                        item.underline, faceName=item.font_face)
        gc.SetFont(font, wx.Colour(*item.color))
        extent = gc.GetTextExtent(item.text)
        tw, th = extent[0], extent[1]
        cx = item.x * self.img_w
        cy = item.y * self.img_h
        gc.DrawText(item.text, cx - tw / 2.0, cy - th / 2.0)
    mdc.SelectObject(wx.NullBitmap)
    return full_bmp

这是全文"所见即所得"承诺真正落地的地方,可以对比着 draw_item(预览绘制)来看:

预览(draw_item导出(render_full_bitmap
画布/画板尺寸面板尺寸(跟随窗口变化)原图真实尺寸
字号item.font_size * self.scaleitem.font_size(原样使用)
坐标换算image_to_canvas()(含 offset 居中偏移)直接 item.x * img_w(无需 offset,因为画布就是整张原图,不需要居中)
绘图引擎wx.GraphicsContext.Create(dc)dc 是屏幕 PaintDCwx.GraphicsContext.Create(mdc)mdc 是内存 MemoryDC

可以看到两处绘制逻辑本质上是"同一套算法在不同尺度下的两次运行":draw_item 是"缩小版画在屏幕上给用户看",render_full_bitmap 是"原始尺寸画在内存位图里给用户导出",因为坐标系统从一开始就设计成了归一化坐标 + 真实字号,这两处代码不需要任何特殊的"缩放矫正"就能自然得出一致的排版效果。

mdc.SelectObject(wx.NullBitmap) 这一行容易被忽略但不可省略wx.MemoryDC 在持有一个 wx.Bitmap 期间,这个 Bitmap 会被"锁定",其他地方(比如后续要把这个 bitmap 转成 wx.Image 保存文件)无法安全访问,必须先把 DC 关联的 Bitmap 换成"空位图"(相当于解除绑定/释放锁),才能把之前那个 full_bmp 安全地传出去使用。这是 wx 绘图 API 里一个经典但容易被新手忽略的资源管理细节。


七、主窗口 MainFrame:属性面板的双向绑定

7.1 界面布局:Sizer 嵌套

main_sizer = wx.BoxSizer(wx.VERTICAL)      # 整体:工具栏在上,内容在下
content_sizer = wx.BoxSizer(wx.HORIZONTAL) # 内容区:画布在左,属性面板在右

这是 wxPython 里最经典的"垂直大盒子套水平小盒子"布局写法:最外层 main_sizer 竖直排列"工具栏 + 内容区",内容区 content_sizer 里再水平排列"画布(权重 3,占大头)+ 属性面板(固定宽度 300px)"。这种嵌套 Sizer 的写法比手工计算控件坐标(SetPosition)更能适应窗口缩放,用户拖拽窗口大小时画布会自动跟着变大变小,属性面板宽度保持不变。

7.2 属性面板与选中文字的双向数据绑定

这是这个项目里 UI 交互逻辑最"绕"的一块,核心是要解决一个经典问题:"程序设置控件的值"和"用户操作控件触发的事件"用的是同一套事件机制,如果不做区分就会造成死循环或者数据错乱

具体表现在两个方向:

方向一:选中某个文字组件 → 把它的属性"填充"到右侧面板

def load_props_from_item(self, item):
    self._loading = True          # ① 打开屏蔽开关
    self.txt_content.SetValue(item.text)
    ...
    self.clr_picker.SetColour(wx.Colour(*item.color))
    self._loading = False          # ② 关闭屏蔽开关

方向二:用户在面板上改了某个控件的值 → 同步回当前选中的文字组件

def on_prop_change(self, evt):
    if self._loading:             # ③ 检测屏蔽开关
        return
    idx = self.canvas.selected_index
    ...
    item.text = self.txt_content.GetValue() or "文字"
    ...
    self.canvas.Refresh()
    self.refresh_list()

问题在于:self.txt_content.SetValue(item.text)(方向一里的"回填"操作)本身也会触发 wx.EVT_TEXT 事件,如果不加处理,就会立刻触发 on_prop_change(方向二的逻辑),而这时候 idx 对应的 item 其实还是同一个对象、值也没有实质变化,看似"无害",但会带来两个问题:一是不必要的 canvas.Refresh() 造成性能浪费和潜在的视觉闪烁;二是如果未来加入"撤销/重做"这类需要监听真实用户操作的功能,这种"程序自己触发的假事件"会污染操作历史。

解决方式就是这里用到的 self._loading 标志位模式:在"程序回填控件值"前把标志位设为 True,回填完毕后设回 False;而 on_prop_change 一开始就检查这个标志位,如果是 True 说明当前正处于"程序自动回填"阶段,直接 return 不做任何同步,这样就干净地切断了这个潜在的事件循环。这是 GUI 开发里处理"双向绑定"场景的一个通用套路,值得记住。

7.3 导出流程走查

def on_export(self, evt):
    if self.canvas.bg_image is None:
        wx.MessageBox("请先打开一张照片。", "提示", wx.OK | wx.ICON_WARNING)
        return

    start_dir = self.export_dir if os.path.isdir(self.export_dir) else APP_DIR
    with wx.DirDialog(self, "选择导出文件夹", defaultPath=start_dir) as dlg:
        if dlg.ShowModal() == wx.ID_CANCEL:
            return
        folder = dlg.GetPath()
    self.export_dir = folder
    ...
    bmp = self.canvas.render_full_bitmap()
    ...
    image = bmp.ConvertToImage()
    ext = os.path.splitext(filename)[1].lower()
    fmt_map = {".jpg": wx.BITMAP_TYPE_JPEG, ".png": wx.BITMAP_TYPE_PNG, ...}
    fmt = fmt_map.get(ext, wx.BITMAP_TYPE_JPEG)
    if fmt == wx.BITMAP_TYPE_JPEG and image.HasAlpha():
        image.ClearAlpha()
    ok = image.SaveFile(out_path, fmt)

几个健壮性设计:

  • start_dir = self.export_dir if os.path.isdir(self.export_dir) else APP_DIR:如果上次记住的导出目录后来被用户删掉了(比如移动硬盘拔出),打开文件夹选择对话框时不会因为路径不存在而报错或行为异常,会自动回退到程序所在目录。
  • 根据用户输入的文件扩展名自动决定保存格式fmt_map),而不是写死成某一种格式,用户输入 xxx.png 就存 PNG,输入 xxx.jpg 就存 JPEG。
  • if fmt == wx.BITMAP_TYPE_JPEG and image.HasAlpha(): image.ClearAlpha():JPEG 格式不支持透明通道(Alpha),如果背景图或者合成过程中意外带上了 Alpha 通道信息,直接存成 JPEG 可能会导致颜色异常甚至保存失败,这里提前判断并清除 Alpha 通道,是一个容易被忽略但很必要的兼容性处理。
  • 导出成功后立即调用 self.save_state() 把当前状态写入数据库——这意味着每次成功导出都会顺带触发一次持久化,不需要用户额外操作。

7.4 状态加载与保存:程序生命周期的两端

def load_state(self):
    if self.image_path and os.path.isfile(self.image_path):
        if self.canvas.load_image(self.image_path):
            self.canvas.items = self.db.load_text_items()
            self.refresh_list()

def save_state(self):
    self.db.set_setting("image_path", self.image_path or "")
    self.db.set_setting("export_dir", self.export_dir or "")
    self.db.save_text_items(self.canvas.items)

def on_close(self, evt):
    try:
        self.save_state()
    finally:
        self.db.close()
    evt.Skip()

load_state 里有个容易忽略但很重要的判断:os.path.isfile(self.image_path)。数据库里存的照片路径是上一次运行时的路径,如果用户在两次启动之间把那张照片删除、改名或者移动了(这种情况在实际使用中并不罕见),程序不会傻乎乎地尝试加载一个不存在的文件导致报错,而是安静地跳过,画布保持空白状态,等待用户重新打开照片。

on_closetry...finally 包裹,确保即使 save_state() 过程中出现异常(比如磁盘写满导致 SQLite 写入失败),self.db.close() 也一定会被执行,避免数据库连接泄漏;最后的 evt.Skip() 是 wx 事件处理的标准写法,表示"我处理完了,请继续把这个关闭事件传递给默认处理逻辑(真正销毁窗口)",如果漏掉这一行,窗口的关闭按钮可能会失效。


八、从这个项目里能学到的通用工程技巧

回顾整个实现,有几个技巧是通用的、可以迁移到其他 GUI 项目里的

  1. 归一化坐标系统:任何"画布跟随窗口缩放,但内容需要跨分辨率保持一致排版"的场景(截图标注工具、海报编辑器、图片水印工具……)都可以用"存储用归一化坐标,绘制时按当前缩放系数换算"这套方案。
  2. 预览绘制与导出绘制复用同一套坐标换算逻辑:只要保证两处用的是同一个数学模型(差别只在"缩放系数是多少"),就能天然保证所见即所得,不需要额外写"预览和导出对齐"的补丁代码。
  3. _loading 标志位模式:处理"数据 → 控件"和"控件 → 数据"这种双向绑定场景的标准解法,避免程序自己触发的事件污染业务逻辑。
  4. sys.frozen 判断 + sys.executable:PyInstaller 打包后路径定位的标准解法,几乎适用于所有需要打包分发的 Python 桌面程序。
  5. SQLite 的 upsert 语法(ON CONFLICT DO UPDATE:处理"配置项存在则更新、不存在则插入"场景,比手写 SELECT 再判断分支要简洁可靠。
  6. CaptureMouse/ReleaseMouse 配对使用:任何需要"鼠标可能拖出控件范围"的拖拽交互,都应该考虑用鼠标捕获,否则快速操作容易"拖丢"。

九、可以继续扩展的方向

这个版本已经覆盖了题目要求的全部功能,如果要进一步打磨成一个更完整的产品,可以考虑:

  • 撤销/重做(Undo/Redo):目前对文字的每次修改都是直接原地改属性,没有操作历史栈,可以引入命令模式记录每次操作。
  • 图层上移/下移:目前的图层顺序完全由添加顺序(z_order)决定,没有提供手动调整层级的入口。
  • 模板/预设保存:把一组文字排版方案存成模板,应用到不同的照片上(比如批量给多张证件照加同样位置的水印文字)。
  • 批量导出:选择一个文件夹,对里面所有图片应用同一套文字排版批量导出。
  • 文字描边/阴影wx.GraphicsContext 支持渐变、阴影等更复杂的绘制效果,可以进一步丰富文字的视觉表现。

结语

这个「照片文字合成工具」虽然只有 680 行代码、单文件即可运行,但完整覆盖了一个典型桌面小工具需要处理的几个核心工程问题:自适应画布布局、跨分辨率一致的坐标系统、鼠标拖拽交互、GUI 双向数据绑定、SQLite 轻量持久化、以及 PyInstaller 打包后的路径兼容性。这些技巧不只适用于"照片加文字"这一个场景,稍加改造就能用在标注工具、海报编辑器、表单设计器等一大类"画布 + 可拖拽组件"的桌面应用上,希望这篇源码解析能给同样在做类似工具的你一些参考。

完整源码见文首(或评论区索取),欢迎在评论区交流你在 wxPython 开发中踩过的坑 👇

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值