Initial commit of Roster
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
|
||||
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
|
||||
|
||||
from pypdfium2._helpers.unsupported import *
|
||||
from pypdfium2._helpers.misc import *
|
||||
from pypdfium2._helpers.matrix import *
|
||||
from pypdfium2._helpers.bitmap import *
|
||||
from pypdfium2._helpers.document import *
|
||||
from pypdfium2._helpers.attachment import *
|
||||
from pypdfium2._helpers.page import *
|
||||
from pypdfium2._helpers.pageobjects import *
|
||||
from pypdfium2._helpers.textpage import *
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,142 @@
|
||||
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
|
||||
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
|
||||
|
||||
__all__ = ("PdfAttachment", )
|
||||
|
||||
import ctypes
|
||||
import pypdfium2.raw as pdfium_c
|
||||
import pypdfium2.internal as pdfium_i
|
||||
from pypdfium2._helpers.misc import PdfiumError
|
||||
|
||||
|
||||
def _encode_key(key):
|
||||
if isinstance(key, str):
|
||||
return (key + "\x00").encode("utf-8")
|
||||
else:
|
||||
raise TypeError(f"Key must be str, but {type(key).__name__} was given.")
|
||||
|
||||
|
||||
class PdfAttachment (pdfium_i.AutoCastable):
|
||||
"""
|
||||
Attachment helper class.
|
||||
See PDF 1.7, Section 7.11 "File Specifications".
|
||||
|
||||
Attributes:
|
||||
raw (FPDF_ATTACHMENT):
|
||||
The underlying PDFium attachment handle.
|
||||
pdf (PdfDocument):
|
||||
Reference to the document this attachment belongs to. Must remain valid as long as the attachment is used.
|
||||
"""
|
||||
|
||||
# TODO consider using AutoCloseable machienery to guarantee `pdf` remains alive as long as the attachment object exists
|
||||
|
||||
# Problems with PDFium's attachment API:
|
||||
# - https://crbug.com/pdfium/1939
|
||||
# - https://crbug.com/pdfium/893
|
||||
|
||||
|
||||
def __init__(self, raw, pdf):
|
||||
self.raw, self.pdf = raw, pdf
|
||||
|
||||
|
||||
def get_name(self):
|
||||
"""
|
||||
Returns:
|
||||
str: Name of the attachment.
|
||||
"""
|
||||
n_bytes = pdfium_c.FPDFAttachment_GetName(self, None, 0)
|
||||
buffer = ctypes.create_string_buffer(n_bytes)
|
||||
buffer_ptr = ctypes.cast(buffer, ctypes.POINTER(pdfium_c.FPDF_WCHAR))
|
||||
pdfium_c.FPDFAttachment_GetName(self, buffer_ptr, n_bytes)
|
||||
return buffer.raw[:n_bytes-2].decode("utf-16-le")
|
||||
|
||||
|
||||
def get_data(self):
|
||||
"""
|
||||
Returns:
|
||||
ctypes.Array: The attachment's file data (as :class:`~ctypes.c_char` array).
|
||||
"""
|
||||
|
||||
n_bytes = ctypes.c_ulong()
|
||||
pdfium_c.FPDFAttachment_GetFile(self, None, 0, n_bytes)
|
||||
n_bytes = n_bytes.value
|
||||
if n_bytes == 0:
|
||||
raise PdfiumError(f"Failed to extract attachment (buffer length {n_bytes}).")
|
||||
|
||||
buffer = ctypes.create_string_buffer(n_bytes)
|
||||
out_buflen = ctypes.c_ulong()
|
||||
ok = pdfium_c.FPDFAttachment_GetFile(self, buffer, n_bytes, out_buflen)
|
||||
out_buflen = out_buflen.value
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to extract attachment (error status).")
|
||||
if n_bytes < out_buflen:
|
||||
raise PdfiumError(f"Failed to extract attachment (expected {n_bytes} bytes, but got {out_buflen}).")
|
||||
|
||||
return buffer
|
||||
|
||||
|
||||
def set_data(self, data):
|
||||
"""
|
||||
Set the attachment's file data.
|
||||
If this function is called on an existing attachment, it will be changed to point at the new data,
|
||||
but the previous data will not be removed from the file (as of PDFium 5418).
|
||||
|
||||
Parameters:
|
||||
data (bytes | ctypes.Array):
|
||||
New file data for the attachment. May be any data type that can be implicitly converted to :class:`~ctypes.c_void_p`.
|
||||
"""
|
||||
ok = pdfium_c.FPDFAttachment_SetFile(self, self.pdf, data, len(data))
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to set attachment data.")
|
||||
|
||||
|
||||
def has_key(self, key):
|
||||
"""
|
||||
Parameters:
|
||||
key (str):
|
||||
A key to look for in the attachment's params dictionary.
|
||||
Returns:
|
||||
bool: True if *key* is contained in the params dictionary, False otherwise.
|
||||
"""
|
||||
return pdfium_c.FPDFAttachment_HasKey(self, _encode_key(key))
|
||||
|
||||
|
||||
def get_value_type(self, key):
|
||||
"""
|
||||
Returns:
|
||||
int: Type of the value of *key* in the params dictionary (:attr:`FPDF_OBJECT_*`).
|
||||
"""
|
||||
return pdfium_c.FPDFAttachment_GetValueType(self, _encode_key(key))
|
||||
|
||||
|
||||
def get_str_value(self, key):
|
||||
"""
|
||||
Returns:
|
||||
str: The value of *key* in the params dictionary, if it is a string or name.
|
||||
Otherwise, an empty string will be returned. On other failures, an exception will be raised.
|
||||
"""
|
||||
|
||||
enc_key = _encode_key(key)
|
||||
n_bytes = pdfium_c.FPDFAttachment_GetStringValue(self, enc_key, None, 0)
|
||||
if n_bytes <= 0:
|
||||
raise PdfiumError(f"Failed to get value of key '{key}'.")
|
||||
|
||||
buffer = ctypes.create_string_buffer(n_bytes)
|
||||
buffer_ptr = ctypes.cast(buffer, ctypes.POINTER(pdfium_c.FPDF_WCHAR))
|
||||
pdfium_c.FPDFAttachment_GetStringValue(self, enc_key, buffer_ptr, n_bytes)
|
||||
|
||||
return buffer.raw[:n_bytes-2].decode("utf-16-le")
|
||||
|
||||
|
||||
def set_str_value(self, key, value):
|
||||
"""
|
||||
Set the attribute specified by *key* to the string *value*.
|
||||
|
||||
Parameters:
|
||||
value (str): New string value for the attribute.
|
||||
"""
|
||||
enc_value = (value + "\x00").encode("utf-16-le")
|
||||
enc_value_ptr = ctypes.cast(enc_value, pdfium_c.FPDF_WIDESTRING)
|
||||
ok = pdfium_c.FPDFAttachment_SetStringValue(self, _encode_key(key), enc_value_ptr)
|
||||
if not ok:
|
||||
raise PdfiumError(f"Failed to set attachment param '{key}' to '{value}'.")
|
||||
@@ -0,0 +1,327 @@
|
||||
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
|
||||
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
|
||||
|
||||
__all__ = ("PdfBitmap", "PdfBitmapInfo")
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import weakref
|
||||
from collections import namedtuple
|
||||
import pypdfium2.raw as pdfium_c
|
||||
import pypdfium2.internal as pdfium_i
|
||||
from pypdfium2._helpers.misc import PdfiumError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import PIL.Image
|
||||
except ImportError:
|
||||
PIL = None
|
||||
|
||||
try:
|
||||
import numpy
|
||||
except ImportError:
|
||||
numpy = None
|
||||
|
||||
|
||||
class PdfBitmap (pdfium_i.AutoCloseable):
|
||||
"""
|
||||
Bitmap helper class.
|
||||
|
||||
Hint:
|
||||
This class provides built-in converters (e. g. :meth:`.to_pil`, :meth:`.to_numpy`) that may be used to create a different representation of the bitmap.
|
||||
Converters can be applied on :class:`.PdfBitmap` objects either as bound method (``bitmap.to_*()``), or as function (``PdfBitmap.to_*(bitmap)``)
|
||||
The second pattern is useful for API methods that need to apply a caller-provided converter (e. g. :meth:`.PdfDocument.render`)
|
||||
|
||||
.. _PIL Modes: https://pillow.readthedocs.io/en/stable/handbook/concepts.html#concept-modes
|
||||
|
||||
Note:
|
||||
All attributes of :class:`.PdfBitmapInfo` are available in this class as well.
|
||||
|
||||
Warning:
|
||||
``bitmap.close()``, which frees the buffer of foreign bitmaps, is not validated for safety.
|
||||
A bitmap must not be closed when other objects still depend on its buffer!
|
||||
|
||||
Attributes:
|
||||
raw (FPDF_BITMAP):
|
||||
The underlying PDFium bitmap handle.
|
||||
buffer (~ctypes.c_ubyte):
|
||||
A ctypes array representation of the pixel data (each item is an unsigned byte, i. e. a number ranging from 0 to 255).
|
||||
"""
|
||||
|
||||
def __init__(self, raw, buffer, width, height, stride, format, rev_byteorder, needs_free):
|
||||
self.raw, self.buffer, self.width, self.height = raw, buffer, width, height
|
||||
self.stride, self.format, self.rev_byteorder = stride, format, rev_byteorder
|
||||
self.n_channels = pdfium_i.BitmapTypeToNChannels[self.format]
|
||||
self.mode = (pdfium_i.BitmapTypeToStrReverse if self.rev_byteorder else pdfium_i.BitmapTypeToStr)[self.format]
|
||||
super().__init__(pdfium_c.FPDFBitmap_Destroy, needs_free=needs_free, obj=self.buffer)
|
||||
|
||||
|
||||
@property
|
||||
def parent(self): # AutoCloseable hook
|
||||
return None
|
||||
|
||||
|
||||
def get_info(self):
|
||||
"""
|
||||
Returns:
|
||||
PdfBitmapInfo: A namedtuple describing the bitmap.
|
||||
"""
|
||||
return PdfBitmapInfo(
|
||||
width=self.width, height=self.height, stride=self.stride, format=self.format,
|
||||
rev_byteorder=self.rev_byteorder, n_channels=self.n_channels, mode=self.mode,
|
||||
)
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, raw, rev_byteorder=False, ex_buffer=None):
|
||||
"""
|
||||
Construct a :class:`.PdfBitmap` wrapper around a raw PDFium bitmap handle.
|
||||
|
||||
Parameters:
|
||||
raw (FPDF_BITMAP):
|
||||
PDFium bitmap handle.
|
||||
rev_byteorder (bool):
|
||||
Whether the bitmap uses reverse byte order.
|
||||
ex_buffer (~ctypes.c_ubyte | None):
|
||||
If the bitmap was created from a buffer allocated by Python/ctypes, pass in the ctypes array to keep it referenced.
|
||||
"""
|
||||
|
||||
width = pdfium_c.FPDFBitmap_GetWidth(raw)
|
||||
height = pdfium_c.FPDFBitmap_GetHeight(raw)
|
||||
format = pdfium_c.FPDFBitmap_GetFormat(raw)
|
||||
stride = pdfium_c.FPDFBitmap_GetStride(raw)
|
||||
|
||||
if ex_buffer is None:
|
||||
needs_free = True
|
||||
buffer_ptr = pdfium_c.FPDFBitmap_GetBuffer(raw)
|
||||
if buffer_ptr is None:
|
||||
raise PdfiumError("Failed to get bitmap buffer (null pointer returned)")
|
||||
buffer = ctypes.cast(buffer_ptr, ctypes.POINTER(ctypes.c_ubyte * (stride * height))).contents
|
||||
else:
|
||||
needs_free = False
|
||||
buffer = ex_buffer
|
||||
|
||||
return cls(
|
||||
raw=raw, buffer=buffer, width=width, height=height, stride=stride,
|
||||
format=format, rev_byteorder=rev_byteorder, needs_free=needs_free,
|
||||
)
|
||||
|
||||
|
||||
# TODO support setting stride if external buffer is provided
|
||||
@classmethod
|
||||
def new_native(cls, width, height, format, rev_byteorder=False, buffer=None):
|
||||
"""
|
||||
Create a new bitmap using :func:`FPDFBitmap_CreateEx`, with a buffer allocated by Python/ctypes.
|
||||
Bitmaps created by this function are always packed (no unused bytes at line end).
|
||||
"""
|
||||
|
||||
stride = width * pdfium_i.BitmapTypeToNChannels[format]
|
||||
if buffer is None:
|
||||
buffer = (ctypes.c_ubyte * (stride * height))()
|
||||
raw = pdfium_c.FPDFBitmap_CreateEx(width, height, format, buffer, stride)
|
||||
|
||||
# alternatively, we could call the constructor directly with the information from above
|
||||
return cls.from_raw(raw, rev_byteorder, buffer)
|
||||
|
||||
|
||||
@classmethod
|
||||
def new_foreign(cls, width, height, format, rev_byteorder=False, force_packed=False):
|
||||
"""
|
||||
Create a new bitmap using :func:`FPDFBitmap_CreateEx`, with a buffer allocated by PDFium.
|
||||
|
||||
Using this method is discouraged. Prefer :meth:`.new_native` instead.
|
||||
"""
|
||||
stride = width * pdfium_i.BitmapTypeToNChannels[format] if force_packed else 0
|
||||
raw = pdfium_c.FPDFBitmap_CreateEx(width, height, format, None, stride)
|
||||
return cls.from_raw(raw, rev_byteorder)
|
||||
|
||||
|
||||
@classmethod
|
||||
def new_foreign_simple(cls, width, height, use_alpha, rev_byteorder=False):
|
||||
"""
|
||||
Create a new bitmap using :func:`FPDFBitmap_Create`. The buffer is allocated by PDFium.
|
||||
The resulting bitmap is supposed to be packed (i. e. no gap of unused bytes between lines).
|
||||
|
||||
Using this method is discouraged. Prefer :meth:`.new_native` instead.
|
||||
"""
|
||||
raw = pdfium_c.FPDFBitmap_Create(width, height, use_alpha)
|
||||
return cls.from_raw(raw, rev_byteorder)
|
||||
|
||||
|
||||
def fill_rect(self, left, top, width, height, color):
|
||||
"""
|
||||
Fill a rectangle on the bitmap with the given color.
|
||||
The coordinate system starts at the top left corner of the image.
|
||||
|
||||
Note:
|
||||
This function replaces the color values in the given rectangle. It does not perform alpha compositing.
|
||||
|
||||
Parameters:
|
||||
color (tuple[int, int, int, int]):
|
||||
RGBA fill color (a tuple of 4 integers ranging from 0 to 255).
|
||||
"""
|
||||
c_color = pdfium_i.color_tohex(color, self.rev_byteorder)
|
||||
pdfium_c.FPDFBitmap_FillRect(self, left, top, width, height, c_color)
|
||||
|
||||
|
||||
# Requirement: If the result is a view of the buffer (not a copy), it keeps the referenced memory valid.
|
||||
#
|
||||
# Note that memory management differs between native and foreign bitmap buffers:
|
||||
# - With native bitmaps, the memory is allocated by python on creation of the buffer object (transparent).
|
||||
# - With foreign bitmaps, the buffer object is merely a view of memory allocated by pdfium and will be freed by finalizer (opaque).
|
||||
#
|
||||
# It is necessary that receivers correctly handle both cases, e.g. by keeping the buffer object itself alive.
|
||||
# As of May 2023, this seems to hold true for NumPy and PIL. New converters should be carefully tested.
|
||||
#
|
||||
# We could consider attaching a buffer keep-alive finalizer to any converted objects referencing the buffer,
|
||||
# but then we'd have to rely on third parties to actually create a reference at all times, otherwise we would unnecessarily delay releasing memory.
|
||||
|
||||
|
||||
def to_numpy(self):
|
||||
"""
|
||||
Convert the bitmap to a :mod:`numpy` array.
|
||||
|
||||
The array contains as many rows as the bitmap is high.
|
||||
Each row contains as many pixels as the bitmap is wide.
|
||||
The length of each pixel corresponds to the number of channels.
|
||||
|
||||
The resulting array is supposed to share memory with the original bitmap buffer,
|
||||
so changes to the buffer should be reflected in the array, and vice versa.
|
||||
|
||||
Returns:
|
||||
numpy.ndarray: NumPy array (representation of the bitmap buffer).
|
||||
"""
|
||||
|
||||
# https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html#numpy.ndarray
|
||||
|
||||
array = numpy.ndarray(
|
||||
# layout: row major
|
||||
shape = (self.height, self.width, self.n_channels),
|
||||
dtype = ctypes.c_ubyte,
|
||||
buffer = self.buffer,
|
||||
# number of bytes per item for each nesting level (outer->inner, i. e. row, pixel, value)
|
||||
strides = (self.stride, self.n_channels, 1),
|
||||
)
|
||||
|
||||
return array
|
||||
|
||||
|
||||
def to_pil(self):
|
||||
"""
|
||||
Convert the bitmap to a :mod:`PIL` image, using :func:`PIL.Image.frombuffer`.
|
||||
|
||||
For ``RGBA``, ``RGBX`` and ``L`` buffers, PIL is supposed to share memory with
|
||||
the original bitmap buffer, so changes to the buffer should be reflected in the image, and vice versa.
|
||||
Otherwise, PIL will make a copy of the data.
|
||||
|
||||
Returns:
|
||||
PIL.Image.Image: PIL image (representation or copy of the bitmap buffer).
|
||||
|
||||
.. versionchanged:: 4.16 Set ``image.readonly = False`` so that changes to the image are also reflected in the buffer.
|
||||
"""
|
||||
|
||||
# https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.frombuffer
|
||||
# https://pillow.readthedocs.io/en/stable/handbook/writing-your-own-image-plugin.html#the-raw-decoder
|
||||
|
||||
dest_mode = pdfium_i.BitmapTypeToStrReverse[self.format]
|
||||
image = PIL.Image.frombuffer(
|
||||
dest_mode, # target color format
|
||||
(self.width, self.height), # size
|
||||
self.buffer, # buffer
|
||||
"raw", # decoder
|
||||
self.mode, # input color format
|
||||
self.stride, # bytes per line
|
||||
1, # orientation (top->bottom)
|
||||
)
|
||||
image.readonly = False
|
||||
|
||||
return image
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_pil(cls, pil_image, recopy=False):
|
||||
"""
|
||||
Convert a :mod:`PIL` image to a PDFium bitmap.
|
||||
Due to the restricted number of color formats and bit depths supported by PDFium's
|
||||
bitmap implementation, this may be a lossy operation.
|
||||
|
||||
Bitmaps returned by this function should be treated as immutable (i.e. don't call :meth:`.fill_rect`).
|
||||
|
||||
Parameters:
|
||||
pil_image (PIL.Image.Image):
|
||||
The image.
|
||||
Returns:
|
||||
PdfBitmap: PDFium bitmap (with a copy of the PIL image's data).
|
||||
|
||||
.. deprecated:: 4.25
|
||||
The *recopy* parameter has been deprecated.
|
||||
"""
|
||||
|
||||
if pil_image.mode in pdfium_i.BitmapStrToConst:
|
||||
# PIL always seems to represent BGR(A/X) input as RGB(A/X), so this code passage is probably only hit for L
|
||||
format = pdfium_i.BitmapStrToConst[pil_image.mode]
|
||||
else:
|
||||
pil_image = _pil_convert_for_pdfium(pil_image)
|
||||
format = pdfium_i.BitmapStrReverseToConst[pil_image.mode]
|
||||
|
||||
py_buffer = pil_image.tobytes()
|
||||
if recopy:
|
||||
buffer = (ctypes.c_ubyte * len(py_buffer)).from_buffer_copy(py_buffer)
|
||||
else:
|
||||
buffer = py_buffer
|
||||
|
||||
w, h = pil_image.size
|
||||
return cls.new_native(w, h, format, rev_byteorder=False, buffer=buffer)
|
||||
|
||||
|
||||
# TODO implement from_numpy()
|
||||
|
||||
|
||||
def _pil_convert_for_pdfium(pil_image):
|
||||
|
||||
# FIXME? convoluted / hard to understand; improve control flow
|
||||
|
||||
if pil_image.mode == "1":
|
||||
pil_image = pil_image.convert("L")
|
||||
elif pil_image.mode.startswith("RGB"):
|
||||
pass
|
||||
elif "A" in pil_image.mode:
|
||||
pil_image = pil_image.convert("RGBA")
|
||||
else:
|
||||
pil_image = pil_image.convert("RGB")
|
||||
|
||||
# convert RGB(A/X) to BGR(A) for PDFium
|
||||
if pil_image.mode == "RGB":
|
||||
r, g, b = pil_image.split()
|
||||
pil_image = PIL.Image.merge("RGB", (b, g, r))
|
||||
elif pil_image.mode == "RGBA":
|
||||
r, g, b, a = pil_image.split()
|
||||
pil_image = PIL.Image.merge("RGBA", (b, g, r, a))
|
||||
elif pil_image.mode == "RGBX":
|
||||
# technically the x channel may be unnecessary, but preserve what the caller passes in
|
||||
r, g, b, x = pil_image.split()
|
||||
pil_image = PIL.Image.merge("RGBX", (b, g, r, x))
|
||||
|
||||
return pil_image
|
||||
|
||||
|
||||
PdfBitmapInfo = namedtuple("PdfBitmapInfo", "width height stride format rev_byteorder n_channels mode")
|
||||
"""
|
||||
Attributes:
|
||||
width (int):
|
||||
Width of the bitmap (horizontal size).
|
||||
height (int):
|
||||
Height of the bitmap (vertical size).
|
||||
stride (int):
|
||||
Number of bytes per line in the bitmap buffer.
|
||||
Depending on how the bitmap was created, there may be a padding of unused bytes at the end of each line, so this value can be greater than ``width * n_channels``.
|
||||
format (int):
|
||||
PDFium bitmap format constant (:attr:`FPDFBitmap_*`)
|
||||
rev_byteorder (bool):
|
||||
Whether the bitmap is using reverse byte order.
|
||||
n_channels (int):
|
||||
Number of channels per pixel.
|
||||
mode (str):
|
||||
The bitmap format as string (see `PIL Modes`_).
|
||||
"""
|
||||
@@ -0,0 +1,707 @@
|
||||
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
|
||||
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
|
||||
|
||||
__all__ = ("PdfDocument", "PdfFormEnv", "PdfXObject", "PdfOutlineItem")
|
||||
|
||||
import os
|
||||
import ctypes
|
||||
import logging
|
||||
import inspect
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from collections import namedtuple
|
||||
import multiprocessing as mp
|
||||
|
||||
import pypdfium2.raw as pdfium_c
|
||||
import pypdfium2.internal as pdfium_i
|
||||
from pypdfium2.version import PDFIUM_INFO
|
||||
from pypdfium2._helpers.misc import PdfiumError
|
||||
from pypdfium2._helpers.page import PdfPage
|
||||
from pypdfium2._helpers.pageobjects import PdfObject
|
||||
from pypdfium2._helpers.attachment import PdfAttachment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PdfDocument (pdfium_i.AutoCloseable):
|
||||
"""
|
||||
Document helper class.
|
||||
|
||||
Parameters:
|
||||
input_data (str | pathlib.Path | bytes | ctypes.Array | typing.BinaryIO | FPDF_DOCUMENT):
|
||||
The input PDF given as file path, bytes, ctypes array, byte buffer, or raw PDFium document handle.
|
||||
A byte buffer is defined as an object that implements ``seek() tell() read() readinto()``.
|
||||
password (str | None):
|
||||
A password to unlock the PDF, if encrypted. Otherwise, None or an empty string may be passed.
|
||||
If a password is given but the PDF is not encrypted, it will be ignored (as of PDFium 5418).
|
||||
autoclose (bool):
|
||||
Whether byte buffer input should be automatically closed on finalization.
|
||||
|
||||
Raises:
|
||||
PdfiumError: Raised if the document failed to load. The exception message is annotated with the reason reported by PDFium.
|
||||
FileNotFoundError: Raised if an invalid or non-existent file path was given.
|
||||
|
||||
Hint:
|
||||
* :func:`len` may be called to get a document's number of pages.
|
||||
* Looping over a document will yield its pages from beginning to end.
|
||||
* Pages may be loaded using list index access.
|
||||
* The ``del`` keyword and list index access may be used to delete pages.
|
||||
|
||||
Attributes:
|
||||
raw (FPDF_DOCUMENT):
|
||||
The underlying PDFium document handle.
|
||||
formenv (PdfFormEnv | None):
|
||||
Form env, if the document has forms and :meth:`.init_forms` was called.
|
||||
"""
|
||||
|
||||
def __init__(self, input, password=None, autoclose=False):
|
||||
|
||||
if isinstance(input, str):
|
||||
input = Path(input)
|
||||
if isinstance(input, Path):
|
||||
input = input.expanduser().resolve()
|
||||
if not input.is_file():
|
||||
raise FileNotFoundError(input)
|
||||
|
||||
self._input = input
|
||||
self._password = password
|
||||
self._autoclose = autoclose
|
||||
self._data_holder = []
|
||||
self._data_closer = []
|
||||
|
||||
# question: can we make attributes like formenv effectively immutable for the caller?
|
||||
self.formenv = None
|
||||
|
||||
if isinstance(self._input, pdfium_c.FPDF_DOCUMENT):
|
||||
self.raw = self._input
|
||||
else:
|
||||
self.raw, to_hold, to_close = _open_pdf(self._input, self._password, self._autoclose)
|
||||
self._data_holder += to_hold
|
||||
self._data_closer += to_close
|
||||
|
||||
super().__init__(PdfDocument._close_impl, self._data_holder, self._data_closer)
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
if isinstance(self._input, Path):
|
||||
input_r = repr( str(self._input) )
|
||||
elif isinstance(self._input, bytes):
|
||||
input_r = f"<bytes object at {hex(id(self._input))}>"
|
||||
elif isinstance(self._input, pdfium_c.FPDF_DOCUMENT):
|
||||
input_r = f"<FPDF_DOCUMENT at {hex(id(self._input))}>"
|
||||
else:
|
||||
input_r = repr(self._input)
|
||||
return f"{super().__repr__()[:-1]} from {input_r}>"
|
||||
|
||||
|
||||
@property
|
||||
def parent(self): # AutoCloseable hook
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _close_impl(raw, data_holder, data_closer):
|
||||
pdfium_c.FPDF_CloseDocument(raw)
|
||||
for data in data_holder:
|
||||
id(data)
|
||||
for data in data_closer:
|
||||
data.close()
|
||||
data_holder.clear()
|
||||
data_closer.clear()
|
||||
|
||||
|
||||
def __len__(self):
|
||||
return pdfium_c.FPDF_GetPageCount(self)
|
||||
|
||||
def __iter__(self):
|
||||
for i in range( len(self) ):
|
||||
yield self[i]
|
||||
|
||||
def __getitem__(self, i):
|
||||
return self.get_page(i)
|
||||
|
||||
def __delitem__(self, i):
|
||||
self.del_page(i)
|
||||
|
||||
|
||||
@classmethod
|
||||
def new(cls):
|
||||
"""
|
||||
Returns:
|
||||
PdfDocument: A new, empty document.
|
||||
"""
|
||||
new_pdf = pdfium_c.FPDF_CreateNewDocument()
|
||||
return cls(new_pdf)
|
||||
|
||||
|
||||
def init_forms(self, config=None):
|
||||
"""
|
||||
Initialize a form env, if the document has forms. If already initialized, nothing will be done.
|
||||
See the :attr:`formenv` attribute.
|
||||
|
||||
Attention:
|
||||
If form rendering is desired, this method shall be called immediately after document construction, before getting document length or page handles.
|
||||
|
||||
Parameters:
|
||||
config (FPDF_FORMFILLINFO | None):
|
||||
Custom form config interface to use (optional).
|
||||
"""
|
||||
|
||||
formtype = self.get_formtype()
|
||||
if formtype == pdfium_c.FORMTYPE_NONE or self.formenv:
|
||||
return
|
||||
|
||||
# safety check for older binaries to prevent a segfault (could be removed at some point)
|
||||
# https://github.com/bblanchon/pdfium-binaries/issues/105
|
||||
if "V8" in PDFIUM_INFO.flags and PDFIUM_INFO.origin != "sourcebuild" and PDFIUM_INFO.build <= 5677:
|
||||
raise RuntimeError("V8 enabled pdfium-binaries builds <= 5677 crash on init_forms().")
|
||||
|
||||
if not config:
|
||||
if "XFA" in PDFIUM_INFO.flags:
|
||||
js_platform = pdfium_c.IPDF_JSPLATFORM(version=3)
|
||||
config = pdfium_c.FPDF_FORMFILLINFO(version=2, xfa_disabled=False, m_pJsPlatform=ctypes.pointer(js_platform))
|
||||
else:
|
||||
config = pdfium_c.FPDF_FORMFILLINFO(version=2)
|
||||
|
||||
raw = pdfium_c.FPDFDOC_InitFormFillEnvironment(self, config)
|
||||
if not raw:
|
||||
raise PdfiumError(f"Initializing form env failed for document {self}.")
|
||||
self.formenv = PdfFormEnv(raw, config, self)
|
||||
self._add_kid(self.formenv)
|
||||
|
||||
if formtype in (pdfium_c.FORMTYPE_XFA_FULL, pdfium_c.FORMTYPE_XFA_FOREGROUND):
|
||||
if "XFA" in PDFIUM_INFO.flags:
|
||||
ok = pdfium_c.FPDF_LoadXFA(self)
|
||||
if not ok:
|
||||
err = pdfium_c.FPDF_GetLastError()
|
||||
logger.warning(f"FPDF_LoadXFA() failed with {pdfium_i.XFAErrorToStr.get(err)}")
|
||||
else:
|
||||
logger.warning(
|
||||
"init_forms() called on XFA pdf, but this pdfium binary was compiled without XFA support.\n"
|
||||
"Run `PDFIUM_PLATFORM=auto-v8 pip install -v pypdfium2 --no-binary pypdfium2` to get a build with XFA support."
|
||||
)
|
||||
|
||||
|
||||
# TODO?(v5) consider cached property
|
||||
def get_formtype(self):
|
||||
"""
|
||||
Returns:
|
||||
int: PDFium form type that applies to the document (:attr:`FORMTYPE_*`).
|
||||
:attr:`FORMTYPE_NONE` if the document has no forms.
|
||||
"""
|
||||
return pdfium_c.FPDF_GetFormType(self)
|
||||
|
||||
|
||||
# TODO?(v5) consider cached property
|
||||
def get_pagemode(self):
|
||||
"""
|
||||
Returns:
|
||||
int: Page displaying mode (:attr:`PAGEMODE_*`).
|
||||
"""
|
||||
return pdfium_c.FPDFDoc_GetPageMode(self)
|
||||
|
||||
|
||||
# TODO?(v5) consider cached property
|
||||
def is_tagged(self):
|
||||
"""
|
||||
Returns:
|
||||
bool: Whether the document is tagged (cf. PDF 1.7, 10.7 "Tagged PDF").
|
||||
"""
|
||||
return bool( pdfium_c.FPDFCatalog_IsTagged(self) )
|
||||
|
||||
|
||||
def save(self, dest, version=None, flags=pdfium_c.FPDF_NO_INCREMENTAL):
|
||||
"""
|
||||
Save the document at its current state.
|
||||
|
||||
Parameters:
|
||||
dest (str | pathlib.Path | io.BytesIO):
|
||||
File path or byte buffer the document shall be written to.
|
||||
version (int | None):
|
||||
The PDF version to use, given as an integer (14 for 1.4, 15 for 1.5, ...).
|
||||
If None (the default), PDFium will set a version automatically.
|
||||
flags (int):
|
||||
PDFium saving flags (defaults to :attr:`FPDF_NO_INCREMENTAL`).
|
||||
"""
|
||||
|
||||
if isinstance(dest, (str, Path)):
|
||||
buffer, need_close = open(dest, "wb"), True
|
||||
elif pdfium_i.is_buffer(dest, "w"):
|
||||
buffer, need_close = dest, False
|
||||
else:
|
||||
raise ValueError(f"Cannot save to '{dest}'")
|
||||
|
||||
try:
|
||||
saveargs = (self, pdfium_i.get_bufwriter(buffer), flags)
|
||||
ok = pdfium_c.FPDF_SaveAsCopy(*saveargs) if version is None else pdfium_c.FPDF_SaveWithVersion(*saveargs, version)
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to save document.")
|
||||
finally:
|
||||
if need_close:
|
||||
buffer.close()
|
||||
|
||||
|
||||
def get_identifier(self, type=pdfium_c.FILEIDTYPE_PERMANENT):
|
||||
"""
|
||||
Parameters:
|
||||
type (int):
|
||||
The identifier type to retrieve (:attr:`FILEIDTYPE_*`), either permanent or changing.
|
||||
If the file was updated incrementally, the permanent identifier stays the same,
|
||||
while the changing identifier is re-calculated.
|
||||
Returns:
|
||||
bytes: Unique file identifier from the PDF's trailer dictionary.
|
||||
See PDF 1.7, Section 14.4 "File Identifiers".
|
||||
"""
|
||||
n_bytes = pdfium_c.FPDF_GetFileIdentifier(self, type, None, 0)
|
||||
buffer = ctypes.create_string_buffer(n_bytes)
|
||||
pdfium_c.FPDF_GetFileIdentifier(self, type, buffer, n_bytes)
|
||||
return buffer.raw[:n_bytes-2]
|
||||
|
||||
|
||||
def get_version(self):
|
||||
"""
|
||||
Returns:
|
||||
int | None: The PDF version of the document (14 for 1.4, 15 for 1.5, ...),
|
||||
or None if the document is new or its version could not be determined.
|
||||
"""
|
||||
version = ctypes.c_int()
|
||||
ok = pdfium_c.FPDF_GetFileVersion(self, version)
|
||||
if not ok:
|
||||
return None
|
||||
return version.value
|
||||
|
||||
|
||||
def get_metadata_value(self, key):
|
||||
"""
|
||||
Returns:
|
||||
str: Value of the given key in the PDF's metadata dictionary.
|
||||
If the key is not contained, an empty string will be returned.
|
||||
"""
|
||||
enc_key = (key + "\x00").encode("utf-8")
|
||||
n_bytes = pdfium_c.FPDF_GetMetaText(self, enc_key, None, 0)
|
||||
buffer = ctypes.create_string_buffer(n_bytes)
|
||||
pdfium_c.FPDF_GetMetaText(self, enc_key, buffer, n_bytes)
|
||||
return buffer.raw[:n_bytes-2].decode("utf-16-le")
|
||||
|
||||
|
||||
METADATA_KEYS = ("Title", "Author", "Subject", "Keywords", "Creator", "Producer", "CreationDate", "ModDate")
|
||||
|
||||
|
||||
def get_metadata_dict(self, skip_empty=False):
|
||||
"""
|
||||
Get the document's metadata as dictionary.
|
||||
|
||||
Parameters:
|
||||
skip_empty (bool):
|
||||
If True, skip items whose value is an empty string.
|
||||
Returns:
|
||||
dict: PDF metadata.
|
||||
"""
|
||||
metadata = {k: self.get_metadata_value(k) for k in self.METADATA_KEYS}
|
||||
if skip_empty:
|
||||
metadata = {k: v for k, v in metadata.items() if v}
|
||||
return metadata
|
||||
|
||||
|
||||
def count_attachments(self):
|
||||
"""
|
||||
Returns:
|
||||
int: The number of embedded files in the document.
|
||||
"""
|
||||
return pdfium_c.FPDFDoc_GetAttachmentCount(self)
|
||||
|
||||
|
||||
def get_attachment(self, index):
|
||||
"""
|
||||
Returns:
|
||||
PdfAttachment: The attachment at *index* (zero-based).
|
||||
"""
|
||||
raw_attachment = pdfium_c.FPDFDoc_GetAttachment(self, index)
|
||||
if not raw_attachment:
|
||||
raise PdfiumError(f"Failed to get attachment at index {index}.")
|
||||
return PdfAttachment(raw_attachment, self)
|
||||
|
||||
|
||||
def new_attachment(self, name):
|
||||
"""
|
||||
Add a new attachment to the document. It may appear at an arbitrary index (as of PDFium 5418).
|
||||
|
||||
Parameters:
|
||||
name (str):
|
||||
The name the attachment shall have. Usually a file name with extension.
|
||||
Returns:
|
||||
PdfAttachment: Handle to the new, empty attachment.
|
||||
"""
|
||||
enc_name = (name + "\x00").encode("utf-16-le")
|
||||
enc_name_ptr = ctypes.cast(enc_name, pdfium_c.FPDF_WIDESTRING)
|
||||
raw_attachment = pdfium_c.FPDFDoc_AddAttachment(self, enc_name_ptr)
|
||||
if not raw_attachment:
|
||||
raise PdfiumError(f"Failed to create new attachment '{name}'.")
|
||||
return PdfAttachment(raw_attachment, self)
|
||||
|
||||
|
||||
def del_attachment(self, index):
|
||||
"""
|
||||
Unlink the attachment at *index* (zero-based).
|
||||
It will be hidden from the viewer, but is still present in the file (as of PDFium 5418).
|
||||
Following attachments shift one slot to the left in the array representation used by PDFium's API.
|
||||
|
||||
Handles to the attachment in question received from :meth:`.get_attachment`
|
||||
must not be accessed anymore after this method has been called.
|
||||
"""
|
||||
ok = pdfium_c.FPDFDoc_DeleteAttachment(self, index)
|
||||
if not ok:
|
||||
raise PdfiumError(f"Failed to delete attachment at index {index}.")
|
||||
|
||||
|
||||
# TODO deprecate in favour of index access?
|
||||
def get_page(self, index):
|
||||
"""
|
||||
Returns:
|
||||
PdfPage: The page at *index* (zero-based).
|
||||
Note:
|
||||
This calls ``FORM_OnAfterLoadPage()`` if the document has an active form env.
|
||||
The form env must not be closed before the page is closed!
|
||||
"""
|
||||
|
||||
raw_page = pdfium_c.FPDF_LoadPage(self, index)
|
||||
if not raw_page:
|
||||
raise PdfiumError("Failed to load page.")
|
||||
page = PdfPage(raw_page, self, self.formenv)
|
||||
|
||||
if self.formenv:
|
||||
pdfium_c.FORM_OnAfterLoadPage(page, self.formenv)
|
||||
self.formenv._add_kid(page)
|
||||
else:
|
||||
self._add_kid(page)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
def new_page(self, width, height, index=None):
|
||||
"""
|
||||
Insert a new, empty page into the document.
|
||||
|
||||
Parameters:
|
||||
width (float):
|
||||
Target page width (horizontal size).
|
||||
height (float):
|
||||
Target page height (vertical size).
|
||||
index (int | None):
|
||||
Suggested zero-based index at which the page shall be inserted.
|
||||
If None or larger that the document's current last index, the page will be appended to the end.
|
||||
Returns:
|
||||
PdfPage: The newly created page.
|
||||
"""
|
||||
if index is None:
|
||||
index = len(self)
|
||||
raw_page = pdfium_c.FPDFPage_New(self, index, width, height)
|
||||
page = PdfPage(raw_page, self, None)
|
||||
# not doing formenv calls for new pages as we don't see the point
|
||||
self._add_kid(page)
|
||||
return page
|
||||
|
||||
|
||||
def del_page(self, index):
|
||||
"""
|
||||
Remove the page at *index* (zero-based).
|
||||
"""
|
||||
# FIXME what if the caller still has a handle to the page?
|
||||
pdfium_c.FPDFPage_Delete(self, index)
|
||||
|
||||
|
||||
def import_pages(self, pdf, pages=None, index=None):
|
||||
"""
|
||||
Import pages from a foreign document.
|
||||
|
||||
Parameters:
|
||||
pdf (PdfDocument):
|
||||
The document from which to import pages.
|
||||
pages (list[int] | str | None):
|
||||
The pages to include. It may either be a list of zero-based page indices, or a string of one-based page numbers and ranges.
|
||||
If None, all pages will be included.
|
||||
index (int):
|
||||
Zero-based index at which to insert the given pages. If None, they are appended to the end of the document.
|
||||
"""
|
||||
|
||||
if index is None:
|
||||
index = len(self)
|
||||
|
||||
if isinstance(pages, str):
|
||||
ok = pdfium_c.FPDF_ImportPages(self, pdf, pages.encode("ascii"), index)
|
||||
else:
|
||||
page_count = 0
|
||||
c_pages = None
|
||||
if pages:
|
||||
page_count = len(pages)
|
||||
c_pages = (ctypes.c_int * page_count)(*pages)
|
||||
ok = pdfium_c.FPDF_ImportPagesByIndex(self, pdf, c_pages, page_count, index)
|
||||
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to import pages.")
|
||||
|
||||
|
||||
def get_page_size(self, index):
|
||||
"""
|
||||
Returns:
|
||||
(float, float): Width and height in PDF canvas units of the page at *index* (zero-based).
|
||||
"""
|
||||
size = pdfium_c.FS_SIZEF()
|
||||
ok = pdfium_c.FPDF_GetPageSizeByIndexF(self, index, size)
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to get page size by index.")
|
||||
return (size.width, size.height)
|
||||
|
||||
|
||||
def get_page_label(self, index):
|
||||
"""
|
||||
Returns:
|
||||
str: Label of the page at *index* (zero-based).
|
||||
(A page label is essentially an alias that may be displayed instead of the page number.)
|
||||
"""
|
||||
n_bytes = pdfium_c.FPDF_GetPageLabel(self, index, None, 0)
|
||||
buffer = ctypes.create_string_buffer(n_bytes)
|
||||
pdfium_c.FPDF_GetPageLabel(self, index, buffer, n_bytes)
|
||||
return buffer.raw[:n_bytes-2].decode("utf-16-le")
|
||||
|
||||
|
||||
def page_as_xobject(self, index, dest_pdf):
|
||||
"""
|
||||
Capture a page as XObject and attach it to a document's resources.
|
||||
|
||||
Parameters:
|
||||
index (int):
|
||||
Zero-based index of the page.
|
||||
dest_pdf (PdfDocument):
|
||||
Target document to which the XObject shall be added.
|
||||
Returns:
|
||||
PdfXObject: The page as XObject.
|
||||
"""
|
||||
raw_xobject = pdfium_c.FPDF_NewXObjectFromPage(dest_pdf, self, index)
|
||||
if raw_xobject is None:
|
||||
raise PdfiumError(f"Failed to capture page at index {index} as FPDF_XOBJECT.")
|
||||
xobject = PdfXObject(raw=raw_xobject, pdf=dest_pdf)
|
||||
self._add_kid(xobject)
|
||||
return xobject
|
||||
|
||||
|
||||
# TODO(apibreak) consider switching to a wrapper class around the raw bookmark
|
||||
# (either with getter methods, or possibly cached properties)
|
||||
def _get_bookmark(self, bookmark, level):
|
||||
|
||||
n_bytes = pdfium_c.FPDFBookmark_GetTitle(bookmark, None, 0)
|
||||
buffer = ctypes.create_string_buffer(n_bytes)
|
||||
pdfium_c.FPDFBookmark_GetTitle(bookmark, buffer, n_bytes)
|
||||
title = buffer.raw[:n_bytes-2].decode('utf-16-le')
|
||||
|
||||
# TODO(apibreak) just expose count as-is rather than using two variables and doing extra work
|
||||
count = pdfium_c.FPDFBookmark_GetCount(bookmark)
|
||||
is_closed = True if count < 0 else None if count == 0 else False
|
||||
n_kids = abs(count)
|
||||
|
||||
dest = pdfium_c.FPDFBookmark_GetDest(self, bookmark)
|
||||
page_index = pdfium_c.FPDFDest_GetDestPageIndex(self, dest)
|
||||
if page_index == -1:
|
||||
page_index = None
|
||||
|
||||
n_params = ctypes.c_ulong()
|
||||
view_pos = (pdfium_c.FS_FLOAT * 4)()
|
||||
view_mode = pdfium_c.FPDFDest_GetView(dest, n_params, view_pos)
|
||||
view_pos = list(view_pos)[:n_params.value]
|
||||
|
||||
return PdfOutlineItem(
|
||||
level = level,
|
||||
title = title,
|
||||
is_closed = is_closed,
|
||||
n_kids = n_kids,
|
||||
page_index = page_index,
|
||||
view_mode = view_mode,
|
||||
view_pos = view_pos,
|
||||
)
|
||||
|
||||
|
||||
# TODO(apibreak) change outline API (see above)
|
||||
def get_toc(
|
||||
self,
|
||||
max_depth = 15,
|
||||
parent = None,
|
||||
level = 0,
|
||||
seen = None,
|
||||
):
|
||||
"""
|
||||
Iterate through the bookmarks in the document's table of contents.
|
||||
|
||||
Parameters:
|
||||
max_depth (int):
|
||||
Maximum recursion depth to consider.
|
||||
Yields:
|
||||
:class:`.PdfOutlineItem`: Bookmark information.
|
||||
"""
|
||||
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
bookmark = pdfium_c.FPDFBookmark_GetFirstChild(self, parent)
|
||||
|
||||
while bookmark:
|
||||
|
||||
address = ctypes.addressof(bookmark.contents)
|
||||
if address in seen:
|
||||
logger.warning("A circular bookmark reference was detected whilst parsing the table of contents.")
|
||||
break
|
||||
else:
|
||||
seen.add(address)
|
||||
|
||||
yield self._get_bookmark(bookmark, level)
|
||||
if level < max_depth-1:
|
||||
yield from self.get_toc(
|
||||
max_depth = max_depth,
|
||||
parent = bookmark,
|
||||
level = level + 1,
|
||||
seen = seen,
|
||||
)
|
||||
|
||||
bookmark = pdfium_c.FPDFBookmark_GetNextSibling(self, bookmark)
|
||||
|
||||
|
||||
def render(
|
||||
self,
|
||||
converter,
|
||||
renderer = PdfPage.render,
|
||||
page_indices = None,
|
||||
pass_info = False,
|
||||
n_processes = None, # ignored, retained for compat
|
||||
mk_formconfig = None, # ignored, retained for compat
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
.. deprecated:: 4.19
|
||||
This method will be removed with the next major release due to serious issues rooted in the original API design. Use :meth:`.PdfPage.render()` instead.
|
||||
*Note that the CLI provides parallel rendering using a proper caller-side process pool with inline saving in rendering jobs.*
|
||||
|
||||
.. versionchanged:: 4.25
|
||||
Removed the original process pool implementation and turned this into a wrapper for linear rendering, due to the serious conceptual issues and possible memory load escalation, especially with expensive receiving code (e.g. PNG encoding) or long documents. See the changelog for more info
|
||||
"""
|
||||
|
||||
warnings.warn("The document-level pdf.render() API is deprecated and uncored due to serious issues in the original concept. Use page.render() and a caller-side loop or process pool instead.", category=DeprecationWarning)
|
||||
|
||||
if not page_indices:
|
||||
page_indices = [i for i in range(len(self))]
|
||||
for i in page_indices:
|
||||
bitmap = renderer(self[i], **kwargs)
|
||||
if pass_info:
|
||||
yield (converter(bitmap), bitmap.get_info())
|
||||
else:
|
||||
yield converter(bitmap)
|
||||
|
||||
|
||||
class PdfFormEnv (pdfium_i.AutoCloseable):
|
||||
"""
|
||||
Form environment helper class.
|
||||
|
||||
Attributes:
|
||||
raw (FPDF_FORMHANDLE):
|
||||
The underlying PDFium form env handle.
|
||||
config (FPDF_FORMFILLINFO):
|
||||
Accompanying form configuration interface, to be kept alive.
|
||||
pdf (PdfDocument):
|
||||
Parent document this form env belongs to.
|
||||
"""
|
||||
|
||||
def __init__(self, raw, config, pdf):
|
||||
self.raw, self.config, self.pdf = raw, config, pdf
|
||||
super().__init__(PdfFormEnv._close_impl, self.config, self.pdf)
|
||||
|
||||
@property
|
||||
def parent(self): # AutoCloseable hook
|
||||
return self.pdf
|
||||
|
||||
@staticmethod
|
||||
def _close_impl(raw, config, pdf):
|
||||
pdfium_c.FPDFDOC_ExitFormFillEnvironment(raw)
|
||||
id(config)
|
||||
pdf.formenv = None
|
||||
|
||||
|
||||
class PdfXObject (pdfium_i.AutoCloseable):
|
||||
"""
|
||||
XObject helper class.
|
||||
|
||||
Attributes:
|
||||
raw (FPDF_XOBJECT): The underlying PDFium XObject handle.
|
||||
pdf (PdfDocument): Reference to the document this XObject belongs to.
|
||||
"""
|
||||
|
||||
def __init__(self, raw, pdf):
|
||||
self.raw, self.pdf = raw, pdf
|
||||
super().__init__(pdfium_c.FPDF_CloseXObject)
|
||||
|
||||
@property
|
||||
def parent(self): # AutoCloseable hook
|
||||
return self.pdf
|
||||
|
||||
def as_pageobject(self):
|
||||
"""
|
||||
Returns:
|
||||
PdfObject: An independent page object representation of the XObject.
|
||||
If multiple page objects are created from one XObject, they share resources.
|
||||
Page objects created from an XObject remain valid after the XObject is closed.
|
||||
"""
|
||||
raw_pageobj = pdfium_c.FPDF_NewFormObjectFromXObject(self)
|
||||
return PdfObject( # not a child object (see above)
|
||||
raw = raw_pageobj,
|
||||
pdf = self.pdf,
|
||||
)
|
||||
|
||||
|
||||
def _open_pdf(input_data, password, autoclose):
|
||||
|
||||
to_hold, to_close = (), ()
|
||||
if password is not None:
|
||||
password = (password+"\x00").encode("utf-8")
|
||||
|
||||
if isinstance(input_data, Path):
|
||||
pdf = pdfium_c.FPDF_LoadDocument((str(input_data)+"\x00").encode("utf-8"), password)
|
||||
elif isinstance(input_data, (bytes, ctypes.Array)):
|
||||
pdf = pdfium_c.FPDF_LoadMemDocument64(input_data, len(input_data), password)
|
||||
to_hold = (input_data, )
|
||||
elif pdfium_i.is_buffer(input_data, "r"):
|
||||
bufaccess, to_hold = pdfium_i.get_bufreader(input_data)
|
||||
if autoclose:
|
||||
to_close = (input_data, )
|
||||
pdf = pdfium_c.FPDF_LoadCustomDocument(bufaccess, password)
|
||||
else:
|
||||
raise TypeError(f"Invalid input type '{type(input_data).__name__}'")
|
||||
|
||||
if pdfium_c.FPDF_GetPageCount(pdf) < 1:
|
||||
err_code = pdfium_c.FPDF_GetLastError()
|
||||
raise PdfiumError(f"Failed to load document (PDFium: {pdfium_i.ErrorToStr.get(err_code)}).")
|
||||
|
||||
return pdf, to_hold, to_close
|
||||
|
||||
|
||||
# TODO(apibreak) change outline API (see above)
|
||||
PdfOutlineItem = namedtuple("PdfOutlineItem", "level title is_closed n_kids page_index view_mode view_pos")
|
||||
"""
|
||||
Bookmark information.
|
||||
|
||||
Parameters:
|
||||
level (int):
|
||||
Number of parent items.
|
||||
title (str):
|
||||
Title string of the bookmark.
|
||||
is_closed (bool):
|
||||
True if child items shall be collapsed, False if they shall be expanded.
|
||||
None if the item has no descendants (i. e. ``n_kids == 0``).
|
||||
n_kids (int):
|
||||
Absolute number of child items, according to the PDF.
|
||||
page_index (int | None):
|
||||
Zero-based index of the page the bookmark points to.
|
||||
May be None if the bookmark has no target page (or it could not be determined).
|
||||
view_mode (int):
|
||||
A view mode constant (:data:`PDFDEST_VIEW_*`) defining how the coordinates of *view_pos* shall be interpreted.
|
||||
view_pos (list[float]):
|
||||
Target position on the page the viewport should jump to when the bookmark is clicked.
|
||||
It is a sequence of :class:`float` values in PDF canvas units.
|
||||
Depending on *view_mode*, it may contain between 0 and 4 coordinates.
|
||||
"""
|
||||
@@ -0,0 +1,183 @@
|
||||
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
|
||||
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
|
||||
|
||||
__all__ = ("PdfMatrix", )
|
||||
|
||||
import math
|
||||
import ctypes
|
||||
import pypdfium2.raw as pdfium_c
|
||||
|
||||
|
||||
# TODO consider adding PdfRectangle support model to calculate size and corner points
|
||||
# NOTE the code below was written by a non-mathematician - might contain mistakes!
|
||||
|
||||
|
||||
class PdfMatrix:
|
||||
"""
|
||||
PDF transformation matrix helper class.
|
||||
|
||||
See the PDF 1.7 specification, Section 8.3.3 ("Common Transformations").
|
||||
|
||||
Note:
|
||||
* The PDF format uses row vectors.
|
||||
* Transformations operate from the origin of the coordinate system
|
||||
(PDF coordinates: bottom left corner, Device coordinates: top left corner).
|
||||
* Matrix calculations are implemented independently in Python.
|
||||
* Matrix objects are immutable, so transforming methods return a new matrix.
|
||||
* Matrix objects implement ctypes auto-conversion to ``FS_MATRIX`` for easy use as C function parameter.
|
||||
|
||||
Attributes:
|
||||
a (float): Matrix value [0][0].
|
||||
b (float): Matrix value [0][1].
|
||||
c (float): Matrix value [1][0].
|
||||
d (float): Matrix value [1][1].
|
||||
e (float): Matrix value [2][0] (X translation).
|
||||
f (float): Matrix value [2][1] (Y translation).
|
||||
"""
|
||||
|
||||
# See also pdfium/core/fxcrt/fx_coordinates.{h,cpp} (unfortunately, pdfium's matrix implementation is non-public)
|
||||
|
||||
def __init__(self, a=1, b=0, c=0, d=1, e=0, f=0):
|
||||
self.a, self.b, self.c, self.d, self.e, self.f = a, b, c, d, e, f
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
return f"PdfMatrix{self.get()}"
|
||||
|
||||
|
||||
def __eq__(self, other):
|
||||
if type(self) is not type(other):
|
||||
return False
|
||||
return (self.get() == other.get())
|
||||
|
||||
|
||||
@property
|
||||
def _as_parameter_(self):
|
||||
return ctypes.byref( self.to_raw() )
|
||||
|
||||
|
||||
def get(self):
|
||||
"""
|
||||
Get the matrix as tuple of the form (a, b, c, d, e, f).
|
||||
"""
|
||||
return (self.a, self.b, self.c, self.d, self.e, self.f)
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, raw):
|
||||
"""
|
||||
Load a :class:`.PdfMatrix` from a raw :class:`FS_MATRIX` object.
|
||||
"""
|
||||
return cls(raw.a, raw.b, raw.c, raw.d, raw.e, raw.f)
|
||||
|
||||
|
||||
def to_raw(self):
|
||||
"""
|
||||
Convert the matrix to a raw :class:`FS_MATRIX` object.
|
||||
"""
|
||||
return pdfium_c.FS_MATRIX(*self.get())
|
||||
|
||||
|
||||
def multiply(self, other):
|
||||
"""
|
||||
Multiply this matrix by another :class:`.PdfMatrix`, to concatenate transformations.
|
||||
"""
|
||||
# M1 x M2 (self x other)
|
||||
# (a1, b1, 0) (a2, b2, 0) (a1a2+b1c2, a1b2+b1d2, 0)
|
||||
# (c1, d1, 0) x (c2, d2, 0) = (c1a2+d1c2, c1b2+d1d2, 0)
|
||||
# (e1, f1, 1) (e2, f2, 1) (e1a2+f1c2+e2, e1b2+f1d2+f2, 1)
|
||||
return PdfMatrix(
|
||||
a = self.a*other.a + self.b*other.c,
|
||||
b = self.a*other.b + self.b*other.d,
|
||||
c = self.c*other.a + self.d*other.c,
|
||||
d = self.c*other.b + self.d*other.d,
|
||||
e = self.e*other.a + self.f*other.c + other.e,
|
||||
f = self.e*other.b + self.f*other.d + other.f,
|
||||
)
|
||||
|
||||
|
||||
def translate(self, x, y):
|
||||
"""
|
||||
Parameters:
|
||||
x (float): Horizontal shift (<0: left, >0: right).
|
||||
y (float): Vertical shift.
|
||||
"""
|
||||
# same as return PdfMatrix(self.a, self.b, self.c, self.d, self.e+x, self.f+y)
|
||||
return self.multiply( PdfMatrix(1, 0, 0, 1, x, y) )
|
||||
|
||||
|
||||
def scale(self, x, y):
|
||||
"""
|
||||
Parameters:
|
||||
x (float): A factor to scale the X axis (<1: compress, >1: stretch).
|
||||
y (float): A factor to scale the Y axis.
|
||||
"""
|
||||
# same as return PdfMatrix(self.a*x, self.b*y, self.c*x, self.d*y, self.e*x, self.f*y)
|
||||
return self.multiply( PdfMatrix(x, 0, 0, y) )
|
||||
|
||||
|
||||
def rotate(self, angle, ccw=False, rad=False):
|
||||
"""
|
||||
Parameters:
|
||||
angle (float): Angle by which to rotate the matrix.
|
||||
ccw (bool): If True, rotate counter-clockwise.
|
||||
rad (bool): If True, interpret the angle as radians.
|
||||
"""
|
||||
if not rad:
|
||||
angle = math.radians(angle)
|
||||
c, s = math.cos(angle), math.sin(angle)
|
||||
return self.multiply( PdfMatrix(c, s, -s, c) if ccw else PdfMatrix(c, -s, s, c) )
|
||||
|
||||
|
||||
def mirror(self, v, h):
|
||||
"""
|
||||
Parameters:
|
||||
v (bool): Whether to mirror vertically (at the Y axis).
|
||||
h (bool): Whether to mirror horizontall (at the X axis).
|
||||
"""
|
||||
return self.scale(x=(-1 if v else 1), y=(-1 if h else 1))
|
||||
|
||||
|
||||
def skew(self, x_angle, y_angle, rad=False):
|
||||
"""
|
||||
Parameters:
|
||||
x_angle (float): Inner angle to skew the X axis.
|
||||
y_angle (float): Inner angle to skew the Y axis.
|
||||
rad (bool): If True, interpret the angles as radians.
|
||||
"""
|
||||
if not rad:
|
||||
x_angle = math.radians(x_angle)
|
||||
y_angle = math.radians(y_angle)
|
||||
return self.multiply( PdfMatrix(1, math.tan(x_angle), math.tan(y_angle), 1) )
|
||||
|
||||
|
||||
def on_point(self, x, y):
|
||||
"""
|
||||
Returns:
|
||||
(float, float): Transformed point.
|
||||
"""
|
||||
# (x, y) -> (ax+cy+e, bx+dy+f)
|
||||
return ( # new point
|
||||
self.a*x + self.c*y + self.e, # x
|
||||
self.b*x + self.d*y + self.f, # y
|
||||
)
|
||||
|
||||
|
||||
def on_rect(self, left, bottom, right, top):
|
||||
"""
|
||||
Returns:
|
||||
(float, float, float, float): Transformed rectangle.
|
||||
"""
|
||||
points = (
|
||||
self.on_point(left, top),
|
||||
self.on_point(left, bottom),
|
||||
self.on_point(right, top),
|
||||
self.on_point(right, bottom),
|
||||
)
|
||||
# NOTE maybe a single loop with min/max x/y vars and </> comparisons would be more efficient...
|
||||
return ( # new rect
|
||||
min(p[0] for p in points), # left
|
||||
min(p[1] for p in points), # bottom
|
||||
max(p[0] for p in points), # right
|
||||
max(p[1] for p in points), # top
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
|
||||
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
|
||||
|
||||
__all__ = ("PdfiumError", )
|
||||
|
||||
|
||||
class PdfiumError (RuntimeError):
|
||||
""" An exception from the PDFium library, detected by function return code. """
|
||||
pass
|
||||
@@ -0,0 +1,533 @@
|
||||
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
|
||||
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
|
||||
|
||||
__all__ = ("PdfPage", "PdfColorScheme")
|
||||
|
||||
import math
|
||||
import ctypes
|
||||
import logging
|
||||
import pypdfium2.raw as pdfium_c
|
||||
import pypdfium2.internal as pdfium_i
|
||||
from pypdfium2._helpers.misc import PdfiumError
|
||||
from pypdfium2._helpers.bitmap import PdfBitmap
|
||||
from pypdfium2._helpers.textpage import PdfTextPage
|
||||
from pypdfium2._helpers.pageobjects import PdfObject
|
||||
|
||||
c_float = ctypes.c_float
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PdfPage (pdfium_i.AutoCloseable):
|
||||
"""
|
||||
Page helper class.
|
||||
|
||||
Attributes:
|
||||
raw (FPDF_PAGE): The underlying PDFium page handle.
|
||||
pdf (PdfDocument): Reference to the document this page belongs to.
|
||||
"""
|
||||
|
||||
def __init__(self, raw, pdf, formenv):
|
||||
self.raw, self.pdf, self.formenv = raw, pdf, formenv
|
||||
super().__init__(PdfPage._close_impl, self.formenv)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _close_impl(raw, formenv):
|
||||
if formenv:
|
||||
pdfium_c.FORM_OnBeforeClosePage(raw, formenv)
|
||||
pdfium_c.FPDF_ClosePage(raw)
|
||||
|
||||
|
||||
@property
|
||||
def parent(self): # AutoCloseable hook
|
||||
# Might want to have this point to the direct parent, i. e. (self.pdf if formenv is None else self.formenv), but this might confuse callers expecting that parent be always pdf for pages.
|
||||
return self.pdf
|
||||
|
||||
|
||||
def get_width(self):
|
||||
"""
|
||||
Returns:
|
||||
float: Page width (horizontal size), in PDF canvas units.
|
||||
"""
|
||||
return pdfium_c.FPDF_GetPageWidthF(self)
|
||||
|
||||
def get_height(self):
|
||||
"""
|
||||
Returns:
|
||||
float: Page height (vertical size), in PDF canvas units.
|
||||
"""
|
||||
return pdfium_c.FPDF_GetPageHeightF(self)
|
||||
|
||||
def get_size(self):
|
||||
"""
|
||||
Returns:
|
||||
(float, float): Page width and height, in PDF canvas units.
|
||||
"""
|
||||
return (self.get_width(), self.get_height())
|
||||
|
||||
|
||||
# NOTE {get,set}_rotation() deliberately fail with dict access error in case of invalid values
|
||||
|
||||
def get_rotation(self):
|
||||
"""
|
||||
Returns:
|
||||
int: Clockwise page rotation in degrees.
|
||||
"""
|
||||
return pdfium_i.RotationToDegrees[ pdfium_c.FPDFPage_GetRotation(self) ]
|
||||
|
||||
def set_rotation(self, rotation):
|
||||
"""
|
||||
Define the absolute, clockwise page rotation (0, 90, 180, or 270 degrees).
|
||||
"""
|
||||
pdfium_c.FPDFPage_SetRotation(self, pdfium_i.RotationToConst[rotation])
|
||||
|
||||
|
||||
def _get_box(self, box_func, fallback_func, fallback_ok):
|
||||
left, bottom, right, top = c_float(), c_float(), c_float(), c_float()
|
||||
ok = box_func(self, left, bottom, right, top)
|
||||
if not ok:
|
||||
return (fallback_func() if fallback_ok else None)
|
||||
return (left.value, bottom.value, right.value, top.value)
|
||||
|
||||
# NOTE in case further arguments are needed (besides fallback_ok), then use *args, **kwargs in callers
|
||||
|
||||
def get_mediabox(self, fallback_ok=True):
|
||||
"""
|
||||
Returns:
|
||||
(float, float, float, float) | None:
|
||||
The page MediaBox in PDF canvas units, consisting of four coordinates (usually x0, y0, x1, y1).
|
||||
If MediaBox is not defined, returns ANSI A (0, 0, 612, 792) if ``fallback_ok=True``, None otherwise.
|
||||
Note:
|
||||
Due to quirks in PDFium's public API, all ``get_*box()`` functions except :meth:`.get_bbox`
|
||||
do not inherit from parent nodes in the page tree (as of PDFium 5418).
|
||||
"""
|
||||
# https://crbug.com/pdfium/1786
|
||||
return self._get_box(pdfium_c.FPDFPage_GetMediaBox, lambda: (0, 0, 612, 792), fallback_ok)
|
||||
|
||||
def set_mediabox(self, l, b, r, t):
|
||||
"""
|
||||
Set the page's MediaBox by passing four :class:`float` coordinates (usually x0, y0, x1, y1).
|
||||
"""
|
||||
pdfium_c.FPDFPage_SetMediaBox(self, l, b, r, t)
|
||||
|
||||
def get_cropbox(self, fallback_ok=True):
|
||||
"""
|
||||
Returns:
|
||||
The page's CropBox (If not defined, falls back to MediaBox).
|
||||
"""
|
||||
return self._get_box(pdfium_c.FPDFPage_GetCropBox, self.get_mediabox, fallback_ok)
|
||||
|
||||
def set_cropbox(self, l, b, r, t):
|
||||
"""
|
||||
Set the page's CropBox.
|
||||
"""
|
||||
pdfium_c.FPDFPage_SetCropBox(self, l, b, r, t)
|
||||
|
||||
def get_bleedbox(self, fallback_ok=True):
|
||||
"""
|
||||
Returns:
|
||||
The page's BleedBox (If not defined, falls back to CropBox).
|
||||
"""
|
||||
return self._get_box(pdfium_c.FPDFPage_GetBleedBox, self.get_cropbox, fallback_ok)
|
||||
|
||||
def set_bleedbox(self, l, b, r, t):
|
||||
"""
|
||||
Set the page's BleedBox.
|
||||
"""
|
||||
pdfium_c.FPDFPage_SetBleedBox(self, l, b, r, t)
|
||||
|
||||
def get_trimbox(self, fallback_ok=True):
|
||||
"""
|
||||
Returns:
|
||||
The page's TrimBox (If not defined, falls back to CropBox).
|
||||
"""
|
||||
return self._get_box(pdfium_c.FPDFPage_GetTrimBox, self.get_cropbox, fallback_ok)
|
||||
|
||||
def set_trimbox(self, l, b, r, t):
|
||||
"""
|
||||
Set the page's TrimBox.
|
||||
"""
|
||||
pdfium_c.FPDFPage_SetTrimBox(self, l, b, r, t)
|
||||
|
||||
def get_artbox(self, fallback_ok=True):
|
||||
"""
|
||||
Returns:
|
||||
The page's ArtBox (If not defined, falls back to CropBox).
|
||||
"""
|
||||
return self._get_box(pdfium_c.FPDFPage_GetArtBox, self.get_cropbox, fallback_ok)
|
||||
|
||||
def set_artbox(self, l, b, r, t):
|
||||
"""
|
||||
Set the page's ArtBox.
|
||||
"""
|
||||
pdfium_c.FPDFPage_SetArtBox(self, l, b, r, t)
|
||||
|
||||
|
||||
def get_bbox(self):
|
||||
"""
|
||||
Returns:
|
||||
The bounding box of the page (the intersection between its media box and crop box).
|
||||
"""
|
||||
rect = pdfium_c.FS_RECTF()
|
||||
ok = pdfium_c.FPDF_GetPageBoundingBox(self, rect)
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to get page bounding box.")
|
||||
return (rect.left, rect.bottom, rect.right, rect.top)
|
||||
|
||||
|
||||
# TODO add bindings to FPDFPage_TransFormWithClip()
|
||||
|
||||
|
||||
def get_textpage(self):
|
||||
"""
|
||||
Returns:
|
||||
PdfTextPage: A new text page handle for this page.
|
||||
"""
|
||||
raw_textpage = pdfium_c.FPDFText_LoadPage(self)
|
||||
if not raw_textpage:
|
||||
raise PdfiumError("Failed to load text page.")
|
||||
textpage = PdfTextPage(raw_textpage, self)
|
||||
self._add_kid(textpage)
|
||||
return textpage
|
||||
|
||||
|
||||
def insert_obj(self, pageobj):
|
||||
"""
|
||||
Insert a page object into the page.
|
||||
|
||||
The page object must not belong to a page yet. If it belongs to a PDF, this page must be part of the PDF.
|
||||
|
||||
Position and form are defined by the object's matrix.
|
||||
If it is the identity matrix, the object will appear as-is on the bottom left corner of the page.
|
||||
|
||||
Parameters:
|
||||
pageobj (PdfObject): The page object to insert.
|
||||
"""
|
||||
|
||||
if pageobj.page:
|
||||
raise ValueError("The pageobject you attempted to insert already belongs to a page.")
|
||||
if pageobj.pdf and (pageobj.pdf is not self.pdf):
|
||||
raise ValueError("The pageobject you attempted to insert belongs to a different PDF.")
|
||||
|
||||
pdfium_c.FPDFPage_InsertObject(self, pageobj)
|
||||
pageobj._detach_finalizer()
|
||||
pageobj.page = self
|
||||
pageobj.pdf = self.pdf
|
||||
|
||||
|
||||
def remove_obj(self, pageobj):
|
||||
"""
|
||||
Remove a page object from the page.
|
||||
As of PDFium 5692, detached page objects may be only re-inserted into existing pages of the same document.
|
||||
If the page object is not re-inserted into a page, its ``close()`` method may be called.
|
||||
|
||||
Parameters:
|
||||
pageobj (PdfObject): The page object to remove.
|
||||
"""
|
||||
|
||||
if pageobj.page is not self:
|
||||
raise ValueError("The page object you attempted to remove is not part of this page.")
|
||||
|
||||
ok = pdfium_c.FPDFPage_RemoveObject(self, pageobj)
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to remove pageobject.")
|
||||
pageobj.page = None
|
||||
pageobj._attach_finalizer()
|
||||
|
||||
|
||||
def gen_content(self):
|
||||
"""
|
||||
Generate page content to apply additions, removals or modifications of page objects.
|
||||
|
||||
If page content was changed, this function should be called once before saving the document or re-loading the page.
|
||||
"""
|
||||
ok = pdfium_c.FPDFPage_GenerateContent(self)
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to generate page content.")
|
||||
|
||||
|
||||
def get_objects(self, filter=None, max_depth=2, form=None, level=0):
|
||||
"""
|
||||
Iterate through the page objects on this page.
|
||||
|
||||
Parameters:
|
||||
filter (list[int] | None):
|
||||
An optional list of page object types to filter (:attr:`FPDF_PAGEOBJ_*`).
|
||||
Any objects whose type is not contained will be skipped.
|
||||
If None or empty, all objects will be provided, regardless of their type.
|
||||
max_depth (int):
|
||||
Maximum recursion depth to consider when descending into Form XObjects.
|
||||
|
||||
Yields:
|
||||
:class:`.PdfObject`: A page object.
|
||||
"""
|
||||
|
||||
# TODO? close skipped objects explicitly ?
|
||||
|
||||
if form:
|
||||
count_objects = pdfium_c.FPDFFormObj_CountObjects
|
||||
get_object = pdfium_c.FPDFFormObj_GetObject
|
||||
parent = form
|
||||
else:
|
||||
count_objects = pdfium_c.FPDFPage_CountObjects
|
||||
get_object = pdfium_c.FPDFPage_GetObject
|
||||
parent = self
|
||||
|
||||
n_objects = count_objects(parent)
|
||||
if n_objects < 0:
|
||||
raise PdfiumError("Failed to get number of page objects.")
|
||||
|
||||
for i in range(n_objects):
|
||||
|
||||
raw_obj = get_object(parent, i)
|
||||
if raw_obj is None:
|
||||
raise PdfiumError("Failed to get page object.")
|
||||
|
||||
# Not a child object, because the lifetime of pageobjects that are part of a page is managed by pdfium. The .page reference is enough to keep the parent alive, unless the caller explicitly closes it (which may not merit storing countless of weakrefs).
|
||||
helper_obj = PdfObject(raw_obj, page=self, pdf=self.pdf, level=level)
|
||||
if not filter or helper_obj.type in filter:
|
||||
yield helper_obj
|
||||
|
||||
if helper_obj.type == pdfium_c.FPDF_PAGEOBJ_FORM and level < max_depth-1:
|
||||
yield from self.get_objects(
|
||||
filter = filter,
|
||||
max_depth = max_depth,
|
||||
form = raw_obj,
|
||||
level = level + 1,
|
||||
)
|
||||
|
||||
|
||||
# non-public because it doesn't really work (returns success but does nothing on all samples we tried)
|
||||
def _flatten(self, flag=pdfium_c.FLAT_NORMALDISPLAY):
|
||||
"""
|
||||
Attempt to flatten annotations and form fields into the page contents.
|
||||
|
||||
Parameters:
|
||||
flag (int): PDFium flattening target (:attr:`FLAT_*`)
|
||||
Returns:
|
||||
int: PDFium flattening status (:attr:`FLATTEN_*`). :attr:`FLATTEN_FAIL` is handled internally.
|
||||
"""
|
||||
rc = pdfium_c.FPDFPage_Flatten(self, flag)
|
||||
if rc == pdfium_c.FLATTEN_FAIL:
|
||||
raise PdfiumError("Failed to flatten annotations / form fields.")
|
||||
return rc
|
||||
|
||||
|
||||
# TODO
|
||||
# - add helpers for matrix-based and interruptible rendering
|
||||
# - add lower-level renderer that takes a caller-provided bitmap
|
||||
# e. g. render(), render_ex(), render_matrix(), render_matrix_ex()
|
||||
|
||||
|
||||
def render(
|
||||
self,
|
||||
scale = 1,
|
||||
rotation = 0,
|
||||
crop = (0, 0, 0, 0),
|
||||
may_draw_forms = True,
|
||||
bitmap_maker = PdfBitmap.new_native,
|
||||
color_scheme = None,
|
||||
fill_to_stroke = False,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Rasterize the page to a :class:`.PdfBitmap`.
|
||||
|
||||
Parameters:
|
||||
|
||||
scale (float):
|
||||
A factor scaling the number of pixels per PDF canvas unit. This defines the resolution of the image.
|
||||
To convert a DPI value to a scale factor, multiply it by the size of 1 canvas unit in inches (usually 1/72in). [#user_unit]_
|
||||
|
||||
rotation (int):
|
||||
Additional rotation in degrees (0, 90, 180, or 270).
|
||||
|
||||
crop (tuple[float, float, float, float]):
|
||||
Amount in PDF canvas units to cut off from page borders (left, bottom, right, top). Crop is applied after rotation.
|
||||
|
||||
may_draw_forms (bool):
|
||||
If True, render form fields (provided the document has forms and :meth:`~PdfDocument.init_forms` was called).
|
||||
|
||||
bitmap_maker (typing.Callable):
|
||||
Callback function used to create the :class:`.PdfBitmap`.
|
||||
|
||||
color_scheme (PdfColorScheme | None):
|
||||
An optional, custom rendering color scheme.
|
||||
|
||||
fill_to_stroke (bool):
|
||||
If True and rendering with custom color scheme, fill paths will be stroked.
|
||||
|
||||
fill_color (tuple[int, int, int, int]):
|
||||
Color the bitmap will be filled with before rendering (RGBA values from 0 to 255).
|
||||
|
||||
grayscale (bool):
|
||||
If True, render in grayscale mode.
|
||||
|
||||
optimize_mode (None | str):
|
||||
Page rendering optimization mode (None, "lcd", "print").
|
||||
|
||||
draw_annots (bool):
|
||||
If True, render page annotations.
|
||||
|
||||
no_smoothtext (bool):
|
||||
If True, disable text anti-aliasing. Overrides ``optimize_mode="lcd"``.
|
||||
|
||||
no_smoothimage (bool):
|
||||
If True, disable image anti-aliasing.
|
||||
|
||||
no_smoothpath (bool):
|
||||
If True, disable path anti-aliasing.
|
||||
|
||||
force_halftone (bool):
|
||||
If True, always use halftone for image stretching.
|
||||
|
||||
limit_image_cache (bool):
|
||||
If True, limit image cache size.
|
||||
|
||||
rev_byteorder (bool):
|
||||
If True, render with reverse byte order, leading to ``RGB(A/X)`` output instead of ``BGR(A/X)``.
|
||||
Other pixel formats are not affected.
|
||||
|
||||
prefer_bgrx (bool):
|
||||
If True, prefer four-channel over three-channel pixel formats, even if the alpha byte is unused.
|
||||
Other pixel formats are not affected.
|
||||
|
||||
force_bitmap_format (int | None):
|
||||
If given, override automatic pixel format selection and enforce use of the given format (one of the :attr:`FPDFBitmap_*` constants).
|
||||
|
||||
extra_flags (int):
|
||||
Additional PDFium rendering flags. May be combined with bitwise OR (``|`` operator).
|
||||
|
||||
Returns:
|
||||
PdfBitmap: Bitmap of the rendered page.
|
||||
|
||||
.. [#user_unit] Since PDF 1.6, pages may define an additional user unit factor. In this case, 1 canvas unit is equivalent to ``user_unit * (1/72)`` inches. PDFium currently does not have an API to get the user unit, so this is not taken into account.
|
||||
"""
|
||||
|
||||
src_width = math.ceil(self.get_width() * scale)
|
||||
src_height = math.ceil(self.get_height() * scale)
|
||||
if rotation in (90, 270):
|
||||
src_width, src_height = src_height, src_width
|
||||
|
||||
crop = [math.ceil(c*scale) for c in crop]
|
||||
width = src_width - crop[0] - crop[2]
|
||||
height = src_height - crop[1] - crop[3]
|
||||
if any(d < 1 for d in (width, height)):
|
||||
raise ValueError("Crop exceeds page dimensions")
|
||||
|
||||
cl_format, rev_byteorder, fill_color, flags = _parse_renderopts(**kwargs)
|
||||
if (color_scheme is not None) and fill_to_stroke:
|
||||
flags |= pdfium_c.FPDF_CONVERT_FILL_TO_STROKE
|
||||
|
||||
bitmap = bitmap_maker(width, height, format=cl_format, rev_byteorder=rev_byteorder)
|
||||
bitmap.fill_rect(0, 0, width, height, fill_color)
|
||||
|
||||
render_args = (bitmap, self, -crop[0], -crop[3], src_width, src_height, pdfium_i.RotationToConst[rotation], flags)
|
||||
|
||||
if color_scheme is None:
|
||||
pdfium_c.FPDF_RenderPageBitmap(*render_args)
|
||||
else:
|
||||
|
||||
pause = pdfium_c.IFSDK_PAUSE(version=1)
|
||||
pdfium_i.set_callback(pause, "NeedToPauseNow", lambda _: False)
|
||||
|
||||
fpdf_cs = color_scheme.convert(rev_byteorder)
|
||||
status = pdfium_c.FPDF_RenderPageBitmapWithColorScheme_Start(*render_args, fpdf_cs, pause)
|
||||
assert status == pdfium_c.FPDF_RENDER_DONE
|
||||
pdfium_c.FPDF_RenderPage_Close(self)
|
||||
|
||||
if may_draw_forms and self.formenv:
|
||||
pdfium_c.FPDF_FFLDraw(self.formenv, *render_args)
|
||||
|
||||
return bitmap
|
||||
|
||||
|
||||
def _auto_bitmap_format(fill_color, grayscale, prefer_bgrx):
|
||||
# TODO(apibreak) we'd like to also use BGRA if FPDFPage_HasTransparency(page) is True for performance reasons (see [1]), but this may break caller format expectations, and would make format selection document-dependent
|
||||
# [1]: https://chromium.googlesource.com/chromium/src/+/21e456b92bfadc625c947c718a6c4c5bf0c4c61b
|
||||
if (fill_color[3] < 255):
|
||||
return pdfium_c.FPDFBitmap_BGRA
|
||||
elif grayscale:
|
||||
return pdfium_c.FPDFBitmap_Gray
|
||||
elif prefer_bgrx:
|
||||
return pdfium_c.FPDFBitmap_BGRx
|
||||
else:
|
||||
return pdfium_c.FPDFBitmap_BGR
|
||||
|
||||
|
||||
def _parse_renderopts(
|
||||
fill_color = (255, 255, 255, 255),
|
||||
grayscale = False,
|
||||
optimize_mode = None,
|
||||
draw_annots = True,
|
||||
no_smoothtext = False,
|
||||
no_smoothimage = False,
|
||||
no_smoothpath = False,
|
||||
force_halftone = False,
|
||||
limit_image_cache = False,
|
||||
rev_byteorder = False,
|
||||
prefer_bgrx = False,
|
||||
force_bitmap_format = None,
|
||||
extra_flags = 0,
|
||||
):
|
||||
|
||||
if force_bitmap_format is None:
|
||||
cl_format = _auto_bitmap_format(fill_color, grayscale, prefer_bgrx)
|
||||
else:
|
||||
cl_format = force_bitmap_format
|
||||
|
||||
if cl_format == pdfium_c.FPDFBitmap_Gray:
|
||||
rev_byteorder = False
|
||||
|
||||
flags = extra_flags
|
||||
if grayscale:
|
||||
flags |= pdfium_c.FPDF_GRAYSCALE
|
||||
if draw_annots:
|
||||
flags |= pdfium_c.FPDF_ANNOT
|
||||
if no_smoothtext:
|
||||
flags |= pdfium_c.FPDF_RENDER_NO_SMOOTHTEXT
|
||||
if no_smoothimage:
|
||||
flags |= pdfium_c.FPDF_RENDER_NO_SMOOTHIMAGE
|
||||
if no_smoothpath:
|
||||
flags |= pdfium_c.FPDF_RENDER_NO_SMOOTHPATH
|
||||
if force_halftone:
|
||||
flags |= pdfium_c.FPDF_RENDER_FORCEHALFTONE
|
||||
if limit_image_cache:
|
||||
flags |= pdfium_c.FPDF_RENDER_LIMITEDIMAGECACHE
|
||||
if rev_byteorder:
|
||||
flags |= pdfium_c.FPDF_REVERSE_BYTE_ORDER
|
||||
|
||||
if optimize_mode:
|
||||
optimize_mode = optimize_mode.lower()
|
||||
if optimize_mode == "lcd":
|
||||
flags |= pdfium_c.FPDF_LCD_TEXT
|
||||
elif optimize_mode == "print":
|
||||
flags |= pdfium_c.FPDF_PRINTING
|
||||
else:
|
||||
raise ValueError(f"Invalid optimize_mode {optimize_mode}")
|
||||
|
||||
# TODO consider using a namedtuple or something
|
||||
return cl_format, rev_byteorder, fill_color, flags
|
||||
|
||||
|
||||
class PdfColorScheme:
|
||||
"""
|
||||
Rendering color scheme.
|
||||
Each color shall be provided as a list of values for red, green, blue and alpha, ranging from 0 to 255.
|
||||
"""
|
||||
|
||||
def __init__(self, path_fill, path_stroke, text_fill, text_stroke):
|
||||
self.colors = dict(
|
||||
path_fill_color=path_fill, path_stroke_color=path_stroke,
|
||||
text_fill_color=text_fill, text_stroke_color=text_stroke,
|
||||
)
|
||||
|
||||
def convert(self, rev_byteorder):
|
||||
"""
|
||||
Returns:
|
||||
The color scheme as :class:`FPDF_COLORSCHEME` object.
|
||||
"""
|
||||
fpdf_cs = pdfium_c.FPDF_COLORSCHEME()
|
||||
for key, value in self.colors.items():
|
||||
setattr(fpdf_cs, key, pdfium_i.color_tohex(value, rev_byteorder))
|
||||
return fpdf_cs
|
||||
@@ -0,0 +1,425 @@
|
||||
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
|
||||
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
|
||||
|
||||
__all__ = ("PdfObject", "PdfImage")
|
||||
|
||||
import ctypes
|
||||
from ctypes import c_uint, c_float
|
||||
from pathlib import Path
|
||||
from collections import namedtuple
|
||||
import pypdfium2.raw as pdfium_c
|
||||
import pypdfium2.internal as pdfium_i
|
||||
from pypdfium2._helpers.misc import PdfiumError
|
||||
from pypdfium2._helpers.matrix import PdfMatrix
|
||||
from pypdfium2._helpers.bitmap import PdfBitmap
|
||||
|
||||
try:
|
||||
import PIL.Image
|
||||
except ImportError:
|
||||
PIL = None
|
||||
|
||||
|
||||
class PdfObject (pdfium_i.AutoCloseable):
|
||||
"""
|
||||
Page object helper class.
|
||||
|
||||
When constructing a :class:`.PdfObject`, an instance of a more specific subclass may be returned instead,
|
||||
depending on the object's :attr:`.type` (e. g. :class:`.PdfImage`).
|
||||
|
||||
Attributes:
|
||||
raw (FPDF_PAGEOBJECT):
|
||||
The underlying PDFium pageobject handle.
|
||||
type (int):
|
||||
The object's type (:data:`FPDF_PAGEOBJ_*`).
|
||||
page (PdfPage):
|
||||
Reference to the page this pageobject belongs to. May be None if it does not belong to a page yet.
|
||||
pdf (PdfDocument):
|
||||
Reference to the document this pageobject belongs to. May be None if the object does not belong to a document yet.
|
||||
This attribute is always set if :attr:`.page` is set.
|
||||
level (int):
|
||||
Nesting level signifying the number of parent Form XObjects, at the time of construction.
|
||||
Zero if the object is not nested in a Form XObject.
|
||||
"""
|
||||
|
||||
|
||||
def __new__(cls, raw, *args, **kwargs):
|
||||
|
||||
type = pdfium_c.FPDFPageObj_GetType(raw)
|
||||
if type == pdfium_c.FPDF_PAGEOBJ_IMAGE:
|
||||
instance = super().__new__(PdfImage)
|
||||
else:
|
||||
instance = super().__new__(PdfObject)
|
||||
|
||||
instance.type = type
|
||||
return instance
|
||||
|
||||
|
||||
def __init__(self, raw, page=None, pdf=None, level=0):
|
||||
|
||||
self.raw, self.page, self.pdf, self.level = raw, page, pdf, level
|
||||
|
||||
if page is not None:
|
||||
if self.pdf is None:
|
||||
self.pdf = page.pdf
|
||||
elif self.pdf is not page.pdf:
|
||||
raise ValueError("*page* must belong to *pdf* when constructing a pageobject.")
|
||||
|
||||
super().__init__(pdfium_c.FPDFPageObj_Destroy, needs_free=(page is None))
|
||||
|
||||
|
||||
@property
|
||||
def parent(self): # AutoCloseable hook
|
||||
# May be None (loose pageobject)
|
||||
return self.pdf if self.page is None else self.page
|
||||
|
||||
|
||||
def get_pos(self):
|
||||
"""
|
||||
Get the position of the object on the page.
|
||||
|
||||
Returns:
|
||||
A tuple of four :class:`float` coordinates for left, bottom, right, and top.
|
||||
"""
|
||||
if self.page is None:
|
||||
raise RuntimeError("Must not call get_pos() on a loose pageobject.")
|
||||
|
||||
l, b, r, t = c_float(), c_float(), c_float(), c_float()
|
||||
ok = pdfium_c.FPDFPageObj_GetBounds(self, l, b, r, t)
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to locate pageobject.")
|
||||
|
||||
return (l.value, b.value, r.value, t.value)
|
||||
|
||||
|
||||
def get_matrix(self):
|
||||
"""
|
||||
Returns:
|
||||
PdfMatrix: The pageobject's current transform matrix.
|
||||
"""
|
||||
fs_matrix = pdfium_c.FS_MATRIX()
|
||||
ok = pdfium_c.FPDFPageObj_GetMatrix(self, fs_matrix)
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to get matrix of pageobject.")
|
||||
return PdfMatrix.from_raw(fs_matrix)
|
||||
|
||||
|
||||
def set_matrix(self, matrix):
|
||||
"""
|
||||
Parameters:
|
||||
matrix (PdfMatrix): Set this matrix as the pageobject's transform matrix.
|
||||
"""
|
||||
ok = pdfium_c.FPDFPageObj_SetMatrix(self, matrix)
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to set matrix of pageobject.")
|
||||
|
||||
|
||||
def transform(self, matrix):
|
||||
"""
|
||||
Parameters:
|
||||
matrix (PdfMatrix): Multiply the page object's current transform matrix by this matrix.
|
||||
"""
|
||||
pdfium_c.FPDFPageObj_Transform(self, *matrix.get())
|
||||
|
||||
|
||||
# In principle, we would like to move PdfImage to a separate file, but it's not that easy because of the two-fold connection with PdfObject, which would run us into a circular import. (However, what we could do is externalize the class under a different name and turn PdfImage into a wrapper which merely inherits from that class.)
|
||||
|
||||
class PdfImage (PdfObject):
|
||||
"""
|
||||
Image object helper class (specific kind of page object).
|
||||
"""
|
||||
|
||||
# cf. https://crbug.com/pdfium/1203
|
||||
#: Filters applied by :func:`FPDFImageObj_GetImageDataDecoded`. Hereafter referred to as "simple filters", while non-simple filters will be called "complex filters".
|
||||
SIMPLE_FILTERS = ("ASCIIHexDecode", "ASCII85Decode", "RunLengthDecode", "FlateDecode", "LZWDecode")
|
||||
|
||||
|
||||
@classmethod
|
||||
def new(cls, pdf):
|
||||
"""
|
||||
Parameters:
|
||||
pdf (PdfDocument): The document to which the new image object shall be added.
|
||||
Returns:
|
||||
PdfImage: Handle to a new, empty image.
|
||||
Note that position and size of the image are defined by its matrix, which defaults to the identity matrix.
|
||||
This means that new images will appear as a tiny square of 1x1 units on the bottom left corner of the page.
|
||||
Use :class:`.PdfMatrix` and :meth:`.set_matrix` to adjust size and position.
|
||||
"""
|
||||
raw_img = pdfium_c.FPDFPageObj_NewImageObj(pdf)
|
||||
return cls(raw_img, page=None, pdf=pdf)
|
||||
|
||||
|
||||
def get_metadata(self):
|
||||
"""
|
||||
Retrieve image metadata including DPI, bits per pixel, color space, and size.
|
||||
If the image does not belong to a page yet, bits per pixel and color space will be unset (0).
|
||||
|
||||
Note:
|
||||
* The DPI values signify the resolution of the image on the PDF page, not the DPI metadata embedded in the image file.
|
||||
* Due to issues in PDFium, this function can be slow. If you only need image size, prefer the faster :meth:`.get_size` instead.
|
||||
|
||||
Returns:
|
||||
FPDF_IMAGEOBJ_METADATA: Image metadata structure
|
||||
"""
|
||||
# https://crbug.com/pdfium/1928
|
||||
metadata = pdfium_c.FPDF_IMAGEOBJ_METADATA()
|
||||
ok = pdfium_c.FPDFImageObj_GetImageMetadata(self, self.page, metadata)
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to get image metadata.")
|
||||
return metadata
|
||||
|
||||
|
||||
def get_size(self):
|
||||
"""
|
||||
.. versionadded:: 4.8/5731
|
||||
|
||||
Returns:
|
||||
(int, int): Image dimensions as a tuple of (width, height).
|
||||
"""
|
||||
# https://pdfium-review.googlesource.com/c/pdfium/+/106290
|
||||
w, h = c_uint(), c_uint()
|
||||
ok = pdfium_c.FPDFImageObj_GetImagePixelSize(self, w, h)
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to get image size.")
|
||||
return w.value, h.value
|
||||
|
||||
|
||||
def load_jpeg(self, source, pages=None, inline=False, autoclose=True):
|
||||
"""
|
||||
Set a JPEG as the image object's content.
|
||||
|
||||
Parameters:
|
||||
source (str | pathlib.Path | typing.BinaryIO):
|
||||
Input JPEG, given as file path or readable byte buffer.
|
||||
pages (list[PdfPage] | None):
|
||||
If replacing an image, pass in a list of loaded pages that might contain it, to update their cache.
|
||||
(The same image may be shown multiple times in different transforms across a PDF.)
|
||||
May be None or an empty sequence if the image is not shared.
|
||||
inline (bool):
|
||||
Whether to load the image content into memory. If True, the buffer may be closed after this function call.
|
||||
Otherwise, the buffer needs to remain open until the PDF is closed.
|
||||
autoclose (bool):
|
||||
If the input is a buffer, whether it should be automatically closed once not needed by the PDF anymore.
|
||||
"""
|
||||
|
||||
if isinstance(source, (str, Path)):
|
||||
buffer = open(source, "rb")
|
||||
autoclose = True
|
||||
elif pdfium_i.is_buffer(source, "r"):
|
||||
buffer = source
|
||||
else:
|
||||
raise ValueError(f"Cannot load JPEG from {source} - not a file path or byte buffer.")
|
||||
|
||||
bufaccess, to_hold = pdfium_i.get_bufreader(buffer)
|
||||
loader = pdfium_c.FPDFImageObj_LoadJpegFileInline if inline else pdfium_c.FPDFImageObj_LoadJpegFile
|
||||
|
||||
c_pages, page_count = pdfium_i.pages_c_array(pages)
|
||||
ok = loader(c_pages, page_count, self, bufaccess)
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to load JPEG into image object.")
|
||||
|
||||
if inline:
|
||||
for data in to_hold:
|
||||
id(data)
|
||||
if autoclose:
|
||||
buffer.close()
|
||||
else:
|
||||
self.pdf._data_holder += to_hold
|
||||
if autoclose:
|
||||
self.pdf._data_closer.append(buffer)
|
||||
|
||||
|
||||
def set_bitmap(self, bitmap, pages=None):
|
||||
"""
|
||||
Set a bitmap as the image object's content.
|
||||
The pixel data will be flate compressed (as of PDFium 5418).
|
||||
|
||||
Parameters:
|
||||
bitmap (PdfBitmap):
|
||||
The bitmap to inject into the image object.
|
||||
pages (list[PdfPage] | None):
|
||||
A list of loaded pages that might contain the image object. See :meth:`.load_jpeg`.
|
||||
"""
|
||||
c_pages, page_count = pdfium_i.pages_c_array(pages)
|
||||
ok = pdfium_c.FPDFImageObj_SetBitmap(c_pages, page_count, self, bitmap)
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to set image to bitmap.")
|
||||
|
||||
|
||||
def get_bitmap(self, render=False):
|
||||
"""
|
||||
Get a bitmap rasterization of the image.
|
||||
|
||||
Parameters:
|
||||
render (bool):
|
||||
Whether the image should be rendered, thereby applying possible transform matrices and alpha masks.
|
||||
Returns:
|
||||
PdfBitmap: Image bitmap (with a buffer allocated by PDFium).
|
||||
"""
|
||||
|
||||
if render:
|
||||
if self.pdf is None:
|
||||
raise RuntimeError("Cannot get rendered bitmap of loose page object.")
|
||||
raw_bitmap = pdfium_c.FPDFImageObj_GetRenderedBitmap(self.pdf, self.page, self)
|
||||
else:
|
||||
raw_bitmap = pdfium_c.FPDFImageObj_GetBitmap(self)
|
||||
|
||||
if raw_bitmap is None:
|
||||
raise PdfiumError(f"Failed to get bitmap of image {self}.")
|
||||
|
||||
return PdfBitmap.from_raw(raw_bitmap)
|
||||
|
||||
|
||||
def get_data(self, decode_simple=False):
|
||||
"""
|
||||
Parameters:
|
||||
decode_simple (bool):
|
||||
If True, apply simple filters, resulting in semi-decoded data (see :attr:`.SIMPLE_FILTERS`).
|
||||
Otherwise, the raw data will be returned.
|
||||
Returns:
|
||||
ctypes.Array: The data of the image stream (as :class:`~ctypes.c_ubyte` array).
|
||||
"""
|
||||
func = pdfium_c.FPDFImageObj_GetImageDataDecoded if decode_simple else pdfium_c.FPDFImageObj_GetImageDataRaw
|
||||
n_bytes = func(self, None, 0)
|
||||
buffer = (ctypes.c_ubyte * n_bytes)()
|
||||
func(self, buffer, n_bytes)
|
||||
return buffer
|
||||
|
||||
|
||||
def get_filters(self, skip_simple=False):
|
||||
"""
|
||||
Parameters:
|
||||
skip_simple (bool):
|
||||
If True, exclude simple filters.
|
||||
Returns:
|
||||
list[str]: A list of image filters, to be applied in order (from lowest to highest index).
|
||||
"""
|
||||
|
||||
filters = []
|
||||
count = pdfium_c.FPDFImageObj_GetImageFilterCount(self)
|
||||
|
||||
for i in range(count):
|
||||
length = pdfium_c.FPDFImageObj_GetImageFilter(self, i, None, 0)
|
||||
buffer = ctypes.create_string_buffer(length)
|
||||
pdfium_c.FPDFImageObj_GetImageFilter(self, i, buffer, length)
|
||||
f = buffer.value.decode("utf-8")
|
||||
if skip_simple and f in self.SIMPLE_FILTERS:
|
||||
continue
|
||||
filters.append(f)
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def extract(self, dest, *args, **kwargs):
|
||||
# TODO rewrite/simplify docstring
|
||||
"""
|
||||
Extract the image into an independently usable file or byte buffer.
|
||||
Where possible within PDFium's limited public API, it will be attempted to transfer the image data directly,
|
||||
avoiding an unnecessary layer of decoding and re-encoding.
|
||||
Otherwise, the fully decoded data will be retrieved and (re-)encoded using :mod:`PIL`.
|
||||
|
||||
As PDFium does not expose all required information, only DCTDecode (JPEG) and JPXDecode (JPEG 2000) images can be extracted directly.
|
||||
For images with complex filters, the bitmap data is used. Otherwise, ``get_data(decode_simple=True)`` is used, which avoids lossy conversion for images whose bit depth or colour format is not supported by PDFium's bitmap implementation.
|
||||
|
||||
Parameters:
|
||||
dest (str | io.BytesIO):
|
||||
File prefix or byte buffer to which the image shall be written.
|
||||
fb_format (str):
|
||||
The image format to use in case it is necessary to (re-)encode the data.
|
||||
fb_render (bool):
|
||||
Whether the image should be rendered if falling back to bitmap-based extraction.
|
||||
"""
|
||||
|
||||
# https://crbug.com/pdfium/1930
|
||||
|
||||
extraction_gen = _extract_smart(self, *args, **kwargs)
|
||||
format = next(extraction_gen)
|
||||
|
||||
if isinstance(dest, (str, Path)):
|
||||
with open(f"{dest}.{format}", "wb") as buf:
|
||||
extraction_gen.send(buf)
|
||||
elif pdfium_i.is_buffer(dest, "w"):
|
||||
extraction_gen.send(dest)
|
||||
else:
|
||||
raise ValueError(f"Cannot extract to '{dest}'")
|
||||
|
||||
|
||||
ImageInfo = namedtuple("ImageInfo", "format mode metadata all_filters complex_filters")
|
||||
|
||||
|
||||
class ImageNotExtractableError (Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _get_pil_mode(colorspace, bpp):
|
||||
# In theory, indexed (palettized) and ICC-based color spaces could be handled as well, but PDFium currently does not provide access to the palette or the ICC profile
|
||||
if colorspace == pdfium_c.FPDF_COLORSPACE_DEVICEGRAY:
|
||||
if bpp == 1:
|
||||
return "1"
|
||||
else:
|
||||
return "L"
|
||||
elif colorspace == pdfium_c.FPDF_COLORSPACE_DEVICERGB:
|
||||
return "RGB"
|
||||
elif colorspace == pdfium_c.FPDF_COLORSPACE_DEVICECMYK:
|
||||
return "CMYK"
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_smart(image_obj, fb_format=None, fb_render=False):
|
||||
|
||||
# FIXME somewhat hard to read...
|
||||
|
||||
try:
|
||||
data, info = _extract_direct(image_obj)
|
||||
except ImageNotExtractableError:
|
||||
# TODO? log reason why the image cannot be extracted directly
|
||||
pil_image = image_obj.get_bitmap(render=fb_render).to_pil()
|
||||
else:
|
||||
pil_image = None
|
||||
format = info.format
|
||||
if format == "raw":
|
||||
metadata = info.metadata
|
||||
pil_image = PIL.Image.frombuffer(
|
||||
info.mode,
|
||||
(metadata.width, metadata.height),
|
||||
image_obj.get_data(decode_simple=True),
|
||||
"raw", info.mode, 0, 1,
|
||||
)
|
||||
|
||||
if pil_image:
|
||||
format = fb_format if fb_format else "tiff" if pil_image.mode == "CMYK" else "png"
|
||||
|
||||
buffer = yield format
|
||||
pil_image.save(buffer, format=format) if pil_image else buffer.write(data)
|
||||
|
||||
yield # breakpoint preventing StopIteration on .send()
|
||||
|
||||
|
||||
def _extract_direct(image_obj):
|
||||
|
||||
all_filters = image_obj.get_filters()
|
||||
complex_filters = [f for f in all_filters if f not in PdfImage.SIMPLE_FILTERS]
|
||||
metadata = image_obj.get_metadata()
|
||||
mode = _get_pil_mode(metadata.colorspace, metadata.bits_per_pixel)
|
||||
|
||||
if len(complex_filters) == 0:
|
||||
if mode:
|
||||
out_data = image_obj.get_data(decode_simple=True)
|
||||
out_format = "raw"
|
||||
else:
|
||||
raise ImageNotExtractableError(f"Unhandled color space {pdfium_i.ColorspaceToStr.get(metadata.colorspace)} - don't know how to treat data.")
|
||||
elif len(complex_filters) == 1:
|
||||
f = complex_filters[0]
|
||||
if f == "DCTDecode":
|
||||
out_data = image_obj.get_data(decode_simple=True)
|
||||
out_format = "jpg"
|
||||
elif f == "JPXDecode":
|
||||
out_data = image_obj.get_data(decode_simple=True)
|
||||
out_format = "jp2"
|
||||
else:
|
||||
raise ImageNotExtractableError(f"Unhandled complex filter {f}.")
|
||||
else:
|
||||
raise ImageNotExtractableError(f"Cannot handle multiple complex filters {complex_filters}.")
|
||||
|
||||
info = ImageInfo(out_format, mode, metadata, all_filters, complex_filters)
|
||||
return out_data, info
|
||||
@@ -0,0 +1,313 @@
|
||||
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
|
||||
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
|
||||
|
||||
__all__ = ("PdfTextPage", "PdfTextSearcher")
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import warnings
|
||||
import pypdfium2.raw as pdfium_c
|
||||
import pypdfium2.internal as pdfium_i
|
||||
from pypdfium2._helpers.misc import PdfiumError
|
||||
from pypdfium2.version import PDFIUM_INFO
|
||||
|
||||
c_double = ctypes.c_double
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PdfTextPage (pdfium_i.AutoCloseable):
|
||||
"""
|
||||
Text page helper class.
|
||||
|
||||
Attributes:
|
||||
raw (FPDF_TEXTPAGE): The underlying PDFium textpage handle.
|
||||
page (PdfPage): Reference to the page this textpage belongs to.
|
||||
"""
|
||||
|
||||
def __init__(self, raw, page):
|
||||
self.raw = raw
|
||||
self.page = page
|
||||
super().__init__(pdfium_c.FPDFText_ClosePage)
|
||||
|
||||
@property
|
||||
def parent(self): # AutoCloseable hook
|
||||
return self.page
|
||||
|
||||
|
||||
def _get_active_text_range(self, c_start, c_end, l_passive=0, r_passive=0):
|
||||
|
||||
if c_start > c_end:
|
||||
return 0 # no active chars in range
|
||||
|
||||
t_start = pdfium_c.FPDFText_GetTextIndexFromCharIndex(self, c_start)
|
||||
if t_start == -1:
|
||||
return self._get_active_text_range(c_start+1, c_end, l_passive+1, r_passive)
|
||||
|
||||
t_end = pdfium_c.FPDFText_GetTextIndexFromCharIndex(self, c_end)
|
||||
if t_end == -1:
|
||||
return self._get_active_text_range(c_start, c_end-1, l_passive, r_passive+1)
|
||||
|
||||
return t_start, t_end, l_passive, r_passive
|
||||
|
||||
|
||||
def get_text_range(self, index=0, count=-1, errors="ignore", force_this=False):
|
||||
"""
|
||||
Warning:
|
||||
.. versionchanged:: 4.28
|
||||
For various reasons, calling this method with default params now implicitly translates to :meth:`.get_text_bounded` (pass ``force_this=True`` to circumvent).
|
||||
|
||||
Extract text from a given range.
|
||||
|
||||
Parameters:
|
||||
index (int): Index of the first char to include.
|
||||
count (int): Number of chars to cover, relative to the internal char list. Defaults to -1 for all remaining chars after *index*.
|
||||
errors (str): Error handling when decoding the data (see :meth:`bytes.decode`).
|
||||
Returns:
|
||||
str: The text in the range in question, or an empty string if no text was found.
|
||||
|
||||
Note:
|
||||
* The returned text's length does not have to match *count*, even if it will for most PDFs.
|
||||
This is because the underlying API may exclude/insert chars compared to the internal list, although rare in practice.
|
||||
This means, if the char at ``i`` is excluded, ``get_text_range(i, 2)[1]`` will raise an index error.
|
||||
Pdfium provides raw APIs ``FPDFText_GetTextIndexFromCharIndex()`` / ``FPDFText_GetCharIndexFromTextIndex()`` to translate between the two views and identify excluded/inserted chars.
|
||||
* In case of leading/trailing excluded characters, pypdfium2 modifies *index* and *count* accordingly to prevent pdfium from unexpectedly reading beyond ``range(index, index+count)``.
|
||||
"""
|
||||
|
||||
# https://github.com/pypdfium2-team/pypdfium2/issues/298
|
||||
# https://crbug.com/pdfium/2133
|
||||
if (index, count) == (0, -1) and not force_this:
|
||||
warnings.warn("get_text_range() call with default params will be implicitly redirected to get_text_bounded()")
|
||||
return self.get_text_bounded(errors=errors)
|
||||
|
||||
if count == -1:
|
||||
count = self.count_chars() - index
|
||||
|
||||
# https://github.com/pypdfium2-team/pypdfium2/issues/261
|
||||
# https://crbug.com/pdfium/2079
|
||||
active_range = self._get_active_text_range(index, index+count-1)
|
||||
if active_range == 0:
|
||||
return ""
|
||||
|
||||
# NOTE since we have converted indices from char to text, they will shift accordingly for inserted/excluded chars, so this will calculate the exact output count
|
||||
t_start, t_end, l_passive, r_passive = active_range
|
||||
index += l_passive
|
||||
count -= l_passive + r_passive
|
||||
in_count = t_end+1 - t_start
|
||||
|
||||
# pdfium fea01fa9e2 (>6167) to d6a4b27d80 (<6415) requires assuming 4 bytes per character
|
||||
# https://github.com/pypdfium2-team/pypdfium2/issues/298
|
||||
# https://crbug.com/pdfium/2133
|
||||
if 6167 < PDFIUM_INFO.build < 6415:
|
||||
in_count *= 2
|
||||
in_count += 1 # null terminator
|
||||
|
||||
buffer = ctypes.create_string_buffer(in_count * 2)
|
||||
buffer_ptr = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ushort))
|
||||
out_count = pdfium_c.FPDFText_GetText(self, index, count, buffer_ptr)
|
||||
assert in_count >= out_count, f"Buffer too small: {in_count} vs {out_count}"
|
||||
|
||||
return buffer.raw[:(out_count-1)*2].decode("utf-16-le", errors=errors)
|
||||
|
||||
|
||||
def get_text_bounded(self, left=None, bottom=None, right=None, top=None, errors="ignore"):
|
||||
"""
|
||||
Extract text from given boundaries in PDF coordinates.
|
||||
If a boundary value is None, it defaults to the corresponding value of :meth:`.PdfPage.get_bbox`.
|
||||
|
||||
Parameters:
|
||||
errors (str): Error treatment when decoding the data (see :meth:`bytes.decode`).
|
||||
Returns:
|
||||
str: The text on the page area in question, or an empty string if no text was found.
|
||||
"""
|
||||
|
||||
bbox = self.page.get_bbox()
|
||||
if left is None:
|
||||
left = bbox[0]
|
||||
if bottom is None:
|
||||
bottom = bbox[1]
|
||||
if right is None:
|
||||
right = bbox[2]
|
||||
if top is None:
|
||||
top = bbox[3]
|
||||
|
||||
args = (self, left, top, right, bottom)
|
||||
n_chars = pdfium_c.FPDFText_GetBoundedText(*args, None, 0)
|
||||
if n_chars <= 0:
|
||||
return ""
|
||||
|
||||
buffer = ctypes.create_string_buffer(n_chars * 2)
|
||||
buffer_ptr = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ushort))
|
||||
pdfium_c.FPDFText_GetBoundedText(*args, buffer_ptr, n_chars)
|
||||
return buffer.raw.decode("utf-16-le", errors=errors)
|
||||
|
||||
|
||||
def count_chars(self):
|
||||
"""
|
||||
Returns:
|
||||
int: The number of characters on the text page.
|
||||
"""
|
||||
n_chars = pdfium_c.FPDFText_CountChars(self)
|
||||
if n_chars == -1:
|
||||
raise PdfiumError("Failed to get character count.")
|
||||
return n_chars
|
||||
|
||||
|
||||
def count_rects(self, index=0, count=-1):
|
||||
"""
|
||||
Parameters:
|
||||
index (int): Start character index.
|
||||
count (int): Character count to consider (defaults to -1 for all remaining).
|
||||
Returns:
|
||||
int: The number of text rectangles in the given character range.
|
||||
"""
|
||||
n_rects = pdfium_c.FPDFText_CountRects(self, index, count)
|
||||
if n_rects == -1:
|
||||
raise PdfiumError("Failed to count rectangles.")
|
||||
return n_rects
|
||||
|
||||
|
||||
def get_index(self, x, y, x_tol, y_tol):
|
||||
"""
|
||||
Get the index of a character by position.
|
||||
|
||||
Parameters:
|
||||
x (float): Horizontal position (in PDF canvas units).
|
||||
y (float): Vertical position.
|
||||
x_tol (float): Horizontal tolerance.
|
||||
y_tol (float): Vertical tolerance.
|
||||
Returns:
|
||||
int | None: The index of the character at or nearby the point (x, y).
|
||||
May be None if there is no character or an error occurred.
|
||||
"""
|
||||
index = pdfium_c.FPDFText_GetCharIndexAtPos(self, x, y, x_tol, y_tol)
|
||||
if index < 0:
|
||||
return None
|
||||
return index
|
||||
|
||||
|
||||
def get_charbox(self, index, loose=False):
|
||||
"""
|
||||
Get the bounding box of a single character.
|
||||
|
||||
Parameters:
|
||||
index (int):
|
||||
Index of the character to work with, in the page's character array.
|
||||
loose (bool):
|
||||
Get a more comprehensive box covering the entire font bounds, as opposed to the default tight box specific to the one character.
|
||||
Returns:
|
||||
Float values for left, bottom, right and top in PDF canvas units.
|
||||
"""
|
||||
|
||||
if loose:
|
||||
rect = pdfium_c.FS_RECTF()
|
||||
ok = pdfium_c.FPDFText_GetLooseCharBox(self, index, rect)
|
||||
l, b, r, t = rect.left, rect.bottom, rect.right, rect.top
|
||||
else:
|
||||
l, b, r, t = c_double(), c_double(), c_double(), c_double()
|
||||
ok = pdfium_c.FPDFText_GetCharBox(self, index, l, r, b, t) # yes, lrbt!
|
||||
l, b, r, t = l.value, b.value, r.value, t.value
|
||||
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to get charbox.")
|
||||
|
||||
return l, b, r, t
|
||||
|
||||
|
||||
def get_rect(self, index):
|
||||
"""
|
||||
Get the bounding box of a text rectangle at the given index.
|
||||
Note that :meth:`.count_rects` must be called once with default parameters
|
||||
before subsequent :meth:`.get_rect` calls for this function to work (due to PDFium's API).
|
||||
|
||||
Returns:
|
||||
Float values for left, bottom, right and top in PDF canvas units.
|
||||
"""
|
||||
l, b, r, t = c_double(), c_double(), c_double(), c_double()
|
||||
ok = pdfium_c.FPDFText_GetRect(self, index, l, t, r, b) # yes, ltrb!
|
||||
if not ok:
|
||||
raise PdfiumError("Failed to get rectangle. (Make sure count_rects() was called with default params once before subsequent get_rect() calls.)")
|
||||
return (l.value, b.value, r.value, t.value)
|
||||
|
||||
|
||||
def search(self, text, index=0, match_case=False, match_whole_word=False, consecutive=False):
|
||||
"""
|
||||
Locate text on the page.
|
||||
|
||||
Parameters:
|
||||
text (str):
|
||||
The string to search for.
|
||||
index (int):
|
||||
Character index at which to start searching.
|
||||
match_case (bool):
|
||||
If True, the search will be case-specific (upper and lower letters treated as different characters).
|
||||
match_whole_word (bool):
|
||||
If True, substring occurrences will be ignored (e. g. `cat` would not match `category`).
|
||||
consecutive (bool):
|
||||
If False (the default), :meth:`.search` will skip past the current match to look for the next match.
|
||||
If True, parts of the previous match may be caught again (e. g. searching for `aa` in `aaaa` would match 3 rather than 2 times).
|
||||
Returns:
|
||||
PdfTextSearcher: A helper object to search text.
|
||||
"""
|
||||
|
||||
if len(text) == 0:
|
||||
raise ValueError("Text length must be greater than 0.")
|
||||
|
||||
flags = 0
|
||||
if match_case:
|
||||
flags |= pdfium_c.FPDF_MATCHCASE
|
||||
if match_whole_word:
|
||||
flags |= pdfium_c.FPDF_MATCHWHOLEWORD
|
||||
if consecutive:
|
||||
flags |= pdfium_c.FPDF_CONSECUTIVE
|
||||
|
||||
enc_text = (text + "\x00").encode("utf-16-le")
|
||||
enc_text_ptr = ctypes.cast(enc_text, ctypes.POINTER(ctypes.c_ushort))
|
||||
raw_searcher = pdfium_c.FPDFText_FindStart(self, enc_text_ptr, flags, index)
|
||||
searcher = PdfTextSearcher(raw_searcher, self)
|
||||
self._add_kid(searcher)
|
||||
return searcher
|
||||
|
||||
|
||||
class PdfTextSearcher (pdfium_i.AutoCloseable):
|
||||
"""
|
||||
Text searcher helper class.
|
||||
|
||||
Attributes:
|
||||
raw (FPDF_SCHHANDLE): The underlying PDFium searcher handle.
|
||||
textpage (PdfTextPage): Reference to the textpage this searcher belongs to.
|
||||
"""
|
||||
|
||||
def __init__(self, raw, textpage):
|
||||
self.raw = raw
|
||||
self.textpage = textpage
|
||||
super().__init__(pdfium_c.FPDFText_FindClose)
|
||||
|
||||
@property
|
||||
def parent(self): # AutoCloseable hook
|
||||
return self.textpage
|
||||
|
||||
|
||||
def _get_occurrence(self, find_func):
|
||||
ok = find_func(self)
|
||||
if not ok:
|
||||
return None
|
||||
index = pdfium_c.FPDFText_GetSchResultIndex(self)
|
||||
count = pdfium_c.FPDFText_GetSchCount(self)
|
||||
return index, count
|
||||
|
||||
def get_next(self):
|
||||
"""
|
||||
Returns:
|
||||
(int, int): Start character index and count of the next occurrence,
|
||||
or None if the last occurrence was passed.
|
||||
"""
|
||||
return self._get_occurrence(pdfium_c.FPDFText_FindNext)
|
||||
|
||||
def get_prev(self):
|
||||
"""
|
||||
Returns:
|
||||
(int, int): Start character index and count of the previous occurrence (i. e. the one before the last valid occurrence),
|
||||
or None if the last occurrence was passed.
|
||||
"""
|
||||
return self._get_occurrence(pdfium_c.FPDFText_FindPrev)
|
||||
@@ -0,0 +1,59 @@
|
||||
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
|
||||
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
|
||||
|
||||
__all__ = ("PdfUnspHandler", )
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import pypdfium2.raw as pdfium_c
|
||||
import pypdfium2.internal as pdfium_i
|
||||
|
||||
lib_logger = logging.getLogger("pypdfium2")
|
||||
|
||||
|
||||
class PdfUnspHandler:
|
||||
"""
|
||||
Unsupported feature handler helper class.
|
||||
|
||||
Attributes:
|
||||
handlers (dict[str, typing.Callable]):
|
||||
A dictionary of named handler functions to be called with an unsupported code (:attr:`FPDF_UNSP_*`) when PDFium detects an unsupported feature.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.handlers = {}
|
||||
self._config = None
|
||||
|
||||
|
||||
def __call__(self, _, type):
|
||||
for handler in self.handlers.values():
|
||||
handler(type)
|
||||
|
||||
|
||||
def setup(self, add_default=True):
|
||||
"""
|
||||
Attach the handler to PDFium, and register an exit function to keep the object alive for the rest of the session.
|
||||
|
||||
Parameters:
|
||||
add_default (bool):
|
||||
If True, add a default callback that will log unsupported features as warning.
|
||||
"""
|
||||
|
||||
self._config = pdfium_c.UNSUPPORT_INFO(version=1)
|
||||
pdfium_i.set_callback(self._config, "FSDK_UnSupport_Handler", self)
|
||||
pdfium_c.FSDK_SetUnSpObjProcessHandler(self._config)
|
||||
|
||||
atexit.register(self._keep)
|
||||
|
||||
if add_default:
|
||||
self.handlers["default"] = PdfUnspHandler._default
|
||||
|
||||
|
||||
def _keep(self):
|
||||
id(self.handlers)
|
||||
id(self._config)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _default(type):
|
||||
lib_logger.warning(f"Unsupported PDF feature: {pdfium_i.UnsupportedInfoToStr.get(type)}")
|
||||
Reference in New Issue
Block a user