From 143c2b75efd22e6735cad8bc7c9536a71a28a1dc Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 24 Oct 2026 22:08:02 +0300 Subject: [PATCH] feat: add ebook plugin for EPUB, FB2 and CBZ --- README.md | 1 + orvanitag/cmd.py | 2 +- orvanitag/orvani.py | 1 + orvanitag/plugins/_zip.py | 33 ++++++++++ orvanitag/plugins/ebook.py | 115 ++++++++++++++++++++++++++++++++++ orvanitag/plugins/office.py | 18 +----- tests/test_ebook.py | 119 ++++++++++++++++++++++++++++++++++++ 7 files changed, 272 insertions(+), 17 deletions(-) create mode 100644 orvanitag/plugins/_zip.py create mode 100644 orvanitag/plugins/ebook.py create mode 100644 tests/test_ebook.py diff --git a/README.md b/README.md index 53ff0d8..268179c 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ $ otag write id3 song.mp3 | `id3` | `.mp3` `.wav` `.flac` `.ogg` | The comment tag | | `pdf` | `.pdf` | The keywords field | | `office` | `.docx` `.xlsx` `.pptx` `.odt` `.ods` `.odp`, reading also `.doc` `.xls` `.ppt` | The description field | +| `ebook` | `.epub` `.fb2` `.cbz` | One otag line where other apps don't look: a `` in the EPUB package file, a `` in FB2, the archive comment of CBZ (never overwriting another tool's comment); the book's own metadata is left untouched | | `frontmatter` | `.md` `.markdown` `.txt` | `projects`, `contexts`, `tags` and `note` keys of the YAML front matter; other keys, comments and formatting are left untouched | | `comment` | Source code such as `.py` `.sh` `.toml` `.js` `.c` `.go` `.rs` `.php` `.html` | An `otag: +project @context #tag note` comment line within the first ten lines, written in the file's comment style below any shebang, encoding, `` or ` ArgumentParser: no_recursive(sub) sub = command('write', ['w'], 'write the entry of a file into the file itself', run_write) - sub.add_argument('plugin', help='comment, frontmatter, id3, office or pdf') + sub.add_argument('plugin', help='comment, ebook, frontmatter, id3, office or pdf') sub.add_argument('file') sub = command('export', ['e'], 'write all entries of a directory tree to a text file', run_export) diff --git a/orvanitag/orvani.py b/orvanitag/orvani.py index abfa896..e21674a 100644 --- a/orvanitag/orvani.py +++ b/orvanitag/orvani.py @@ -18,6 +18,7 @@ SEPARATOR = ' --- ' # between file name and contexts, in context files and ex DEFAULT_PLUGINS = [ 'frontmatter', 'comment', + 'ebook', 'id3', 'office', 'pdf' diff --git a/orvanitag/plugins/_zip.py b/orvanitag/plugins/_zip.py new file mode 100644 index 0000000..2df51d0 --- /dev/null +++ b/orvanitag/plugins/_zip.py @@ -0,0 +1,33 @@ +""" +Shared by the plugins for zip based formats; not a plugin itself. +""" +import os +import shutil +from pathlib import Path +from zipfile import ZipFile + + +def rewrite(path, changes: dict = None, comment: bytes = None): + """ + Rewrites a zip file atomically, with some text members changed and optionally a new archive comment + + changes (dict): member name -> function from old to new text + comment (bytes): new archive comment, None keeps the old one + """ + changes = changes or {} + path = Path(path) + tmp = path.with_name(f'.{path.name}.tmp') + try: + # Copying every ZipInfo keeps entry order and compression, which ODF and EPUB + # require for their leading, uncompressed 'mimetype' entry + with ZipFile(path) as source, ZipFile(tmp, 'w') as target: + for info in source.infolist(): + data = source.read(info) + if info.filename in changes: + data = changes[info.filename](data.decode('utf-8')).encode('utf-8') + target.writestr(info, data) + target.comment = source.comment if comment is None else comment + shutil.copymode(path, tmp) + os.replace(tmp, path) + finally: + tmp.unlink(missing_ok=True) diff --git a/orvanitag/plugins/ebook.py b/orvanitag/plugins/ebook.py new file mode 100644 index 0000000..e1f9d80 --- /dev/null +++ b/orvanitag/plugins/ebook.py @@ -0,0 +1,115 @@ +""" +Tags in ebooks as one otag line in a place other apps ignore, so the book's own subjects and description stay untouched: +an OPF `` in EPUB, a `` in FB2 and the archive comment of CBZ. +""" +import codecs +import os +import re +import shutil +from xml.sax.saxutils import escape, unescape +from zipfile import ZipFile +from orvanitag.plugins._zip import rewrite + +NAME = 'ebook' +TYPES = ['.epub', '.fb2', '.cbz'] +MARKER = 'otag:' # starts the CBZ archive comment +QUOTES = {'"': '"'} + +# Element prefixes like `opf:` are matched, so the new element can use the same namespace prefix +ROOTFILE = re.compile(r']*\bfull-path=["\']([^"\']+)["\']') +META = re.compile(r'<([\w-]+:)?meta\b[^>]*\bname=["\']otag["\'][^>]*>(?:\s*)?') +CONTENT = re.compile(r'\bcontent=(["\'])(.*?)\1', re.S) +METADATA_END = re.compile(r'') +CUSTOM = re.compile(r'<(?:[\w-]+:)?custom-info\b[^>]*\binfo-type=["\']otag["\'][^>]*>(.*?)', re.S) +# FB2 allows only `output` elements after custom-info +DESCRIPTION_END = re.compile(r'<([\w-]+:)?output\b|') +ENCODING = re.compile(rb'^<\?xml[^>]*\bencoding=["\']([\w.-]+)["\']') + + +def package(archive: ZipFile) -> str: + """The name of the EPUB's OPF package file""" + found = ROOTFILE.search(archive.read('META-INF/container.xml').decode('utf-8')) + if not found: + raise ValueError('EPUB without package file') + return unescape(found.group(1)) + + +def read_fb2(path): + """Returns the text, its encoding and whether it starts with a byte order mark""" + data = path.read_bytes() + bom = data.startswith(codecs.BOM_UTF8) + data = data.removeprefix(codecs.BOM_UTF8) + found = ENCODING.match(data) + encoding = found.group(1).decode() if found else 'utf-8' + return data.decode(encoding), encoding, bom + + +def reader(o): + suffix = o.file.suffix.lower() + text = '' + if suffix == '.epub': + with ZipFile(o.file) as archive: + opf = archive.read(package(archive)).decode('utf-8') + meta = META.search(opf) + content = CONTENT.search(meta.group(0)) if meta else None + text = unescape(content.group(2), {'"': '"'}) if content else '' + elif suffix == '.fb2': + found = CUSTOM.search(read_fb2(o.file)[0]) + text = unescape(found.group(1)) if found else '' + elif suffix == '.cbz': + with ZipFile(o.file) as archive: + comment = archive.comment.decode('utf-8', 'replace') + text = comment.removeprefix(MARKER) if comment.startswith(MARKER) else '' + if text.strip(): + return o.context(o.string(text)) + + +def epub_meta(opf: str, line: str) -> str: + """Replaces the otag meta element, or adds one at the end of the metadata""" + found = META.search(opf) + start, end = (found.start(), found.end()) if found else (None, None) + if not found: + found = METADATA_END.search(opf) + if not found: + raise ValueError('EPUB package file without metadata') + start = end = found.start() + return opf[:start] + f'<{found.group(1) or ""}meta name="otag" content="{escape(line, QUOTES)}"/>' + opf[end:] + + +def fb2_custom_info(text: str, line: str) -> str: + """Replaces the text of the otag custom-info element, or adds one at the end of the description""" + found = CUSTOM.search(text) + if found: + return text[:found.start(1)] + escape(line) + text[found.end(1):] + found = DESCRIPTION_END.search(text) + if not found: + raise ValueError('FB2 file without description') + prefix = found.group(1) or found.group(2) or '' + return text[:found.start()] + f'<{prefix}custom-info info-type="otag">{escape(line)}' + text[found.start():] + + +def writer(o): + suffix = o.file.suffix.lower() + line = ' '.join(o.collect(o.context)) + if suffix == '.epub': + with ZipFile(o.file) as archive: + name = package(archive) + rewrite(o.file, {name: lambda opf: epub_meta(opf, line)}) + + elif suffix == '.fb2': + text, encoding, bom = read_fb2(o.file) + data = fb2_custom_info(text, line).encode(encoding, 'xmlcharrefreplace') + tmp = o.file.with_name(f'.{o.file.name}.tmp') + try: + tmp.write_bytes((codecs.BOM_UTF8 if bom else b'') + data) + shutil.copymode(o.file, tmp) + os.replace(tmp, o.file) + finally: + tmp.unlink(missing_ok=True) + + elif suffix == '.cbz': + with ZipFile(o.file) as archive: + comment = archive.comment + if comment and not comment.startswith(MARKER.encode()): + raise ValueError(f'the archive comment of {o.file} is used by another tool') + rewrite(o.file, comment=f'{MARKER} {line}'.encode('utf-8')) diff --git a/orvanitag/plugins/office.py b/orvanitag/plugins/office.py index d335f37..c06603e 100644 --- a/orvanitag/plugins/office.py +++ b/orvanitag/plugins/office.py @@ -1,7 +1,7 @@ -import os import re from xml.sax.saxutils import escape, unescape from zipfile import ZipFile +from orvanitag.plugins._zip import rewrite NAME = 'office' TYPES = ['.doc', '.xls', '.ppt', '.docx', '.xlsx', '.pptx', '.odt', '.ods', '.odp'] @@ -46,19 +46,5 @@ def writer(o): if suffix not in META_FILES: raise NotImplementedError(f'writing metadata to {suffix} files is not supported') - meta_file = META_FILES[suffix] string = escape(' '.join(o.collect(o.context))) - tmp = o.file.with_name(f'.{o.file.name}.tmp') - try: - # Copying every ZipInfo keeps entry order and compression, which ODF - # requires for its leading, uncompressed 'mimetype' entry - with ZipFile(o.file) as source, ZipFile(tmp, 'w') as target: - for info in source.infolist(): - data = source.read(info) - if info.filename == meta_file: - meta = XML_REGEX.sub(lambda m: m['pre'] + string + m['post'], data.decode('utf-8')) - data = meta.encode('utf-8') - target.writestr(info, data) - os.replace(tmp, o.file) - finally: - tmp.unlink(missing_ok=True) + rewrite(o.file, {META_FILES[suffix]: lambda meta: XML_REGEX.sub(lambda m: m['pre'] + string + m['post'], meta)}) diff --git a/tests/test_ebook.py b/tests/test_ebook.py new file mode 100644 index 0000000..f3d8c59 --- /dev/null +++ b/tests/test_ebook.py @@ -0,0 +1,119 @@ +import zipfile + +import pytest + +import orvanitag.orvani as orvani +from orvanitag.orvani import OrvaniTag + +CONTAINER = ( + '' + '' +) +OPF = ( + '' + '' + 'x' + 'BookFiction' +) +PREFIXED_OPF = ( + '' + '' + 'Book' +) +FB2 = ( + '' + '' + 'космос

Аннотация

' + '

Текст

' +) + + +@pytest.fixture +def cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + return tmp_path + + +def make_epub(path, opf=OPF): + with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as z: + z.writestr(zipfile.ZipInfo('mimetype'), 'application/epub+zip') + z.writestr('META-INF/container.xml', CONTAINER) + z.writestr('OEBPS/content.opf', opf) + + +def make_cbz(path, comment=b''): + with zipfile.ZipFile(path, 'w') as z: + z.writestr('001.png', b'png') + z.comment = comment + + +def roundtrip(cwd, name, *tags): + OrvaniTag().add(name, *tags) + OrvaniTag().write_metadata(name, 'ebook') + expected = (cwd / orvani.CONTEXT_FILE).read_text() + (cwd / orvani.CONTEXT_FILE).unlink() + OrvaniTag().read_metadata(name) + assert (cwd / orvani.CONTEXT_FILE).read_text() == expected + return expected + + +def opf_of(path): + with zipfile.ZipFile(path) as z: + return z.read('OEBPS/content.opf').decode() + + +def test_epub_roundtrip_keeps_book_metadata(cwd): + make_epub(cwd / 'book.epub') + line = roundtrip(cwd, 'book.epub', '+reading', '#scifi', 'R&B', '"quoted"', 'C:\\1') + assert line == 'book.epub --- +reading #scifi R&B "quoted" C:\\1\n' + opf = opf_of(cwd / 'book.epub') + assert 'BookFiction' in opf + assert '' in opf + with zipfile.ZipFile(cwd / 'book.epub') as z: + first = z.infolist()[0] + assert first.filename == 'mimetype' and first.compress_type == zipfile.ZIP_STORED + + +def test_epub_second_write_replaces_the_meta(cwd): + make_epub(cwd / 'book.epub') + o = OrvaniTag() + o.add('book.epub', '#one') + o.write_metadata('book.epub', 'ebook') + o.add('book.epub', '#two') + o.write_metadata('book.epub', 'ebook') + opf = opf_of(cwd / 'book.epub') + assert opf.count('name="otag"') == 1 + assert 'content="#one #two"' in opf + + +def test_epub_with_prefixed_metadata(cwd): + make_epub(cwd / 'book.epub', PREFIXED_OPF) + roundtrip(cwd, 'book.epub', '#scifi') + assert '' in opf_of(cwd / 'book.epub') + + +@pytest.mark.parametrize('encoding', ['utf-8', 'windows-1251']) +def test_fb2_roundtrip_keeps_encoding(cwd, encoding): + (cwd / 'book.fb2').write_bytes(FB2.format(encoding=encoding).encode(encoding)) + line = roundtrip(cwd, 'book.fb2', '#фантастика', 'заметка') + assert line == 'book.fb2 --- #фантастика заметка\n' + text = (cwd / 'book.fb2').read_bytes().decode(encoding) + assert 'космос

Аннотация

' in text + assert '#фантастика заметка' in text + + +def test_cbz_roundtrip_and_replacing_own_comment(cwd): + make_cbz(cwd / 'comic.cbz', b'otag: #old') + roundtrip(cwd, 'comic.cbz', '+series', '#manga') + with zipfile.ZipFile(cwd / 'comic.cbz') as z: + assert z.comment == b'otag: +series #manga' + assert z.read('001.png') == b'png' + + +def test_cbz_foreign_comment_is_kept(cwd): + make_cbz(cwd / 'comic.cbz', b'{"ComicBookInfo/1.0": {}}') + before = (cwd / 'comic.cbz').read_bytes() + OrvaniTag().add('comic.cbz', '#manga') + with pytest.raises(ValueError): + OrvaniTag().write_metadata('comic.cbz', 'ebook') + assert (cwd / 'comic.cbz').read_bytes() == before