From b9ac86c69c78104865c5f4b9da22ed7b05702e27 Mon Sep 17 00:00:00 2001 From: zhaoyulin1117 <3166922571@qq.com> Date: Wed, 15 Oct 2025 23:04:50 +0800 Subject: [PATCH 1/8] Update schema.py --- beeref/fileio/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beeref/fileio/schema.py b/beeref/fileio/schema.py index 6ca7f1b..eca1433 100644 --- a/beeref/fileio/schema.py +++ b/beeref/fileio/schema.py @@ -1,4 +1,4 @@ -USER_VERSION = 2 +USER_VERSION = 2 # 数据库版本号,用于控制数据库迁移(当结构变更时递增版本号) APPLICATION_ID = 2060242126 From dd177204ae6390acd200c1c6f7a0d04242466060 Mon Sep 17 00:00:00 2001 From: zhaoyulin1117 <3166922571@qq.com> Date: Wed, 15 Oct 2025 23:08:40 +0800 Subject: [PATCH 2/8] Update schema.py --- beeref/fileio/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beeref/fileio/schema.py b/beeref/fileio/schema.py index eca1433..8595ab0 100644 --- a/beeref/fileio/schema.py +++ b/beeref/fileio/schema.py @@ -1,5 +1,5 @@ USER_VERSION = 2 # 数据库版本号,用于控制数据库迁移(当结构变更时递增版本号) -APPLICATION_ID = 2060242126 +APPLICATION_ID = 2060242126 # 应用标识ID,通常用于区分不同应用的数据库(避免冲突) SCHEMA = [ From 6712da907d544829f39cf01a495e192260dee517 Mon Sep 17 00:00:00 2001 From: zhaoyulin1117 <3166922571@qq.com> Date: Thu, 16 Oct 2025 16:19:03 +0800 Subject: [PATCH 3/8] Update schema.py --- beeref/fileio/schema.py | 51 +++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/beeref/fileio/schema.py b/beeref/fileio/schema.py index 8595ab0..99c001e 100644 --- a/beeref/fileio/schema.py +++ b/beeref/fileio/schema.py @@ -1,41 +1,48 @@ -USER_VERSION = 2 # 数据库版本号,用于控制数据库迁移(当结构变更时递增版本号) -APPLICATION_ID = 2060242126 # 应用标识ID,通常用于区分不同应用的数据库(避免冲突) +# 数据库版本号,用于跟踪数据库结构的更改 +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格式中 ], } From 9ca67215b116a1e647d44ce4df31ca16fcf9de93 Mon Sep 17 00:00:00 2001 From: zhaoyulin1117 <3166922571@qq.com> Date: Thu, 16 Oct 2025 17:07:16 +0800 Subject: [PATCH 4/8] Update errors.py --- beeref/fileio/errors.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/beeref/fileio/errors.py b/beeref/fileio/errors.py index 55e7dff..2437d23 100644 --- a/beeref/fileio/errors.py +++ b/beeref/fileio/errors.py @@ -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 . +# You should have received a copy of the GNU General Public License # 您应该已经收到了 GNU 通用公共许可证的副本 +# along with BeeRef. If not, see . # 如果没有,请参阅 +# 图片加载错误的提示消息,当图片格式未知或过大时显示 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 # 保存发生错误的文件名 + From 53ffc3fe91ff49b329970d2b40a7859ea65079ca Mon Sep 17 00:00:00 2001 From: zhaoyulin1117 <3166922571@qq.com> Date: Fri, 17 Oct 2025 15:11:07 +0800 Subject: [PATCH 5/8] Update export.py --- beeref/fileio/export.py | 501 ++++++++++++++++++++----------------- beeref/widgets/settings.py | 4 +- 2 files changed, 275 insertions(+), 230 deletions(-) diff --git a/beeref/fileio/export.py b/beeref/fileio/export.py index 69267b3..23b8857 100644 --- a/beeref/fileio/export.py +++ b/beeref/fileio/export.py @@ -13,269 +13,307 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . -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, []) # 发送导出任务完成信号,参数为目录名和空列表 diff --git a/beeref/widgets/settings.py b/beeref/widgets/settings.py index 26bdcf2..679dd98 100644 --- a/beeref/widgets/settings.py +++ b/beeref/widgets/settings.py @@ -13,10 +13,10 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . -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 From 244118d20f5764c8dc4362fd188c11755cf9413f Mon Sep 17 00:00:00 2001 From: zhaoyulin1117 <3166922571@qq.com> Date: Sun, 19 Oct 2025 14:46:30 +0800 Subject: [PATCH 6/8] update _init_.py --- beeref/fileio/__init__.py | 142 ++++++++++++++++++++++++-------------- 1 file changed, 90 insertions(+), 52 deletions(-) diff --git a/beeref/fileio/__init__.py b/beeref/fileio/__init__.py index 5834d97..a43ae9c 100644 --- a/beeref/fileio/__init__.py +++ b/beeref/fileio/__init__.py @@ -13,92 +13,130 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . -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 From 31d664f8e1ab02633ca53f325e2ebd9ae0250ae9 Mon Sep 17 00:00:00 2001 From: zhaoyulin1117 <3166922571@qq.com> Date: Sun, 19 Oct 2025 16:10:14 +0800 Subject: [PATCH 7/8] update image.py --- beeref/fileio/image.py | 125 +++++++++++++++++++++++------------------ 1 file changed, 71 insertions(+), 54 deletions(-) diff --git a/beeref/fileio/image.py b/beeref/fileio/image.py index 9e6449c..7b8fec9 100644 --- a/beeref/fileio/image.py +++ b/beeref/fileio/image.py @@ -13,101 +13,118 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . -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 From 8b4f36bdbc12d45cfc44e7c63fd8c81963c8dcc5 Mon Sep 17 00:00:00 2001 From: zhaoyulin1117 <3166922571@qq.com> Date: Tue, 28 Oct 2025 10:50:33 +0800 Subject: [PATCH 8/8] update sql.py --- beeref/fileio/sql.py | 404 ++++++++++++++++++++++++------------------- 1 file changed, 225 insertions(+), 179 deletions(-) diff --git a/beeref/fileio/sql.py b/beeref/fileio/sql.py index 9e2a2b5..59b3381 100644 --- a/beeref/fileio/sql.py +++ b/beeref/fileio/sql.py @@ -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() # 提交事务,确保所有更改生效