Module refinery.units.formats.office.docmeta

Expand source code Browse git
from __future__ import annotations

import re

from enum import Enum
from pathlib import Path

from refinery.lib import xml
from refinery.lib.dt import isodate
from refinery.units.formats import JSONTableUnit, Unit
from refinery.units.formats.office.xtdoc import xtdoc


class _Prop(str, Enum):
    app = 'app.xml'
    core = 'core.xml'
    custom = 'custom.xml'


class _Kind(str, Enum):
    access = 'access'
    ole = 'ole'
    ooxml = 'ooxml'
    opendocument = 'opendocument'
    pdf = 'pdf'
    rtf = 'rtf'


def _interpret(value: str | dict):
    if isinstance(value, dict):
        return {k: _interpret(v) for k, v in value.items()}
    if value.isdigit():
        return int(value)
    casefold = value.lower()
    if casefold == 'false':
        return False
    if casefold == 'true':
        return True
    return isodate(value) or value


class docmeta(JSONTableUnit):
    """
    Extract metadata from documents. This covers legacy OLE2 Office documents (Word, Excel, and
    PowerPoint), the modern OOXML formats (DOCX, XLSX, and PPTX), OpenDocument files (ODT, ODS, and
    ODP), PDF documents, and RTF documents. For OOXML files, this includes custom document
    properties; for Microsoft Access databases, this includes the database engine, timestamps, and
    the user profile paths leaked by the compiled VBA project and by the import/export
    specifications.
    """
    @Unit.Requires('pymupdf', 1)
    def _mupdf():
        import os
        for setting in ('PYMUPDF_MESSAGE', 'PYMUPDF_LOG'):
            os.environ[setting] = F'path:{os.devnull}'
        import pymupdf
        return pymupdf

    @classmethod
    def _classify(cls, data) -> _Kind | None:
        from refinery.lib.access import is_access_database
        from refinery.lib.id import Fmt, buffer_contains, get_microsoft_format, get_office_xml_type
        if is_access_database(data):
            return _Kind.access
        if data[:5] == B'%PDF-':
            return _Kind.pdf
        if re.match(BR'\s{0,500}\{\\rtf', memoryview(data)[:505]):
            return _Kind.rtf
        if data[:2] == B'PK':
            if get_office_xml_type(data) in (Fmt.DOCX, Fmt.XLSX, Fmt.PPTX):
                return _Kind.ooxml
            if buffer_contains(data, B'vnd.oasis.opendocument'):
                return _Kind.opendocument
            return None
        if get_microsoft_format(data) in (Fmt.DOC, Fmt.XLS, Fmt.PPT, Fmt.CFF):
            return _Kind.ole
        return None

    @classmethod
    def handles(cls, data) -> bool | None:
        return cls._classify(data) is not None

    def json(self, data: bytearray):
        kind = self._classify(data)
        if kind is None:
            return None
        dispatch = {
            _Kind.access       : self._json_access,
            _Kind.ole          : self._json_ole,
            _Kind.ooxml        : self._json_ooxml,
            _Kind.opendocument : self._json_opendocument,
            _Kind.pdf          : self._json_pdf,
            _Kind.rtf          : self._json_rtf,
        }
        return dispatch[kind](data)

    def _json_access(self, data: bytearray):
        from refinery.lib.access import AccessDatabase
        return AccessDatabase(data).metadata()

    def _json_ole(self, data: bytearray):
        from refinery.lib.ole.file import OleFile
        with OleFile(data) as ole:
            meta = ole.get_metadata().dump()
        for noise in ('codepage', 'codepage_doc', 'thumbnail'):
            meta.pop(noise, None)
        return meta or None

    def _json_rtf(self, data: bytearray):
        from refinery.lib.ole.rtf import RtfInfoParser
        return RtfInfoParser(data).info or None

    def _json_pdf(self, data: bytearray):
        from refinery.lib.dt import pdfdate
        from refinery.lib.tools import NoLogging
        with NoLogging():
            document = self._mupdf.open(stream=data, filetype='pdf')
            meta = document.metadata
        if not meta:
            return None
        result = {}
        for key, value in meta.items():
            if not value:
                continue
            if key.endswith('Date') and (parsed := pdfdate(value)) is not None:
                value = parsed
            result[key] = value
        return result or None

    def _json_opendocument(self, data: bytearray):
        from refinery.units.formats.archive.xtzip import xtzip
        meta = data | xtzip('meta.xml') | bytearray
        if not meta or (dom := xml.parse(meta)) is None:
            return None
        while dom.tag.rpartition(':')[2].lower() != 'document-meta':
            if not dom.children:
                return None
            dom = dom.children[0]
        contents = {}
        for wrapper in dom:
            if wrapper.tag.rpartition(':')[2].lower() != 'meta':
                continue
            for node in wrapper:
                _, _, name = node.tag.partition(':')
                if content := node.content:
                    contents[name or node.tag] = _interpret(content.strip())
                elif node.attributes:
                    for key, value in node.attributes.items():
                        _, _, subname = key.partition(':')
                        contents[subname or key] = _interpret(value)
        return {'meta': contents} if contents else None

    def _json_ooxml(self, data: bytearray):
        props = data | xtdoc('docProps/*.xml', exact=True, path=b'path') | {'path': bytearray}
        result = {}

        for path, page in props.items():
            name = Path(path).name
            if (dom := xml.parse(page)) is None:
                self.log_info(F'failed to parse as XML: {path}')
                continue
            try:
                prop = _Prop(name)
            except ValueError:
                self.log_info(F'skipped unknown property: {name}')
                continue

            result[prop.name] = contents = {}

            if prop == _Prop.custom:
                while dom.tag.lower() != 'properties':
                    dom = dom.children[0]
                for node in dom:
                    assert node.tag.lower() == 'property'
                    assert len(node.children) == 1
                    content = node.children[0].content
                    if content is None:
                        continue
                    contents[node.attributes['name']] = content.strip()
            elif prop == _Prop.app:
                while dom.tag.lower() != 'properties':
                    dom = dom.children[0]
                for node in dom:
                    if not (content := node.content):
                        continue
                    contents[node.tag] = content
            elif prop == _Prop.core:
                while dom.tag.lower() != 'cp:coreproperties':
                    dom = dom.children[0]
                for node in dom:
                    t, _, name = node.tag.partition(':')
                    if not name:
                        continue
                    if not (content := node.content):
                        continue
                    contents[name] = content
            for name, value in contents.items():
                contents[name] = _interpret(value)

        return result

Classes

class docmeta (tabular=False, minimal=False)

Extract metadata from documents. This covers legacy OLE2 Office documents (Word, Excel, and PowerPoint), the modern OOXML formats (DOCX, XLSX, and PPTX), OpenDocument files (ODT, ODS, and ODP), PDF documents, and RTF documents. For OOXML files, this includes custom document properties; for Microsoft Access databases, this includes the database engine, timestamps, and the user profile paths leaked by the compiled VBA project and by the import/export specifications.

Expand source code Browse git
class docmeta(JSONTableUnit):
    """
    Extract metadata from documents. This covers legacy OLE2 Office documents (Word, Excel, and
    PowerPoint), the modern OOXML formats (DOCX, XLSX, and PPTX), OpenDocument files (ODT, ODS, and
    ODP), PDF documents, and RTF documents. For OOXML files, this includes custom document
    properties; for Microsoft Access databases, this includes the database engine, timestamps, and
    the user profile paths leaked by the compiled VBA project and by the import/export
    specifications.
    """
    @Unit.Requires('pymupdf', 1)
    def _mupdf():
        import os
        for setting in ('PYMUPDF_MESSAGE', 'PYMUPDF_LOG'):
            os.environ[setting] = F'path:{os.devnull}'
        import pymupdf
        return pymupdf

    @classmethod
    def _classify(cls, data) -> _Kind | None:
        from refinery.lib.access import is_access_database
        from refinery.lib.id import Fmt, buffer_contains, get_microsoft_format, get_office_xml_type
        if is_access_database(data):
            return _Kind.access
        if data[:5] == B'%PDF-':
            return _Kind.pdf
        if re.match(BR'\s{0,500}\{\\rtf', memoryview(data)[:505]):
            return _Kind.rtf
        if data[:2] == B'PK':
            if get_office_xml_type(data) in (Fmt.DOCX, Fmt.XLSX, Fmt.PPTX):
                return _Kind.ooxml
            if buffer_contains(data, B'vnd.oasis.opendocument'):
                return _Kind.opendocument
            return None
        if get_microsoft_format(data) in (Fmt.DOC, Fmt.XLS, Fmt.PPT, Fmt.CFF):
            return _Kind.ole
        return None

    @classmethod
    def handles(cls, data) -> bool | None:
        return cls._classify(data) is not None

    def json(self, data: bytearray):
        kind = self._classify(data)
        if kind is None:
            return None
        dispatch = {
            _Kind.access       : self._json_access,
            _Kind.ole          : self._json_ole,
            _Kind.ooxml        : self._json_ooxml,
            _Kind.opendocument : self._json_opendocument,
            _Kind.pdf          : self._json_pdf,
            _Kind.rtf          : self._json_rtf,
        }
        return dispatch[kind](data)

    def _json_access(self, data: bytearray):
        from refinery.lib.access import AccessDatabase
        return AccessDatabase(data).metadata()

    def _json_ole(self, data: bytearray):
        from refinery.lib.ole.file import OleFile
        with OleFile(data) as ole:
            meta = ole.get_metadata().dump()
        for noise in ('codepage', 'codepage_doc', 'thumbnail'):
            meta.pop(noise, None)
        return meta or None

    def _json_rtf(self, data: bytearray):
        from refinery.lib.ole.rtf import RtfInfoParser
        return RtfInfoParser(data).info or None

    def _json_pdf(self, data: bytearray):
        from refinery.lib.dt import pdfdate
        from refinery.lib.tools import NoLogging
        with NoLogging():
            document = self._mupdf.open(stream=data, filetype='pdf')
            meta = document.metadata
        if not meta:
            return None
        result = {}
        for key, value in meta.items():
            if not value:
                continue
            if key.endswith('Date') and (parsed := pdfdate(value)) is not None:
                value = parsed
            result[key] = value
        return result or None

    def _json_opendocument(self, data: bytearray):
        from refinery.units.formats.archive.xtzip import xtzip
        meta = data | xtzip('meta.xml') | bytearray
        if not meta or (dom := xml.parse(meta)) is None:
            return None
        while dom.tag.rpartition(':')[2].lower() != 'document-meta':
            if not dom.children:
                return None
            dom = dom.children[0]
        contents = {}
        for wrapper in dom:
            if wrapper.tag.rpartition(':')[2].lower() != 'meta':
                continue
            for node in wrapper:
                _, _, name = node.tag.partition(':')
                if content := node.content:
                    contents[name or node.tag] = _interpret(content.strip())
                elif node.attributes:
                    for key, value in node.attributes.items():
                        _, _, subname = key.partition(':')
                        contents[subname or key] = _interpret(value)
        return {'meta': contents} if contents else None

    def _json_ooxml(self, data: bytearray):
        props = data | xtdoc('docProps/*.xml', exact=True, path=b'path') | {'path': bytearray}
        result = {}

        for path, page in props.items():
            name = Path(path).name
            if (dom := xml.parse(page)) is None:
                self.log_info(F'failed to parse as XML: {path}')
                continue
            try:
                prop = _Prop(name)
            except ValueError:
                self.log_info(F'skipped unknown property: {name}')
                continue

            result[prop.name] = contents = {}

            if prop == _Prop.custom:
                while dom.tag.lower() != 'properties':
                    dom = dom.children[0]
                for node in dom:
                    assert node.tag.lower() == 'property'
                    assert len(node.children) == 1
                    content = node.children[0].content
                    if content is None:
                        continue
                    contents[node.attributes['name']] = content.strip()
            elif prop == _Prop.app:
                while dom.tag.lower() != 'properties':
                    dom = dom.children[0]
                for node in dom:
                    if not (content := node.content):
                        continue
                    contents[node.tag] = content
            elif prop == _Prop.core:
                while dom.tag.lower() != 'cp:coreproperties':
                    dom = dom.children[0]
                for node in dom:
                    t, _, name = node.tag.partition(':')
                    if not name:
                        continue
                    if not (content := node.content):
                        continue
                    contents[name] = content
            for name, value in contents.items():
                contents[name] = _interpret(value)

        return result

Ancestors

Subclasses

Class variables

var reverse

The type of the None singleton.

Methods

def json(self, data)
Expand source code Browse git
def json(self, data: bytearray):
    kind = self._classify(data)
    if kind is None:
        return None
    dispatch = {
        _Kind.access       : self._json_access,
        _Kind.ole          : self._json_ole,
        _Kind.ooxml        : self._json_ooxml,
        _Kind.opendocument : self._json_opendocument,
        _Kind.pdf          : self._json_pdf,
        _Kind.rtf          : self._json_rtf,
    }
    return dispatch[kind](data)

Inherited members