feat: add ebook plugin for EPUB, FB2 and CBZ

This commit is contained in:
randogoth 2026-10-24 22:08:02 +03:00
parent e4fc6c0a59
commit 143c2b75ef
7 changed files with 272 additions and 17 deletions

View file

@ -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 `<meta name="otag">` in the EPUB package file, a `<custom-info info-type="otag">` 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, `<!DOCTYPE>` or `<?php` line |

View file

@ -230,7 +230,7 @@ def parser() -> 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)

View file

@ -18,6 +18,7 @@ SEPARATOR = ' --- ' # between file name and contexts, in context files and ex
DEFAULT_PLUGINS = [
'frontmatter',
'comment',
'ebook',
'id3',
'office',
'pdf'

33
orvanitag/plugins/_zip.py Normal file
View file

@ -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)

115
orvanitag/plugins/ebook.py Normal file
View file

@ -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 `<meta name="otag">` in EPUB, a `<custom-info info-type="otag">` 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 = {'"': '&quot;'}
# Element prefixes like `opf:` are matched, so the new element can use the same namespace prefix
ROOTFILE = re.compile(r'<rootfile\b[^>]*\bfull-path=["\']([^"\']+)["\']')
META = re.compile(r'<([\w-]+:)?meta\b[^>]*\bname=["\']otag["\'][^>]*>(?:\s*</(?:[\w-]+:)?meta>)?')
CONTENT = re.compile(r'\bcontent=(["\'])(.*?)\1', re.S)
METADATA_END = re.compile(r'</([\w-]+:)?metadata>')
CUSTOM = re.compile(r'<(?:[\w-]+:)?custom-info\b[^>]*\binfo-type=["\']otag["\'][^>]*>(.*?)</(?:[\w-]+:)?custom-info>', re.S)
# FB2 allows only `output` elements after custom-info
DESCRIPTION_END = re.compile(r'<([\w-]+:)?output\b|</([\w-]+:)?description>')
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), {'&quot;': '"'}) 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)}</{prefix}custom-info>' + 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'))

View file

@ -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)})

119
tests/test_ebook.py Normal file
View file

@ -0,0 +1,119 @@
import zipfile
import pytest
import orvanitag.orvani as orvani
from orvanitag.orvani import OrvaniTag
CONTAINER = (
'<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
'<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>'
)
OPF = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">'
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="id">x</dc:identifier>'
'<dc:title>Book</dc:title><dc:subject>Fiction</dc:subject></metadata><manifest/><spine/></package>'
)
PREFIXED_OPF = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<opf:package xmlns:opf="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="id">'
'<opf:metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Book</dc:title></opf:metadata></opf:package>'
)
FB2 = (
'<?xml version="1.0" encoding="{encoding}"?>'
'<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0"><description><title-info>'
'<keywords>космос</keywords><annotation><p>Аннотация</p></annotation></title-info></description>'
'<body><p>Текст</p></body></FictionBook>'
)
@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 '<dc:title>Book</dc:title><dc:subject>Fiction</dc:subject>' in opf
assert '<meta name="otag" content="+reading #scifi R&amp;B &quot;quoted&quot; C:\\1"/></metadata>' 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 '<opf:meta name="otag" content="#scifi"/></opf:metadata>' 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 '<keywords>космос</keywords><annotation><p>Аннотация</p></annotation></title-info>' in text
assert '<custom-info info-type="otag">#фантастика заметка</custom-info></description>' 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