mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
Merge pull request #2 from THE-FIFTH-TEAM111/feature/zyl/code-review
将Feature/zyl/code review分支合并到develop分支
This commit is contained in:
commit
7a445d4a02
7 changed files with 706 additions and 548 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 # 保存发生错误的文件名
|
||||
|
||||
|
|
|
|||
|
|
@ -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元素树写入文件,参数为文件对象,编码为unicode,XML声明为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, []) # 发送导出任务完成信号,参数为目录名和空列表
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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格式中
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() # 提交事务,确保所有更改生效
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@
|
|||
# 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
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
from PyQt6 import QtWidgets
|
||||
from PyQt6 import QtWidgets
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
from beeref import constants
|
||||
|
|
|
|||
Loading…
Reference in a new issue