1.7 PySide6 三栏工作台:QPainter 渲染、坐标契约与框选/涂抹交互
清印 ClearMark 系列第 7 篇 · 共 12 篇
前六篇我们都在讲算法------PDF 内容流解析、矢量检测、扫描件像素修复、图像聚类。这一篇开始进入 UI 层。
UI 层是用户直接接触的部分,也是踩坑最多的一层。这一篇讲
MainWindow三栏布局、PreviewWidget的 QPainter 渲染、文档坐标与屏幕坐标的转换契约、鼠标框选/画笔涂抹交互。每个点都有真实调试故事。
一、MainWindow 三栏布局
ui/main_window.py 是主窗口。整体布局遵循"左导航 + 中工作区 + 右详情"的经典三栏:
python
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(APP_NAME)
self.setMinimumSize(820, 540)
...
self._setup_ui()
self._setup_toolbar()
self._setup_statusbar()
self._connect_signals()
_setup_ui 用 QSplitter 实现三栏:
python
def _setup_ui(self):
central = QWidget()
self.setCentralWidget(central)
layout = QHBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
splitter = QSplitter(Qt.Horizontal)
# 左栏:文件/任务列表
self.file_list = QListWidget()
self.file_list.setMinimumWidth(180)
self.file_list.setMaximumWidth(360)
# 中栏:预览工作区
self.preview = PreviewWidget()
# 右栏:检测结果面板
self.result_panel = ResultPanel()
self.result_panel.setMinimumWidth(220)
splitter.addWidget(self.file_list)
splitter.addWidget(self.preview)
splitter.addWidget(self.result_panel)
# 初始宽度比例 1:4:1.5
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 4)
splitter.setStretchFactor(2, 2)
splitter.setSizes([220, 800, 280])
layout.addWidget(splitter)
几个设计取舍:
1.1 setMinimumSize(820, 540)
主窗口最小尺寸 820×540。这个数字不是随便写的------左栏至少 180px 显示文件名,右栏至少 220px 显示候选列表,中栏至少 420px 显示预览,加起来正好 820。竖向 540 是工具栏(40) + 中栏预览(440) + 状态栏(30) + 边距(30)。
太小的窗口会让三栏挤成一团,用户体验灾难。所以必须设最小尺寸,阻止用户拖太小。
1.2 setStretchFactor 与 setSizes 的区别
setStretchFactor 控制窗口放大时各栏按比例分配多余空间,setSizes 控制初始尺寸。两者要配合------只设 setSizes 的话,用户拖大窗口后中栏不会跟着放大;只设 setStretchFactor 的话,初始尺寸是默认的,可能太挤。
我们的设置:左栏拉伸因子 1,中栏 4,右栏 2。窗口放大时,多出的空间 4/7 给中栏,2/7 给右栏,1/7 给左栏------中栏优先放大,因为预览是最重要的。
1.3 操作按钮固定在右栏底部
python
# 右栏:检测结果面板
self.result_panel = ResultPanel()
ResultPanel 内部布局:
┌──────────────────────┐
│ 候选列表(可滚动) │
│ │
│ ─── 分隔线 ─── │
│ □ 候选1 [高置信度] │
│ □ 候选2 [中置信度] │
│ □ 候选3 [低置信度] │
│ │
├──────────────────────┤
│ [全选] [全不选] │ ← 固定底部
│ [移除水印并保存] │
└──────────────────────┘
操作按钮(全选/全不选/移除并保存)固定在底部,永不随列表滚动消失。这是 V2.0 改了一版才确定的------第一版把按钮放在列表头部,结果列表一长按钮就被滚到屏幕外,用户找不到移除按钮。
实现技巧:用 QVBoxLayout,候选列表设 setSizePolicy(Expanding, Expanding) 占据所有可用空间,按钮区设 setSizePolicy(Preferred, Fixed) 固定高度。这样列表自动滚动,按钮永远在底部可见。
二、PreviewWidget:QPainter 渲染核心
ui/preview_widget.py 是整个 UI 最复杂的部分。它要做的事:
- 渲染 PDF/图片页面到屏幕(QPainter.drawPixmap)。
- 在页面上叠加红框标记检测到的水印候选。
- 显示用户框选/涂抹轨迹。
- 支持鼠标交互:左键框选、画笔涂抹、橡皮擦除。
- 支持对比模式:左右并排显示原图与处理后图。
- 翻页、缩放、适应窗口。
这么多事必须用 QPainter 手绘,不能用 QLabel + QPixmap 的简单方案------后者无法精细控制 z-order 和叠加层。
2.1 整体类设计
python
class PreviewWidget(QWidget):
"""预览工作区
综合渲染:页面 pixmap + 候选红框 + 用户标记 + 框选/画笔轨迹 + 对比双图
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setMouseTracking(True)
self.setAcceptDrops(True)
self.setFocusPolicy(Qt.StrongFocus)
# 数据
self._pixmap: Optional[QPixmap] = None
self._page_rect = QRectF(0, 0, 0, 0) # 文档坐标的页面矩形
self._render_scale: float = 1.5 # 渲染缩放(文档→屏幕像素)
self._zoom: float = 1.0 # 用户缩放(屏幕像素额外放大)
# 候选与标记
self._candidates: List[WatermarkCandidate] = []
self._marks: List[UserMark] = []
self._current_mark: Optional[UserMark] = None
self._tool = 'select' # 'select' | 'rect' | 'brush' | 'eraser' | 'lasso'
# 对比模式
self._compare_mode: bool = False
self._compare_pixmap: Optional[QPixmap] = None
# 鼠标状态
self._mouse_pos: Optional[QPointF] = None
self._drag_start: Optional[QPointF] = None
几个关键成员:
_pixmap:渲染后的页面 pixmap(屏幕像素)。_page_rect:文档坐标的页面矩形(PDF 点或原图像素)。_render_scale:渲染缩放,文档单位 → 屏幕像素的比例。_zoom:用户缩放,屏幕像素的额外放大。_candidates:检测到的水印候选列表。_marks:用户已完成的标记列表。_current_mark:正在画的标记。_compare_mode:对比模式开关。_compare_pixmap:对比用的处理后 pixmap。
2.2 _render_scale 与 _zoom 的区别
这是坐标系统最关键的设计:
_render_scale:调用processor.render_page(zoom=1.5)时传的 1.5。文档单位 1pt 对应 1.5 个屏幕像素。这个值在调用render_page后被固定。_zoom:用户在 UI 上按 Ctrl+滚轮时的额外放大。这不重新渲染页面(避免重渲染开销),只是在paintEvent里把 pixmap 进一步放大显示。
完整缩放链:
文档坐标 (pt) ──× render_scale (1.5) ──→ 屏幕像素 ──× _zoom (1.0) ──→ 显示像素
为什么要分两层?因为 render_page 开销大(PyMuPDF 要光栅化整页),频繁调用会卡。把"用户缩放"独立出来,只缩放已渲染的 pixmap,开销小(GPU 加速的图像缩放)。
代价是用户放大太多后画面会模糊(pixmap 分辨率不够)。所以我们设了上限------_zoom 最大 5.0,超过会提示用户用 Ctrl+F 适应窗口。
三、paintEvent:分层渲染
paintEvent 是 QPainter 渲染的核心:
python
def paintEvent(self, event: QPaintEvent):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.setRenderHint(QPainter.SmoothPixmapTransform)
# 1. 背景
painter.fillRect(self.rect(), QColor(40, 40, 40))
if self._pixmap is None or self._pixmap.isNull():
self._draw_placeholder(painter)
return
# 2. 计算显示矩形
pix_size = self._pixmap.size()
zoomed_w = int(pix_size.width() * self._zoom)
zoomed_h = int(pix_size.height() * self._zoom)
# 居中
x = (self.width() - zoomed_w) // 2
y = (self.height() - zoomed_h) // 2
if x < 0: x = 0
if y < 0: y = 0
target_rect = QRectF(x, y, zoomed_w, zoomed_h)
source_rect = QRectF(0, 0, pix_size.width(), pix_size.height())
# 3. 绘制页面 pixmap(必须用三参数版本!)
painter.drawPixmap(target_rect, self._pixmap, source_rect)
# 4. 候选红框(文档坐标 → 屏幕坐标 → 绘制)
if not self._compare_mode:
self._draw_candidates(painter, target_rect)
# 5. 用户标记
self._draw_marks(painter, target_rect)
# 6. 当前正在画的标记
if self._current_mark:
self._draw_mark(painter, self._current_mark, target_rect)
# 7. 对比模式:右半边画处理后图
if self._compare_mode and self._compare_pixmap:
self._draw_compare(painter, target_rect)
3.1 drawPixmap 必须用三参数版本
注释里强调的"必须用三参数版本"是踩过的坑:
python
# 正确写法(三参数)
painter.drawPixmap(target_rect, self._pixmap, source_rect)
# 错误写法(两参数)
# painter.drawPixmap(target_rect, self._pixmap) # 不存在!会调用别的重载或崩溃
PySide6 的 QPainter.drawPixmap 有多个重载:
drawPixmap(QRectF, QPixmap, QRectF)--- 把源矩形画到目标矩形,支持缩放。drawPixmap(QPoint, QPixmap)--- 把 pixmap 画到指定位置,不缩放。drawPixmap(QRectF, QPixmap)--- 不存在!写出来要么调用错误重载(图像不缩放),要么直接报错。
我们的场景需要缩放(用户缩放、适应窗口),必须用三参数版本。第一版写了两参数,结果缩放功能完全失效------pixmap 始终原尺寸显示,用户按 Ctrl+滚轮没反应。
3.2 三参数的语义
python
target_rect = QRectF(x, y, zoomed_w, zoomed_h) # 屏幕上的目标矩形
source_rect = QRectF(0, 0, pix_size.width(), pix_size.height()) # pixmap 的源矩形
painter.drawPixmap(target_rect, self._pixmap, source_rect)
含义:把 pixmap 的 source_rect 区域(通常是整个 pixmap)画到屏幕的 target_rect 区域。如果两者尺寸不同,自动缩放。
这种写法的好处:
- 可以只画 pixmap 的一部分(修改 source_rect),实现局部放大。
- 可以指定目标尺寸(修改 target_rect),实现用户缩放。
- 避免裁剪问题:Qt 会自动按 target_rect 裁剪到 painter 的 clip region。
四、坐标契约:文档 ↔ 屏幕
整个 UI 最绕的地方是 坐标转换。水印候选的 bbox 是文档坐标(PDF 点或原图像素),但要在屏幕上画红框,必须转到屏幕坐标。
4.1 文档坐标 → 屏幕坐标
python
def _doc_to_screen(self, doc_point: QPointF, target_rect: QRectF) -> QPointF:
"""文档坐标 → 屏幕坐标"""
if self._page_rect.width() == 0 or self._page_rect.height() == 0:
return QPointF(0, 0)
sx = target_rect.x() + (doc_point.x() / self._page_rect.width()) * target_rect.width()
sy = target_rect.y() + (doc_point.y() / self._page_rect.height()) * target_rect.height()
return QPointF(sx, sy)
线性变换:
screen_x = target_x + (doc_x / doc_width) * target_width
target_rect 是当前 pixmap 在屏幕上的显示矩形(来自 paintEvent 计算)。page_rect 是文档坐标的页面矩形(PDF 的 page.rect 或原图尺寸)。
4.2 屏幕坐标 → 文档坐标
鼠标事件回调用:
python
def _screen_to_doc(self, screen_point: QPointF, target_rect: QRectF) -> QPointF:
"""屏幕坐标 → 文档坐标"""
if target_rect.width() == 0 or target_rect.height() == 0:
return QPointF(0, 0)
dx = (screen_point.x() - target_rect.x()) / target_rect.width() * self._page_rect.width()
dy = (screen_point.y() - target_rect.y()) / target_rect.height() * self._page_rect.height()
return QPointF(dx, dy)
逆向变换,用于把鼠标点击位置转回文档坐标。
4.3 为什么标记存文档坐标
UserMark.bbox 存的是文档坐标,不是屏幕坐标。原因:
- 窗口大小变化时坐标不变。用户拖大窗口,pixmap 显示尺寸变了,但水印的文档位置没变,红框应该跟着 pixmap 一起缩放。如果存屏幕坐标,窗口一变红框就错位。
- 缩放时坐标不变。用户按 Ctrl+滚轮放大,pixmap 变大,但水印的文档位置没变,红框跟着 pixmap 缩放即可。
- 传给处理器时无需转换 。处理器接收的是文档坐标,与
WatermarkCandidate.bbox同坐标系,可以直接做几何运算。
4.4 扫描件的特殊坐标转换
PdfScannedProcessor 的候选 bbox 是 内嵌图像素 ,不是页面点。直接画到屏幕上会错位。所以它重写了基类的 candidate_page_bbox:
python
def candidate_page_bbox(self, cand) -> tuple:
"""内嵌图像素候选 bbox → 页面点坐标(供 UI 在渲染图上定位)"""
info = next((i for i in infos if i['xref'] == cand.xref), None)
img_w, img_h = info['width'], info['height']
px0, py0, px1, py1 = info['bbox']
place_w = px1 - px0
place_h = py1 - py0
x0 = px0 + cand.bbox[0] / img_w * place_w
y0 = py0 + cand.bbox[1] / img_h * place_h
x1 = px0 + cand.bbox[2] / img_w * place_w
y1 = py0 + cand.bbox[3] / img_h * place_h
return (x0, y0, x1, y1)
把内嵌图像素坐标按"内嵌图在页面上的放置位置与缩放"换算回页面点。这是扫描件水印红框能正确显示的关键。
UI 层只需要调 processor.candidate_page_bbox(cand),不感知背后的坐标系差异。这是策略模式的好处。
五、鼠标交互:框选/画笔/套索
5.1 工具切换
PreviewWidget 支持 5 种工具:
python
self._tool = 'select' # 'select' | 'rect' | 'brush' | 'eraser' | 'lasso'
工具切换由 MainWindow 的工具栏触发:
python
def set_tool(self, tool: str):
self._tool = tool
if tool == 'select':
self.setCursor(Qt.ArrowCursor)
elif tool == 'rect':
self.setCursor(Qt.CrossCursor)
elif tool in ('brush', 'eraser'):
self.setCursor(Qt.BlankCursor) # 自定义画笔光标
elif tool == 'lasso':
self.setCursor(Qt.CrossCursor)
self.update()
不同工具改光标样式,给用户视觉反馈。
5.2 鼠标按下:开始标记
python
def mousePressEvent(self, event: QMouseEvent):
if event.button() != Qt.LeftButton:
return
target_rect = self._current_target_rect()
if target_rect is None:
return
doc_pos = self._screen_to_doc(event.position(), target_rect)
if self._tool == 'select':
# 选择工具:点击水印候选切换选中状态
self._toggle_candidate_by_click(doc_pos)
return
# 其他工具:开始一个新标记
self._drag_start = event.position()
if self._tool == 'rect':
self._current_mark = UserMark(
mark_type=MarkType.RECT,
page_index=self._current_page,
bbox=(doc_pos.x(), doc_pos.y(), doc_pos.x(), doc_pos.y()),
points=[],
)
elif self._tool in ('brush', 'eraser'):
self._current_mark = UserMark(
mark_type=MarkType.BRUSH if self._tool == 'brush' else MarkType.ERASER,
page_index=self._current_page,
points=[(doc_pos.x(), doc_pos.y())],
brush_radius=self._brush_radius / self._render_scale,
)
elif self._tool == 'lasso':
self._current_mark = UserMark(
mark_type=MarkType.LASSO,
page_index=self._current_page,
points=[(doc_pos.x(), doc_pos.y())],
)
self.update()
关键点:
_screen_to_doc把鼠标位置转到文档坐标,标记存文档坐标。brush_radius除以_render_scale------画笔半径用户在屏幕上看到的是 10 像素,但存到文档坐标要换算成文档单位。否则缩放后画笔粗细会变。select工具特殊:点击不创建标记,而是切换水印候选的选中状态。
5.3 鼠标移动:更新标记
python
def mouseMoveEvent(self, event: QMouseEvent):
self._mouse_pos = event.position()
if not self._current_mark:
self.update()
return
target_rect = self._current_target_rect()
if target_rect is None:
return
doc_pos = self._screen_to_doc(event.position(), target_rect)
if self._current_mark.mark_type == MarkType.RECT:
x0 = min(self._current_mark.bbox[0], doc_pos.x())
y0 = min(self._current_mark.bbox[1], doc_pos.y())
x1 = max(self._current_mark.bbox[0], doc_pos.x())
y1 = max(self._current_mark.bbox[1], doc_pos.y())
self._current_mark.bbox = (x0, y0, x1, y1)
elif self._current_mark.mark_type in (MarkType.BRUSH, MarkType.ERASER):
self._current_mark.points.append((doc_pos.x(), doc_pos.y()))
elif self._current_mark.mark_type == MarkType.LASSO:
self._current_mark.points.append((doc_pos.x(), doc_pos.y()))
self.update()
矩形工具更新 bbox 的 (x1, y1) 角点(取 min/max 防止反向拖拽时坐标错乱)。画笔/套索工具持续追加 points。
5.4 鼠标释放:完成标记
python
def mouseReleaseEvent(self, event: QMouseEvent):
if event.button() != Qt.LeftButton:
return
if not self._current_mark:
return
# 矩形太小 → 丢弃
if self._current_mark.mark_type == MarkType.RECT:
w = abs(self._current_mark.bbox[2] - self._current_mark.bbox[0])
h = abs(self._current_mark.bbox[3] - self._current_mark.bbox[1])
if w < 5 or h < 5:
self._current_mark = None
self.update()
return
# 画笔/套索点太少 → 丢弃
elif self._current_mark.mark_type in (MarkType.BRUSH, MarkType.ERASER, MarkType.LASSO):
if len(self._current_mark.points) < 2:
self._current_mark = None
self.update()
return
self._marks.append(self._current_mark)
self._current_mark = None
self.update()
# 通知主窗口更新
self.marks_changed.emit(self._marks)
完成标记的过滤:
- 矩形要求 ≥5×5 文档单位,太小丢弃(防误触)。
- 画笔/套索要求 ≥2 个点,单击不算误触。
完成后通过信号 marks_changed 通知 MainWindow。
六、绘制候选红框:避免与用户标记叠加
_draw_candidates 把水印候选画成红框:
python
def _draw_candidates(self, painter: QPainter, target_rect: QRectF):
"""绘制检测到的水印候选(红框 + 半透明填充)"""
if not self._candidates:
return
pen = QPen(QColor(255, 60, 60), 2)
painter.setPen(pen)
for cand in self._candidates:
# 处理器可能重写了 candidate_page_bbox(扫描件)
page_bbox = self._processor.candidate_page_bbox(cand) \
if self._processor else tuple(cand.bbox)
# 文档坐标 → 屏幕坐标
x0, y0 = self._doc_to_screen(QPointF(page_bbox[0], page_bbox[1]), target_rect)
x1, y1 = self._doc_to_screen(QPointF(page_bbox[2], page_bbox[3]), target_rect)
# 红框
rect = QRectF(x0, y0, x1 - x0, y1 - y0).normalized()
# 半透明填充(选中状态不同)
if cand.selected:
painter.setBrush(QColor(255, 60, 60, 60))
else:
painter.setBrush(QColor(255, 60, 60, 20))
painter.drawRect(rect)
# 标签
painter.setPen(QColor(255, 220, 220))
painter.drawText(rect.topLeft() + QPointF(2, 12),
f'{cand.detail[:30]} [{int(cand.confidence * 100)}%]')
关键设计:
6.1 调用处理器的 candidate_page_bbox
python
page_bbox = self._processor.candidate_page_bbox(cand) \
if self._processor else tuple(cand.bbox)
这是策略模式的精髓------UI 层不感知 PDF 矢量/扫描件/图片的坐标差异,统一调 candidate_page_bbox 拿到页面坐标。处理器自己负责把内部坐标系(页面点/内嵌图像素/原图像素)转换到页面坐标。
6.2 选中状态视觉区分
python
if cand.selected:
painter.setBrush(QColor(255, 60, 60, 60)) # 较深半透明
else:
painter.setBrush(QColor(255, 60, 60, 20)) # 较浅半透明
选中的候选红框填充更深(alpha=60),未选中更浅(alpha=20)。一眼能看出哪些会被移除。
6.3 对比模式不画候选
python
if not self._compare_mode:
self._draw_candidates(painter, target_rect)
对比模式(左右并排原图与处理后图)时不画候选红框------避免红框遮挡处理后图的视觉效果。用户要看的是"移除效果好不好",不是"水印在哪里"。
七、画笔轨迹绘制:屏幕坐标
画笔轨迹的绘制用屏幕坐标,不是文档坐标:
python
def _draw_mark(self, painter: QPainter, mark: UserMark, target_rect: QRectF):
"""绘制一个用户标记"""
if mark.mark_type == MarkType.RECT:
# 矩形:文档坐标 → 屏幕坐标
p0 = self._doc_to_screen(QPointF(mark.bbox[0], mark.bbox[1]), target_rect)
p1 = self._doc_to_screen(QPointF(mark.bbox[2], mark.bbox[3]), target_rect)
painter.setPen(QPen(QColor(60, 200, 255), 2, Qt.DashLine))
painter.setBrush(Qt.NoBrush)
painter.drawRect(QRectF(p0, p1).normalized())
elif mark.mark_type in (MarkType.BRUSH, MarkType.ERASER):
# 画笔:把每个点转到屏幕,连成线
pen_color = QColor(60, 200, 255) if mark.mark_type == MarkType.BRUSH \
else QColor(255, 200, 60)
r = mark.brush_radius * self._render_scale * self._zoom
painter.setPen(QPen(pen_color, max(1, int(r * 2)), Qt.SolidLine,
Qt.RoundCap, Qt.RoundJoin))
for i in range(len(mark.points) - 1):
p0 = self._doc_to_screen(QPointF(*mark.points[i]), target_rect)
p1 = self._doc_to_screen(QPointF(*mark.points[i + 1]), target_rect)
painter.drawLine(p0, p1)
elif mark.mark_type == MarkType.LASSO:
# 套索:闭合多边形
painter.setPen(QPen(QColor(60, 200, 255), 2, Qt.DashLine))
painter.setBrush(QColor(60, 200, 255, 30))
pts = [self._doc_to_screen(QPointF(*p), target_rect) for p in mark.points]
poly = QPolygonF(pts)
painter.drawPolygon(poly)
画笔半径的换算:
python
r = mark.brush_radius * self._render_scale * self._zoom
mark.brush_radius 是文档单位,× render_scale 转屏幕像素,× _zoom 应用用户缩放。这样画笔在屏幕上的实际粗细随缩放等比变化。
八、QPixmap 空判断的坑
最后讲一个 UI 层的经典坑------QPixmap 真值判断:
python
# 错误写法
if self._pixmap:
# pixmap 可能是空 pixmap 但真值为 True!
# 正确写法
if self._pixmap is not None and not self._pixmap.isNull():
# 真正确认非空
QPixmap 的 __bool__ 总是返回 True(Python 对象总为真),即使是空 pixmap。空 pixmap 的判断必须用 isNull()。
我们封装了一个工具函数:
python
def _pixmap_is_valid(p: Optional[QPixmap]) -> bool:
return p is not None and not p.isNull()
所有 pixmap 使用前都过这个函数。否则渲染空 pixmap 会让 drawPixmap 抛警告或崩溃。
九、本篇总结
这一篇我们讲了 UI 层的核心:
- MainWindow 三栏布局 :
QSplitter+ 拉伸因子 + 初始尺寸 + 最小尺寸 820×540。 - 操作按钮固定底部 :
ResultPanel用QVBoxLayout+SizePolicy让按钮永远可见。 - PreviewWidget QPainter 渲染:分层绘制(背景 → pixmap → 候选红框 → 用户标记 → 对比图)。
drawPixmap三参数版本 :drawPixmap(target_rect, pixmap, source_rect)是唯一支持缩放的版本,两参数版本不存在或行为不同。- 坐标契约 :标记存文档坐标,渲染时转屏幕坐标;扫描件候选通过
candidate_page_bbox换算到页面坐标。 _render_scalevs_zoom:渲染缩放(固定)+ 用户缩放(动态),避免频繁重渲染。- 五种工具:select/rect/brush/eraso/lasso,鼠标按下/移动/释放三段式交互。
- 画笔半径换算 :
brush_radius × render_scale × zoom,保证缩放时粗细等比变化。 - 候选红框选中状态视觉区分:alpha=60 vs alpha=20。
- QPixmap 空判断 :必须
is not None and not isNull(),不能用真值判断。
下一篇我们继续 UI 层,讲 前后对比模式 和 多分辨率图标系统------前者让用户直观看到移除效果,后者保证任务栏图标在高 DPI 屏幕上清晰显示。
📥 完整源码与可执行程序已上传 CSDN 资源,搜索"清印 ClearMark 智能文档去水印工作台"。本系列代码片段对应文件
ui/main_window.py(约 800 行)和ui/preview_widget.py(约 500 行)。