feat: add ebook plugin for EPUB, FB2 and CBZ
This commit is contained in:
parent
e4fc6c0a59
commit
143c2b75ef
7 changed files with 272 additions and 17 deletions
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
33
orvanitag/plugins/_zip.py
Normal 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
115
orvanitag/plugins/ebook.py
Normal 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 = {'"': '"'}
|
||||
|
||||
# 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), {'"': '"'}) 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'))
|
||||
|
|
@ -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
119
tests/test_ebook.py
Normal 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&B "quoted" 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue