From b1ec7f42cac5051fed0e6edfc87bdc7ed681e36f Mon Sep 17 00:00:00 2001 From: S Y Date: Fri, 30 Oct 2026 03:29:30 +0000 Subject: [PATCH] OPDS comics: Honor manga reading direction from ComicInfo.xml --- manual/server.rst | 4 +++ src/calibre/srv/comic.py | 23 ++++++++++++ src/calibre/srv/tests/comic.py | 64 +++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/manual/server.rst b/manual/server.rst index 2d6737ccb571..a5e87ec6ccb4 100644 --- a/manual/server.rst +++ b/manual/server.rst @@ -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. diff --git a/src/calibre/srv/comic.py b/src/calibre/srv/comic.py index 2efbeb03fed8..288a06b3f3ff 100644 --- a/src/calibre/srv/comic.py +++ b/src/calibre/srv/comic.py @@ -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 @@ -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' @@ -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): @@ -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): @@ -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 diff --git a/src/calibre/srv/tests/comic.py b/src/calibre/srv/tests/comic.py index fe46a9058180..b1ee5e06e307 100644 --- a/src/calibre/srv/tests/comic.py +++ b/src/calibre/srv/tests/comic.py @@ -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): @@ -29,6 +30,67 @@ def cbz(pages): class ComicTest(LibraryBaseTest): + def test_comic_reading_direction(self): + rtl = b'YesAndRightToLeft' + for filename, data, expected in ( + ('ComicInfo.xml', rtl, True), + ('COMICINFO.XML', rtl, True), + ('ComicInfo.xml', b' YesAndRightToLeft ', True), + ('folder/ComicInfo.xml', rtl, False), + ('__MACOSX/ComicInfo.xml', rtl, False), + ('ComicInfo.xml', b'Yes', False), + ('ComicInfo.xml', b'No', False), + ('ComicInfo.xml', b'Unknown', False), + ('ComicInfo.xml', b'', False), + ('ComicInfo.xml', b'YesAndRightToLeft', False), + ('ComicInfo.xml', b'YesAndRightToLeft', 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']>&direction;'.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'YesAndRightToLeft')], 'rtl'), + ([('ComicInfo.xml', b'No')], 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: