mirror of
https://github.com/hyugogirubato/cbz.git
synced 2026-09-26 21:41:03 +00:00
Merge pull request #7 from piskunqa/main
Implementation of preview book, by analogy of pillow.Image.show
This commit is contained in:
+75
-1
@@ -4,6 +4,7 @@ import json
|
|||||||
import zipfile
|
import zipfile
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
import xmltodict
|
import xmltodict
|
||||||
@@ -12,6 +13,7 @@ from cbz import utils
|
|||||||
from cbz.constants import xml_name
|
from cbz.constants import xml_name
|
||||||
from cbz.models import ComicModel
|
from cbz.models import ComicModel
|
||||||
from cbz.page import PageInfo
|
from cbz.page import PageInfo
|
||||||
|
from cbz.ui import show_in_tk
|
||||||
|
|
||||||
|
|
||||||
class ComicInfo(ComicModel):
|
class ComicInfo(ComicModel):
|
||||||
@@ -32,6 +34,13 @@ class ComicInfo(ComicModel):
|
|||||||
def from_pages(cls, pages: [PageInfo], **kwargs) -> ComicInfo:
|
def from_pages(cls, pages: [PageInfo], **kwargs) -> ComicInfo:
|
||||||
return cls(pages, **kwargs)
|
return cls(pages, **kwargs)
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
"""
|
||||||
|
display cbz for preview, after call open ui with info and pages and wait for close preview windows
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
show_in_tk(self.title, self.__pages, utils.dumps(self._get()))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_cbz(cls, path: Union[Path, str]) -> ComicInfo:
|
def from_cbz(cls, path: Union[Path, str]) -> ComicInfo:
|
||||||
"""
|
"""
|
||||||
@@ -104,9 +113,74 @@ class ComicInfo(ComicModel):
|
|||||||
xml_name,
|
xml_name,
|
||||||
xmltodict.unparse({'ComicInfo': self.dumps()}, pretty=True).encode('utf-8')
|
xmltodict.unparse({'ComicInfo': self.dumps()}, pretty=True).encode('utf-8')
|
||||||
)
|
)
|
||||||
|
|
||||||
for i, page in enumerate(self.__pages):
|
for i, page in enumerate(self.__pages):
|
||||||
zip_file.writestr(f'page-{i + 1:03d}{page.suffix}', page.content)
|
zip_file.writestr(f'page-{i + 1:03d}{page.suffix}', page.content)
|
||||||
result = zip_buffer.getvalue()
|
result = zip_buffer.getvalue()
|
||||||
zip_buffer.close()
|
zip_buffer.close()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def get_page(self, index: int) -> PageInfo:
|
||||||
|
"""
|
||||||
|
Get page by index
|
||||||
|
:param index:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return self.__pages[index]
|
||||||
|
|
||||||
|
def show_page(self, index: int) -> None:
|
||||||
|
"""
|
||||||
|
Display page by index
|
||||||
|
:param index:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
self.__pages[index].show()
|
||||||
|
|
||||||
|
def delete_page(self, index: int) -> PageInfo:
|
||||||
|
"""
|
||||||
|
Delete page by index
|
||||||
|
:param index:
|
||||||
|
:return: deleted page
|
||||||
|
"""
|
||||||
|
return self.__pages.pop(index)
|
||||||
|
|
||||||
|
def add_page(self, page: PageInfo) -> list[PageInfo]:
|
||||||
|
"""
|
||||||
|
Add new page to the book
|
||||||
|
:param page:
|
||||||
|
:return: new list of pages
|
||||||
|
"""
|
||||||
|
self.__pages.append(page)
|
||||||
|
return self.__pages
|
||||||
|
|
||||||
|
def insert_page(self, index: int, page: PageInfo) -> list[PageInfo]:
|
||||||
|
"""
|
||||||
|
Add new page to position
|
||||||
|
:param page:
|
||||||
|
:param index:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
self.__pages.insert(index, page)
|
||||||
|
return self.__pages
|
||||||
|
|
||||||
|
def get_pages_count(self) -> int:
|
||||||
|
"""
|
||||||
|
Get count of pages
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return len(self.__pages)
|
||||||
|
|
||||||
|
def get_all_pages(self) -> list[PageInfo]:
|
||||||
|
"""
|
||||||
|
Get all pages of book
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return self.__pages
|
||||||
|
|
||||||
|
def save_page(self, index: int, path: Union[Path, str]) -> None:
|
||||||
|
"""
|
||||||
|
Save page to the file
|
||||||
|
:param index: page index
|
||||||
|
:param path: path to new file, str or Path
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
self.__pages[index].save(path)
|
||||||
|
|||||||
@@ -162,3 +162,15 @@ FIELDS = (
|
|||||||
(("language_iso", "LanguageISO"), ("language_iso", (ValidLanguage, str))),
|
(("language_iso", "LanguageISO"), ("language_iso", (ValidLanguage, str))),
|
||||||
(("community_rating", "CommunityRating"), ("community_rating", (ValidRating, float, int, str))),
|
(("community_rating", "CommunityRating"), ("community_rating", (ValidRating, float, int, str))),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PAGE_FIELDS = (
|
||||||
|
# model: (key, xml key): (variable name, (expected type, second expected type,...))
|
||||||
|
# in case of multiple expected formats, value cast to first type in tuple
|
||||||
|
(("type", "Type"), ("type", (PageType, str))),
|
||||||
|
(("double", "DoublePage"), ("double", (bool,))),
|
||||||
|
(("key", "Key"), ("key", (str,))),
|
||||||
|
(("bookmark", "Bookmark"), ("bookmark", (str,))),
|
||||||
|
(("image_size", "ImageSize"), ("_image_size", (int,))),
|
||||||
|
(("image_width", "ImageWidth"), ("_image_width", (int,))),
|
||||||
|
(("image_height", "ImageHeight"), ("_image_height", (int,))),
|
||||||
|
)
|
||||||
+22
-32
@@ -1,5 +1,5 @@
|
|||||||
from cbz.constants import YesNo, Manga, AgeRating, Format, FIELDS
|
from cbz.constants import YesNo, Manga, AgeRating, Format, PageType, FIELDS, PAGE_FIELDS
|
||||||
from typing import Any
|
from cbz.utils import _get, _set
|
||||||
|
|
||||||
|
|
||||||
class ComicModel:
|
class ComicModel:
|
||||||
@@ -48,35 +48,25 @@ class ComicModel:
|
|||||||
_filepath: str = ""
|
_filepath: str = ""
|
||||||
|
|
||||||
def __init__(self, kwargs: dict):
|
def __init__(self, kwargs: dict):
|
||||||
"""
|
_set(self, FIELDS, kwargs)
|
||||||
Set class variables from input dictionary, check types of input and cast values to correct type
|
|
||||||
:param kwargs: dictionary of input data
|
|
||||||
"""
|
|
||||||
for kwarg_key, kwarg_value in kwargs.items():
|
|
||||||
for keys, values in FIELDS:
|
|
||||||
if kwarg_key in keys:
|
|
||||||
variable, types = values
|
|
||||||
if hasattr(self, variable):
|
|
||||||
setattr(self, variable, self._check_type(kwarg_key, kwarg_value, types))
|
|
||||||
break
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _check_type(key: str, value: Any, types: tuple) -> Any:
|
|
||||||
"""
|
|
||||||
Check type of value, and cast this value to first type of the types
|
|
||||||
:param key: name of key of variable (using only for best error information)
|
|
||||||
:param value: value for a check
|
|
||||||
:param types: list of the allowed types
|
|
||||||
:return: value with new type
|
|
||||||
"""
|
|
||||||
if not isinstance(value, types):
|
|
||||||
raise ValueError(
|
|
||||||
f"Unexpected type of {key}, got: {type(value).__name__}, expected: {[i.__name__ for i in types]}")
|
|
||||||
return types[0](value)
|
|
||||||
|
|
||||||
def _get(self) -> dict:
|
def _get(self) -> dict:
|
||||||
"""
|
return _get(self, FIELDS)
|
||||||
Create dictionary from variables by FIELDS
|
|
||||||
:return: dictionary with variables value
|
|
||||||
"""
|
class PageModel:
|
||||||
return {key[1]: self._check_type(value[0], getattr(self, value[0]), value[1]) for key, value in FIELDS}
|
suffix: str
|
||||||
|
_content: bytes
|
||||||
|
type: PageType = PageType.STORY
|
||||||
|
double: bool = False
|
||||||
|
_image_size: int = 0
|
||||||
|
key: str = ""
|
||||||
|
bookmark: str = ""
|
||||||
|
_image_width: int = 0
|
||||||
|
_image_height: int = 0
|
||||||
|
|
||||||
|
def __init__(self, kwargs: dict):
|
||||||
|
_set(self, PAGE_FIELDS, kwargs)
|
||||||
|
|
||||||
|
def _get(self) -> dict:
|
||||||
|
return _get(self, PAGE_FIELDS)
|
||||||
|
|||||||
+44
-17
@@ -3,35 +3,45 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from cbz import utils
|
from cbz import utils
|
||||||
from cbz.constants import PageType
|
from cbz.models import PageModel
|
||||||
|
|
||||||
|
|
||||||
class PageInfo:
|
class PageInfo(PageModel):
|
||||||
|
|
||||||
def __init__(self, content: bytes, **kwargs):
|
def __init__(self, content: bytes, **kwargs):
|
||||||
with Image.open(BytesIO(content)) as image:
|
super(PageInfo, self).__init__(kwargs)
|
||||||
|
self.content = content
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content(self) -> bytes:
|
||||||
|
"""
|
||||||
|
content getter
|
||||||
|
:return: image content
|
||||||
|
"""
|
||||||
|
return self._content
|
||||||
|
|
||||||
|
@content.setter
|
||||||
|
def content(self, value) -> None:
|
||||||
|
"""
|
||||||
|
Set image info by current content
|
||||||
|
:param value:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
with Image.open(BytesIO(value)) as image:
|
||||||
self.suffix = f'.{image.format.lower()}'
|
self.suffix = f'.{image.format.lower()}'
|
||||||
self.content = content
|
self._image_width = int(image.width)
|
||||||
self.__info = {
|
self._image_height = int(image.height)
|
||||||
# 'Image': 0,
|
self._image_size = len(value)
|
||||||
'Type': PageType(kwargs.get('type', PageType.STORY)),
|
self._content = value
|
||||||
'DoublePage': bool(kwargs.get('double', False)),
|
|
||||||
'ImageSize': len(content),
|
|
||||||
'Key': str(kwargs.get('key', '')),
|
|
||||||
'Bookmark': str(kwargs.get('bookmark', '')),
|
|
||||||
'ImageWidth': int(image.width),
|
|
||||||
'ImageHeight': int(image.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
def dumps(self) -> dict:
|
def dumps(self) -> dict:
|
||||||
return utils.dumps(self.__info)
|
return utils.dumps(self._get())
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return json.dumps(self.dumps(), indent=2)
|
return json.dumps(self.dumps(), indent=2)
|
||||||
@@ -50,3 +60,20 @@ class PageInfo:
|
|||||||
raise ValueError(f'Expecting Path object or path string, got {path!r}')
|
raise ValueError(f'Expecting Path object or path string, got {path!r}')
|
||||||
with Path(path).open(mode='rb') as f:
|
with Path(path).open(mode='rb') as f:
|
||||||
return cls(f.read(), **kwargs)
|
return cls(f.read(), **kwargs)
|
||||||
|
|
||||||
|
def show(self) -> None:
|
||||||
|
"""
|
||||||
|
display this page
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
with Image.open(BytesIO(self.content)) as image:
|
||||||
|
image.show()
|
||||||
|
|
||||||
|
def save(self, path: Union[Path, str]):
|
||||||
|
"""
|
||||||
|
Save page to file
|
||||||
|
:param path:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
with Path(path).open(mode='wb') as f:
|
||||||
|
f.write(self.content)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from tkinter import (Tk, Button, BOTTOM, TOP, PhotoImage, Scrollbar, X, Y, Canvas, HORIZONTAL, VERTICAL, YES, BOTH,
|
||||||
|
SUNKEN, RIGHT, LEFT, DISABLED, NORMAL, Label, Frame, ALL)
|
||||||
|
from PIL import ImageTk, Image
|
||||||
|
|
||||||
|
|
||||||
|
class ScrolledCanvas(Frame):
|
||||||
|
image_cursor: int = -2
|
||||||
|
info_text: str = ""
|
||||||
|
pages = list()
|
||||||
|
next_btn = None
|
||||||
|
prev_btn = None
|
||||||
|
counter = None
|
||||||
|
image: PhotoImage
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
Frame.__init__(self, parent)
|
||||||
|
self.master.title("Spectrogram Viewer")
|
||||||
|
self.pack(expand=YES, fill=BOTH)
|
||||||
|
self.canvas = Canvas(self, relief=SUNKEN)
|
||||||
|
self.canvas.config(width=400, height=200)
|
||||||
|
self.canvas.config(highlightthickness=0)
|
||||||
|
|
||||||
|
s_bar_v = Scrollbar(self, orient=VERTICAL)
|
||||||
|
s_bar_h = Scrollbar(self, orient=HORIZONTAL)
|
||||||
|
|
||||||
|
s_bar_v.config(command=self.canvas.yview)
|
||||||
|
s_bar_h.config(command=self.canvas.xview)
|
||||||
|
|
||||||
|
self.canvas.config(yscrollcommand=s_bar_v.set)
|
||||||
|
self.canvas.config(xscrollcommand=s_bar_h.set)
|
||||||
|
|
||||||
|
s_bar_v.pack(side=RIGHT, fill=Y)
|
||||||
|
s_bar_h.pack(side=BOTTOM, fill=X)
|
||||||
|
|
||||||
|
self.canvas.pack(side=LEFT, expand=YES, fill=BOTH)
|
||||||
|
|
||||||
|
def set_text(self) -> None:
|
||||||
|
self.canvas.delete('image')
|
||||||
|
self.canvas.create_text(0, 0, anchor="nw", text=self.info_text, tags="image")
|
||||||
|
self.canvas.config(scrollregion=self.canvas.bbox(ALL))
|
||||||
|
|
||||||
|
def set_image(self, content: bytes) -> None:
|
||||||
|
self.canvas.delete('image')
|
||||||
|
with BytesIO(content) as f:
|
||||||
|
with Image.open(f) as image:
|
||||||
|
self.image = ImageTk.PhotoImage(image)
|
||||||
|
self.canvas.create_image(0, 0, anchor="nw", image=self.image, tags='image')
|
||||||
|
self.canvas.config(scrollregion=self.canvas.bbox(ALL))
|
||||||
|
|
||||||
|
def show_image(self, n: int) -> None:
|
||||||
|
self.image_cursor += n
|
||||||
|
if self.image_cursor == -1:
|
||||||
|
self.set_text()
|
||||||
|
self.prev_btn.config(state=DISABLED)
|
||||||
|
self.next_btn.config(state=NORMAL if self.pages else DISABLED)
|
||||||
|
elif self.image_cursor >= 0:
|
||||||
|
self.set_image(self.pages[self.image_cursor].content)
|
||||||
|
if self.image_cursor == len(self.pages) - 1:
|
||||||
|
self.next_btn.config(state=DISABLED)
|
||||||
|
self.prev_btn.config(state=NORMAL)
|
||||||
|
else:
|
||||||
|
self.prev_btn.config(state=NORMAL)
|
||||||
|
self.next_btn.config(state=NORMAL)
|
||||||
|
self.counter.config(text=f"{self.image_cursor + 1}/{len(self.pages)}")
|
||||||
|
self.canvas.yview_moveto(0)
|
||||||
|
self.canvas.xview_moveto(0)
|
||||||
|
|
||||||
|
|
||||||
|
def show_in_tk(title, pages, info):
|
||||||
|
root = Tk()
|
||||||
|
root.geometry("700x700")
|
||||||
|
frame = ScrolledCanvas(root)
|
||||||
|
frame.info_text = "\n".join([f"{key}: {value}" for key, value in info.items()])
|
||||||
|
frame.pages = pages
|
||||||
|
frame.pack(side=TOP)
|
||||||
|
button_frame = Frame(root)
|
||||||
|
button_next = Button(button_frame, text="Next", command=lambda: frame.show_image(1))
|
||||||
|
button_exit = Button(button_frame, text="Exit", command=root.quit)
|
||||||
|
counter = Label(button_frame)
|
||||||
|
button_prev = Button(button_frame, text="Previous", command=lambda: frame.show_image(-1))
|
||||||
|
counter.grid(row=0, column=1)
|
||||||
|
button_exit.grid(row=0, column=2)
|
||||||
|
button_next.grid(row=0, column=3)
|
||||||
|
button_prev.grid(row=0, column=0)
|
||||||
|
frame.next_btn = button_next
|
||||||
|
frame.prev_btn = button_prev
|
||||||
|
frame.counter = counter
|
||||||
|
button_frame.pack(side=BOTTOM)
|
||||||
|
root.bind('<Left>', lambda x: frame.show_image(-1) if button_prev["state"] == NORMAL else True)
|
||||||
|
root.bind('<Right>', lambda x: frame.show_image(1) if button_next["state"] == NORMAL else True)
|
||||||
|
root.bind('<Escape>', lambda x: root.quit())
|
||||||
|
frame.show_image(1)
|
||||||
|
root.title(title)
|
||||||
|
root.mainloop()
|
||||||
@@ -1,5 +1,42 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
def dumps(data: dict) -> dict:
|
def dumps(data: dict) -> dict:
|
||||||
return {k: v.value if isinstance(v, Enum) else v for k, v in data.items() if v and v != -1 and v != 'Unknown'}
|
return {k: v.value if isinstance(v, Enum) else v for k, v in data.items() if v and v != -1 and v != 'Unknown'}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_type(key: str, value: Any, types: tuple) -> Any:
|
||||||
|
"""
|
||||||
|
Check type of value, and cast this value to first type of the types
|
||||||
|
:param key: name of key of variable (using only for best error information)
|
||||||
|
:param value: value for a check
|
||||||
|
:param types: list of the allowed types
|
||||||
|
:return: value with new type
|
||||||
|
"""
|
||||||
|
if not isinstance(value, types):
|
||||||
|
raise ValueError(
|
||||||
|
f"Unexpected type of {key}, got: {type(value).__name__}, expected: {[i.__name__ for i in types]}")
|
||||||
|
return types[0](value)
|
||||||
|
|
||||||
|
|
||||||
|
def _get(obj: Any, fields: tuple) -> dict:
|
||||||
|
"""
|
||||||
|
Create dictionary from variables by PAGE_FIELDS
|
||||||
|
:return: dictionary with variables value
|
||||||
|
"""
|
||||||
|
return {key[1]: _check_type(value[0], getattr(obj, value[0]), value[1]) for key, value in fields}
|
||||||
|
|
||||||
|
|
||||||
|
def _set(obj: Any, fields: tuple, data_dict: dict) -> None:
|
||||||
|
"""
|
||||||
|
Set class variables from input dictionary, check types of input and cast values to correct type
|
||||||
|
:param kwargs: dictionary of input data
|
||||||
|
"""
|
||||||
|
for kwarg_key, kwarg_value in data_dict.items():
|
||||||
|
for keys, values in fields:
|
||||||
|
if kwarg_key in keys:
|
||||||
|
variable, types = values
|
||||||
|
if hasattr(obj, variable):
|
||||||
|
setattr(obj, variable, _check_type(kwarg_key, kwarg_value, types))
|
||||||
|
break
|
||||||
|
|||||||
Reference in New Issue
Block a user