diff --git a/orvanitag/plugins/_zip.py b/orvanitag/plugins/_zip.py index 2df51d0..9e5a901 100644 --- a/orvanitag/plugins/_zip.py +++ b/orvanitag/plugins/_zip.py @@ -5,6 +5,7 @@ import os import shutil from pathlib import Path from zipfile import ZipFile +from orvanitag.errors import UnsupportedFile def rewrite(path, changes: dict = None, comment: bytes = None): @@ -21,6 +22,9 @@ def rewrite(path, changes: dict = None, comment: bytes = None): # 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: + missing = set(changes) - set(source.namelist()) + if missing: + raise UnsupportedFile(f'{path} has no {", ".join(sorted(missing))}') for info in source.infolist(): data = source.read(info) if info.filename in changes: diff --git a/orvanitag/plugins/office.py b/orvanitag/plugins/office.py index 30b37bd..389dcad 100644 --- a/orvanitag/plugins/office.py +++ b/orvanitag/plugins/office.py @@ -7,7 +7,10 @@ from orvanitag.errors import UnsupportedFile NAME = 'office' TYPES = ['.doc', '.xls', '.ppt', '.docx', '.xlsx', '.pptx', '.odt', '.ods', '.odp'] -XML_REGEX = re.compile(r"(?P
)(?P.*?)(?P<\/dc:description>)", re.S)
+DC = 'http://purl.org/dc/elements/1.1/'
+DESCRIPTION = re.compile(r'|(?P.*?)', re.S)
+# The end of the metadata block a missing description is added to
+METADATA_END = re.compile(r'')
 
 # Detected by suffix because mimetypes doesn't know the ODF/OOXML types on every system
 META_FILES = {
@@ -28,7 +31,7 @@ def read(path, config):
     if suffix in META_FILES:
         with ZipFile(path) as archive:
             meta = archive.read(META_FILES[suffix]).decode('utf-8')
-        found = XML_REGEX.search(meta)
+        found = DESCRIPTION.search(meta)
         if found and found.group('body'):
             return Entry.parse(unescape(found.group('body')).split())
 
@@ -48,5 +51,21 @@ def write(path, entry, config):
     if suffix not in META_FILES:
         raise UnsupportedFile(f'writing metadata to {suffix} files is not supported')
 
-    string = escape(str(entry))
-    rewrite(path, {META_FILES[suffix]: lambda meta: XML_REGEX.sub(lambda m: m['pre'] + string + m['post'], meta)})
+    rewrite(path, {META_FILES[suffix]: lambda meta: describe(meta, escape(str(entry)))})
+
+
+def describe(meta: str, string: str) -> str:
+    """Sets the text of the description element, adding the element if there is none"""
+    found = DESCRIPTION.search(meta)
+    if found and found.group('body') is not None:
+        return meta[:found.start('body')] + string + meta[found.end('body'):]
+
+    start, end = (found.start(), found.end()) if found else (None, None)
+    if not found:
+        closing = METADATA_END.search(meta)
+        if not closing:
+            raise UnsupportedFile('the document properties have no metadata block')
+        start = end = closing.start()
+    rest = meta[:start] + meta[end:]
+    declare = '' if 'xmlns:dc=' in rest else f' xmlns:dc="{DC}"'
+    return meta[:start] + f'{string}' + meta[end:]
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index dba8970..9e1188a 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -186,3 +186,46 @@ def test_cmd_messages_show_square_brackets_in_file_names(cwd, monkeypatch, capsy
     monkeypatch.setattr(sys, 'argv', ['otag', 'a', 'a [demo].mp3', '#lofi'])
     main()
     assert 'a [demo].mp3' in capsys.readouterr().out
+
+
+def make_zip(path, members):
+    with zipfile.ZipFile(path, 'w') as z:
+        for name, data in members.items():
+            z.writestr(name, data)
+
+
+def core_xml(cwd, name):
+    with zipfile.ZipFile(cwd / name) as z:
+        return z.read('docProps/core.xml').decode()
+
+
+def test_docx_without_description_gets_one(cwd):
+    make_zip(cwd / 'a.docx', {'docProps/core.xml': DOCX_CORE.replace('', '')})
+    roundtrip(cwd, 'a.docx')
+    assert 'T+proj #lofi R&B C:\\1' in core_xml(cwd, 'a.docx')
+
+
+def test_self_closing_description_is_replaced(cwd):
+    make_zip(cwd / 'a.docx', {'docProps/core.xml': DOCX_CORE.replace('', '')})
+    roundtrip(cwd, 'a.docx')
+    assert core_xml(cwd, 'a.docx').count('dc:description') == 2
+
+
+def test_inserted_description_declares_its_namespace(cwd):
+    import xml.etree.ElementTree as ET
+    core = ''
+    make_zip(cwd / 'a.docx', {'docProps/core.xml': core})
+    OrvaniTag().add('a.docx', '#lofi')
+    OrvaniTag().write_metadata('a.docx', 'office')
+    root = ET.fromstring(core_xml(cwd, 'a.docx'))
+    assert root.find('{http://purl.org/dc/elements/1.1/}description').text == '#lofi'
+
+
+def test_docx_without_core_properties_is_refused(cwd):
+    from orvanitag.errors import UnsupportedFile
+    make_zip(cwd / 'a.docx', {'[Content_Types].xml': ''})
+    before = (cwd / 'a.docx').read_bytes()
+    OrvaniTag().add('a.docx', '#lofi')
+    with pytest.raises(UnsupportedFile):
+        OrvaniTag().write_metadata('a.docx', 'office')
+    assert (cwd / 'a.docx').read_bytes() == before