最近迷上了韩漫,但是缺少一款合适的阅读器。以前一直用的YACReader,但不适合看韩漫。主要问题就是页面滚动不连贯,换到下一页时直接到界面顶部了,有时候上下页面会把关键的部分分割开,很影响观感。于是想着写一个简单的韩漫阅读器。用PySide6框架开发起来简单又快速。废话不多说,直接上代码。
目录
1. 仅支持zip文件,因为解压rar文件的库不开源。如果用rarfile库又有大小限制。
1. 界面代码
直接用designer设计界面,然后将文件保存在项目根目录下,命令行运行以下命令将ui文件转成python文件:
pyside6-uic 阅读器.ui -o reader.py
或者直接将下面代码复制到reader.py文件中
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file '阅读器.ui'
##
## Created by: Qt User Interface Compiler version 6.7.2
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QApplication, QFrame, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QScrollArea, QSizePolicy,
QSlider, QSpacerItem, QVBoxLayout, QWidget)
class Ui_Form(object):
def setupUi(self, Form):
if not Form.objectName():
Form.setObjectName(u"Form")
Form.resize(380, 693)
self.verticalLayout = QVBoxLayout(Form)
self.verticalLayout.setSpacing(3)
self.verticalLayout.setObjectName(u"verticalLayout")
self.verticalLayout.setContentsMargins(3, 3, 3, 3)
self.widget = QWidget(Form)
self.widget.setObjectName(u"widget")
self.widget.setMinimumSize(QSize(0, 30))
self.widget.setMaximumSize(QSize(16777215, 30))
self.horizontalLayout = QHBoxLayout(self.widget)
self.horizontalLayout.setSpacing(0)
self.horizontalLayout.setObjectName(u"horizontalLayout")
self.horizontalLayout.setContentsMargins(0, 0, 15, 0)
self.pushButton = QPushButton(self.widget)
self.pushButton.setObjectName(u"pushButton")
self.pushButton.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.horizontalLayout.addWidget(self.pushButton)
self.pushButton_2 = QPushButton(self.widget)
self.pushButton_2.setObjectName(u"pushButton_2")
self.pushButton_2.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.horizontalLayout.addWidget(self.pushButton_2)
self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.horizontalLayout.addItem(self.horizontalSpacer)
self.lineEdit = QLineEdit(self.widget)
self.lineEdit.setObjectName(u"lineEdit")
self.lineEdit.setMinimumSize(QSize(50, 0))
self.lineEdit.setMaximumSize(QSize(50, 20))
self.horizontalLayout.addWidget(self.lineEdit)
self.line = QFrame(self.widget)
self.line.setObjectName(u"line")
self.line.setMinimumSize(QSize(0, 20))
self.line.setMaximumSize(QSize(16777215, 20))
self.line.setLineWidth(0)
self.line.setMidLineWidth(2)
self.line.setFrameShape(QFrame.Shape.VLine)
self.line.setFrameShadow(QFrame.Shadow.Sunken)
self.horizontalLayout.addWidget(self.line)
self.label = QLabel(self.widget)
self.label.setObjectName(u"label")
self.label.setMinimumSize(QSize(53, 0))
self.label.setMaximumSize(QSize(53, 16777215))
self.horizontalLayout.addWidget(self.label)
self.label_2 = QLabel(self.widget)
self.label_2.setObjectName(u"label_2")
self.horizontalLayout.addWidget(self.label_2)
self.pushButton_3 = QPushButton(self.widget)
self.pushButton_3.setObjectName(u"pushButton_3")
self.pushButton_3.setMinimumSize(QSize(40, 0))
self.pushButton_3.setMaximumSize(QSize(40, 16777215))
self.pushButton_3.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.horizontalLayout.addWidget(self.pushButton_3)
self.verticalLayout.addWidget(self.widget)
self.scrollArea = QScrollArea(Form)
self.scrollArea.setObjectName(u"scrollArea")
self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scrollArea.setWidgetResizable(True)
self.scrollArea.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.scrollAreaWidgetContents = QWidget()
self.scrollAreaWidgetContents.setObjectName(u"scrollAreaWidgetContents")
self.scrollAreaWidgetContents.setGeometry(QRect(0, 0, 372, 639))
self.scrollAreaWidgetContents.setMinimumSize(QSize(0, 0))
self.verticalLayout_2 = QVBoxLayout(self.scrollAreaWidgetContents)
self.verticalLayout_2.setSpacing(0)
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
self.verticalLayout_2.setContentsMargins(0, 0, 0, 0)
self.scrollArea.setWidget(self.scrollAreaWidgetContents)
self.verticalLayout.addWidget(self.scrollArea)
self.horizontalSlider = QSlider(Form)
self.horizontalSlider.setObjectName(u"horizontalSlider")
self.horizontalSlider.setMinimumSize(QSize(0, 10))
self.horizontalSlider.setMaximumSize(QSize(16777215, 10))
self.horizontalSlider.setCursor(QCursor(Qt.CursorShape.SizeHorCursor))
self.horizontalSlider.setOrientation(Qt.Orientation.Horizontal)
self.verticalLayout.addWidget(self.horizontalSlider)
self.retranslateUi(Form)
QMetaObject.connectSlotsByName(Form)
# setupUi
def retranslateUi(self, Form):
# Form.setWindowTitle(QCoreApplication.translate("Form", u"Form", None))
self.pushButton.setText(QCoreApplication.translate("Form", u"\u6253\u5f00\u6587\u4ef6", None))
self.pushButton_2.setText(QCoreApplication.translate("Form", u"\u9690\u85cf\u8fdb\u5ea6", None))
self.label.setText("")
self.label_2.setText(QCoreApplication.translate("Form", u"\u9875", None))
self.pushButton_3.setText(QCoreApplication.translate("Form", u"\u8df3\u8f6c", None))
# retranslateUi
2. 逻辑代码
新建一个“韩漫阅读器.py”的文件,将以下代码复制进去:
import sys
import zipfile
import rarfile
import py7zr
import os
import PySide6
from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QScrollArea, QLabel, QFileDialog, QMessageBox,QFrame
from PySide6.QtGui import QPixmap, QImage, QIcon
from PySide6.QtSql import *
from PySide6.QtCore import Qt, QTimer, QSettings
from PIL import Image
from reader import Ui_Form as Reader
import re
class ImageViewer(QWidget, Reader):
def __init__(self):
super().__init__()
self.setWindowIcon(QIcon("./resource/reading_black.png"))
self.setWindowTitle("阅读器")
self.file_path = ""
self.setupUi(self)
self.pushButton.clicked.connect(self.get_file)
self.current_index = 0 # 当前图片所在序号
self.imageListName = [] # 所有图片的名称列表
self.totalHeight = 0 # 所有缩放图片的总高度
self.labels = {} # 按图片名称展示每张图片的容器
self.size_origin = {} # 按图片名称存放每张图片的原始的大小
self.size_scale = {} # 按图片名称存放每张图片缩放后的大小
self.size_scale_height = [0] # 按图片顺序存储每张图片缩放后的高度
self.show_list = [] # 当前显示的图片列表,会不断变化
self.first_load_flag = True # 是否是第一次加载标志
self.imgWidth = self.scrollAreaWidgetContents.width()
self.scrollAreaWidgetContents.setStyleSheet("background-color:#4D4D4D;")
self.scrollArea.verticalScrollBar().valueChanged.connect(self.on_scroll)
self.background = ["background-color: transparent;",
"background-color: transparent;",
"background-color: transparent;"]
# self.background = ["background-color: red;", "background-color: #4D4D4D;", "background-color: #4D4D4D;"]
self.timer = QTimer(self)
self.timer.setSingleShot(True)
self.timer.timeout.connect(lambda: self.on_time_out(self.scrollArea.verticalScrollBar().value()))
self.resize_timer = QTimer(self)
self.resize_timer.setSingleShot(True)
self.resize_timer.timeout.connect(self.resize_timer_event)
self.pushButton_3.clicked.connect(self.jump_to_page)
self.pushButton_2.clicked.connect(self.show_or_hidden_process)
self.horizontalSlider.sliderReleased.connect(self.slid_horizontalSlider)
self.verticalLayout_2.setSpacing(0)
self.lineEdit.setText("0")
self.settings = QSettings("YourCompany", "YourApp")
self.last_comic_path = ""
self.read_pages = 0
# self.load_last_comic()
self.load_last_comic()
def load_last_comic(self):
print("打开上次的漫画")
db = QSqlDatabase("QSQLITE")
db.setDatabaseName("ComicReadInfo.comic")
db.open()
print(db.tables())
query = QSqlQuery(db)
query.prepare("""
SELECT path, read_pages FROM recent WHERE id=1;
""")
if query.exec():
if query.next():
self.last_comic_path = query.value(0)
self.load_old_comic(self.last_comic_path)
else:
print("没有上次打开信息")
else:
print("上次打开漫画失败")
pass
db.close()
def load_old_comic(self, path):
print("打开漫画", path)
db = QSqlDatabase("QSQLITE")
db.setDatabaseName("ComicReadInfo.comic")
db.open()
print(db.tables())
query = QSqlQuery(db)
query.prepare(f"""
SELECT path, read_pages FROM comics WHERE path='{path}';
""")
if query.exec():
if query.next():
self.last_comic_path = query.value(0)
self.read_pages = query.value(1)
self.init_self()
self.file_path = self.last_comic_path
# print("上次打开漫画:", self.last_comic_path, self.read_pages)
self.load_images()
index = self.read_pages
height = sum(self.size_scale_height[:index])
self.lineEdit.setText(str(self.read_pages))
self.jump_to_page()
self.horizontalSlider.setValue(int(self.horizontalSlider.maximum()*height/self.totalHeight))
self.get_location(self.scrollArea.verticalScrollBar().value())
self.setWindowTitle(f"阅读器:{os.path.basename(self.last_comic_path)[:-4]},上次阅读到{self.read_pages}页")
else:
print("没有上次打开信息")
self.init_self()
self.file_path = path
self.load_images() # 第一步,先获取所有图片信息
self.lineEdit.setText("1")
self.get_location(self.scrollArea.verticalScrollBar().value()) # 第二部展示当前进度应该展示的几张图片
else:
self.init_self()
self.file_path = path
self.load_images() # 第一步,先获取所有图片信息
self.lineEdit.setText("1")
self.get_location(self.scrollArea.verticalScrollBar().value()) # 第二部展示当前进度应该展示的几张图片
print("上次打开漫画失败")
pass
db.close()
def save_info(self, comic_path, read_pages):
print("保存信息")
db = QSqlDatabase("QSQLITE")
db.setDatabaseName("ComicReadInfo.comic")
db.open()
query = QSqlQuery(db)
if 'comics' not in db.tables():
query.exec("""
CREATE TABLE comics(
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
read_pages INTEGER
);
""")
query.exec(f"""
SELECT * FROM comics WHERE path='{comic_path}'
""")
if not query.next():
query.exec(f"""
INSERT INTO comics (path, read_pages)
VALUES('{comic_path}',{read_pages});
""")
else:
query.exec(f"""
UPDATE comics SET read_pages={read_pages} WHERE path='{comic_path}';
""")
print("更新 comics 成功")
if "recent" not in db.tables():
query.exec("""
CREATE TABLE recent(
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
read_pages INTEGER
);
""")
query.exec(f"""
SELECT * FROM recent where id=1;
""")
if not query.next():
query.exec(f"""
INSERT INTO recent (path, read_pages)
VALUES('{comic_path}',{read_pages});
""")
else:
query.exec(f"""
UPDATE recent SET path='{comic_path}', read_pages={read_pages}
WHERE id=1;
""")
print("更新 recent 成功")
db.close()
def init_self(self):
self.current_index = 0
self.imageListName = []
self.totalHeight = 0
self.labels = {}
self.size_origin = {}
self.size_scale = {}
self.size_scale_height = [0]
self.first_load_flag = True
self.show_list = []
# 清除所有Qlabel
for child in self.scrollAreaWidgetContents.children():
if isinstance(child, QLabel):
self.verticalLayout_2.removeWidget(child)
# print("child.objectName", child.objectName())
self.scrollAreaWidgetContents.setFixedHeight(0)
def on_time_out(self, value):
# print("滚动距离为:", value, " 总高度为:", self.totalHeight)
self.get_location(value)
# 滚动滚轮事件
def on_scroll(self):
for i in range(0, len(self.size_scale_height)-1):
if sum(self.size_scale_height[:i+1]) >= self.scrollArea.verticalScrollBar().value():
self.lineEdit.setText(str(i+1))
break
if self.totalHeight > 0:
self.horizontalSlider.setValue(int(100*self.scrollArea.verticalScrollBar().value()/self.totalHeight))
if not self.timer.isActive():
self.timer.start(100)
# 判断打开文件是否已经打开过
def is_opened(self, file):
db = QSqlDatabase("QSQLITE")
db.setDatabaseName("ComicReadInfo.comic")
db.open()
query = QSqlQuery(db)
query.prepare(f"SELECT * FROM comics WHERE path='{file}'")
if query.exec():
if query.next():
db.close()
return True
db.close()
return False
# 打开文件
def get_file(self):
last_path = self.settings.value("lastOpenPath", "")
new_path = QFileDialog.getOpenFileName(self, "选择文件", last_path)[0]
if new_path:
if not new_path.lower().endswith("zip"):
QMessageBox.critical(self, "warning", "仅支持ZIP压缩包!")
return
# 先更新/保存当前阅读进度
if self.file_path:
self.save_info(self.file_path, int(self.lineEdit.text()))
self.init_self()
self.file_path = new_path
# 如果打开以前度过的漫画:
if self.is_opened(self.file_path):
self.load_old_comic(self.file_path)
# 打开全新的漫画
else:
self.settings.setValue("lastOpenPath", self.file_path)
self.load_images() # 第一步,先获取所有图片信息
self.lineEdit.setText("1")
self.get_location(self.scrollArea.verticalScrollBar().value()) # 第二部展示当前进度应该展示的几张图片
self.setWindowTitle(f"阅读器:{os.path.basename(self.file_path)[:-4]}")
else:
pass
def fill_contents(self, label):
self.verticalLayout_2.addWidget(label)
def load_images(self):
if self.file_path.lower().endswith('.zip'):
with zipfile.ZipFile(self.file_path, 'r') as zip_ref:
# 将图片文件添加到列表
for n in zip_ref.namelist():
if n.lower().endswith(('.png', '.jpg', '.jpeg')):
self.imageListName.append(n)
# 根据提取的数字对文件名进行排序
self.imageListName = img_sort(self.imageListName)
# 设置总页数
self.label.setText(str(len(self.imageListName)))
num = 0
self.size_scale = {}
for file_name in self.imageListName:
num = num % 3
if file_name.lower().endswith(('.png', '.jpg', '.jpeg')):
label = QLabel()
size = Image.open(zip_ref.open(file_name)).size
self.size_origin[file_name] = size
size = [self.imgWidth, int(size[1]*self.imgWidth/size[0])]
self.size_scale[file_name] = size
self.totalHeight += size[1]
label.setFixedSize(size[0], size[1])
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setStyleSheet(self.background[num])
label.setObjectName(file_name)
label.setScaledContents(True)
self.labels[file_name] = label
self.fill_contents(label)
num += 1
elif self.file_path.lower().endswith('.rar'):
with rarfile.RarFile(self.file_path) as rar_ref:
# 将图片文件添加到列表
for n in rar_ref.namelist():
if n.lower().endswith(('.png', '.jpg', '.jpeg')):
self.imageListName.append(n)
# 根据提取的数字对文件名进行排序
self.imageListName = img_sort(self.imageListName)
self.label.setText(str(len(self.imageListName)))
num = 0
self.size_scale = {}
for file_name in rar_ref.namelist():
num = num % 3
if file_name.lower().endswith(('.png', '.jpg', '.jpeg')):
label = QLabel()
size = Image.open(rar_ref.open(file_name)).size
self.size_origin[file_name] = size
size = [self.imgWidth, int(size[1] * self.imgWidth / size[0])]
self.size_scale[file_name] = size
self.totalHeight += size[1]
label.setFixedSize(size[0], size[1])
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setStyleSheet(self.background[num])
label.setObjectName(file_name)
self.labels[file_name] = label
self.fill_contents(label)
num += 1
self.scrollAreaWidgetContents.setFixedHeight(self.totalHeight)
# 按图片顺序存储每张图片缩放后的高度
for i, n in enumerate(self.imageListName):
self.size_scale_height.append(self.size_scale[n][1])
# 调整阅读界面的大小
def resizeEvent(self, event: PySide6.QtGui.QResizeEvent) -> None:
# print("界面变化了")
# print(self.width())
if not self.resize_timer.isActive():
self.resize_timer.start(300)
super().resizeEvent(event)
# 调整阅读界面的大小
def resize_timer_event(self):
# print("界面变化调整")
if self.totalHeight > 0:
old_ratio = self.scrollArea.verticalScrollBar().value()/self.totalHeight
else:
old_ratio = False
self.imgWidth = self.scrollArea.width()
self.totalHeight = 0
self.size_scale = {}
self.size_scale_height = [0]
for n in self.imageListName:
self.totalHeight += int(self.size_origin[n][1]*self.imgWidth/self.size_origin[n][0])
size = [self.imgWidth, int(self.size_origin[n][1]*self.imgWidth/self.size_origin[n][0])]
self.labels[n].setFixedSize(size[0], size[1])
self.size_scale[n] = size
self.size_scale_height.append(size[1])
self.scrollAreaWidgetContents.setFixedHeight(self.totalHeight)
# print("界面大小变化")
for n in self.show_list:
with zipfile.ZipFile(self.file_path, 'r') as zip_ref:
img = zip_ref.open(n).read()
pixmap = QPixmap()
pixmap.loadFromData(img)
self.labels[n].setPixmap(pixmap)
if old_ratio: # 保持当前画面
self.scrollArea.verticalScrollBar().setValue(self.totalHeight*old_ratio)
# 计算当前滚动页面对应那张图片
def get_location(self, value):
if self.first_load_flag:
print("初次加载")
self.first_load_flag = False
self.set_pictures(0)
return
# print(self.current_index, sum(self.size_scale_height[:self.current_index - 2]),
# value, sum(self.size_scale_height[:self.current_index+2]))
if self.current_index - 1 < 0:
if sum(self.size_scale_height[:self.current_index + 2]) > value:
return
elif self.current_index + 1 > len(self.imageListName)-1:
if sum(self.size_scale_height[:self.current_index - 2]) < value:
return
else:
if sum(self.size_scale_height[:self.current_index]) < value < sum(self.size_scale_height[:self.current_index+2]):
return
# if self.current_index
print("get_location: 更新图片")
for i, n in enumerate(self.imageListName):
if sum(self.size_scale_height[:i+1]) > value:
self.current_index = i
self.set_pictures(i-4, 4) # 只显示当前页面前后4页,共8页。
return
# 更新应该显示的图片
def set_pictures(self, index, list_len=4):
old_show_list = self.show_list
self.show_list = []
for i in range(2 * list_len): # 只显示当前页面前后list_len页,共2*list_len页。
if index+i < 0 or index + i > len(self.imageListName) - 1:
pass
else:
self.show_list.append(self.imageListName[index+i])
# print(self.show_list)
for old_p in old_show_list:
if old_p not in self.show_list:
# print("清除old_p ", old_p)
self.labels[old_p].clear()
for n in self.show_list:
if self.labels[n].pixmap():
# print("存在new_p ", n)
pass
else:
if self.file_path.lower().endswith('.zip'):
with zipfile.ZipFile(self.file_path, 'r') as zip_ref:
img = zip_ref.open(n).read()
pixmap = QPixmap()
pixmap.loadFromData(img)
self.labels[n].setPixmap(pixmap)
# 跳转到指定页面
def jump_to_page(self):
index = int(self.lineEdit.text())
height = sum(self.size_scale_height[:index])
self.scrollArea.verticalScrollBar().setValue(height)
# 显示进度条和隐藏进度条:
def show_or_hidden_process(self):
if self.horizontalSlider.isHidden():
self.horizontalSlider.setHidden(False)
self.pushButton_2.setText("隐藏进度")
else:
self.horizontalSlider.setHidden(True)
self.pushButton_2.setText("显示进度")
# 滑动进度条事件
def slid_horizontalSlider(self):
value = self.horizontalSlider.value()
self.scrollArea.verticalScrollBar().setValue(self.totalHeight*(value/self.horizontalSlider.maximum()))
# 程序结束前保存此次comic的信息
def closeEvent(self, event: PySide6.QtGui.QCloseEvent) -> None:
# print("关闭窗口", self.file_path, int(self.lineEdit.text()))
self.save_info(self.file_path, int(self.lineEdit.text()))
super().closeEvent(event)
# 对图片进行排序
def img_sort(img_list):
def extract_numbers_first(filename):
# 使用正则表达式匹配所有数字序列-对每一章的图片相对排序
filename = filename.split("/")[-1]
numbers = re.findall(r'\d+', filename)
if numbers:
first_number = int(numbers[0])
return first_number
else:
return 0
def extract_numbers_last(filename):
# 使用正则表达式匹配所有数字序列-利用排序算法的稳定性对章节排序后,各章图片相对顺序不变
numbers = re.findall(r'\d+', filename)
if numbers:
last_number = int(numbers[-1])
return last_number
else:
return 0
img_star_with_number = []
img_star_with_str = []
for n in img_list:
name = os.path.basename(n)
if re.match(r'^\d', name):
img_star_with_number.append(n)
else:
img_star_with_str.append(n)
# 根据提取的数字对文件名进行排序
img_star_with_number = sorted(img_star_with_number, key=extract_numbers_last)
img_star_with_number = sorted(img_star_with_number, key=extract_numbers_first)
img_star_with_str = sorted(img_star_with_str, key=extract_numbers_last)
img_star_with_str = sorted(img_star_with_str, key=extract_numbers_first)
return img_star_with_number+img_star_with_str
if __name__ == '__main__':
app = QApplication(sys.argv)
viewer = ImageViewer()
viewer.show()
app.exec()
3. 其他
1. 仅支持zip文件,因为解压rar文件的库不开源。如果用rarfile库又有大小限制。
2. 项目结构
├── Project
│ ├── resource
│ ├── reading_black.png
│ ├── read.py
│ ├── 韩漫阅读器.py
3. 打包项目
打包为单独的一个exe文件:
pyinstaller --onefile --noconsole 韩漫阅读器.py
在项目的disk目录下会生成“韩漫阅读器.exe”,如果要正确显示图标,要将exe文件和resource文件夹放在同一目录下。
4. 存在的问题
打开软件后本应该会加载上次观看的漫画的页面,但实际上并没有跳转到上次阅读到的地方。好在页数哪里是上次的页数,点击一下跳转即可。如果点“打开文件”是以前阅读过的漫画,会正常跳转到上次读的页面(逻辑都是一样的,就是不知道为什么打开软件那次加载不跳转)。