Module refinery.units.formats.pdf
Expand source code Browse git
from __future__ import annotations
import codecs
import functools
import re
from itertools import islice
from typing import TYPE_CHECKING, Callable, Generic, TypeVar, cast
if TYPE_CHECKING:
from pikepdf import Object, Pdf
from pymupdf import Document, TextPage
from pypdf.generic import EncodedStreamObject
from refinery.lib import json
from refinery.lib.meta import MV
from refinery.lib.structures import MemoryFile
from refinery.lib.tools import NoLogging, exception_to_string
from refinery.units.formats.archive import ArchiveUnit, UnpackResult
_T = TypeVar('_T')
def isdict(object):
return isinstance(object, dict) or all(hasattr(object, method) for method in ['items', 'values', 'keys'])
class _Deferred(Generic[_T]):
"""
Reads a value from an open PDF document when it is first asked for and keeps it, so that the
outputs which share it read it only once. It holds on to the given document, which has to stay
open until then.
"""
__slots__ = '_document', '_read', '_value'
def __init__(self, document: object, read: Callable[[], _T]):
self._document = document
self._read = read
self._value: _T | None = None
def __call__(self) -> _T:
if (value := self._value) is None:
value = self._value = self._read()
return value
def _stream_contents(stream: Object) -> bytearray:
"""
The decoded data of the given PDF stream, or its raw data if it cannot be decoded.
"""
with NoLogging():
try:
buffer = stream.get_stream_buffer()
except Exception:
buffer = stream.get_raw_stream_buffer()
return bytearray(buffer)
def _text_page(document: Document, page: int) -> TextPage:
"""
The text of the given page of the given document, as pymupdf lays it out.
"""
with NoLogging(NoLogging.Mode.ALL):
return document[page].get_textpage()
_PDF_NAME_OR_REFERENCE = re.compile(r'/([^\s/\[\]<>(){}%]+)|(\d+)\s+\d+\s+R')
def _image_filters(document: Document, xref: int) -> list[str]:
"""
The names of the compression filters of the image with the given cross-reference number, in
the order in which they apply, read as mupdf reads them: the abbreviation `F` takes precedence
over the key `Filter`, references are resolved, and a reference to an object that does not exist
is null. An item of the filter array that is not a name gives an empty name.
"""
def resolved(number: str) -> str:
if not 0 < (n := int(number)) < document.xref_length():
return 'null'
return document.xref_object(n, compressed=True)
def resolved_name(number: str) -> str:
if (item := resolved(number)).startswith('/'):
return item[1:]
return ''
for key in ('F', 'Filter'):
kind, value = document.xref_get_key(xref, key)
if kind != 'null':
break
if kind == 'xref':
value = resolved(value.split()[0])
elif kind not in ('name', 'array'):
return []
if not value.startswith(('/', '[')):
return []
return [name or resolved_name(number) for name, number in _PDF_NAME_OR_REFERENCE.findall(value)]
def _image_extension(document: Document, xref: int) -> str:
"""
The file extension of the image with the given cross-reference number as pymupdf extracts it:
an image with a JPEG 2000 filter anywhere among its filters comes out as JPEG 2000, one whose
last filter is JPEG comes out as JPEG, and every other image is converted to PNG.
"""
filters = _image_filters(document, xref)
if 'JPXDecode' in filters:
return 'jp2'
if filters and filters[-1] in ('DCTDecode', 'DCT'):
return 'jpg'
return 'png'
class xtpdf(ArchiveUnit):
"""
Extract objects from PDF documents.
"""
# @ArchiveUnit.Requires('pypdf>=3.1.0')
# def _pypdf2():
# import pypdf
# import pypdf.generic
# return pypdf
@ArchiveUnit.Requires('pikepdf<=9.5', 1)
def _pikepdf():
import pikepdf
return pikepdf
@ArchiveUnit.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
def _walk_pypdf2(self, blob, memo: set[int] | None = None, *path):
lib = self._pypdf2
while isinstance(blob, lib.generic.IndirectObject):
try:
blob = blob.get_object()
except Exception:
break
if memo is None:
memo = {id(blob)}
elif id(blob) in memo:
return
else:
memo.add(id(blob))
try:
name = blob['/F']
blob = blob['/EF']['/F']
except Exception:
pass
else:
def unhex(match):
return bytes.fromhex(match[1]).decode('latin1')
name = re.sub('#([0-9a-fA-F]{2})', unhex, name)
path = *path[:-1], F'/{name}'
try:
def extract():
with NoLogging():
return get_data()
if TYPE_CHECKING:
blob = cast(EncodedStreamObject, blob)
get_data = blob.get_data
except AttributeError:
pass
else:
yield UnpackResult(''.join(path), extract, **{MV.TYPE: 'object'})
return
if isinstance(blob, lib.generic.ByteStringObject):
yield UnpackResult(''.join(path), blob, **{MV.TYPE: 'bytes'})
return
if isinstance(blob, lib.generic.TextStringObject):
yield UnpackResult(
''.join(path),
blob.encode(self.codec),
**{MV.TYPE: 'string'}
)
return
if isinstance(blob, (
lib.generic.BooleanObject,
lib.generic.ByteStringObject,
lib.generic.FloatObject,
lib.generic.NameObject,
lib.generic.NullObject,
lib.generic.NumberObject,
lib.generic.RectangleObject,
)):
# unhandled PDF objects
return
if isinstance(blob, lib.generic.TreeObject):
blob = list(blob)
pdf = lib.generic.PdfObject
if isinstance(blob, list):
if (
len(blob) % 2 == 0
and all(isinstance(key, str) for key in islice(iter(blob), 0, None, 2))
and all(isinstance(key, pdf) for key in islice(iter(blob), 1, None, 2))
):
blob = dict(zip(*([iter(blob)] * 2)))
else:
for key, value in enumerate(blob):
yield from self._walk_pypdf2(value, memo, *path, F'/{key}')
return
if not isdict(blob):
return
assert isinstance(blob, dict)
for key, value in blob.items():
if not isinstance(key, str):
continue
if not key.startswith('/'):
key = F'/{key}'
yield from self._walk_pypdf2(value, memo, *path, key)
def _walk_pike(
self,
document: Pdf,
blob: Object,
memo: dict[tuple[int, int], _Deferred[bytearray] | None],
*keys,
):
"""
Walks the given object of the given PDF document and hands out what it contains below the
given path; a stream is decoded only when it is extracted. The memo maps every indirect
object that was walked to the reader of its stream, or to `None` if it has none: An object
reached a second time is not walked again, but a stream that a file specification embeds
comes out under every name that embeds it, decoded only once.
"""
pike = self._pikepdf
def indirect(node) -> tuple[int, int] | None:
if isinstance(node, pike.Object) and node.is_indirect:
return node.objgen
return None
if (objgen := indirect(blob)) is not None:
if objgen in memo:
return
memo[objgen] = None
try:
name = blob['/F']
embedded = blob['/EF']['/F']
except Exception:
pass
else:
def unhex(match):
return bytes.fromhex(match[1]).decode('latin1')
name = re.sub('#([0-9a-fA-F]{2})', unhex, str(name))
keys = *keys, F'/{name}'
if (objgen := indirect(embedded)) is not None:
if objgen in memo:
if (contents := memo[objgen]) is not None:
yield UnpackResult(''.join(keys), contents)
return
memo[objgen] = None
blob = embedded
path = ''.join(keys)
if isinstance(blob, pike.Array):
for key, value in enumerate(iter(blob)):
if isinstance(value, pike.Object):
yield from self._walk_pike(document, value, memo, *keys, F'/{key}')
elif isinstance(blob, pike.Stream):
contents = _Deferred(document, lambda b=blob: _stream_contents(b))
if (objgen := indirect(blob)) is not None:
memo[objgen] = contents
yield UnpackResult(path, contents)
elif isinstance(blob, pike.String):
yield UnpackResult(path, bytes(blob))
elif isinstance(blob, pike.Object):
if isinstance(blob, pike.Dictionary):
for key, value in blob.items():
if isinstance(value, pike.Object) and not isinstance(value, pike.Name):
yield from self._walk_pike(document, value, memo, *keys, key)
yield UnpackResult(F'{path}.json', blob.to_json(dereference=True))
def unpack(self, data):
if not (password := self.args.pwd):
password = None
password_raw = b''
password_str = r''
elif isinstance(password, (bytes, bytearray, memoryview)):
password_raw = password
password_str = codecs.decode(password, self.codec)
elif isinstance(password, str):
password_raw = codecs.encode(password, self.codec)
password_str = password
else:
raise TypeError('The password argument is not a (byte) string.')
try:
mu = self._mupdf.open(stream=data, filetype='pdf')
except Exception:
mu = None
else:
if password:
if mu.is_encrypted:
try:
mu.authenticate(password_str)
except Exception:
mu.authenticate(password_raw)
else:
self.log_warn('This PDF document is not protected; ignoring password argument.')
password = None
password_str = r''
password_raw = b''
elif mu.is_encrypted:
raise ValueError('This PDF is password protected.')
with MemoryFile(data, output=bytes) as stream, NoLogging():
try:
pdf = self._pikepdf.open(stream, password=password_raw)
yield from self._walk_pike(pdf, pdf.trailer, {}, 'raw')
except Exception:
raise
pdf = self._pypdf2.PdfReader(stream, password=password)
yield from self._walk_pypdf2(pdf.trailer, None, 'raw')
if mu is None:
return
if (md := mu.metadata) and (md := {k: v for k, v in md.items() if v}):
yield UnpackResult('parsed/meta.json', lambda m=md: json.dumps(m))
codec = self.codec
extractors: list[tuple[str, Callable[[TextPage], str]]] = [
('html', lambda t: t.extractHTML()),
('json', lambda t: t.extractJSON()),
('txt', lambda t: t.extractText()),
]
layout = functools.lru_cache(maxsize=1)(functools.partial(_text_page, mu))
for k in range(len(mu)):
for extension, extract in extractors:
yield UnpackResult(
F'parsed/page{k}.{extension}', lambda e=extract, k=k: e(layout(k)).encode(codec)
)
try:
with NoLogging(NoLogging.Mode.ALL):
images = [(xref, _image_extension(mu, xref)) for xref, *_ in mu.get_page_images(k)]
except Exception as error:
self.log_warn(F'failed to list the images of page {k}: {exception_to_string(error)}')
continue
for j, (xref, extension) in enumerate(images, 1):
yield UnpackResult(
F'parsed/page{k}/img{j}.{extension}', lambda x=xref: mu.extract_image(x)['image']
)
@classmethod
def handles(cls, data) -> bool | None:
return data[:5] == B'%PDF-'
Functions
def isdict(object)-
Expand source code Browse git
def isdict(object): return isinstance(object, dict) or all(hasattr(object, method) for method in ['items', 'values', 'keys'])
Classes
class xtpdf (*paths, list=False, join_path=False, drop_path=False, fuzzy=0, exact=False, regex=False, path=b'path', exclude=None, date=b'date', pwd=b'')-
Extract objects from PDF documents.
Expand source code Browse git
class xtpdf(ArchiveUnit): """ Extract objects from PDF documents. """ # @ArchiveUnit.Requires('pypdf>=3.1.0') # def _pypdf2(): # import pypdf # import pypdf.generic # return pypdf @ArchiveUnit.Requires('pikepdf<=9.5', 1) def _pikepdf(): import pikepdf return pikepdf @ArchiveUnit.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 def _walk_pypdf2(self, blob, memo: set[int] | None = None, *path): lib = self._pypdf2 while isinstance(blob, lib.generic.IndirectObject): try: blob = blob.get_object() except Exception: break if memo is None: memo = {id(blob)} elif id(blob) in memo: return else: memo.add(id(blob)) try: name = blob['/F'] blob = blob['/EF']['/F'] except Exception: pass else: def unhex(match): return bytes.fromhex(match[1]).decode('latin1') name = re.sub('#([0-9a-fA-F]{2})', unhex, name) path = *path[:-1], F'/{name}' try: def extract(): with NoLogging(): return get_data() if TYPE_CHECKING: blob = cast(EncodedStreamObject, blob) get_data = blob.get_data except AttributeError: pass else: yield UnpackResult(''.join(path), extract, **{MV.TYPE: 'object'}) return if isinstance(blob, lib.generic.ByteStringObject): yield UnpackResult(''.join(path), blob, **{MV.TYPE: 'bytes'}) return if isinstance(blob, lib.generic.TextStringObject): yield UnpackResult( ''.join(path), blob.encode(self.codec), **{MV.TYPE: 'string'} ) return if isinstance(blob, ( lib.generic.BooleanObject, lib.generic.ByteStringObject, lib.generic.FloatObject, lib.generic.NameObject, lib.generic.NullObject, lib.generic.NumberObject, lib.generic.RectangleObject, )): # unhandled PDF objects return if isinstance(blob, lib.generic.TreeObject): blob = list(blob) pdf = lib.generic.PdfObject if isinstance(blob, list): if ( len(blob) % 2 == 0 and all(isinstance(key, str) for key in islice(iter(blob), 0, None, 2)) and all(isinstance(key, pdf) for key in islice(iter(blob), 1, None, 2)) ): blob = dict(zip(*([iter(blob)] * 2))) else: for key, value in enumerate(blob): yield from self._walk_pypdf2(value, memo, *path, F'/{key}') return if not isdict(blob): return assert isinstance(blob, dict) for key, value in blob.items(): if not isinstance(key, str): continue if not key.startswith('/'): key = F'/{key}' yield from self._walk_pypdf2(value, memo, *path, key) def _walk_pike( self, document: Pdf, blob: Object, memo: dict[tuple[int, int], _Deferred[bytearray] | None], *keys, ): """ Walks the given object of the given PDF document and hands out what it contains below the given path; a stream is decoded only when it is extracted. The memo maps every indirect object that was walked to the reader of its stream, or to `None` if it has none: An object reached a second time is not walked again, but a stream that a file specification embeds comes out under every name that embeds it, decoded only once. """ pike = self._pikepdf def indirect(node) -> tuple[int, int] | None: if isinstance(node, pike.Object) and node.is_indirect: return node.objgen return None if (objgen := indirect(blob)) is not None: if objgen in memo: return memo[objgen] = None try: name = blob['/F'] embedded = blob['/EF']['/F'] except Exception: pass else: def unhex(match): return bytes.fromhex(match[1]).decode('latin1') name = re.sub('#([0-9a-fA-F]{2})', unhex, str(name)) keys = *keys, F'/{name}' if (objgen := indirect(embedded)) is not None: if objgen in memo: if (contents := memo[objgen]) is not None: yield UnpackResult(''.join(keys), contents) return memo[objgen] = None blob = embedded path = ''.join(keys) if isinstance(blob, pike.Array): for key, value in enumerate(iter(blob)): if isinstance(value, pike.Object): yield from self._walk_pike(document, value, memo, *keys, F'/{key}') elif isinstance(blob, pike.Stream): contents = _Deferred(document, lambda b=blob: _stream_contents(b)) if (objgen := indirect(blob)) is not None: memo[objgen] = contents yield UnpackResult(path, contents) elif isinstance(blob, pike.String): yield UnpackResult(path, bytes(blob)) elif isinstance(blob, pike.Object): if isinstance(blob, pike.Dictionary): for key, value in blob.items(): if isinstance(value, pike.Object) and not isinstance(value, pike.Name): yield from self._walk_pike(document, value, memo, *keys, key) yield UnpackResult(F'{path}.json', blob.to_json(dereference=True)) def unpack(self, data): if not (password := self.args.pwd): password = None password_raw = b'' password_str = r'' elif isinstance(password, (bytes, bytearray, memoryview)): password_raw = password password_str = codecs.decode(password, self.codec) elif isinstance(password, str): password_raw = codecs.encode(password, self.codec) password_str = password else: raise TypeError('The password argument is not a (byte) string.') try: mu = self._mupdf.open(stream=data, filetype='pdf') except Exception: mu = None else: if password: if mu.is_encrypted: try: mu.authenticate(password_str) except Exception: mu.authenticate(password_raw) else: self.log_warn('This PDF document is not protected; ignoring password argument.') password = None password_str = r'' password_raw = b'' elif mu.is_encrypted: raise ValueError('This PDF is password protected.') with MemoryFile(data, output=bytes) as stream, NoLogging(): try: pdf = self._pikepdf.open(stream, password=password_raw) yield from self._walk_pike(pdf, pdf.trailer, {}, 'raw') except Exception: raise pdf = self._pypdf2.PdfReader(stream, password=password) yield from self._walk_pypdf2(pdf.trailer, None, 'raw') if mu is None: return if (md := mu.metadata) and (md := {k: v for k, v in md.items() if v}): yield UnpackResult('parsed/meta.json', lambda m=md: json.dumps(m)) codec = self.codec extractors: list[tuple[str, Callable[[TextPage], str]]] = [ ('html', lambda t: t.extractHTML()), ('json', lambda t: t.extractJSON()), ('txt', lambda t: t.extractText()), ] layout = functools.lru_cache(maxsize=1)(functools.partial(_text_page, mu)) for k in range(len(mu)): for extension, extract in extractors: yield UnpackResult( F'parsed/page{k}.{extension}', lambda e=extract, k=k: e(layout(k)).encode(codec) ) try: with NoLogging(NoLogging.Mode.ALL): images = [(xref, _image_extension(mu, xref)) for xref, *_ in mu.get_page_images(k)] except Exception as error: self.log_warn(F'failed to list the images of page {k}: {exception_to_string(error)}') continue for j, (xref, extension) in enumerate(images, 1): yield UnpackResult( F'parsed/page{k}/img{j}.{extension}', lambda x=xref: mu.extract_image(x)['image'] ) @classmethod def handles(cls, data) -> bool | None: return data[:5] == B'%PDF-'Ancestors
Subclasses
Class variables
var reverse-
The type of the None singleton.
Methods
def unpack(self, data)-
Expand source code Browse git
def unpack(self, data): if not (password := self.args.pwd): password = None password_raw = b'' password_str = r'' elif isinstance(password, (bytes, bytearray, memoryview)): password_raw = password password_str = codecs.decode(password, self.codec) elif isinstance(password, str): password_raw = codecs.encode(password, self.codec) password_str = password else: raise TypeError('The password argument is not a (byte) string.') try: mu = self._mupdf.open(stream=data, filetype='pdf') except Exception: mu = None else: if password: if mu.is_encrypted: try: mu.authenticate(password_str) except Exception: mu.authenticate(password_raw) else: self.log_warn('This PDF document is not protected; ignoring password argument.') password = None password_str = r'' password_raw = b'' elif mu.is_encrypted: raise ValueError('This PDF is password protected.') with MemoryFile(data, output=bytes) as stream, NoLogging(): try: pdf = self._pikepdf.open(stream, password=password_raw) yield from self._walk_pike(pdf, pdf.trailer, {}, 'raw') except Exception: raise pdf = self._pypdf2.PdfReader(stream, password=password) yield from self._walk_pypdf2(pdf.trailer, None, 'raw') if mu is None: return if (md := mu.metadata) and (md := {k: v for k, v in md.items() if v}): yield UnpackResult('parsed/meta.json', lambda m=md: json.dumps(m)) codec = self.codec extractors: list[tuple[str, Callable[[TextPage], str]]] = [ ('html', lambda t: t.extractHTML()), ('json', lambda t: t.extractJSON()), ('txt', lambda t: t.extractText()), ] layout = functools.lru_cache(maxsize=1)(functools.partial(_text_page, mu)) for k in range(len(mu)): for extension, extract in extractors: yield UnpackResult( F'parsed/page{k}.{extension}', lambda e=extract, k=k: e(layout(k)).encode(codec) ) try: with NoLogging(NoLogging.Mode.ALL): images = [(xref, _image_extension(mu, xref)) for xref, *_ in mu.get_page_images(k)] except Exception as error: self.log_warn(F'failed to list the images of page {k}: {exception_to_string(error)}') continue for j, (xref, extension) in enumerate(images, 1): yield UnpackResult( F'parsed/page{k}/img{j}.{extension}', lambda x=xref: mu.extract_image(x)['image'] )
Inherited members
ArchiveUnit:CommonPasswordsCustomJoinBehaviourCustomPathSeparatorFilterEverythingRequiresactassemblecarvedclosecodecconsolefilterfinishhandlesis_quietis_reversibleisattylabelledleniencylog_alwayslog_debuglog_detachlog_faillog_infolog_levellog_warnloggernamenozzleoptional_dependenciesprocessreadread1required_dependenciesresetrunsourcesuperinit