Initial commit of Roster

This commit is contained in:
Media
2025-06-28 01:12:46 -05:00
commit c23d3a7676
4367 changed files with 890519 additions and 0 deletions
@@ -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 *
@@ -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)}")