Skip to content

Commit 4f94886

Browse files
committed
feat: rename to manga-chapters and use openai api to allow for swapping in another provider
1 parent b3092d5 commit 4f94886

9 files changed

Lines changed: 255 additions & 424 deletions

File tree

File renamed without changes.
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,20 @@ requires = ["setuptools"] #, "setuptools-scm"]
33
build-backend = "setuptools.build_meta"
44

55
[project]
6-
name = "manga-toc"
6+
name = "manga-chapters"
77
version = "0.0.0"
88
# dynamic = ["version"]
99
description = "Editor Plugin to generate Manga Table of Contents from an Image Contents page"
1010
readme = "README.md"
1111
authors = [{ name = "Rob Brazier", email = "git+github@brzr.co" }]
12-
requires-python = ">=3.9"
13-
dependencies = ["google-genai", "typing-extensions"]
12+
requires-python = ">=3.11"
13+
dependencies = ["openai>=1.74.0", "pillow>=11.2.1"]
1414

1515
[tool.setuptools_scm]
1616
fallback_version = "0.0.0+unknown"
17-
version_file = "src/manga-toc/_version.py"
17+
version_file = "src/manga-chapters/_version.py"
1818
root = "../../"
19-
tag_regex = '^manga-toc-(?P<version>\d+(?:\.\d+){0,2})$'
19+
tag_regex = '^manga-chapters-(?P<version>\d+(?:\.\d+){0,2})$'
2020
version_file_template = '''
2121
__version__ = "{version}"
2222
__version_tuple__ = {version_tuple}
File renamed without changes.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from qt.core import QWidget, QVBoxLayout, QLabel, QLineEdit
2+
from calibre.utils.config import JSONConfig
3+
4+
# Create a configuration
5+
prefs = JSONConfig("plugins/manga_toc_generator")
6+
7+
# Set defaults
8+
prefs.defaults["llm_endpoint"] = (
9+
"https://generativelanguage.googleapis.com/v1beta/openai/"
10+
)
11+
prefs.defaults["llm_model"] = "gemini-2.0-flash"
12+
prefs.defaults["api_key"] = ""
13+
14+
15+
class ConfigWidget(QWidget):
16+
def __init__(self):
17+
QWidget.__init__(self)
18+
self.layout = QVBoxLayout()
19+
self.setLayout(self.layout)
20+
21+
# API Key
22+
self.layout.addWidget(QLabel("OpenAI-Compatible Endpoint:"))
23+
self.llm_endpoint = QLineEdit(self)
24+
self.llm_endpoint.setText(prefs["llm_endpoint"])
25+
self.layout.addWidget(self.llm_endpoint)
26+
self.layout.addWidget(QLabel("LLM Model Identifier:"))
27+
self.llm_model = QLineEdit(self)
28+
self.llm_model.setText(prefs["llm_model"])
29+
self.layout.addWidget(self.llm_model)
30+
self.layout.addWidget(QLabel("API Key"))
31+
self.api_key = QLineEdit(self)
32+
self.api_key.setText(prefs["api_key"])
33+
self.layout.addWidget(self.api_key)
34+
35+
self.layout.addStretch(1)
36+
37+
def save_settings(self):
38+
prefs["llm_endpoint"] = self.llm_endpoint.text().strip()
39+
prefs["llm_model"] = self.llm_model.text().strip()
40+
prefs["api_key"] = self.api_key.text().strip()

plugins/manga-toc/src/manga_toc/images/chapters.png renamed to plugins/manga-chapters/src/manga_chapters/images/chapters.png

File renamed without changes.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from io import BytesIO
2+
import base64
3+
import openai
4+
from PIL import Image
5+
from pydantic import BaseModel
6+
7+
PROMPT = """
8+
Look at the image and output the chapters that you see. Focus only on the text content visible in the image. Do not generate any content that isn't visible in the image.
9+
Format the output in Title Case. Do not assume the ordering based on the first number that is read, only use numbering that is visible.
10+
11+
For numbered chapters (numbered being either numeric (e.g. 1,2,3) or roman numerals (e.g. I,II,X), format as 'Chapter [number]: [title]'. Ensure that all roman numerals are converted to their numerical equivalents.
12+
For other chapters without numbers, format as '[category]: [title]' when a category is present. Omit the category when not.
13+
"""
14+
15+
16+
class ChapterResponse(BaseModel):
17+
chapters: list[str]
18+
19+
20+
class LLMReader:
21+
def __init__(self, url: str, model: str, api_key: str) -> None:
22+
self.client = openai.OpenAI(api_key=api_key, base_url=url)
23+
self.model = model
24+
25+
@staticmethod
26+
def _trim(input: list[str]) -> list[str]:
27+
return filter(None, [line.trim() for line in input])
28+
29+
def read_chapters(self, image_bytes: bytes) -> list[str]:
30+
image = Image.open(BytesIO(image_bytes))
31+
encoded_image = base64.b64encode(image_bytes).decode("utf-8")
32+
image_url = f"data:{image.get_format_mimetype()};base64,{encoded_image}"
33+
response = self.client.beta.chat.completions.parse(
34+
model=self.model,
35+
messages=[
36+
{
37+
"role": "user",
38+
"content": [
39+
{"type": "text", "text": PROMPT},
40+
{"type": "image_url", "image_url": {"url": image_url}},
41+
],
42+
},
43+
],
44+
response_format=ChapterResponse,
45+
)
46+
chapters = response.choices[0].message.parsed
47+
return chapters.chapters

plugins/manga-toc/src/manga_toc/main.py renamed to plugins/manga-chapters/src/manga_chapters/main.py

Lines changed: 14 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import os
2-
from io import BytesIO
32

43
from calibre.customize import Plugin
54
from calibre.ebooks.oeb.polish.toc import get_toc, commit_toc
65
from calibre.gui2 import error_dialog, question_dialog
76
from calibre.gui2.toc.main import TOC
87
from calibre.gui2.tweak_book.plugin import Tool
98
from qt.core import QAction
9+
from .config import prefs
1010

1111

1212
class MangaTocTool(Tool):
@@ -16,6 +16,7 @@ class MangaTocTool(Tool):
1616

1717
def __init__(self):
1818
self.plugin_path = os.path.dirname(os.path.abspath(__file__))
19+
self.prefs = prefs
1920

2021
def create_action(self, for_toolbar=True):
2122
action = QAction(get_icons("images/chapters.png"), _("Generate ToC"), self.gui)
@@ -30,18 +31,6 @@ def __enter__(self, *args):
3031
def __exit__(self, *args):
3132
Plugin.__exit__(self, *args)
3233

33-
def _get_client(self):
34-
from .config import prefs
35-
36-
api_key = prefs["api_key"]
37-
if not api_key:
38-
raise Exception(
39-
"A Gemini API Key is required - please configure in settings"
40-
)
41-
from google import genai
42-
43-
return genai.Client(api_key=api_key)
44-
4534
@staticmethod
4635
def _normalise_path(base, path) -> str:
4736
base_dir = os.path.dirname(base)
@@ -77,28 +66,17 @@ def parse_links(self, toc, container) -> tuple[str, list[str], int]:
7766
]
7867
return image, links, contents_index
7968

80-
def _get_image_contents(self, container, path):
81-
from PIL import Image
82-
83-
data = container.raw_data(path, decode=False)
84-
image = Image.open(BytesIO(data))
85-
return image
86-
87-
def _gemini_read_chapters(self, image) -> list[str]:
88-
client = self._get_client()
89-
result = client.models.generate_content(
90-
model="gemini-2.0-flash",
91-
contents=[
92-
image,
93-
"\n\n",
94-
"Please can you extract the chapter names from this attached image. Only return the chapter names, no page numbers or any other information that is present in the image. Just chapter names.\n",
95-
"Convert the text to Title Case and ensure that the chapters are numerically numbered (converting other schemes into numbers).\n",
96-
"e.g. CH102 -> Chapter 102, Chapter I -> Chapter 1, Chapter X -> Chapter 10.\n",
97-
"In the case of additional chapters e.g. 'Extra Story', 'Origial Story', 'Bonus' etc., leave those as-is, but still performing the Title Case transformations",
98-
"Do not format the response in any way, just do one chapter per line.",
99-
],
100-
)
101-
return result.text.splitlines()
69+
def _get_image_contents(self, container, path) -> bytes:
70+
return container.raw_data(path, decode=False)
71+
72+
def _read_chapters(self, image: bytes) -> list[str]:
73+
from .llm import LLMReader
74+
75+
url = self.prefs["llm_endpoint"]
76+
model = self.prefs["llm_model"]
77+
api_key = self.prefs["api_key"]
78+
reader = LLMReader(url, model, api_key)
79+
return reader.read_chapters(image)
10280

10381
def _confirm_apply(self, changes):
10482
mappings_string = "\n".join(changes)
@@ -134,7 +112,7 @@ def generate_toc(self):
134112
toc = get_toc(container)
135113
image, links, contents_idx = self.parse_links(toc, container)
136114
contents_image = self._get_image_contents(container, image)
137-
chapters = self._gemini_read_chapters(contents_image)
115+
chapters = self._read_chapters(contents_image)
138116
entries = {}
139117
if len(links) != len(chapters):
140118
raise Exception(

plugins/manga-toc/src/manga_toc/config.py

Lines changed: 0 additions & 26 deletions
This file was deleted.

0 commit comments

Comments
 (0)