Merge branch 'develop' into feature/lyy/code-review

This commit is contained in:
ZMY051025 2025-12-17 15:16:42 +08:00 committed by GitHub
commit 191486a8c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2357 additions and 2179 deletions

View file

@ -13,92 +13,130 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
import logging
# 导入必要的模块
import logging # 用于日志记录
from PyQt6 import QtCore
from PyQt6 import QtCore # 导入PyQt6的QtCore模块用于处理Qt的核心功能
from beeref import commands
from beeref.fileio.errors import BeeFileIOError
from beeref.fileio.image import load_image
from beeref.fileio.sql import SQLiteIO, is_bee_file
from beeref.items import BeePixmapItem
from beeref import commands # 导入commands命令模块用于实现撤销和重做功能
from beeref.fileio.errors import BeeFileIOError # 导入自定义的文件IO错误类
from beeref.fileio.image import load_image # 导入加载图片的函数
from beeref.fileio.sql import SQLiteIO, is_bee_file # 导入SQLite数据库相关的类和函数
from beeref.items import BeePixmapItem # 导入自定义的图片项类
# 定义公共API指定从该模块导入时可访问的名称
__all__ = [
'is_bee_file',
'load_bee',
'save_bee',
'load_images',
'ThreadedLoader',
'BeeFileIOError',
'is_bee_file', # 判断文件是否为BeeRef格式的函数
'load_bee', # 加载BeeRef原生文件的函数
'save_bee', # 保存BeeRef原生文件的函数
'load_images', # 加载图像函数
'ThreadedLoader', # 线程加载器类
'BeeFileIOError', # 自定义的文件IO错误类
]
# 创建日志记录器
logger = logging.getLogger(__name__)
# 加载BeeRef原生文件函数
def load_bee(filename, scene, worker=None):
"""Load BeeRef native file."""
logger.info(f'Loading from file {filename}...')
io = SQLiteIO(filename, scene, readonly=True, worker=worker)
return io.read()
"""
参数:
filename: 要加载的文件路径
scene: 目标场景对象
worker: 可选的工作线程对象用于报告进度
"""
logger.info(f'Loading from file {filename}...') # 记录加载文件的信息日志
io = SQLiteIO(filename, scene, readonly=True, worker=worker) # 创建SQLiteIO对象用于读取文件设置为只读模式
return io.read() # 调用read方法读取文件内容并返回
# 保存BeeRef原生文件函数
def save_bee(filename, scene, create_new=False, worker=None):
"""Save BeeRef native file."""
logger.info(f'Saving to file {filename}...')
logger.debug(f'Create new: {create_new}')
io = SQLiteIO(filename, scene, create_new, worker=worker)
io.write()
logger.info('End save')
"""
参数:
filename: 要保存的文件路径
scene: 要保存的场景对象
create_new: 是否创建新文件
worker: 可选的工作线程对象用于报告进度
"""
logger.info(f'Saving to file {filename}...') # 记录保存文件的信息日志
logger.debug(f'Create new: {create_new}') # 记录创建新文件的调试日志
io = SQLiteIO(filename, scene, create_new, worker=worker) # 创建SQLiteIO对象用于写入文件根据create_new参数确定是否创建新文件
io.write() # 调用write方法将场景数据写入文件
logger.info('End save') # 记录保存结束的信息日志
# 加载图像到现有场景函数
def load_images(filenames, pos, scene, worker):
"""Add images to existing scene."""
errors = []
items = []
worker.begin_processing.emit(len(filenames))
"""
参数:
filenames: 图像文件路径列表
pos: 图像在场景中的位置
scene: 目标场景对象
worker: 工作线程对象用于报告进度和处理取消操作
"""
errors = [] # 存储加载失败的图像文件路径
items = [] # 存储成功加载的图片项对象
worker.begin_processing.emit(len(filenames)) # 发送开始处理信号,参数为图像文件数量
# 遍历图像文件路径列表
for i, filename in enumerate(filenames):
logger.info(f'Loading image from file {filename}')
img, filename = load_image(filename)
worker.progress.emit(i)
if img.isNull():
logger.info(f'Could not load file {filename}')
errors.append(filename)
continue
logger.info(f'Loading image from file {filename}') # 记录加载图像文件的信息日志
img, filename = load_image(filename) # 调用load_image函数加载图像文件返回图像对象和文件名
worker.progress.emit(i) # 发送进度更新信号
if img.isNull(): # 检查图像是否加载失败
logger.info(f'Could not load file {filename}') # 记录加载失败的图像文件路径的信息日志
errors.append(filename) # 将加载失败的图像文件路径添加到errors列表中
continue # 继续处理下一个图像文件
item = BeePixmapItem(img, filename)
item.set_pos_center(pos)
scene.add_item_later({'item': item, 'type': 'pixmap'}, selected=True)
items.append(item)
item = BeePixmapItem(img, filename) # 创建BeePixmapItem对象参数为加载的图像对象和文件名
item.set_pos_center(pos) # 设置图片项的位置为指定的中心位置
scene.add_item_later({'item': item, 'type': 'pixmap'}, selected=True) # 将图片项添加到场景中,参数为字典,包含项对象和类型
items.append(item) # 将图片项对象添加到items列表中
if worker.canceled:
break
break # 如果工作线程被取消,跳出循环
# Give main thread time to process items:
worker.msleep(10)
worker.msleep(10) # 线程休眠10毫秒允许主线程处理其他任务
# 将添加项的操作添加到撤销栈
scene.undo_stack.push(
commands.InsertItems(scene, items, ignore_first_redo=True))
worker.finished.emit('', errors)
worker.finished.emit('', errors) # 发送完成信号,传递错误列表
# 用于加载和保存的专用线程类
class ThreadedIO(QtCore.QThread):
"""Dedicated thread for loading and saving."""
progress = QtCore.pyqtSignal(int)
finished = QtCore.pyqtSignal(str, list)
begin_processing = QtCore.pyqtSignal(int)
user_input_required = QtCore.pyqtSignal(str)
# 定义信号
progress = QtCore.pyqtSignal(int) # 进度更新信号
finished = QtCore.pyqtSignal(str, list) # 完成信号,传递文件名和和错误列表
begin_processing = QtCore.pyqtSignal(int) # 开始处理信号,传递总处理数量
user_input_required = QtCore.pyqtSignal(str) # 用户输入required信号参数为提示信息
# 初始化线程,设置要执行的函数和参数
def __init__(self, func, *args, **kwargs):
super().__init__()
self.func = func
self.args = args
self.kwargs = kwargs
self.kwargs['worker'] = self
self.canceled = False
"""
参数:
func: 要在线程中执行的函数
*args: 传递给func的位置参数
**kwargs: 传递给func的关键字参数
"""
super().__init__() # 调用父类QThread构造函数
self.func = func # 存储要执行的函数
self.args = args # 存储要传递给函数的位置参数
self.kwargs = kwargs # 存储要传递给函数的关键字参数
self.kwargs['worker'] = self # 将自身作为worker参数传递给函数
self.canceled = False # 取消标志初始化为False
# 线程执行函数
def run(self):
self.func(*self.args, **self.kwargs)
self.func(*self.args, **self.kwargs) # 调用指定的函数执行IO操作
# 处理取消操作的方法
def on_canceled(self):
self.canceled = True
self.canceled = True # 设置取消标志为True

View file

@ -1,25 +1,30 @@
# This file is part of BeeRef.
# This file is part of BeeRef. # 此文件是 BeeRef 项目的一部分
#
# BeeRef is free software: you can redistribute it and/or modify # BeeRef 是自由软件:您可以重新分发和/或修改它
# it under the terms of the GNU General Public License as published by # 根据 GNU 通用公共许可证的条款,由 Free Software Foundation 发布,
# the Free Software Foundation, either version 3 of the License, or # 无论是版本 3 的许可证,还是(在您选择的情况下)任何更高版本。
#
# BeeRef is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BeeRef is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# BeeRef is distributed in the hope that it will be useful, # BeeRef 是分发在希望它会有用的基础上
# but WITHOUT ANY WARRANTY; without even the implied warranty of # 但没有任何保修;甚至没有对适销性或特定用途适用性的暗示保证。
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # 有关更多详细信息,请参阅 GNU 通用公共许可证
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
# You should have received a copy of the GNU General Public License # 您应该已经收到了 GNU 通用公共许可证的副本
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>. # 如果没有,请参阅 <https://www.gnu.org/licenses/>
# 图片加载错误的提示消息,当图片格式未知或过大时显示
IMG_LOADING_ERROR_MSG = (
'Unknown format or too big?\n'
'Check Settings -> Images & Items -> Maximum Image Size')
'Unknown format or too big?\n' # 未知格式或文件太大?
'Check Settings -> Images & Items -> Maximum Image Size') # 请检查设置 -> 图像和项目 -> 最大图像大小
# 自定义的文件IO错误类继承自Python内置的Exception类
class BeeFileIOError(Exception):
# 构造函数,初始化错误消息和文件名
def __init__(self, msg, filename):
self.msg = msg
self.filename = filename
self.msg = msg # 保存错误消息
self.filename = filename # 保存发生错误的文件名

View file

@ -13,269 +13,307 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
import base64
import logging
import pathlib
from xml.etree import ElementTree as ET
# 导入必要的模块和类
import base64 # 用于编码图片数据
import logging # 用于记录日志
import pathlib # 用于处理文件路径
from xml.etree import ElementTree as ET # 用于创建XML文档
from PyQt6 import QtCore, QtGui
from PyQt6 import QtCore, QtGui # 用于处理Qt的核心功能和图形用户界面
from .errors import BeeFileIOError
from beeref import constants, widgets
from beeref.items import BeePixmapItem
from .errors import BeeFileIOError # 自定义异常类,用于导出时的错误处理
from beeref import constants, widgets # 导入 BeeRef 常量和小部件
from beeref.items import BeePixmapItem # 导入 BeeRef 图片项类
logger = logging.getLogger(__name__)
# 创建日志记录器
logger = logging.getLogger(__name__)
class ExporterRegistry(dict):
# 导出器注册类,继承自字典类
class ExporterRegistry(dict):
DEFAULT_TYPE = 0
DEFAULT_TYPE = 0 # 默认导出器类型
def __getitem__(self, key):
key = key.removeprefix('.')
exp = self.get(key, super().__getitem__(self.DEFAULT_TYPE))
logger.debug(f'Exporter for type {key}: {exp}')
return exp
def __getitem__(self, key): # 获取指定类型的导出器
key = key.removeprefix('.') # 移除类型前缀的点号
exp = self.get(key, super().__getitem__(self.DEFAULT_TYPE)) # 获取指定类型的导出器,若不存在则返回默认导出器
logger.debug(f'Exporter for type {key}: {exp}') # 记录获取的导出器
return exp # 返回获取的导出器
exporter_registry = ExporterRegistry()
# 创建导出器注册表实例
exporter_registry = ExporterRegistry()
def register_exporter(cls):
exporter_registry[cls.TYPE] = cls
return cls
# 导出器注册装饰器,用于注册导出器类
def register_exporter(cls):
exporter_registry[cls.TYPE] = cls # 将导出器类注册到导出器注册实例中
return cls # 返回导出器类注册装饰器,用于注册导出器类
# 导出器基类,用于定义导出器的通用方法
class ExporterBase:
# 发送开始处理信号
def emit_begin_processing(self, worker, start):
if worker:
worker.begin_processing.emit(start)
if worker: # 如果有工作线程
worker.begin_processing.emit(start) # 触发工作线程的开始处理信号
else: # 若没有工作线程
logger.debug(f'No worker, emit begin processing signal: {start}') # 若没有工作线程,则记录日志
# 发送进度更新信号
def emit_progress(self, worker, progress):
if worker:
worker.progress.emit(progress)
if worker: # 若有工作线程
worker.progress.emit(progress) # 若有工作线程,则触发工作线程的进度更新信号
# 发送完成信号
def emit_finished(self, worker, filename, errors):
filename = str(filename)
if worker:
worker.finished.emit(filename, errors)
filename = str(filename) # 确保文件名是字符串类型
if worker: # 若有工作线程
worker.finished.emit(filename, errors) # 若有工作线程,则触发工作线程的完成信号
# 发送需要用户输入信号
def emit_user_input_required(self, worker, msg):
if worker:
worker.user_input_required.emit(msg)
if worker: # 若有工作线程
worker.user_input_required.emit(msg) # 若有工作线程,则触发工作线程的用户输入要求信号
# 处理导出错误
def handle_export_error(self, filename, error, worker):
filename = str(filename)
logger.debug(f'Export failed: {error}')
if worker:
worker.finished.emit(filename, [str(error)])
return
else:
e = error if isinstance(error, Exception) else None
raise BeeFileIOError(msg=str(error), filename=filename) from e
filename = str(filename) # 确保文件名是字符串类型
logger.debug(f'Export failed: {error}') # 记录导出失败的日志
if worker: # 若有工作线程
worker.finished.emit(filename, [str(error)]) # 若有工作线程,则触发工作线程的完成信号,包含错误信息
return # 若有工作线程,则返回错误信息
else: # 若没有工作线程
e = error if isinstance(error, Exception) else None # 若错误不是异常类型则设为None
raise BeeFileIOError(msg=str(error), filename=filename) from e # 抛出自定义异常,包含错误信息和文件名
# 场景导出器基类,用于定义场景导出器为单个图像
class SceneExporterBase(ExporterBase):
"""For exporting the scene to a single image."""
"""For exporting the scene to a single image.""" # 场景导出器基类,用于定义场景导出器的通用方法
# 场景导出器基类的初始化方法
def __init__(self, scene):
self.scene = scene
self.scene.cancel_active_modes()
self.scene.deselect_all_items()
self.scene = scene # 场景导出器基类的初始化方法,接收场景作为参数
self.scene.cancel_active_modes() # 取消场景中所有活动模式
self.scene.deselect_all_items() # 取消场景中所有项的选择
# Selection outlines/handles will be rendered to the exported
# image, so deselect first. (Alternatively, pass an attribute
# to paint functions to not paint them?)
rect = self.scene.itemsBoundingRect()
logger.trace(f'Items bounding rect: {rect}')
size = QtCore.QSize(int(rect.width()), int(rect.height()))
logger.trace(f'Export size without margins: {size}')
self.margin = max(size.width(), size.height()) * 0.03
self.default_size = size.grownBy(
QtCore.QMargins(*([int(self.margin)] * 4)))
logger.debug(f'Default export margin: {self.margin}')
logger.debug(f'Default export size with margins: {self.default_size}')
rect = self.scene.itemsBoundingRect() # 获取场景中所有项的边界矩形
logger.trace(f'Items bounding rect: {rect}') # 记录场景中所有项的边界矩形
size = QtCore.QSize(int(rect.width()), int(rect.height())) # 计算导出图像的大小,取项边界矩形的宽度和高度
logger.trace(f'Export size without margins: {size}') # 记录导出图像的大小,不包含边距
self.margin = max(size.width(), size.height()) * 0.03 # 计算导出图像的边距取宽度和高度的较大值乘以0.03
self.default_size = size.grownBy( # 计算导出图像的大小,包含边距
QtCore.QMargins(*([int(self.margin)] * 4))) # 计算导出图像的大小,包含边距
logger.debug(f'Default export margin: {self.margin}') # 记录导出图像的边距
logger.debug(f'Default export size with margins: {self.default_size}')# 记录导出图像的大小,包含边距
# 注册场景到像素图导出器
@register_exporter
class SceneToPixmapExporter(SceneExporterBase):
class SceneToPixmapExporter(SceneExporterBase): # 场景导出器基类,用于定义场景导出器的通用方法
TYPE = ExporterRegistry.DEFAULT_TYPE
TYPE = ExporterRegistry.DEFAULT_TYPE # 设置为默认导出类型
# 场景导出器基类的用户输入方法,用于获取用户输入的导出大小
def get_user_input(self, parent):
"""Ask user for final export size."""
"""Ask user for final export size.""" # 场景导出器基类的用户输入方法,用于获取用户输入的导出大小
dialog = widgets.SceneToPixmapExporterDialog(
parent=parent,
default_size=self.default_size,
# 创建导出对话框,让用户输入最终导出尺寸
dialog = widgets.SceneToPixmapExporterDialog( # 创建场景导出器对话框实例
parent=parent, # 场景导出器对话框的父窗口
default_size=self.default_size, # 场景导出器对话框的默认导出大小
)
if dialog.exec():
size = dialog.value()
logger.debug(f'Got export size {size}')
self.size = size
return True
else:
return False
if dialog.exec(): # 若场景导出器对话框执行成功
size = dialog.value() # 获取场景导出器对话框中用户输入的导出大小
logger.debug(f'Got export size {size}') # 记录用户输入的导出大小
self.size = size # 将用户输入的导出大小赋值给场景导出器基类的属性
return True # 返回True表示用户输入成功
else: # 若场景导出器对话框执行失败
return False # 返回False表示用户输入失败
# 场景导出器基类的渲染方法,用于将场景渲染为图像
def render_to_image(self):
logger.debug(f'Final export size: {self.size}')
margin = self.margin * self.size.width() / self.default_size.width()
logger.debug(f'Final export margin: {margin}')
logger.debug(f'Final export size: {self.size}') # 记录最终导出图像的大小
# 按比例调整边距
margin = self.margin * self.size.width() / self.default_size.width() # 计算最终导出图像的边距取宽度和高度的较大值乘以0.03
logger.debug(f'Final export margin: {margin}') # 记录最终导出图像的边距
image = QtGui.QImage(self.size, QtGui.QImage.Format.Format_RGB32)
image.fill(QtGui.QColor(*constants.COLORS['Scene:Canvas']))
painter = QtGui.QPainter(image)
image = QtGui.QImage(self.size, QtGui.QImage.Format.Format_RGB32) # 创建导出图像大小为最终导出大小格式为RGB32
image.fill(QtGui.QColor(*constants.COLORS['Scene:Canvas'])) # 用画布颜色作为导出图像的背景颜色
painter = QtGui.QPainter(image) # 创建画家对象
# 定义目标矩形
target_rect = QtCore.QRectF(
margin,
margin,
self.size.width() - 2 * margin,
self.size.height() - 2 * margin)
logger.trace(f'Final export target_rect: {target_rect}')
self.scene.render(painter,
source=self.scene.itemsBoundingRect(),
target=target_rect)
painter.end()
return image
margin, # 取导出图像的边距作为偏移量
margin, # 取导出图像的边距作为偏移量
self.size.width() - 2 * margin, # 取导出图像的宽度减去2倍边距作为矩形宽度
self.size.height() - 2 * margin) # 取导出图像的高度减去2倍边距作为矩形高度
logger.trace(f'Final export target_rect: {target_rect}') # 记录最终导出图像的目标矩形
self.scene.render(painter, # 渲染场景到导出图像的绘图器
source=self.scene.itemsBoundingRect(), # 获取场景中所有项的边界矩形
target=target_rect) # 将场景渲染到最终导出图像的目标矩形
painter.end() # 结束导出图像的绘图器
return image # 返回导出图像
# 场景导出器基类的导出方法,用于将场景导出为图像文件
def export(self, filename, worker=None):
logger.debug(f'Exporting scene to {filename}')
self.emit_begin_processing(worker, 1)
image = self.render_to_image()
logger.debug(f'Exporting scene to {filename}') # 记录导出场景的文件名
self.emit_begin_processing(worker, 1) # 发送导出开始信号,参数为导出器实例和导出任务数量
image = self.render_to_image() # 调用场景导出器基类的渲染方法,渲染场景到图像
# 若导出器实例存在且取消导出标志为True
if worker and worker.canceled:
logger.debug('Export canceled')
self.emit_finished(worker, filename, [])
return
logger.debug('Export canceled') # 记录导出被取消的信息
self.emit_finished(worker, filename, []) # 发送导出完成信号,参数为导出器实例、导出文件名和空列表
return # 若导出器实例存在且取消导出标志为True则直接返回不导出图像
# 若导出图像保存失败
if not image.save(filename, quality=90):
self.handle_export_error(filename, 'Error writing file', worker)
return
logger.debug('Export finished')
self.emit_progress(worker, 1)
self.emit_finished(worker, filename, [])
self.handle_export_error(filename, 'Error writing file', worker) # 处理导出错误,参数为导出文件名、错误信息和导出器实例
return # 返回,不继续导出
logger.debug('Export finished') # 记录导出完成的信息
self.emit_progress(worker, 1) # 发送导出进度信号参数为导出器实例和导出进度值1
self.emit_finished(worker, filename, []) # 发送导出完成信号,参数为导出器实例、导出文件名和空列表
# 注册场景到SVG
@register_exporter
class SceneToSVGExporter(SceneExporterBase):
TYPE = 'svg'
TYPE = 'svg' # 场景导出器基类的导出类型属性,值为'svg'表示导出为SVG格式
# 场景导出器基类的用户输入方法,用于获取用户输入的导出大小
def get_user_input(self, parent):
self.size = self.default_size
return True
self.size = self.default_size # 将场景导出器基类的默认导出大小赋值给场景导出器基类的属性
return True # 返回True表示用户输入成功
# 场景导出器基类的文本样式方法,用于获取项的字体样式
def _get_textstyles(self, item):
# 字体样式映射字典将QFont的字体样式枚举值映射为SVG的字体样式字符串
fontstylemap = {
QtGui.QFont.Style.StyleNormal: 'normal',
QtGui.QFont.Style.StyleItalic: 'italic',
QtGui.QFont.Style.StyleOblique: 'oblique',
QtGui.QFont.Style.StyleNormal: 'normal', # 将QFont的字体样式枚举值StyleNormal映射为SVG的字体样式字符串normal
QtGui.QFont.Style.StyleItalic: 'italic', # 将QFont的字体样式枚举值StyleItalic映射为SVG的字体样式字符串italic
QtGui.QFont.Style.StyleOblique: 'oblique', # 将QFont的字体样式枚举值StyleOblique映射为SVG的字体样式字符串oblique
}
font = item.font()
fontsize = font.pointSize() * item.scale()
families = ', '.join(font.families())
fontstyle = fontstylemap[font.style()]
font = item.font() # 获取项的字体
fontsize = font.pointSize() * item.scale() # 计算项的字体大小,取字体点大小乘以项的缩放比例
families = ', '.join(font.families()) # 取字体的所有字体家族,用逗号分隔
fontstyle = fontstylemap[font.style()] # 取字体的字体样式根据字体样式映射表映射为SVG的字体样式字符串
# 返回CSS样式元组
return ('white-space:pre',
f'font-size:{fontsize}pt',
f'font-family:{families}',
f'font-weight:{font.weight()}',
f'font-stretch:{font.stretch()}',
f'font-style:{fontstyle}')
f'font-size:{fontsize}pt', # 取项的字体大小,取字体点大小乘以项的缩放比例
f'font-family:{families}', # 取项的字体家族,用逗号分隔
f'font-weight:{font.weight()}', # 取项的字体重量
f'font-stretch:{font.stretch()}', # 取项的字体拉伸
f'font-style:{fontstyle}') # 取项的字体样式根据字体样式映射表映射为SVG的字体样式字符串
# 场景导出器基类的导出方法用于将场景导出为SVG文件
def render_to_svg(self, worker=None):
svg = ET.Element(
svg = ET.Element( # 创建SVG元素参数为元素标签名svg
'svg',
attrib={'width': str(self.size.width()),
'height': str(self.size.height()),
'xmlns': 'http://www.w3.org/2000/svg',
'xmlns:xlink': 'http://www.w3.org/1999/xlink',
attrib={'width': str(self.size.width()), # 设置SVG元素的宽度属性值为场景导出器基类的属性size的宽度
'height': str(self.size.height()), # 设置SVG元素的高度属性值为场景导出器基类的属性size的高度
'xmlns': 'http://www.w3.org/2000/svg', # 设置SVG元素的XML命名空间属性值为http://www.w3.org/2000/svg
'xmlns:xlink': 'http://www.w3.org/1999/xlink', # 设置SVG元素的XML链接命名空间属性值为http://www.w3.org/1999/xlink
})
rect = self.scene.itemsBoundingRect()
offset = rect.topLeft() - QtCore.QPointF(self.margin, self.margin)
rect = self.scene.itemsBoundingRect() # 获取场景中所有项的边界矩形
offset = rect.topLeft() - QtCore.QPointF(self.margin, self.margin)# 计算项的位置偏移量取场景中所有项的边界矩形的左上角坐标减去导出器基类的属性margin
# 按z值排序项目并遍历
for i, item in enumerate(sorted(self.scene.items(),
key=lambda x: x.zValue())):
# z order in SVG specified via the order of elements in the tree
pos = item.pos() - offset
anchor = pos
pos = item.pos() - offset # 计算项的位置,取项的位置减去项的位置偏移量
anchor = pos # 设置项的变换锚点,取项的位置
if item.TYPE == 'text':
styles = self._get_textstyles(item)
element = ET.Element(
'text',
attrib={'style': ';'.join(styles),
'dominant-baseline': 'hanging'})
element.text = item.toPlainText()
if item.TYPE == 'pixmap':
width = item.width * item.scale()
height = item.height * item.scale()
pixmap, imgformat = item.pixmap_to_bytes(
apply_grayscale=True,
apply_crop=True)
pixmap = base64.b64encode(pixmap).decode('ascii')
element = ET.Element(
'image',
attrib={
'xlink:href':
f'data:image/{imgformat};base64,{pixmap}',
'width': str(width),
'height': str(height),
'image-rendering': ('crisp-edges' if item.scale() > 2
# 处理文本项
if item.TYPE == 'text': # 若项的类型为文本项
styles = self._get_textstyles(item) # 获取项的字体样式
element = ET.Element( # 创建文本元素参数为元素标签名text
'text', # 设置文本元素的标签名值为text
attrib={'style': ';'.join(styles), # 将样式列表用分号连接成CSS样式字符串设置为元素的style属性
'dominant-baseline': 'hanging'}) # 设置文本元素的属性值为hanging指定文本的基线位置为悬挂基线
element.text = item.toPlainText() # 设置文本元素的文本内容,值为项的纯文本内容
# 处理图片项
if item.TYPE == 'pixmap': # 若项的类型为图片项
width = item.width * item.scale() # 计算项的宽度,取项的宽度乘以项的缩放比例
height = item.height * item.scale() # 计算项的高度,取项的高度乘以项的缩放比例
pixmap, imgformat = item.pixmap_to_bytes( # 将项的像素图转换为字节数组,参数为项的像素图,是否应用灰度化,是否应用裁剪
apply_grayscale=True, # 是否应用灰度化值为True
apply_crop=True) # 是否应用裁剪值为True
pixmap = base64.b64encode(pixmap).decode('ascii')# 将项的像素图字节数组转换为Base64编码的字符串
element = ET.Element( # 创建图片元素参数为元素标签名image
'image', # 设置图片元素的标签名值为image
attrib={ # 设置图片元素的属性值为项的像素图Base64编码的字符串
'xlink:href': # 设置图片元素的XML链接属性值为项的像素图Base64编码的字符串
f'data:image/{imgformat};base64,{pixmap}',# 设置图片元素的XML链接属性值为项的像素图Base64编码的字符串
'width': str(width), # 设置图片元素的宽度属性,值为项的宽度乘以项的缩放比例
'height': str(height), # 设置图片元素的高度属性,值为项的高度乘以项的缩放比例
'image-rendering': ('crisp-edges' if item.scale() > 2# 设置图片元素的渲染属性值为crisp-edges或optimizeQuality根据项的缩放比例判断
else 'optimizeQuality')})
pos = pos + item.crop.topLeft()
pos = pos + item.crop.topLeft() # 计算项的位置,取项的位置加上项的裁剪区域的左上角坐标
transforms = []
if item.flip() == -1:
#设置变换属性
transforms = [] # 创建变换列表,用于存储项的变换操作
if item.flip() == -1: # 若项的翻转方向为-1
# The following is not recognised by Inkscape and not an
# official standard:
# element.set('transform-origin', f'{anchor.x()} {anchor.y()}')
# Thus we need to fix the origin manually
transforms.append(f'translate({anchor.x()} {anchor.y()})')
transforms.append(f'scale({item.flip()} 1)')
transforms.append(f'translate(-{anchor.x()} -{anchor.y()})')
transforms.append(f'translate({anchor.x()} {anchor.y()})') # 若项的翻转方向为-1添加变换操作将项的锚点平移到项的位置
transforms.append(f'scale({item.flip()} 1)') # 添加变换操作,将项的像素图水平翻转
transforms.append(f'translate(-{anchor.x()} -{anchor.y()})') # 添加变换操作,将项的锚点平移回项的位置
#添加变换操作,将项的像素图旋转项的旋转角度
transforms.append(
f'rotate({item.rotation()} {anchor.x()} {anchor.y()})')
f'rotate({item.rotation()} {anchor.x()} {anchor.y()})') #围绕项的锚点旋转项的旋转角度
element.set('transform', ' '.join(transforms))
element.set('x', str(pos.x()))
element.set('y', str(pos.y()))
element.set('opacity', str(item.opacity()))
#应用变换
element.set('transform', ' '.join(transforms)) # 设置元素的变换属性,值为变换列表用空格连接成的字符串
element.set('x', str(pos.x())) # 设置元素的x属性值为项的位置的x坐标
element.set('y', str(pos.y())) # 设置元素的y属性值为项的位置的y坐标
element.set('opacity', str(item.opacity())) # 设置元素的透明度属性,值为项的透明度
svg.append(element)
self.emit_progress(worker, i)
if worker and worker.canceled:
return
svg.append(element) # 将元素添加到SVG根元素中
self.emit_progress(worker, i) # 发送进度信号参数为项的索引i
if worker and worker.canceled: # 若导出任务被取消
return # 若导出任务被取消,直接返回
return svg
return svg # 返回SVG根元素
#导出场景到SVG文件
def export(self, filename, worker=None):
logger.debug(f'Exporting scene to {filename}')
self.emit_begin_processing(worker, len(self.scene.items()))
svg = self.render_to_svg(worker)
logger.debug(f'Exporting scene to {filename}') # 调试日志输出导出场景到SVG文件的文件名
self.emit_begin_processing(worker, len(self.scene.items())) # 发送开始处理信号,设置总进度为项的数量
svg = self.render_to_svg(worker) # 渲染场景到SVG元素参数为导出任务的工作线程
if worker and worker.canceled:
logger.debug('Export canceled')
worker.finished.emit(filename, [])
return
if worker and worker.canceled: # 若导出任务被取消
logger.debug('Export canceled') # 调试日志,输出导出任务被取消
worker.finished.emit(filename, []) # 发送导出任务完成信号,参数为文件名和空列表
return # 若导出任务被取消,直接返回
tree = ET.ElementTree(svg)
ET.indent(tree, space=' ')
tree = ET.ElementTree(svg) # 创建XML元素树参数为SVG根元素
ET.indent(tree, space=' ') # 缩进XML元素树参数为空格字符串
try:
with open(filename, 'w') as f:
tree.write(f, encoding='unicode', xml_declaration=True)
except OSError as e:
self.handle_export_error(filename, e, worker)
return
with open(filename, 'w') as f: # 以UTF-8编码打开文件参数为文件名和写入模式
tree.write(f, encoding='unicode', xml_declaration=True) # 将XML元素树写入文件参数为文件对象编码为unicodeXML声明为True
except OSError as e: # 若导出任务发生OS错误
self.handle_export_error(filename, e, worker) # 处理导出任务错误,参数为文件名、错误对象和导出任务的工作线程
return # 若导出任务发生OS错误直接返回
logger.debug('Export finished')
self.emit_finished(worker, filename, [])
logger.debug('Export finished') # 调试日志,输出导出任务完成
self.emit_finished(worker, filename, []) # 发送导出任务完成信号,参数为导出任务的工作线程、文件名和空列表
# 图像到目录导出器
class ImagesToDirectoryExporter(ExporterBase):
"""Export all images to a folder.
@ -283,75 +321,82 @@ class ImagesToDirectoryExporter(ExporterBase):
not auto-detected by file extension.
"""
#初始化图像到目录导出器
def __init__(self, scene, dirname):
self.scene = scene
self.dirname = dirname
self.items = list(self.scene.items_by_type(BeePixmapItem.TYPE))
self.max_save_id = 0
for item in self.items:
if item.save_id:
self.max_save_id = max(self.max_save_id, item.save_id)
self.num_total = len(self.items)
self.start_from = 0
self.handle_existing = None
self.scene = scene # 场景对象
self.dirname = dirname # 目录名
self.items = list(self.scene.items_by_type(BeePixmapItem.TYPE)) # 获取所有像素图项的列表
self.max_save_id = 0 # 最大保存ID
#查找当前最大保存ID
for item in self.items: # 遍历所有像素图项
if item.save_id: # 若项有保存ID
self.max_save_id = max(self.max_save_id, item.save_id) # 更新最大保存ID为项的保存ID和当前最大保存ID中的较大值
self.num_total = len(self.items) # 总项数
self.start_from = 0 # 起始索引
self.handle_existing = None # 处理已存在文件的方式初始值为None
#导出场景中的图像到目录
def export(self, worker=None):
logger.debug(f'Exporting images to {self.dirname}')
logger.debug(f'Starting at {self.start_from}')
self.emit_begin_processing(worker, self.num_total)
self.emit_progress(worker, self.start_from)
logger.debug(f'Exporting images to {self.dirname}') # 调试日志,输出导出图像到目录的目录名
logger.debug(f'Starting at {self.start_from}') # 调试日志,输出导出任务的起始索引
self.emit_begin_processing(worker, self.num_total) # 发送开始处理信号,参数为项的数量
self.emit_progress(worker, self.start_from) # 发送当前进度信号,参数为起始索引
#遍历项目
for i, item in enumerate(
self.items[self.start_from:], start=self.start_from):
if worker and worker.canceled:
logger.debug('Export canceled')
worker.finished.emit(self.dirname, [])
return
self.items[self.start_from:], start=self.start_from): # 遍历从起始索引开始的项
self.emit_progress(worker, i) # 发送当前进度信号参数为项的索引i
if worker and worker.canceled: # 若导出任务被取消
logger.debug('Export canceled') # 调试日志,输出导出任务被取消
worker.finished.emit(self.dirname, []) # 发送导出任务完成信号,参数为目录名和空列表
return # 若导出任务被取消,直接返回
pixmap, imgformat = item.pixmap_to_bytes()
pixmap, imgformat = item.pixmap_to_bytes() # 获取项的像素图和图像格式
if item.save_id:
filename = item.get_filename_for_export(imgformat)
else:
self.max_save_id += 1
save_id = self.max_save_id
filename = item.get_filename_for_export(imgformat, save_id)
#确定文件名
if item.save_id: # 若项有保存ID
filename = item.get_filename_for_export(imgformat) # 获取项的导出文件名,参数为图像格式
else: # 若项没有保存ID
self.max_save_id += 1 # 最大保存ID加1
save_id = self.max_save_id # 保存ID为最大保存ID
filename = item.get_filename_for_export(imgformat, save_id) # 获取项的导出文件名参数为图像格式和保存ID
try:
path = pathlib.Path(self.dirname) / filename
path_exists = path.exists()
except OSError as e:
self.handle_export_error(self.dirname, e, worker)
return
path = pathlib.Path(self.dirname) / filename # 构建文件路径,参数为目录名和文件名
path_exists = path.exists() # 检查文件路径是否存在
except OSError as e: # 若导出任务发生OS错误
self.handle_export_error(self.dirname, e, worker) # 处理导出任务错误,参数为目录名、错误对象和导出任务的工作线程
return # 直接返回
if path_exists:
logger.debug(f'File already exists: {path}')
if self.handle_existing is None:
self.start_from = i
self.emit_user_input_required(worker, str(path))
return
else:
if self.handle_existing == 'skip':
self.handle_existing = None
logger.debug('Skipping file')
continue
elif self.handle_existing == 'skip_all':
logger.debug('Skipping file')
continue
elif self.handle_existing == 'overwrite':
self.handle_existing = None
logger.debug('Overwrite file')
elif self.handle_existing == 'overwrite_all':
logger.debug('Overwrite file')
#处理文件已存在的情况
if path_exists: # 若文件路径已存在
logger.debug(f'File already exists: {path}') # 调试日志,输出文件已存在的路径
if self.handle_existing is None: # 若处理已存在文件的方式为None
self.start_from = i # 更新起始索引为当前项的索引,记录当前位置
self.emit_user_input_required(worker, str(path)) # 发送用户输入要求信号,请求用户输入
return # 若导出任务发生OS错误直接返回
else: # 若处理已存在文件的方式不为None
if self.handle_existing == 'skip': # 若处理已存在文件的方式为跳过
self.handle_existing = None # 处理已存在文件的方式设为None记录当前位置
logger.debug('Skipping file') # 调试日志,输出跳过文件
continue # 若处理已存在文件的方式为跳过,继续下一项
elif self.handle_existing == 'skip_all': # 若处理已存在文件的方式为跳过所有
logger.debug('Skipping file') # 调试日志,输出跳过文件
continue # 若处理已存在文件的方式为跳过所有,继续下一项
elif self.handle_existing == 'overwrite': # 若处理已存在文件的方式为覆盖
self.handle_existing = None # 处理已存在文件的方式设为None记录当前位置
logger.debug('Overwrite file') # 调试日志,输出覆盖文件
elif self.handle_existing == 'overwrite_all':# 若处理已存在文件的方式为覆盖所有
logger.debug('Overwrite file') # 调试日志,输出覆盖文件
logger.debug(f'Writing file: {path}')
logger.debug(f'Writing file: {path}') # 调试日志,输出写入文件的路径
try:
path.write_bytes(pixmap)
except OSError as e:
self.handle_export_error(path, e, worker)
return
path.write_bytes(pixmap) # 写入像素图到文件路径,参数为像素图
except OSError as e: # 若写入像素图到文件路径发生OS错误
self.handle_export_error(path, e, worker) # 处理导出任务错误,参数为文件路径、错误对象和导出任务的工作线程
return # 若导出任务发生OS错误直接返回
self.emit_progress(worker, i)
self.emit_progress(worker, i) # 发送进度信号,参数为当前项的索引,记录当前位置
self.emit_finished(worker, self.dirname, [])
self.emit_finished(worker, self.dirname, []) # 发送导出任务完成信号,参数为目录名和空列表

View file

@ -13,101 +13,118 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
import logging
import os.path
import tempfile
from urllib.error import URLError
from urllib import parse, request
# 导入必要的模块
import logging # 导入日志模块,用于记录运行时信息和错误
import os.path # 导入os.path模块用于处理文件路径
import tempfile # 导入tempfile模块用于创建临时文件
from urllib.error import URLError # 导入URLError类用于处理URL错误
from urllib import parse, request # 导入urllib模块中的parse和request子模块用于处理URL解析和请求
from PyQt6 import QtGui
import exif
from lxml import etree
import plum
from PyQt6 import QtGui # 导入QtGui模块用于处理图像和绘图
import exif # 导入exif模块用于处理图像的EXIF数据
from lxml import etree # 导入lxml模块中的etree子模块用于解析HTML
import plum # 导入plum模块用于处理图像的二进制数据
# 创建日志记录器实例
logger = logging.getLogger(__name__)
# 定义函数exif_rotated_image用于根据图像的EXIF数据进行旋转和镜像变换
def exif_rotated_image(path=None):
"""Returns a QImage that is transformed according to the source's
orientation EXIF data.
"""
# 创建QImage对象
img = QtGui.QImage(path)
# 检查图像是否为空(无法加载)
if img.isNull():
return img
return img # 如果图像为空,直接返回
# 尝试读取EXIF数据
with open(path, 'rb') as f:
try:
exifimg = exif.Image(f)
except (plum.exceptions.UnpackError, NotImplementedError):
logger.exception(f'Exif parser failed on image: {path}')
return img
exifimg = exif.Image(f) # 从文件对象创建EXIF图像对象
except (plum.exceptions.UnpackError, NotImplementedError): # 处理EXIF解析错误
logger.exception(f'Exif parser failed on image: {path}') # 记录EXIF解析错误日志
return img # 如果EXIF解析错误直接返回原始图像
try:
if 'orientation' in exifimg.list_all():
orientation = exifimg.orientation
if 'orientation' in exifimg.list_all(): # 检查是否存在方向EXIF信息
orientation = exifimg.orientation # 获取方向EXIF信息
else:
return img
except (NotImplementedError, ValueError):
logger.exception(f'Exif failed reading orientation of image: {path}')
return img
return img # 如果不存在方向EXIF信息直接返回原始图像
except (NotImplementedError, ValueError): # 处理其他EXIF读取错误
logger.exception(f'Exif failed reading orientation of image: {path}') # 记录EXIF读取错误日志
return img # 如果EXIF读取错误直接返回原始图像
# 创建变换对象
transform = QtGui.QTransform()
if orientation == exif.Orientation.TOP_RIGHT:
# 根据EXIF方向值进行相应的变换
if orientation == exif.Orientation.TOP_RIGHT: # 方向值2方向为顶部右侧需要水平镜像
return img.mirrored(horizontal=True, vertical=False)
if orientation == exif.Orientation.BOTTOM_RIGHT:
if orientation == exif.Orientation.BOTTOM_RIGHT: # 方向值3方向为底部右侧需要旋转180度
transform.rotate(180)
return img.transformed(transform)
if orientation == exif.Orientation.BOTTOM_LEFT:
if orientation == exif.Orientation.BOTTOM_LEFT: # 方向值4方向为底部左侧需要垂直镜像
return img.mirrored(horizontal=False, vertical=True)
if orientation == exif.Orientation.LEFT_TOP:
if orientation == exif.Orientation.LEFT_TOP: # 方向值5方向为左侧顶部需要顺时针旋转90度
transform.rotate(90)
return img.transformed(transform).mirrored(
horizontal=True, vertical=False)
if orientation == exif.Orientation.RIGHT_TOP:
if orientation == exif.Orientation.RIGHT_TOP: # 方向值6方向为右侧顶部需要逆时针旋转90度
transform.rotate(90)
return img.transformed(transform)
if orientation == exif.Orientation.RIGHT_BOTTOM:
if orientation == exif.Orientation.RIGHT_BOTTOM: # 方向值7方向为右侧底部需要顺时针旋转270度
transform.rotate(270)
return img.transformed(transform).mirrored(
horizontal=True, vertical=False)
if orientation == exif.Orientation.LEFT_BOTTOM:
if orientation == exif.Orientation.LEFT_BOTTOM: # 方向值8方向为左侧底部需要逆时针旋转270度
transform.rotate(270)
return img.transformed(transform)
return img
return img # 方向值1或其他未知值返回原始图像
# 加载图像的主要函数
def load_image(path):
if isinstance(path, str):
path = os.path.normpath(path)
return (exif_rotated_image(path), path)
if path.isLocalFile():
path = os.path.normpath(path.toLocalFile())
return (exif_rotated_image(path), path)
# 处理本地文件路径(字符串形式)
if isinstance(path, str): # 检查路径是否为字符串类型
path = os.path.normpath(path) # 规范路径格式,处理路径中的特殊字符和点号
return (exif_rotated_image(path), path) # 加载图像并处理EXIF方向
# 处理QUrl对象指向的本地文件
if path.isLocalFile(): # 检查路径是否为本地文件路径
path = os.path.normpath(path.toLocalFile()) # 转化为本地路径并规范本地文件路径格式
return (exif_rotated_image(path), path) # 加载图像并处理EXIF方向
url = bytes(path.toEncoded()).decode()
domain = '.'.join(parse.urlparse(url).netloc.split(".")[-2:])
img = exif_rotated_image()
# 处理远程URL
url = bytes(path.toEncoded()).decode() # 将QUrl对象转化为URL字符串并解码
domain = '.'.join(parse.urlparse(url).netloc.split(".")[-2:]) # 从URL中提取域名例如'pinterest.com'
img = exif_rotated_image() # 创建一个空的QImage对象
# 对Pinterest特定处理
if domain == 'pinterest.com':
try:
page_data = request.urlopen(url).read()
root = etree.HTML(page_data)
url = root.xpath("//img")[0].get('src')
except Exception as e:
logger.debug(f'Pinterest image download failed: {e}')
try: # 尝试下载Pinterest页面HTML内容
page_data = request.urlopen(url).read() # 下载Pinterest页面HTML内容
root = etree.HTML(page_data) # 解析HTML内容为ElementTree对象
url = root.xpath("//img")[0].get('src') # 从HTML中提取第一个img标签的src属性值图像URL
except Exception as e: # 处理下载Pinterest页面HTML内容时的异常
logger.debug(f'Pinterest image download failed: {e}') # 如果Pinterest图片下载失败记录Pinterest图像下载失败日志
# 尝试下载图像数据
try:
imgdata = request.urlopen(url).read()
except URLError as e:
logger.debug(f'Downloading image failed: {e.reason}')
imgdata = request.urlopen(url).read() # 下载图像数据
except URLError as e: # 处理下载图像数据时的异常
logger.debug(f'Downloading image failed: {e.reason}') # 如果图像下载失败,记录图像下载失败日志
else:
with tempfile.TemporaryDirectory() as tmp:
fname = os.path.join(tmp, 'img')
with open(fname, 'wb') as f:
f.write(imgdata)
logger.debug(f'Temporarily saved in: {fname}')
img = exif_rotated_image(fname)
return (img, url)
with tempfile.TemporaryDirectory() as tmp: # 创建一个临时文件用于存储下载成功的图像数据
fname = os.path.join(tmp, 'img') # 构建临时文件路径(例如:'/tmp/img'
with open(fname, 'wb') as f: # 以二进制写入模式打开临时文件
f.write(imgdata) # 将图像数据写入临时文件
logger.debug(f'Temporarily saved in: {fname}') # 记录临时保存的图像路径
img = exif_rotated_image(fname) # 加载临时图像文件并处理EXIF方向
return (img, url) # 返回处理后的图像对象和原始URL

View file

@ -1,41 +1,48 @@
# 数据库版本号,用于跟踪数据库结构的更改
USER_VERSION = 2
# SQLite数据库的应用程序ID用于标识特定应用程序的数据库文件
APPLICATION_ID = 2060242126
# 数据库架构定义包含创建表的SQL语句列表
SCHEMA = [
# 创建items表存储应用中的所有项目信息
"""
CREATE TABLE items (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
x REAL DEFAULT 0,
y REAL DEFAULT 0,
z REAL DEFAULT 0,
scale REAL DEFAULT 1,
rotation REAL DEFAULT 0,
flip INTEGER DEFAULT 1,
data JSON
id INTEGER PRIMARY KEY, # 主键,唯一标识每个项目
type TEXT NOT NULL, # 项目类型,如图片、文本等
x REAL DEFAULT 0, # X坐标位置
y REAL DEFAULT 0, # Y坐标位置
z REAL DEFAULT 0, # Z轴位置用于确定项目的显示顺序
scale REAL DEFAULT 1, # 缩放比例
rotation REAL DEFAULT 0, # 旋转角度(以度为单位)
flip INTEGER DEFAULT 1, # 翻转状态,用于水平或垂直翻转项目
data JSON # JSON格式的数据存储项目的其他属性
)
""",
# 创建sqlar表用于存储与项目关联的二进制数据
"""
CREATE TABLE sqlar (
name TEXT PRIMARY KEY,
item_id INTEGER NOT NULL UNIQUE,
mode INT,
mtime INT default current_timestamp,
sz INT,
data BLOB,
FOREIGN KEY (item_id)
name TEXT PRIMARY KEY, # 文件名作为主键
item_id INTEGER NOT NULL UNIQUE, # 关联的items表中的项目ID
mode INT, # 文件模式
mtime INT default current_timestamp, # 修改时间戳
sz INT, # 文件大小
data BLOB, # 二进制数据内容
FOREIGN KEY (item_id) # 外键约束关联items表
REFERENCES items (id)
ON DELETE CASCADE
ON UPDATE NO ACTION
ON DELETE CASCADE # 级联删除当items表中的记录被删除时相关的sqlar记录也会被删除
ON UPDATE NO ACTION # 禁止更新关联字段
)
""",
]
# 数据库迁移定义键为目标版本号值为实现该版本升级所需的SQL语句列表
MIGRATIONS = {
# 从版本1迁移到版本2的SQL语句
2: [
"ALTER TABLE items ADD COLUMN data JSON",
"UPDATE items SET data = json_object('filename', filename)",
"ALTER TABLE items ADD COLUMN data JSON", # 添加JSON类型的data列
"UPDATE items SET data = json_object('filename', filename)", # 将原有的filename数据迁移到新的JSON格式中
],
}

View file

@ -23,308 +23,354 @@ https://www.sqlite.org/appfileformat.html
https://www.sqlite.org/sqlar.html
"""
import json
import logging
import os
import pathlib
import shutil
import sqlite3
import tempfile
# 导入必要的模块
import json # 导入 JSON 模块,用于处理 JSON 数据
import logging # 导入日志模块,用于记录程序运行时的信息
import os # 导入 os 模块,用于处理文件路径
import pathlib # 导入 pathlib 模块,用于处理文件路径
import shutil # 导入 shutil 模块,用于文件操作
import sqlite3 # 导入 sqlite3 模块,用于操作 SQLite 数据库
import tempfile # 导入 tempfile 模块,用于创建临时文件
from PyQt6 import QtGui
from beeref import constants
from beeref.items import BeePixmapItem, BeeErrorItem
from .errors import BeeFileIOError, IMG_LOADING_ERROR_MSG
from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID
from PyQt6 import QtGui # 导入 PyQt6 模块,用于 GUI 编程
from beeref import constants # 导入 BeeRef 模块,用于定义常量
from beeref.items import BeePixmapItem, BeeErrorItem # 导入 BeeRef 模块,用于定义项
from .errors import BeeFileIOError, IMG_LOADING_ERROR_MSG # 导入 BeeRef 模块,用于定义错误
from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID # 导入 BeeRef 模块,用于定义数据库模式
# 配置日志记录器
logger = logging.getLogger(__name__)
# 检查文件是否为bee文件
def is_bee_file(path):
"""Check whether the file at the given path is a bee file."""
return os.path.splitext(path)[1] == '.bee'
return os.path.splitext(path)[1] == '.bee' # 使用os.path.splitext分割文件名和扩展名
# 处理SQLite错误
def handle_sqlite_errors(func):
def wrapper(self, *args, **kwargs):
try:
func(self, *args, **kwargs)
except Exception as e:
logger.exception(f'Error while reading/writing {self.filename}')
try:
# 尝试执行原始函数
func(self, *args, **kwargs) # 执行函数
except Exception as e: # 捕获所有异常
logger.exception(f'Error while reading/writing {self.filename}')# 记录异常日志,包含文件名信息
try: # 尝试回滚事务
# Try to roll back transaction if there is any
if (hasattr(self, '_connection')
and self._connection.in_transaction):
self.ex('ROLLBACK')
logger.debug('Transaction rolled back')
except sqlite3.Error:
pass
self._close_connection()
if self.worker:
self.worker.finished.emit(self.filename, [str(e)])
else:
raise BeeFileIOError(msg=str(e), filename=self.filename) from e
return wrapper
if (hasattr(self, '_connection') # 检查是否有数据库连接属性
and self._connection.in_transaction): # 检查是否有未提交的事务
self.ex('ROLLBACK') # 执行SQL回滚命令
logger.debug('Transaction rolled back') # 记录回滚信息
except sqlite3.Error as e: # 捕获SQLite错误
pass # 如果回滚失败忽略SQLite错误
self._close_connection() # 关闭数据库连接
if self.worker: # 如果有工作线程,则向其发送完成信号并附带错误信息
self.worker.finished.emit(self.filename, [str(e)]) # 发射工作线程完成信号
else: # 如果没有工作线程
raise BeeFileIOError(msg=str(e), filename=self.filename) from e# 抛出 BeeFileIOError 异常
return wrapper # 返回包装后的函数
# 定义SQLiteIO类用于操作SQLite数据库文件
class SQLiteIO:
# 初始化SQLiteIO类
def __init__(self, filename, scene, create_new=False, readonly=False,
worker=None):
self.scene = scene
self.create_new = create_new
self.filename = filename
self.readonly = readonly
self.worker = worker
self.retry = False
self.scene = scene # 保存场景引用,用于添加或获取项目
self.create_new = create_new # 设置是否创建新文件的标志
self.filename = filename # 保存数据库文件名
self.readonly = readonly # 设置是否为只读模式
self.worker = worker # 保存工作线程引用,用于进度更新
self.retry = False # 初始化重试标志,用于错误恢复
# 析构函数
def __del__(self):
self._close_connection()
self._close_connection() # 在对象被销毁时调用,确保资源被正确释放
# 关闭数据库连接
def _close_connection(self):
if hasattr(self, '_connection'):
self._connection.close()
delattr(self, '_connection')
if hasattr(self, '_cursor'):
delattr(self, '_cursor')
if hasattr(self, '_tmpdir'):
self._tmpdir.cleanup()
delattr(self, '_tmpdir')
# 检查并关闭数据库连接
if hasattr(self, '_connection'): # 检查是否有数据库连接属性
self._connection.close() # 关闭数据库连接
delattr(self, '_connection') # 删除数据库连接属性
# 检查并删除游标引用
if hasattr(self, '_cursor'): # 检查是否有游标属性
delattr(self, '_cursor') # 删除游标属性
# 检查并清理临时目录
if hasattr(self, '_tmpdir'): # 检查是否有临时目录属性
self._tmpdir.cleanup() # 清理临时目录
delattr(self, '_tmpdir') # 删除临时目录属性
# 建立数据库连接
def _establish_connection(self):
if (self.create_new
and not self.readonly
and os.path.exists(self.filename)):
os.remove(self.filename)
# 如果需要创建新文件且文件已存在,则删除现有文件
if (self.create_new # 检查是否创建新文件
and not self.readonly # 检查是否为只读模式
and os.path.exists(self.filename)): # 检查文件是否已存在
os.remove(self.filename) # 删除已存在的文件
if self.create_new:
self.scene.clear_save_ids()
# 如果创建新文件清除场景中所有项目的保存I
if self.create_new: # 检查是否创建新文件
self.scene.clear_save_ids() # 清除场景中所有项目的保存ID
uri = pathlib.Path(self.filename).resolve().as_uri()
if self.readonly:
uri = f'{uri}?mode=rw'
self._connection = sqlite3.connect(uri, uri=True)
self._cursor = self.connection.cursor()
if not self.create_new:
uri = pathlib.Path(self.filename).resolve().as_uri()# 将文件路径转换为URI格式确保跨平台兼容性
if self.readonly: # 检查是否为只读模式
uri = f'{uri}?mode=rw' # 如果是只读模式,添加查询参数以指定为读写模式
self._connection = sqlite3.connect(uri, uri=True) # 建立数据库连接使用URI格式
self._cursor = self._connection.cursor() # 创建游标对象用于执行SQL语句
# 如果不是创建新文件,则尝试迁移数据库
if not self.create_new: # 检查是否不是创建新文件
try:
self._migrate()
except Exception:
self._migrate() # 尝试迁移数据库
except Exception as e: # 捕获所有异常
# Updating a file failed; try creating it from scratch instead
logger.exception('Error migrating bee file')
self.create_new = True
self._establish_connection()
logger.exception('Error migrating bee file: %s', e) # 记录异常日志,包含异常信息
self.create_new = True # 标记为创建新文件
self._establish_connection() # 重新建立数据库连接
# 迁移数据库
def _migrate(self):
"""Migrate database if necessary."""
version = self.fetchone('PRAGMA user_version')[0]
logger.debug(f'Found bee file version: {version}')
if version >= USER_VERSION:
logger.debug('Version ok; no migrations necessary')
return
version = self.fetchone('PRAGMA user_version')[0] # 获取数据库用户版本号
logger.debug(f'Found bee file version: {version}') # 记录数据库版本号
if version >= USER_VERSION: # 检查数据库版本号是否大于等于用户版本号
logger.debug('Version ok; no migrations necessary') # 如果版本号大于等于用户版本号,无需迁移
return # 如果版本号大于等于用户版本号,无需迁移
# 如果版本已经是最新的,不需要迁移
if self.readonly:
try:
# See whether file is writable so we can migrate it directly
self.ex('PRAGMA application_id=%s' % APPLICATION_ID)
except sqlite3.Error:
logger.debug('File not writable; use temporary copy instead')
self._connection.close()
self._tmpdir = tempfile.TemporaryDirectory(
prefix=constants.APPNAME)
tmpname = os.path.join(self._tmpdir.name, 'mig.bee')
shutil.copyfile(self.filename, tmpname)
self._connection = sqlite3.connect(tmpname)
self._cursor = self.connection.cursor()
self.ex('PRAGMA application_id=%s' % APPLICATION_ID) # 设置应用程序ID确保数据库与应用程序兼容
except sqlite3.Error as e: # 捕获 SQLite 错误异常
logger.debug('File not writable; use temporary copy instead')# 记录文件不可写日志,建议使用临时副本
self._connection.close() # 关闭数据库连接
self._tmpdir = tempfile.TemporaryDirectory( # 创建临时目录,用于存储迁移后的数据库文件
prefix=constants.APPNAME) # 设置临时目录前缀,确保与应用程序名称一致
tmpname = os.path.join(self._tmpdir.name, 'mig.bee') # 构建临时数据库文件名
shutil.copyfile(self.filename, tmpname) # 复制原始数据库文件到临时目录
self._connection = sqlite3.connect(tmpname) # 连接到临时数据库文件
self._cursor = self.connection.cursor() # 创建游标对象用于执行SQL语句
self.ex('BEGIN TRANSACTION')
for i in range(version, USER_VERSION):
logger.debug(f'Migrating from version {i} to {i + 1}...')
for migration in MIGRATIONS[i + 1]:
self.ex(migration)
self.write_meta()
self.connection.commit()
logger.debug('Migration finished')
self.ex('BEGIN TRANSACTION') # 开始事务,确保迁移的原子性
for i in range(version, USER_VERSION): # 遍历需要迁移的版本号范围
logger.debug(f'Migrating from version {i} to {i + 1}...') # 记录迁移信息,包含当前版本号和目标版本号
for migration in MIGRATIONS[i + 1]: # 遍历当前版本号对应的迁移SQL语句列表
self.ex(migration) # 执行迁移SQL语句
self.write_meta() # 写入数据库元数据包括应用程序ID和用户版本号
self.connection.commit() # 提交事务,确保所有迁移操作都被永久保存
logger.debug('Migration finished') # 记录迁移完成日志
@property
# 获取数据库连接属性
def connection(self):
if not hasattr(self, '_connection'):
self._establish_connection()
return self._connection
if not hasattr(self, '_connection'): # 检查是否没有数据库连接属性
self._establish_connection() # 如果没有数据库连接属性,建立数据库连接
return self._connection # 返回数据库连接属性
@property
# 获取数据库游标属性
def cursor(self):
if not hasattr(self, '_cursor'):
self._establish_connection()
return self._cursor
if not hasattr(self, '_cursor'): # 检查是否没有数据库游标属性
self._establish_connection() # 如果没有数据库游标属性,建立数据库连接
return self._cursor # 返回数据库游标属性
# 执行SQL语句返回执行结果
def ex(self, *args, **kwargs):
return self.cursor.execute(*args, **kwargs)
# 执行批量SQL语句返回执行结果
def exmany(self, *args, **kwargs):
return self.cursor.executemany(*args, **kwargs)
# 从数据库游标中获取一条记录,返回记录元组
def fetchone(self, *args, **kwargs):
self.ex(*args, **kwargs)
return self.cursor.fetchone()
self.ex(*args, **kwargs) # 执行SQL语句传入参数和关键字参数
return self.cursor.fetchone() # 返回查询结果的第一行
# 从数据库游标中获取所有记录,返回记录列表
def fetchall(self, *args, **kwargs):
self.ex(*args, **kwargs)
return self.cursor.fetchall()
self.ex(*args, **kwargs) # 执行SQL语句传入参数和关键字参数
return self.cursor.fetchall() # 从游标中获取所有记录,返回记录列表
# 写入数据库元数据包括应用程序ID和用户版本号
def write_meta(self):
self.ex('PRAGMA application_id=%s' % APPLICATION_ID)
self.ex('PRAGMA user_version=%s' % USER_VERSION)
self.ex('PRAGMA foreign_keys=ON')
self.ex('PRAGMA application_id=%s' % APPLICATION_ID) # 设置应用程序ID确保数据库与应用程序兼容
self.ex('PRAGMA user_version=%s' % USER_VERSION) # 设置用户版本,用于版本控制
self.ex('PRAGMA foreign_keys=ON') # 启用外键约束,确保数据完整性
# 创建新文件时创建数据库架构
def create_schema_on_new(self):
if self.create_new:
self.write_meta()
for schema in SCHEMA:
self.ex(schema)
if self.create_new: # 仅在创建新文件时执行
self.write_meta() # 写入数据库元数据
for schema in SCHEMA: # 执行所有架构创建SQL语句
self.ex(schema) # 执行架构创建SQL语句
# 从数据库中读取所有项,包括图片和文本项
@handle_sqlite_errors
def read(self):
# 读取所有图像项目及其关联的二进制数据
rows = self.fetchall(
'SELECT items.id, type, x, y, z, scale, rotation, flip, '
'items.data, sqlar.data '
'FROM sqlar JOIN items on sqlar.item_id = items.id')
# Avoid OUTER JOIN for performance reasons; fetch text items
# separately instead
# 读取所有文本项目
rows.extend(self.fetchall(
'SELECT items.id, type, x, y, z, scale, rotation, flip, '
' items.data, null as data '
'FROM items '
'WHERE items.type = "text"'))
if self.worker:
self.worker.begin_processing.emit(len(rows))
if self.worker: # 如果有工作线程
self.worker.begin_processing.emit(len(rows)) # 发送开始处理信号,包含项数
# 遍历所有读取的项目数据
for i, row in enumerate(rows):
# 构建项目数据字典
data = {
'save_id': row[0],
'type': row[1],
'x': row[2],
'y': row[3],
'z': row[4],
'scale': row[5],
'rotation': row[6],
'flip': row[7],
'data': json.loads(row[8]),
'save_id': row[0], # 项目ID
'type': row[1], # 项目类型
'x': row[2], # X坐标
'y': row[3], # Y坐标
'z': row[4], # Z坐标
'scale': row[5], # 缩放比例
'rotation': row[6], # 旋转角度
'flip': row[7], # 是否翻转
'data': json.loads(row[8]), # 项目数据
}
if data['type'] == 'pixmap':
item = BeePixmapItem(QtGui.QImage())
item.pixmap_from_bytes(row[9])
# 处理像素图项目
if data['type'] == 'pixmap': # 如果项目类型为像素图
item = BeePixmapItem(QtGui.QImage()) # 创建空的像素图项目
item.pixmap_from_bytes(row[9]) # 从二进制数据创建像素图
# 检查图像是否加载成功
if item.pixmap().isNull():
item = data['data']['text'] = (
# 如果图像加载失败,创建错误项目
item = data['data']['text'] = (
f'Image could not be loaded: {item.filename}\n'
+ IMG_LOADING_ERROR_MSG)
data['type'] = BeeErrorItem.TYPE
data['item'] = item
self.scene.add_item_later(data)
data['type'] = BeeErrorItem.TYPE # 将项目类型设置为错误项类型
data['item'] = item # 将项目添加到数据字典中
self.scene.add_item_later(data) # 稍后将项目添加到场景中
# 如果有工作线程,更新进度并检查是否取消操作
if self.worker:
logger.trace(f'Emit progress: {i}')
self.worker.progress.emit(i)
if self.worker.canceled:
self.worker.finished.emit('', [])
return
logger.trace(f'Emit progress: {i}') # 记录进度信息
self.worker.progress.emit(i) # 发送进度信号,包含当前项索引
if self.worker.canceled: # 如果工作线程已取消
self.worker.finished.emit('', []) # 发送完成信号,包含空字符串和空列表
return # 退出循环,停止处理
# Give main thread time to process items:
self.worker.msleep(10)
if self.worker:
self.worker.finished.emit(self.filename, [])
self.worker.msleep(10) # 暂停10毫秒允许主线程处理事件
if self.worker: # 如果有工作线程
self.worker.finished.emit(self.filename, [])# 发送完成信号,包含文件名和空列表
@handle_sqlite_errors
# 写入数据库中的所有项,包括图片和文本项
def write(self):
if self.readonly:
raise sqlite3.OperationalError(
# 检查是否为只读模式
if self.readonly: # 如果数据库为只读模式
raise sqlite3.OperationalError( # 抛出操作错误异常
'Attempt to write to a readonly database')
try:
self.create_schema_on_new()
self.write_data()
except Exception:
if self.retry:
try: # 尝试执行写入操作
self.create_schema_on_new() # 创建新文件时创建数据库架构
self.write_data() # 写入所有数据
except Exception as e: # 捕获所有异常
#处理写入错误
if self.retry: # 如果允许重试
# Trying to recover failed
raise
raise # 如果已经重试过一次,则放弃并抛出异常
else:
self.retry = True
self.retry = True # 第一次失败后,标记为需要重试
# Try creating file from scratch and save again
logger.exception(
f'Updating to existing file {self.filename} failed')
self.create_new = True
self._close_connection()
self.write()
logger.exception( # 尝试从头创建文件并再次保存
f'Updating to existing file {self.filename} failed') # 记录更新失败的异常信息
self.create_new = True # 标记为创建新文件
self._close_connection() # 关闭现有连接,以便重新建立连接
self.write() # 递归调用写入方法,尝试重新创建文件并保存
# 写入场景数据到数据库
def write_data(self):
to_delete = {row[0] for row in self.fetchall('SELECT id from ITEMS')}
to_delete = {row[0] for row in self.fetchall('SELECT id from ITEMS')} # 获取所有需要删除的项目ID
# We don't want to touch existing items that are displayed as errors:
keep = {item.original_save_id
for item in self.scene.items_by_type(BeeErrorItem.TYPE)}
logger.debug(f'Not saving error items: {keep}')
to_delete = to_delete - keep
for item in self.scene.items_by_type(BeeErrorItem.TYPE)} # 获取所有错误项的原始保存ID
logger.debug(f'Not saving error items: {keep}') # 记录不保存错误项的ID
to_delete = to_delete - keep # 从待删除列表中移除需要保留的项目
# 获取需要保存的项目列表
to_save = list(self.scene.items_for_save())
# 如果有工作线程,发送开始处理信号
if self.worker:
self.worker.begin_processing.emit(len(to_save))
# 遍历所有需要保存的项目
for i, item in enumerate(to_save):
logger.debug(f'Saving {item} with id {item.save_id}')
if item.save_id:
self.update_item(item)
to_delete.remove(item.save_id)
else:
self.insert_item(item)
logger.debug(f'Saving {item} with id {item.save_id}') # 记录正在保存的项目信息
if item.save_id: # 如果项目已存在数据库中
self.update_item(item) # 更新项目数据
to_delete.remove(item.save_id) # 从待删除列表中移除已更新的项目ID
else: # 如果项目不在数据库中
self.insert_item(item) # 插入新项目到数据库中
# 如果有工作线程,发送进度信号,包含当前项索引
if self.worker:
self.worker.progress.emit(i)
# 如果工作线程已取消,跳出循环,停止处理
if self.worker.canceled:
break
self.delete_items(to_delete)
self.ex('VACUUM')
self.connection.commit()
if self.worker:
self.worker.finished.emit(self.filename, [])
self.delete_items(to_delete) # 删除不再需要的项目
self.ex('VACUUM') # 执行VACUUM命令优化数据库空间
self.connection.commit() # 提交事务,确保所有更改生效
if self.worker: # 如果有工作线程
self.worker.finished.emit(self.filename, []) # 发送完成信号,包含文件名和空列表
# 从数据库中删除指定的项目
def delete_items(self, to_delete):
to_delete = [(pk,) for pk in to_delete]
self.exmany('DELETE FROM items WHERE id=?', to_delete)
self.exmany('DELETE FROM sqlar WHERE item_id=?', to_delete)
self.connection.commit()
to_delete = [(pk,) for pk in to_delete] # 将待删除项目ID转换为元组列表
self.exmany('DELETE FROM items WHERE id=?', to_delete) # 执行批量删除项目的SQL语句
self.exmany('DELETE FROM sqlar WHERE item_id=?', to_delete) # 执行批量删除关联文件的SQL语句
self.connection.commit() # 提交事务,确保所有更改生效
# 向数据库中插入新项目
def insert_item(self, item):
# 插入项目基本信息到items表
self.ex(
'INSERT INTO items (type, x, y, z, scale, rotation, flip, '
'data) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(item.TYPE, item.pos().x(), item.pos().y(), item.zValue(),
item.scale(), item.rotation(), item.flip(),
json.dumps(item.get_extra_save_data())))
item.save_id = self.cursor.lastrowid
(item.TYPE, item.pos().x(), item.pos().y(), item.zValue(), # 插入项目的类型、位置、Z值、缩放比例、旋转角度和翻转状态
item.scale(), item.rotation(), item.flip(), # 插入项目的缩放比例、旋转角度和翻转状态
json.dumps(item.get_extra_save_data()))) # 插入项目的额外保存数据
item.save_id = self.cursor.lastrowid # 获取新插入项目的ID
# 如果项目有像素图数据,也保存它
if hasattr(item, 'pixmap_to_bytes'):
pixmap, imgformat = item.pixmap_to_bytes()
name = item.get_filename_for_export(imgformat)
pixmap, imgformat = item.pixmap_to_bytes() # 获取像素图的二进制数据和图像格式
name = item.get_filename_for_export(imgformat) # 获取导出文件名
# 将二进制数据插入到sqlar表
self.ex(
'INSERT INTO sqlar (item_id, name, mode, sz, data) '
'VALUES (?, ?, ?, ?, ?)',
(item.save_id, name, 0o644, len(pixmap), pixmap))
self.connection.commit()
(item.save_id, name, 0o644, len(pixmap), pixmap)) # 插入像素图数据到sqlar表
self.connection.commit() # 提交事务,确保所有更改生效
# 更新数据库中的项目数据
def update_item(self, item):
"""Update item data.
We only update the item data, not the pixmap data, as pixmap
data never changes and is also time-consuming to save.
"""
# 更新items表中的项目数据
self.ex(
'UPDATE items SET x=?, y=?, z=?, scale=?, rotation=?, flip=?, '
'data=? '
'WHERE id=?',
(item.pos().x(), item.pos().y(), item.zValue(), item.scale(),
item.rotation(), item.flip(),
json.dumps(item.get_extra_save_data()),
item.save_id))
self.connection.commit()
(item.pos().x(), item.pos().y(), item.zValue(), item.scale(), # 更新项目的位置、Z值、缩放比例
item.rotation(), item.flip(), # 更新项目的旋转角度和翻转状态
json.dumps(item.get_extra_save_data()), # 更新项目的额外保存数据
item.save_id)) # 更新项目的ID
self.connection.commit() # 提交事务,确保所有更改生效

File diff suppressed because it is too large Load diff

View file

@ -23,10 +23,10 @@ from beeref.items import BeePixmapItem
from beeref import fileio
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # 主控件日志记录器,用于记录主控件的日志信息
class MainControlsMixin:
class MainControlsMixin: # 主控件混合类,用于处理主窗口和欢迎叠加层的基本控件
"""Basic controls shared by the main view and the welcome overlay:
* Right-click menu
@ -34,114 +34,114 @@ class MainControlsMixin:
* Moving the window without title bar
"""
def init_main_controls(self, main_window):
self.main_window = main_window
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(
self.control_target.on_context_menu)
self.setAcceptDrops(True)
self.movewin_active = False
def init_main_controls(self, main_window): # 初始化主控件,用于处理主窗口和欢迎叠加层的基本控件
self.main_window = main_window # 主窗口引用,用于访问主窗口的属性和方法
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) # 设置上下文菜单策略为自定义上下文菜单,用于处理右键点击事件
self.customContextMenuRequested.connect( # 自定义上下文菜单请求信号槽函数,用于处理右键点击事件
self.control_target.on_context_menu) # 自定义上下文菜单请求信号槽函数,用于处理右键点击事件,调用控制目标的上下文菜单槽函数
self.setAcceptDrops(True) # 设置接受拖放事件,用于处理文件拖放事件
self.movewin_active = False
def on_action_movewin_mode(self):
if self.movewin_active:
def on_action_movewin_mode(self): # 移动窗口模式槽函数,用于处理移动窗口模式的切换
if self.movewin_active: # 如果移动窗口模式已激活
# Pressing the same shortcut again should end the action
self.exit_movewin_mode()
self.exit_movewin_mode() # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换
else:
self.enter_movewin_mode()
self.enter_movewin_mode() # 进入移动窗口模式槽函数,用于处理移动窗口模式的切换
@property
def viewport_or_self(self):
if hasattr(self, 'viewport'):
return self.viewport()
return self
@property # viewport_or_self属性用于返回视口或自身用于处理移动窗口模式的切换
def viewport_or_self(self): # viewport_or_self属性用于返回视口或自身用于处理移动窗口模式的切换
if hasattr(self, 'viewport'): # 如果主控件有视口属性
return self.viewport() # 返回视口
return self # 返回自身
def enter_movewin_mode(self):
logger.debug('Entering movewin mode')
self.setMouseTracking(True)
self.movewin_active = True
self.viewport_or_self.setCursor(Qt.CursorShape.SizeAllCursor)
self.event_start = QtCore.QPointF(self.cursor().pos())
if hasattr(self, 'disable_mouse_events'):
self.disable_mouse_events()
def enter_movewin_mode(self): # 进入移动窗口模式槽函数,用于处理移动窗口模式的切换
logger.debug('Entering movewin mode') # 进入移动窗口模式槽函数,用于处理移动窗口模式的切换
self.setMouseTracking(True) # 设置鼠标跟踪,用于处理移动窗口模式的切换
self.movewin_active = True # 移动窗口模式已激活
self.viewport_or_self.setCursor(Qt.CursorShape.SizeAllCursor) # 设置视口或自身的光标为大小调整光标,用于处理移动窗口模式的切换
self.event_start = QtCore.QPointF(self.cursor().pos()) # 记录鼠标点击位置,用于处理移动窗口模式的切换
if hasattr(self, 'disable_mouse_events'): # 如果主控件有禁用鼠标事件属性
self.disable_mouse_events() # 禁用鼠标事件,用于处理移动窗口模式的切换
def exit_movewin_mode(self):
logger.debug('Exiting movewin mode')
self.setMouseTracking(False)
self.movewin_active = False
self.viewport_or_self.unsetCursor()
if hasattr(self, 'enable_mouse_events'):
self.enable_mouse_events()
def exit_movewin_mode(self): # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换
logger.debug('Exiting movewin mode') # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换
self.setMouseTracking(False) # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换
self.movewin_active = False # 移动窗口模式未激活
self.viewport_or_self.unsetCursor() # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换
if hasattr(self, 'enable_mouse_events'): # 如果主控件有启用鼠标事件属性
self.enable_mouse_events() # 启用鼠标事件,用于处理移动窗口模式的切换
def dragEnterEvent(self, event):
mimedata = event.mimeData()
logger.debug(f'Drag enter event: {mimedata.formats()}')
if mimedata.hasUrls():
event.acceptProposedAction()
elif mimedata.hasImage():
event.acceptProposedAction()
def dragEnterEvent(self, event): # 拖入事件槽函数,用于处理拖入事件
mimedata = event.mimeData() # 获取拖入事件的MIME数据用于处理拖入事件
logger.debug(f'Drag enter event: {mimedata.formats()}') # 拖入事件槽函数用于处理拖入事件记录拖入事件的MIME数据格式
if mimedata.hasUrls(): # 如果拖入事件的MIME数据包含URL
event.acceptProposedAction() # 接受拖入事件的建议操作,用于处理拖入事件
elif mimedata.hasImage(): # 如果拖入事件的MIME数据包含图像
event.acceptProposedAction() # 接受拖入事件的建议操作,用于处理拖入事件
else:
msg = 'Attempted drop not an image or image too big'
logger.info(msg)
widgets.BeeNotification(self.control_target, msg)
msg = 'Attempted drop not an image or image too big' # 拖入事件槽函数用于处理拖入事件记录拖入事件的MIME数据格式
logger.info(msg) # 拖入事件槽函数用于处理拖入事件记录拖入事件的MIME数据格式
widgets.BeeNotification(self.control_target, msg) # 拖入事件槽函数用于处理拖入事件显示拖入事件的MIME数据格式
def dragMoveEvent(self, event):
event.acceptProposedAction()
def dragMoveEvent(self, event): # 拖动移动事件槽函数,用于处理拖动移动事件
event.acceptProposedAction() # 接受拖动移动事件的建议操作,用于处理拖动移动事件
def dropEvent(self, event):
mimedata = event.mimeData()
logger.debug(f'Handling file drop: {mimedata.formats()}')
pos = QtCore.QPoint(round(event.position().x()),
round(event.position().y()))
if mimedata.hasUrls():
logger.debug(f'Found dropped urls: {mimedata.urls()}')
if not self.control_target.scene.items():
def dropEvent(self, event): # 拖放事件槽函数,用于处理拖放事件
mimedata = event.mimeData() # 获取拖放事件的MIME数据用于处理拖放事件
logger.debug(f'Handling file drop: {mimedata.formats()}') # 拖放事件槽函数用于处理拖放事件记录拖放事件的MIME数据格式
pos = QtCore.QPoint(round(event.position().x()), # 记录拖放事件的位置,用于处理拖放事件
round(event.position().y())) # 记录拖放事件的位置,用于处理拖放事件
if mimedata.hasUrls(): # 如果拖放事件的MIME数据包含URL
logger.debug(f'Found dropped urls: {mimedata.urls()}') # 拖放事件槽函数用于处理拖放事件记录拖放事件的URL
if not self.control_target.scene.items(): # 如果场景中没有项目(即没有插入的图片项)
# Check if we have a bee file we can open directly
path = mimedata.urls()[0]
if (path.isLocalFile()
and fileio.is_bee_file(path.toLocalFile())):
self.control_target.open_from_file(path.toLocalFile())
return
self.control_target.do_insert_images(mimedata.urls(), pos)
elif mimedata.hasImage():
img = QtGui.QImage(mimedata.imageData())
item = BeePixmapItem(img)
pos = self.control_target.mapToScene(pos)
path = mimedata.urls()[0] # 获取拖放事件的URL用于处理拖放事件
if (path.isLocalFile() # 如果拖放事件的URL是本地文件
and fileio.is_bee_file(path.toLocalFile())): # 如果拖放事件的URL是本地文件且是BeeRef文件
self.control_target.open_from_file(path.toLocalFile()) # 打开拖放事件的URL用于处理拖放事件
return # 如果拖放事件的URL是本地文件且是BeeRef文件直接打开文件不进行插入图片项操作
self.control_target.do_insert_images(mimedata.urls(), pos) # 插入图片项槽函数用于处理拖放事件插入拖放事件的URL对应的图片项
elif mimedata.hasImage(): # 如果拖放事件的MIME数据包含图像
img = QtGui.QImage(mimedata.imageData()) # 获取拖放事件的图像数据,用于处理拖放事件
item = BeePixmapItem(img) # 创建图片项,用于处理拖放事件
pos = self.control_target.mapToScene(pos) # 记录拖放事件的位置,用于处理拖放事件
self.control_target.undo_stack.push(
commands.InsertItems(self.control_target.scene, [item], pos))
commands.InsertItems(self.control_target.scene, [item], pos)) # 插入图片项槽函数用于处理拖放事件插入拖放事件的URL对应的图片项
else:
logger.info('Drop not an image')
logger.info('Drop not an image') # 拖放事件槽函数用于处理拖放事件记录拖放事件的MIME数据格式
def mousePressEventMainControls(self, event):
if self.movewin_active:
self.exit_movewin_mode()
event.accept()
return True
def mousePressEventMainControls(self, event): # 鼠标按下事件槽函数,用于处理鼠标按下事件
if self.movewin_active: # 如果移动窗口模式激活
self.exit_movewin_mode() # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换
event.accept() # 接受鼠标按下事件的建议操作,用于处理鼠标按下事件
return True # 如果移动窗口模式激活直接返回True不进行其他操作
action, inverted =\
self.control_target.keyboard_settings.mouse_action_for_event(event)
self.control_target.keyboard_settings.mouse_action_for_event(event) # 获取鼠标按下事件对应的操作,用于处理鼠标按下事件
if action == 'movewindow':
self.enter_movewin_mode()
event.accept()
return True
self.enter_movewin_mode() # 进入移动窗口模式槽函数,用于处理移动窗口模式的切换
event.accept() # 接受鼠标按下事件的建议操作,用于处理鼠标按下事件
return True # 如果移动窗口模式激活直接返回True不进行其他操作
def mouseMoveEventMainControls(self, event):
if self.movewin_active:
pos = self.mapToGlobal(event.position())
delta = pos - self.event_start
self.event_start = pos
self.main_window.move(self.main_window.x() + int(delta.x()),
self.main_window.y() + int(delta.y()))
event.accept()
return True
def mouseMoveEventMainControls(self, event): # 鼠标移动事件槽函数,用于处理鼠标移动事件
if self.movewin_active: # 如果移动窗口模式激活
pos = self.mapToGlobal(event.position()) # 获取鼠标移动事件的全局位置,用于处理鼠标移动事件
delta = pos - self.event_start # 计算鼠标移动事件的 delta 向量,用于处理鼠标移动事件
self.event_start = pos # 更新鼠标移动事件的起始位置,用于处理鼠标移动事件
self.main_window.move(self.main_window.x() + int(delta.x()), # 移动主窗口槽函数,用于处理移动窗口模式的切换
self.main_window.y() + int(delta.y())) # 移动主窗口槽函数,用于处理移动窗口模式的切换
event.accept() # 接受鼠标移动事件的建议操作,用于处理鼠标移动事件
return True # 如果移动窗口模式激活直接返回True不进行其他操作
def mouseReleaseEventMainControls(self, event):
if self.movewin_active:
self.exit_movewin_mode()
event.accept()
return True
def mouseReleaseEventMainControls(self, event): # 鼠标释放事件槽函数,用于处理鼠标释放事件
if self.movewin_active: # 如果移动窗口模式激活
self.exit_movewin_mode() # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换
event.accept() # 接受鼠标释放事件的建议操作,用于处理鼠标释放事件
return True # 如果移动窗口模式激活直接返回True不进行其他操作
def keyPressEventMainControls(self, event):
if self.movewin_active:
self.exit_movewin_mode()
event.accept()
return True
def keyPressEventMainControls(self, event): # 键盘按下事件槽函数,用于处理键盘按下事件
if self.movewin_active: # 如果移动窗口模式激活
self.exit_movewin_mode() # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换
event.accept() # 接受键盘按下事件的建议操作,用于处理键盘按下事件
return True # 如果移动窗口模式激活直接返回True不进行其他操作

View file

@ -13,552 +13,555 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
from functools import partial
import logging
import math
from queue import Queue
from functools import partial # 导入偏函数,用于固定函数参数创建新函数
import logging # 导入日志模块,用于记录程序运行时的日志信息
import math # 导入数学模块,用于数学计算(如三角函数、平方根等)
from queue import Queue # 导入队列数据结构,用于线程间通信或任务排队
from PyQt6 import QtCore, QtWidgets, QtGui
from PyQt6.QtCore import Qt
from PyQt6 import QtCore, QtWidgets, QtGui # 导入PyQt6的核心模块、控件模块和GUI模块
from PyQt6.QtCore import Qt # 导入Qt常量定义如对齐方式、事件类型等
import rpack
import rpack # 导入矩形打包库,用于高效排列矩形元素
from beeref import commands
from beeref.config import BeeSettings
from beeref.items import item_registry, BeeErrorItem, sort_by_filename
from beeref.selection import MultiSelectItem, RubberbandItem
from beeref import commands # 导入beeref项目的命令模块处理用户操作的命令实现
from beeref.config import BeeSettings # 导入配置设置类,管理应用程序的用户配置
from beeref.items import item_registry, BeeErrorItem, sort_by_filename # 导入项目项相关组件:注册表、错误项和文件名排序函数
from beeref.selection import MultiSelectItem, RubberbandItem # 导入选择相关类:多选项和橡皮筋选择框
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # 创建当前模块的日志记录器
class BeeGraphicsScene(QtWidgets.QGraphicsScene):
cursor_changed = QtCore.pyqtSignal(QtGui.QCursor)
cursor_cleared = QtCore.pyqtSignal()
class BeeGraphicsScene(QtWidgets.QGraphicsScene): # 定义场景类继承自QGraphicsScene
cursor_changed = QtCore.pyqtSignal(QtGui.QCursor) # 定义光标变化信号
cursor_cleared = QtCore.pyqtSignal() # 定义光标清除信号
MOVE_MODE = 1
RUBBERBAND_MODE = 2
MOVE_MODE = 1 # 定义移动模式常量
RUBBERBAND_MODE = 2 # 定义橡皮筋选择模式常量
def __init__(self, undo_stack):
super().__init__()
self.active_mode = None
self.undo_stack = undo_stack
self.max_z = 0
self.min_z = 0
self.Z_STEP = 0.001
self.selectionChanged.connect(self.on_selection_change)
self.changed.connect(self.on_change)
self.items_to_add = Queue()
self.edit_item = None
self.crop_item = None
self.settings = BeeSettings()
self.clear()
self._clear_ongoing = False
def __init__(self, undo_stack): # 初始化方法,接收撤销栈参数
super().__init__() # 调用父类的初始化方法
self.active_mode = None # 初始化当前活动模式为None
self.undo_stack = undo_stack # 保存撤销栈引用
self.max_z = 0 # 初始化最大Z轴值
self.min_z = 0 # 初始化最小Z轴值
self.Z_STEP = 0.001 # 设置Z轴步长
self.selectionChanged.connect(self.on_selection_change) # 连接选择变化信号到处理函数
self.changed.connect(self.on_change) # 连接场景变化信号到处理函数
self.items_to_add = Queue() # 初始化待添加项目队列
self.edit_item = None # 初始化当前编辑项目为None
self.crop_item = None # 初始化裁剪项目为None
self.settings = BeeSettings() # 初始化设置对象
self.clear() # 清除场景内容
self._clear_ongoing = False # 初始化清除操作标志为False
def clear(self):
self._clear_ongoing = True
super().clear()
self.internal_clipboard = []
self.rubberband_item = RubberbandItem()
self.multi_select_item = MultiSelectItem()
self._clear_ongoing = False
self._clear_ongoing = True # 设置清理进行中标志,避免清理过程中触发其他依赖状态的逻辑
super().clear() # 调用父类的清除方法,清除场景中的所有项目
self.internal_clipboard = [] # 初始化内部剪贴板为空列表,用于存储复制到剪贴板的项目
self.rubberband_item = RubberbandItem() # 重新创建橡皮筋选择框实例,重置选择框状态
self.multi_select_item = MultiSelectItem() # 重新创建多选项实例,重置多选项状态
self._clear_ongoing = False # 清除操作完成,重置清除进行中标志
def addItem(self, item):
logger.debug(f'Adding item {item}')
super().addItem(item)
def addItem(self, item): # 添加项目到场景
logger.debug(f'Adding item {item}') # 记录添加项目的调试日志
super().addItem(item) # 调用父类的添加项目方法,将项目添加到场景中
def removeItem(self, item):
logger.debug(f'Removing item {item}')
super().removeItem(item)
def removeItem(self, item): # 从场景中移除项目
logger.debug(f'Removing item {item}') # 记录移除项目的调试日志
super().removeItem(item) # 调用父类的移除项目方法,将项目从场景中移除
def cancel_active_modes(self):
def cancel_active_modes(self): # 取消所有活动模式
"""Cancels ongoing crop modes, rubberband modes etc, if there are
any.
"""
self.cancel_crop_mode()
self.end_rubberband_mode()
self.cancel_crop_mode() # 取消当前的裁剪模式
self.end_rubberband_mode() # 结束当前的橡皮筋选择模式
def end_rubberband_mode(self):
if self.rubberband_item.scene():
logger.debug('Ending rubberband selection')
self.removeItem(self.rubberband_item)
self.active_mode = None
def end_rubberband_mode(self): # 结束橡皮筋选择模式
if self.rubberband_item.scene(): # 如果橡皮筋选择框存在于场景中
logger.debug('Ending rubberband selection') # 记录结束橡皮筋选择模式的调试日志
self.removeItem(self.rubberband_item) # 从场景中移除橡皮筋选择框
self.active_mode = None # 重置当前活动模式为None
def cancel_crop_mode(self):
"""Cancels an ongoing crop mode, if there is any."""
if self.crop_item:
self.crop_item.exit_crop_mode(confirm=False)
def cancel_crop_mode(self): # 取消当前的裁剪模式
"""Cancels an ongoing crop mode, if there is any."""
if self.crop_item: # 如果存在裁剪项目
self.crop_item.exit_crop_mode(confirm=False) # 退出裁剪模式,不确认裁剪
def copy_selection_to_internal_clipboard(self):
self.internal_clipboard = []
for item in self.selectedItems(user_only=True):
self.internal_clipboard.append(item)
def copy_selection_to_internal_clipboard(self): # 复制当前选择的项目到内部剪贴板
self.internal_clipboard = [] # 清空内部剪贴板
for item in self.selectedItems(user_only=True): # 遍历当前用户选择的项目
self.internal_clipboard.append(item) # 将项目添加到内部剪贴板
def paste_from_internal_clipboard(self, position):
copies = []
def paste_from_internal_clipboard(self, position): # 从内部剪贴板粘贴项目到指定位置
copies = [] # 创建一个空列表,用于存储复制的项目
for item in self.internal_clipboard:
copy = item.create_copy()
copies.append(copy)
self.undo_stack.push(commands.InsertItems(self, copies, position))
copy = item.create_copy() # 创建当前项目的副本
copies.append(copy) # 将副本添加到复制列表中
self.undo_stack.push(commands.InsertItems(self, copies, position)) # 将复制操作添加到撤销栈中
def raise_to_top(self):
self.cancel_active_modes()
items = self.selectedItems(user_only=True)
z_values = map(lambda i: i.zValue(), items)
delta = self.max_z + self.Z_STEP - min(z_values)
logger.debug(f'Raise to top, delta: {delta}')
for item in items:
item.setZValue(item.zValue() + delta)
def raise_to_top(self): # 将当前选择的项目提升到顶部
self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰
items = self.selectedItems(user_only=True) # 获取当前用户选择的项目
z_values = map(lambda i: i.zValue(), items) # 提取项目的Z轴值
delta = self.max_z + self.Z_STEP - min(z_values) # 计算需要提升的Z轴值增量
logger.debug(f'Raise to top, delta: {delta}') # 记录提升到顶部的调试日志
for item in items: # 遍历选择的项目
item.setZValue(item.zValue() + delta) # 提升项目的Z轴值将其提升到顶部
def lower_to_bottom(self):
self.cancel_active_modes()
items = self.selectedItems(user_only=True)
z_values = map(lambda i: i.zValue(), items)
delta = self.min_z - self.Z_STEP - max(z_values)
logger.debug(f'Lower to bottom, delta: {delta}')
def lower_to_bottom(self): # 将当前选择的项目降低到底部
self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰
items = self.selectedItems(user_only=True) # 获取当前用户选择的项目
z_values = map(lambda i: i.zValue(), items) # 提取项目的Z轴值
delta = self.min_z - self.Z_STEP - max(z_values) # 计算需要降低的Z轴值增量
logger.debug(f'Lower to bottom, delta: {delta}') # 记录降低到底部的调试日志
for item in items:
item.setZValue(item.zValue() + delta)
for item in items: # 遍历选择的项目
item.setZValue(item.zValue() + delta) # 降低项目的Z轴值将其降低到底部
def normalize_width_or_height(self, mode):
def normalize_width_or_height(self, mode): # 归一化选择项目的宽度或高度
"""Scale the selected images to have the same width or height, as
specified by ``mode``.
:param mode: "width" or "height".
"""
self.cancel_active_modes()
values = []
items = self.selectedItems(user_only=True)
for item in items:
rect = self.itemsBoundingRect(items=[item])
values.append(getattr(rect, mode)())
if len(values) < 2:
return
avg = sum(values) / len(values)
logger.debug(f'Calculated average {mode} {avg}')
self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰
values = [] # 创建一个空列表,用于存储选择项目的宽度或高度值
items = self.selectedItems(user_only=True) # 获取当前用户选择的项目
for item in items: # 遍历选择的项目
rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形
values.append(getattr(rect, mode)()) # 提取矩形的宽度或高度值,并添加到列表中
if len(values) < 2: # 如果选择的项目数量小于2
return # 直接返回,无需归一化
avg = sum(values) / len(values) # 计算宽度或高度的平均值
logger.debug(f'Calculated average {mode} {avg}') # 记录归一化的平均值调试日志
scale_factors = []
for item in items:
rect = self.itemsBoundingRect(items=[item])
scale_factors.append(avg / getattr(rect, mode)())
scale_factors = [] # 创建一个空列表,用于存储每个项目的归一化缩放因子
for item in items: # 遍历选择的项目
rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形
scale_factors.append(avg / getattr(rect, mode)()) # 计算项目的归一化缩放因子,并添加到列表中
self.undo_stack.push(
commands.NormalizeItems(items, scale_factors))
commands.NormalizeItems(items, scale_factors)) # 将归一化操作添加到撤销栈中
def normalize_height(self):
def normalize_height(self): # 归一化选择项目的高度
"""Scale selected images to the same height."""
return self.normalize_width_or_height('height')
return self.normalize_width_or_height('height') # 归一化选择项目的高度
def normalize_width(self):
def normalize_width(self): # 归一化选择项目的宽度
"""Scale selected images to the same width."""
return self.normalize_width_or_height('width')
return self.normalize_width_or_height('width') # 归一化选择项目的宽度
def normalize_size(self):
def normalize_size(self): # 归一化选择项目的大小
"""Scale selected images to the same size.
Size meaning the area = widh * height.
"""
self.cancel_active_modes()
sizes = []
items = self.selectedItems(user_only=True)
for item in items:
rect = self.itemsBoundingRect(items=[item])
sizes.append(rect.width() * rect.height())
self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰
sizes = [] # 创建一个空列表,用于存储选择项目的面积值
items = self.selectedItems(user_only=True) # 获取当前用户选择的项目
for item in items: # 遍历选择的项目
rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形
sizes.append(rect.width() * rect.height()) # 计算项目的面积值,并添加到列表中
if len(sizes) < 2:
return
if len(sizes) < 2: # 如果选择的项目数量小于2
return # 直接返回,无需归一化
avg = sum(sizes) / len(sizes)
logger.debug(f'Calculated average size {avg}')
avg = sum(sizes) / len(sizes) # 计算选择项目的面积平均值
logger.debug(f'Calculated average size {avg}') # 记录归一化的平均值调试日志
scale_factors = []
for item in items:
rect = self.itemsBoundingRect(items=[item])
scale_factors.append(math.sqrt(avg / rect.width() / rect.height()))
scale_factors = [] # 创建一个空列表,用于存储每个项目的归一化缩放因子
for item in items: # 遍历选择的项目
rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形
scale_factors.append(math.sqrt(avg / rect.width() / rect.height())) # 计算项目的归一化缩放因子,并添加到列表中
self.undo_stack.push(
commands.NormalizeItems(items, scale_factors))
commands.NormalizeItems(items, scale_factors)) # 将归一化操作添加到撤销栈中
def arrange_default(self):
default = self.settings.valueOrDefault('Items/arrange_default')
def arrange_default(self): # 按默认方式排列选择的项目
default = self.settings.valueOrDefault('Items/arrange_default') # 获取默认的排列方式
MAPPING = {
'optimal': self.arrange_optimal,
'horizontal': self.arrange,
'vertical': partial(self.arrange, vertical=True),
'square': self.arrange_square,
'optimal': self.arrange_optimal, # 最优排列方式
'horizontal': self.arrange, # 水平排列方式
'vertical': partial(self.arrange, vertical=True), # 垂直排列方式
'square': self.arrange_square, # 正方形排列方式
}
MAPPING[default]()
MAPPING[default]() # 调用默认的排列方式
def arrange(self, vertical=False):
"""Arrange items in a line (horizontally or vertically)."""
def arrange(self, vertical=False): # 按指定方式排列选择的项目
"""Arrange items in a line (horizontally or vertically).""" # 按指定方式排列选择的项目
self.cancel_active_modes()
self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰
items = sort_by_filename(self.selectedItems(user_only=True))
if len(items) < 2:
return
items = sort_by_filename(self.selectedItems(user_only=True)) # 获取当前用户选择的项目,并按文件名排序
if len(items) < 2:# 如果选择的项目数量小于2
return # 直接返回,无需排列
gap = self.settings.valueOrDefault('Items/arrange_gap')
center = self.get_selection_center()
positions = []
rects = []
for item in items:
rects.append({
'rect': self.itemsBoundingRect(items=[item]),
'item': item})
gap = self.settings.valueOrDefault('Items/arrange_gap') # 获取项目之间的间距
center = self.get_selection_center() # 获取选择项目的中心位置
positions = [] # 创建一个空列表,用于存储每个项目的新位置
rects = [] # 创建一个空列表,用于存储每个项目的边界矩形和项目本身
for item in items: # 遍历选择的项目
rects.append({ # 为每个项目创建一个字典,包含项目的边界矩形和项目本身
'rect': self.itemsBoundingRect(items=[item]), # 获取项目的边界矩形
'item': item}) # 包含项目的边界矩形和项目本身
if vertical:
rects.sort(key=lambda r: r['rect'].topLeft().y())
sum_height = sum(map(lambda r: r['rect'].height(), rects))
y = round(center.y() - sum_height/2)
for rect in rects:
positions.append(
QtCore.QPointF(
round(center.x() - rect['rect'].width()/2), y))
y += rect['rect'].height() + gap
if vertical: # 如果按垂直方向排列
rects.sort(key=lambda r: r['rect'].topLeft().y()) # 按项目的顶部坐标排序
sum_height = sum(map(lambda r: r['rect'].height(), rects)) # 计算所有项目的总高度
y = round(center.y() - sum_height/2) # 计算垂直方向上的起始位置
for rect in rects: # 遍历每个项目
positions.append( # 为每个项目计算新的位置
QtCore.QPointF( # 计算每个项目的新位置
round(center.x() - rect['rect'].width()/2), y)) # 计算每个项目的新位置
y += rect['rect'].height() + gap # 更新垂直方向上的位置,考虑项目高度和间距
else:
rects.sort(key=lambda r: r['rect'].topLeft().x())
sum_width = sum(map(lambda r: r['rect'].width(), rects))
x = round(center.x() - sum_width/2)
for rect in rects:
positions.append(
QtCore.QPointF(
x, round(center.y() - rect['rect'].height()/2)))
x += rect['rect'].width() + gap
else: # 如果按水平方向排列
rects.sort(key=lambda r: r['rect'].topLeft().x()) # 按项目的左侧坐标排序
sum_width = sum(map(lambda r: r['rect'].width(), rects)) # 计算所有项目的总宽度
x = round(center.x() - sum_width/2) # 计算水平方向上的起始位置
for rect in rects: # 遍历每个项目
positions.append( # 为每个项目计算新的位置
QtCore.QPointF( # 计算每个项目的新位置
x, round(center.y() - rect['rect'].height()/2))) # 计算每个项目的新位置
x += rect['rect'].width() + gap # 更新水平方向上的位置,考虑项目宽度和间距
self.undo_stack.push(
commands.ArrangeItems(self,
[r['item'] for r in rects],
positions))
self.undo_stack.push( # 将排列操作添加到撤销栈中
commands.ArrangeItems(self, # 创建一个排列项目的命令对象
[r['item'] for r in rects], # 包含所有要排列的项目
positions)) # 包含所有项目的新位置
def arrange_optimal(self):
self.cancel_active_modes()
def arrange_optimal(self): # 按最优方式排列选择的项目
self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰
items = self.selectedItems(user_only=True)
if len(items) < 2:
return
items = self.selectedItems(user_only=True) # 获取当前用户选择的项目
if len(items) < 2: # 如果选择的项目数量小于2
return # 直接返回,无需排列
gap = self.settings.valueOrDefault('Items/arrange_gap')
gap = self.settings.valueOrDefault('Items/arrange_gap') # 获取项目之间的间距
sizes = []
for item in items:
rect = self.itemsBoundingRect(items=[item])
sizes.append((round(rect.width() + gap),
round(rect.height() + gap)))
sizes = [] # 创建一个空列表,用于存储每个项目的宽度和高度
for item in items: # 遍历选择的项目
rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形
sizes.append((round(rect.width() + gap), # 包含每个项目的宽度和高度
round(rect.height() + gap))) # 包含每个项目的宽度和高度
# The minimal area the items need if they could be packed optimally;
# we use this as a starting shape for the packing algorithm
min_area = sum(map(lambda s: s[0] * s[1], sizes))
width = math.ceil(math.sqrt(min_area))
min_area = sum(map(lambda s: s[0] * s[1], sizes)) # 计算所有项目的最小面积
width = math.ceil(math.sqrt(min_area)) # 计算最小面积的平方根,向上取整作为初始宽度
positions = None
while not positions:
try:
positions = rpack.pack(
positions = None # 初始化位置列表为None
while not positions: # 循环直到成功打包项目
try: # 尝试使用当前宽度打包项目
positions = rpack.pack(
sizes, max_width=width, max_height=width)
except rpack.PackingImpossibleError:
width = math.ceil(width * 1.2)
width = math.ceil(width * 1.2) # 如果打包失败将宽度增加20%,并向上取整
# We want the items to center around the selection's center,
# not (0, 0)
center = self.get_selection_center()
bounds = rpack.bbox_size(sizes, positions)
diff = center - QtCore.QPointF(bounds[0]/2, bounds[1]/2)
positions = [QtCore.QPointF(*pos) + diff for pos in positions]
center = self.get_selection_center() # 获取选择项目的中心位置
bounds = rpack.bbox_size(sizes, positions) # 计算打包后的边界矩形大小
diff = center - QtCore.QPointF(bounds[0]/2, bounds[1]/2) # 计算中心位置与边界矩形中心位置的差值
positions = [QtCore.QPointF(*pos) + diff for pos in positions] # 为每个项目计算新的位置,考虑中心位置的偏移
self.undo_stack.push(commands.ArrangeItems(self, items, positions))
self.undo_stack.push(commands.ArrangeItems(self, items, positions)) # 将排列操作添加到撤销栈中
def arrange_square(self):
self.cancel_active_modes()
max_width = 0
max_height = 0
gap = self.settings.valueOrDefault('Items/arrange_gap')
items = sort_by_filename(self.selectedItems(user_only=True))
def arrange_square(self): # 按正方形方式排列选择的项目
self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰
max_width = 0 # 初始化最大宽度为0
max_height = 0 # 初始化最大高度为0
gap = self.settings.valueOrDefault('Items/arrange_gap') # 获取项目之间的间距
items = sort_by_filename(self.selectedItems(user_only=True)) # 获取当前用户选择的项目,并按文件名排序
if len(items) < 2:
return
if len(items) < 2: # 如果选择的项目数量小于2
return # 直接返回,无需排列
for item in items:
rect = self.itemsBoundingRect(items=[item])
max_width = max(max_width, rect.width() + gap)
max_height = max(max_height, rect.height() + gap)
for item in items: # 遍历选择的项目
rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形
max_width = max(max_width, rect.width() + gap) # 更新最大宽度,考虑项目宽度和间距
max_height = max(max_height, rect.height() + gap) # 更新最大高度,考虑项目高度和间距
# We want the items to center around the selection's center,
# not (0, 0)
num_rows = math.ceil(math.sqrt(len(items)))
center = self.get_selection_center()
diff = center - num_rows/2 * QtCore.QPointF(max_width, max_height)
num_rows = math.ceil(math.sqrt(len(items))) # 计算需要的行数,向上取整
center = self.get_selection_center() # 获取选择项目的中心位置
diff = center - num_rows/2 * QtCore.QPointF(max_width, max_height) # 计算中心位置与边界矩形中心位置的差值
iter_items = iter(items)
positions = []
for j in range(num_rows):
for i in range(num_rows):
iter_items = iter(items) # 创建一个迭代器,用于遍历选择的项目
positions = [] # 创建一个空列表,用于存储每个项目的新位置
for j in range(num_rows):# 遍历行数
for i in range(num_rows):# 遍历列数
try:
item = next(iter_items)
rect = self.itemsBoundingRect(items=[item])
point = QtCore.QPointF(
i * max_width + (max_width - rect.width())/2,
j * max_height + (max_height - rect.height())/2)
positions.append(point + diff)
except StopIteration:
break
item = next(iter_items) # 从迭代器中获取下一个项目
rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形
point = QtCore.QPointF( # 计算项目的新位置
i * max_width + (max_width - rect.width())/2, # 计算项目在当前行的水平位置
j * max_height + (max_height - rect.height())/2) # 计算项目在当前列的垂直位置
positions.append(point + diff) # 计算项目的新位置,考虑中心位置的偏移
except StopIteration:# 当迭代器没有更多项目时,跳出循环
break # 跳出当前列的循环,继续下一行
self.undo_stack.push(commands.ArrangeItems(self, items, positions))
self.undo_stack.push(commands.ArrangeItems(self, items, positions)) # 将排列操作添加到撤销栈中
def flip_items(self, vertical=False):
def flip_items(self, vertical=False): # 垂直或水平翻转选择的项目
"""Flip selected items."""
self.cancel_active_modes()
self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰
self.undo_stack.push(
commands.FlipItems(self.selectedItems(user_only=True),
self.get_selection_center(),
vertical=vertical))
vertical=vertical)) # 将翻转操作添加到撤销栈中
def crop_items(self):
def crop_items(self): # 对选择的项目进行裁剪
"""Crop selected item."""
if self.crop_item:
if self.crop_item:## 如果当前有项目正在裁剪状态
return
if self.has_single_image_selection():
if self.has_single_image_selection():# 如果选择的项目是单个图像
item = self.selectedItems(user_only=True)[0]
if item.is_image:
item.enter_crop_mode()
item.enter_crop_mode() # 进入图像裁剪模式
def sample_color_at(self, position):
item_at_pos = self.itemAt(position, self.views()[0].transform())
def sample_color_at(self, position): # 采样指定位置的颜色
item_at_pos = self.itemAt(position, self.views()[0].transform()) # 获取指定位置上的项目
if item_at_pos:
return item_at_pos.sample_color_at(position)
return item_at_pos.sample_color_at(position) # 如果项目存在,采样该位置的颜色
def select_all_items(self):
self.cancel_active_modes()
path = QtGui.QPainterPath()
path.addRect(self.itemsBoundingRect())
def select_all_items(self): # 选择所有项目
self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰
path = QtGui.QPainterPath() # 创建一个空的绘图路径
path.addRect(self.itemsBoundingRect()) # 创建一个矩形路径,包含所有项目的边界矩形
path.addRect(self.itemsBoundingRect())
path.addRect(self.itemsBoundingRect())
# This is faster than looping through all items and calling setSelected
self.setSelectionArea(path)
self.setSelectionArea(path)# 设置选择区域为创建的路径,包含所有项目的边界矩形
def deselect_all_items(self):
self.cancel_active_modes()
self.clearSelection()
def deselect_all_items(self):# 取消所有项目的选择
self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰
self.clearSelection() # 清除所有项目的选择状态
def has_selection(self):
def has_selection(self):# 检查是否有项目被选择
"""Checks whether there are currently items selected."""
return bool(self.selectedItems(user_only=True))
return bool(self.selectedItems(user_only=True)) # 返回当前用户选择的项目是否为空
def has_single_selection(self):
def has_single_selection(self):# 检查是否有单个项目被选择
"""Checks whether there's currently exactly one item selected."""
return len(self.selectedItems(user_only=True)) == 1
return len(self.selectedItems(user_only=True)) == 1 # 返回当前用户选择的项目是否只有一个
def has_multi_selection(self):
def has_multi_selection(self):# 检查是否有多个项目被选择
"""Checks whether there are currently more than one items selected."""
return len(self.selectedItems(user_only=True)) > 1
return len(self.selectedItems(user_only=True)) > 1 # 返回当前用户选择的项目是否有多个
def has_single_image_selection(self):
def has_single_image_selection(self):# 检查是否有单个图像项目被选择
"""Checks whether the current selection is a single image."""
if self.has_single_selection():
return self.selectedItems(user_only=True)[0].is_image
return False
if self.has_single_selection(): # 如果只有一个项目被选择
return self.selectedItems(user_only=True)[0].is_image # 检查该项目是否为图像
return False # 如果不是图像或没有项目被选择返回False
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.RightButton:
def mousePressEvent(self, event):# 处理鼠标按下事件
if event.button() == Qt.MouseButton.RightButton: # 如果是右键点击
# Right-click invokes the context menu on the
# GraphicsView. We don't need it here.
return
if event.button() == Qt.MouseButton.LeftButton:
self.event_start = event.scenePos()
if event.button() == Qt.MouseButton.LeftButton: # 如果是左键点击
self.event_start = event.scenePos() # 记录鼠标按下时的场景位置
item_at_pos = self.itemAt(
event.scenePos(), self.views()[0].transform())
event.scenePos(), self.views()[0].transform()) # 获取当前鼠标位置上的项目
if self.edit_item:
if item_at_pos != self.edit_item:
self.edit_item.exit_edit_mode()
else:
super().mousePressEvent(event)
return
if self.crop_item:
if item_at_pos != self.crop_item:
self.cancel_crop_mode()
else:
super().mousePressEvent(event)
return
if item_at_pos:
self.active_mode = self.MOVE_MODE
elif self.items():
self.active_mode = self.RUBBERBAND_MODE
if self.edit_item and item_at_pos != self.edit_item: # 如果当前有项目正在编辑状态,且点击的项目不是当前编辑项目
self.edit_item.exit_edit_mode() # 退出当前编辑项目的编辑状态
elif self.edit_item and item_at_pos == self.edit_item: # 如果当前有项目正在编辑状态,且点击的项目是当前编辑项目
super().mousePressEvent(event) # 调用父类的鼠标按下事件处理方法,确保正常编辑操作
return # 结束当前方法,避免后续重复处理
if self.crop_item and item_at_pos != self.crop_item: # 如果当前有项目正在裁剪状态,且点击的项目不是当前裁剪项目
self.cancel_crop_mode() # 取消当前裁剪项目的裁剪状态
elif self.crop_item and item_at_pos == self.crop_item: # 如果当前有项目正在裁剪状态,且点击的项目是当前裁剪项目
super().mousePressEvent(event) # 调用父类的鼠标按下事件处理方法,确保正常裁剪操作
return # 结束当前方法,避免后续重复处理
if item_at_pos: # 如果点击的项目存在
self.active_mode = self.MOVE_MODE # 切换到移动模式
elif self.items(): # 如果场景中存在其他项目
self.active_mode = self.RUBBERBAND_MODE # 切换到橡皮筋选择模式
super().mousePressEvent(event)
super().mousePressEvent(event)# 调用父类的鼠标按下事件处理方法,确保正常处理
def mouseDoubleClickEvent(self, event):
self.cancel_active_modes()
item = self.itemAt(event.scenePos(), self.views()[0].transform())
if item:
if not item.isSelected():
item.setSelected(True)
if item.is_editable:
item.enter_edit_mode()
self.mousePressEvent(event)
else:
def mouseDoubleClickEvent(self, event):# 处理鼠标双击事件
self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰
item = self.itemAt(event.scenePos(), self.views()[0].transform()) # 获取当前鼠标位置上的项目
if item: # 如果点击的项目存在
if not item.isSelected(): # 如果项目未被选择
item.setSelected(True) # 选择该项目
if item.is_editable: # 如果项目可编辑
item.enter_edit_mode() # 进入编辑模式
self.mousePressEvent(event) # 模拟鼠标按下事件,确保编辑模式正常启动
else: # 如果项目不可编辑
self.views()[0].fit_rect(
self.itemsBoundingRect(items=[item]),
toggle_item=item)
toggle_item=item) # 调整场景视图,确保点击的项目在可见区域
return
super().mouseDoubleClickEvent(event)
super().mouseDoubleClickEvent(event) # 调用父类的鼠标双击事件处理方法,确保正常处理
def mouseMoveEvent(self, event):
if self.active_mode == self.RUBBERBAND_MODE:
if not self.rubberband_item.scene():
logger.debug('Activating rubberband selection')
self.addItem(self.rubberband_item)
self.rubberband_item.bring_to_front()
self.rubberband_item.fit(self.event_start, event.scenePos())
self.setSelectionArea(self.rubberband_item.shape())
self.views()[0].reset_previous_transform()
super().mouseMoveEvent(event)
def mouseMoveEvent(self, event): # 处理鼠标移动事件
if self.active_mode == self.RUBBERBAND_MODE: # 如果当前模式是橡皮筋选择模式
if not self.rubberband_item.scene(): # 如果橡皮筋选择项目不在场景中
logger.debug('Activating rubberband selection') # 记录调试信息,激活橡皮筋选择模式
self.addItem(self.rubberband_item) # 将橡皮筋选择项目添加到场景中
self.rubberband_item.bring_to_front() # 将橡皮筋选择项目 bring to front确保可见
self.rubberband_item.fit(self.event_start, event.scenePos()) # 调整橡皮筋选择项目的形状,以适应鼠标移动
self.setSelectionArea(self.rubberband_item.shape()) # 设置场景的选择区域为橡皮筋选择项目的形状
self.views()[0].reset_previous_transform() # 重置场景视图的前一个变换,确保正常显示
super().mouseMoveEvent(event) # 调用父类的鼠标移动事件处理方法,确保正常处理
def mouseReleaseEvent(self, event):
if self.active_mode == self.RUBBERBAND_MODE:
self.end_rubberband_mode()
if (self.active_mode == self.MOVE_MODE
and self.has_selection()
and self.multi_select_item.active_mode is None
and self.selectedItems()[0].active_mode is None):
delta = event.scenePos() - self.event_start
if not delta.isNull():
def mouseReleaseEvent(self, event): # 处理鼠标释放事件
if self.active_mode == self.RUBBERBAND_MODE: # 如果当前模式是橡皮筋选择模式
self.end_rubberband_mode() # 结束橡皮筋选择模式
if (self.active_mode == self.MOVE_MODE # 如果当前模式是移动模式
and self.has_selection() # 如果场景中存在选择项目
and self.multi_select_item.active_mode is None # 如果多选项目的活动模式为空
and self.selectedItems()[0].active_mode is None): # 如果选择项目的活动模式为空
delta = event.scenePos() - self.event_start # 计算鼠标移动的距离
if not delta.isNull(): # 如果鼠标移动距离不为零
self.undo_stack.push(
commands.MoveItemsBy(self.selectedItems(),
delta,
ignore_first_redo=True))
self.active_mode = None
super().mouseReleaseEvent(event)
ignore_first_redo=True)) # 记录移动操作到撤销栈,忽略第一次重做
self.active_mode = None # 切换到无活动模式
super().mouseReleaseEvent(event) # 调用父类的鼠标释放事件处理方法,确保正常处理
def selectedItems(self, user_only=False):
def selectedItems(self, user_only=False): # 返回场景中当前选择的项目
"""If ``user_only`` is set to ``True``, only return items added
by the user (i.e. no multi select outlines and other UI items).
User items are items that have a ``save_id`` attribute.
"""
items = super().selectedItems()
items = super().selectedItems() # 获取场景中当前选择的所有项目
if user_only:
return list(filter(lambda i: hasattr(i, 'save_id'), items))
return items
return list(filter(lambda i: hasattr(i, 'save_id'), items)) # 如果只返回用户项目,过滤掉没有 save_id 属性的项目
return items # 返回所有选择项目
def items_by_type(self, itype):
def items_by_type(self, itype): # 返回场景中所有指定类型的项目
"""Returns all items of the given type."""
return filter(lambda i: getattr(i, 'TYPE', None) == itype,
self.items())
def items_for_save(self):
self.items()) # 返回所有类型为 itype 的项目
def items_for_save(self): # 返回场景中所有可保存的项目
"""Returns the items that are to be saved.
Items to be saved are items that have a save_id attribute.
"""
return filter(lambda i: hasattr(i, 'save_id'),
self.items(order=Qt.SortOrder.AscendingOrder))
self.items(order=Qt.SortOrder.AscendingOrder)) # 返回所有有 save_id 属性的项目
def clear_save_ids(self):
for item in self.items_for_save():
item.save_id = None
def clear_save_ids(self): # 清除场景中所有项目的 save_id 属性
for item in self.items_for_save():# 遍历所有可保存项目
item.save_id = None # 清除项目的 save_id 属性
def on_view_scale_change(self):
for item in self.selectedItems():
item.on_view_scale_change()
def on_view_scale_change(self): # 处理场景视图缩放变化事件
for item in self.selectedItems(): # 遍历当前选择的项目
item.on_view_scale_change() # 调用项目的视图缩放变化处理方法,确保正常显示
def itemsBoundingRect(self, selection_only=False, items=None):
def itemsBoundingRect(self, selection_only=False, items=None): # 返回场景中项目的边界矩形
"""Returns the bounding rect of the scene's items; either all of them
or only selected ones, or the items givin in ``items``.
Re-implemented to not include the items's selection handles.
"""
def filter_user_items(ilist):
return list(filter(lambda i: hasattr(i, 'save_id'), ilist))
def filter_user_items(ilist):# 过滤出场景中所有用户项目(有 save_id 属性的项目)
return list(filter(lambda i: hasattr(i, 'save_id'), ilist)) # 过滤出场景中所有用户项目(有 save_id 属性的项目)
if selection_only:
base = filter_user_items(self.selectedItems())
elif items:
base = items
else:
base = filter_user_items(self.items())
if not base:
return QtCore.QRectF(0, 0, 0, 0)
x = []
y = []
if selection_only: # 如果只返回选择项目的边界矩形
base = filter_user_items(self.selectedItems()) # 过滤出当前选择的用户项目
elif items: # 如果指定了项目列表
base = items # 使用指定的项目列表
else: # 如果没有指定项目列表
base = filter_user_items(self.items()) # 过滤出所有用户项目
for item in base:
for corner in item.corners_scene_coords:
x.append(corner.x())
y.append(corner.y())
if not base: # 如果没有项目可计算边界矩形
return QtCore.QRectF(0, 0, 0, 0) # 返回空矩形
x = [] # 存储所有项目的 x 坐标
y = [] # 存储所有项目的 y 坐标
for item in base: # 遍历所有项目
for corner in item.corners_scene_coords: # 遍历项目的所有场景坐标
x.append(corner.x()) # 存储项目的 x 坐标
y.append(corner.y()) # 存储项目的 y 坐标
return QtCore.QRectF(
QtCore.QPointF(min(x), min(y)),
QtCore.QPointF(max(x), max(y)))
QtCore.QPointF(min(x), min(y)), # 计算所有项目的最小坐标点
QtCore.QPointF(max(x), max(y))) # 计算所有项目的最大坐标点
def get_selection_center(self):
rect = self.itemsBoundingRect(selection_only=True)
return (rect.topLeft() + rect.bottomRight()) / 2
def get_selection_center(self): # 返回场景中当前选择项目的中心坐标
rect = self.itemsBoundingRect(selection_only=True) # 获取当前选择项目的边界矩形
return (rect.topLeft() + rect.bottomRight()) / 2 # 返回矩形的中心坐标
def on_selection_change(self):
def on_selection_change(self): # 处理场景选择变化事件
if self._clear_ongoing:
# Ignore events while clearing the scene since the
# multiselect item will get cleared, too
return
if self.has_multi_selection():
self.multi_select_item.fit_selection_area(
self.itemsBoundingRect(selection_only=True))
if self.has_multi_selection() and not self.multi_select_item.scene():
self.addItem(self.multi_select_item)
self.multi_select_item.bring_to_front()
if not self.has_multi_selection() and self.multi_select_item.scene():
self.removeItem(self.multi_select_item)
if self.has_multi_selection():# 如果有多个项目被选择
self.multi_select_item.fit_selection_area(
self.itemsBoundingRect(selection_only=True)) # 调整多选框的大小以适应选择项目的边界矩形
if self.has_multi_selection() and not self.multi_select_item.scene(): # 如果有多个项目被选择且多选框不在场景中
self.addItem(self.multi_select_item) # 将多选框添加到场景中
self.multi_select_item.bring_to_front() # 将多选框 bring_to_front 到最前面
if not self.has_multi_selection() and self.multi_select_item.scene(): # 如果没有项目被选择且多选框在场景中
self.removeItem(self.multi_select_item) # 从场景中移除多选框
def on_change(self, region):
if self._clear_ongoing:
def on_change(self, region): # 处理场景变化事件
if self._clear_ongoing: # 如果场景正在清除中
# Ignore events while clearing the scene since the
# multiselect item will get cleared, too
return
if (self.multi_select_item.scene()
and self.multi_select_item.active_mode is None):
if (self.multi_select_item.scene() # 如果多选框在场景中
and self.multi_select_item.active_mode is None): # 且多选框未激活任何模式
self.multi_select_item.fit_selection_area(
self.itemsBoundingRect(selection_only=True))
self.itemsBoundingRect(selection_only=True)) # 调整多选框的大小以适应选择项目的边界矩形
def add_item_later(self, itemdata, selected=False):
def add_item_later(self, itemdata, selected=False): # 保持一个项目稍后添加,通过 ``add_queued_items`` 添加
"""Keep an item for adding later via ``add_queued_items``
:param dict itemdata: Defines the item's data
:param bool selected: Whether the item is initialised as selected
"""
self.items_to_add.put((itemdata, selected))
self.items_to_add.put((itemdata, selected)) # 将项目数据和选择状态添加到稍后添加队列中
def add_queued_items(self):
def add_queued_items(self): # 添加稍后添加队列中的项目
"""Adds items added via ``add_item_later``"""
while not self.items_to_add.empty():
data, selected = self.items_to_add.get()
typ = data.pop('type')
cls = item_registry.get(typ)
if not cls:
while not self.items_to_add.empty(): # 循环直到稍后添加队列为空
data, selected = self.items_to_add.get() # 从稍后添加队列中获取项目数据和选择状态
typ = data.pop('type') # 从项目数据中弹出项目类型
cls = item_registry.get(typ) # 从项目类型中获取项目类
if not cls: # 如果项目类型未知
# Just in case we add new item types in future versions
logger.warning(f'Encountered item of unknown type: {typ}')
cls = BeeErrorItem
data['data'] = {'text': f'Item of unknown type: {typ}'}
item = cls.create_from_data(**data)
logger.warning(f'Encountered item of unknown type: {typ}') # 记录警告日志,提示未知项目类型
cls = BeeErrorItem # 默认使用 BeeErrorItem 类
data['data'] = {'text': f'Item of unknown type: {typ}'} # 为未知项目类型添加默认文本数据
item = cls.create_from_data(**data) # 创建项目实例
# Set the values common to all item types:
item.update_from_data(**data)
self.addItem(item)
item.update_from_data(**data) # 更新项目实例的属性值
self.addItem(item) # 将项目实例添加到场景中
# Force recalculation of min/max z values:
item.setZValue(item.zValue())
item.setZValue(item.zValue()) # 强制重新计算项目实例的 z 值
if selected:
item.setSelected(True)
item.bring_to_front()
item.setSelected(True) # 设置项目实例为选中状态
item.bring_to_front() # 将项目实例 bring_to_front 到最前面

File diff suppressed because it is too large Load diff