Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions manual/server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ animated, are only offered in their original format. The application chooses
an image format it supports from those links. JPEG is offered as the default
for AVIF and JPEG XL pages when conversion is available.

For manga, a ``ComicInfo.xml`` file in the root of the archive with
``Manga`` set to ``YesAndRightToLeft`` tells the application to use right-to-left
reading direction. Page images remain in their original order.

The normal username/password and library restrictions apply to manifests and
page images. Complete archive downloads remain available. This feature supports
CBZ archives; CBR and CB7 comics can still be downloaded as complete files.
Expand Down
23 changes: 23 additions & 0 deletions src/calibre/srv/comic.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from typing import TypedDict
from urllib.parse import urlencode

from lxml import etree

from calibre.db.errors import NoSuchFormat
from calibre.ebooks.comic.input import find_pages
from calibre.srv.content import create_file_copy
Expand All @@ -22,6 +24,7 @@
from calibre.srv.routes import endpoint
from calibre.utils.img import image_from_data, image_to_data
from calibre.utils.imghdr import what
from calibre.utils.xml_parse import safe_xml_fromstring
from calibre.utils.zipfile import BadZipfile, ZipFile

DIVINA_PROFILE = 'https://readium.org/webpub-manifest/profiles/divina'
Expand All @@ -38,6 +41,7 @@
# Pages are read into memory to serve or transcode them. Refuse larger ones, as
# the sizes in ZIP headers cannot be trusted to limit decompression.
MAX_PAGE_SIZE = 128 * 1024 * 1024
MAX_COMICINFO_SIZE = 1024 * 1024


class Link(TypedDict):
Expand Down Expand Up @@ -114,6 +118,23 @@ def comic_pages(archive: ZipFile) -> list[str]:
return find_pages([x.filename for x in archive.infolist() if not x.filename.endswith('/')])


def comic_is_right_to_left(archive: ZipFile) -> bool:
info = next((x for x in archive.infolist() if x.filename.lower() == 'comicinfo.xml'), None)
if info is None:
return False
try:
with archive.open(info) as f:
raw = f.read(MAX_COMICINFO_SIZE + 1)
if len(raw) > MAX_COMICINFO_SIZE:
return False
root = safe_xml_fromstring(raw, recover=False)
except BadZipfile, RuntimeError, NotImplementedError, zlib.error, EOFError, etree.XMLSyntaxError:
# ComicInfo is optional. Broken or unreadable metadata must not prevent
# opening a comic whose page images can still be read.
return False
return root.tag == 'ComicInfo' and (root.findtext('Manga') or '').strip() == 'YesAndRightToLeft'


@endpoint('/comic-manifest/{book_id}', types={'book_id': int})
def comic_manifest(ctx: Context, rd: RequestData, book_id: int) -> StaticOutput:
with open_comic(ctx, rd, book_id) as (c, archive, revision):
Expand All @@ -123,6 +144,8 @@ def comic_manifest(ctx: Context, rd: RequestData, book_id: int) -> StaticOutput:
metadata = opds2_metadata(c.db.get_metadata(book_id), c)
metadata.pop('@type', None)
metadata.update(conformsTo=DIVINA_PROFILE, numberOfPages=len(names))
if comic_is_right_to_left(archive):
metadata['readingProgression'] = 'rtl'

assert ctx.url_for is not None

Expand Down
64 changes: 63 additions & 1 deletion src/calibre/srv/tests/comic.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
from zipfile import ZipFile

from calibre.db.tests.base import IMG
from calibre.srv.comic import DIVINA_PROFILE, comic_pages, convertible_types, page_types, readable_types
from calibre.srv.comic import DIVINA_PROFILE, comic_is_right_to_left, comic_pages, convertible_types, page_types, readable_types
from calibre.srv.opds import DIVINA_MIME, OPDS2_MIME
from calibre.srv.tests.base import LibraryBaseTest
from calibre.utils.avif_test import NOT_AN_IMAGE, STILL_AVIF
from calibre.utils.imghdr import identify
from calibre.utils.jxl_test import STILL_JXL
from calibre.utils.resources import get_image_path as I
from calibre.utils.zipfile import ZipFile as ComicZipFile


def cbz(pages):
Expand All @@ -29,6 +30,67 @@ def cbz(pages):


class ComicTest(LibraryBaseTest):
def test_comic_reading_direction(self):
rtl = b'<ComicInfo><Manga>YesAndRightToLeft</Manga></ComicInfo>'
for filename, data, expected in (
('ComicInfo.xml', rtl, True),
('COMICINFO.XML', rtl, True),
('ComicInfo.xml', b'<ComicInfo><Manga> YesAndRightToLeft </Manga></ComicInfo>', True),
('folder/ComicInfo.xml', rtl, False),
('__MACOSX/ComicInfo.xml', rtl, False),
('ComicInfo.xml', b'<ComicInfo><Manga>Yes</Manga></ComicInfo>', False),
('ComicInfo.xml', b'<ComicInfo><Manga>No</Manga></ComicInfo>', False),
('ComicInfo.xml', b'<ComicInfo><Manga>Unknown</Manga></ComicInfo>', False),
('ComicInfo.xml', b'<ComicInfo/>', False),
('ComicInfo.xml', b'<Other><Manga>YesAndRightToLeft</Manga></Other>', False),
('ComicInfo.xml', b'<ComicInfo><Manga>YesAndRightToLeft</Manga>', False),
('ComicInfo.xml', b'not XML', False),
):
with self.subTest(filename=filename, data=data), ComicZipFile(BytesIO(cbz([(filename, data)]))) as archive:
self.ae(comic_is_right_to_left(archive), expected)
with ComicZipFile(BytesIO(cbz([('ComicInfo.xml', rtl)]))) as archive:
with patch('calibre.srv.comic.MAX_COMICINFO_SIZE', len(rtl)):
self.assertTrue(comic_is_right_to_left(archive))
with patch('calibre.srv.comic.MAX_COMICINFO_SIZE', len(rtl) - 1):
self.assertFalse(comic_is_right_to_left(archive))
# Resolving external entities must not read local files into metadata.
path = os.path.join(self.mkdtemp(), 'direction.txt')
with open(path, 'w') as f:
f.write('YesAndRightToLeft')
from pathlib import Path

xml = f'<!DOCTYPE ComicInfo [<!ENTITY direction SYSTEM "{Path(path).as_uri()}">]><ComicInfo><Manga>&direction;</Manga></ComicInfo>'.encode()
with ComicZipFile(BytesIO(cbz([('ComicInfo.xml', xml)]))) as archive:
self.assertFalse(comic_is_right_to_left(archive))
encrypted = bytearray(cbz([('ComicInfo.xml', rtl)]))
encrypted[6] |= 1
encrypted[encrypted.index(b'PK\x01\x02') + 8] |= 1
with ComicZipFile(BytesIO(encrypted)) as archive:
self.assertFalse(comic_is_right_to_left(archive))

def test_comic_manga_manifest(self):
png = I('lt.png', data=True)
pages = [('2.png', png), ('1.jpg', IMG)]
with self.create_server() as server:
db = server.handler.ctx.library_broker.get(None)
conn = server.connect()
for metadata, expected in (
([], None),
([('ComicInfo.xml', b'<ComicInfo><Manga>YesAndRightToLeft</Manga></ComicInfo>')], 'rtl'),
([('ComicInfo.xml', b'<ComicInfo><Manga>No</Manga></ComicInfo>')], None),
([('ComicInfo.xml', b'not XML')], None),
):
with self.subTest(metadata=metadata):
db.add_format(1, 'CBZ', BytesIO(cbz([*pages, *metadata])), run_hooks=False)
_, manifest = self.manifest(conn)
self.ae(manifest['metadata'].get('readingProgression'), expected)
self.ae(manifest['metadata']['numberOfPages'], 2)
self.ae([x['type'] for x in manifest['readingOrder']], ['image/jpeg', 'image/png'])
for resource, data in zip(manifest['readingOrder'], (IMG, png)):
response, raw = self.request(conn, resource['href'])
self.ae(response.status, OK)
self.ae(raw, data)

def request(self, conn, url, *, user=None, accept=None, headers=None, method='GET'):
headers = dict(headers or {})
if user:
Expand Down
Loading