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,7 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import pypdfium2._library_scope
from pypdfium2.version import *
from pypdfium2._helpers import *
from pypdfium2 import raw, internal
@@ -0,0 +1,68 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import sys
import argparse
import importlib
from pypdfium2.version import PYPDFIUM_INFO, PDFIUM_INFO
from pypdfium2._cli._parsers import setup_logging
from pypdfium2_raw.bindings import _libs_info
pdfium_path = _libs_info["pdfium"]["path"]
SubCommands = {
"arrange": "rearrange/merge documents",
"attachments": "list/extract/edit embedded files",
"extract-images": "extract images",
"extract-text": "extract text",
"imgtopdf": "convert images to PDF",
"pageobjects": "print info on page objects",
"pdfinfo": "print info on document and pages",
"render": "rasterize pages",
"tile": "tile pages (N-up)",
"toc": "print table of contents",
}
CmdToModule = {n: importlib.import_module(f"pypdfium2._cli.{n.replace('-', '_')}") for n in SubCommands}
def get_parser():
main_parser = argparse.ArgumentParser(
prog = "pypdfium2",
formatter_class = argparse.RawTextHelpFormatter,
description = "Command line interface to the pypdfium2 library (Python binding to PDFium)",
)
main_parser.add_argument(
"--version", "-v",
action = "version",
version = f"pypdfium2 {PYPDFIUM_INFO}\n" f"pdfium {PDFIUM_INFO} at {pdfium_path}"
)
subparsers = main_parser.add_subparsers(dest="subcommand")
for name, help in SubCommands.items():
subparser = subparsers.add_parser(name, description=help, help=help)
CmdToModule[name].attach(subparser)
return main_parser
def api_main(raw_args=sys.argv[1:]):
parser = get_parser()
args = parser.parse_args(raw_args)
if not args.subcommand:
parser.print_help()
return
CmdToModule[args.subcommand].main(args)
def cli_main():
setup_logging()
api_main()
if __name__ == "__main__":
cli_main()
@@ -0,0 +1,2 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
@@ -0,0 +1,117 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import os
import sys
import argparse
import logging
from pathlib import Path
import pypdfium2._helpers as pdfium
import pypdfium2.internal as pdfium_i
def setup_logging():
pdfium_i.DEBUG_AUTOCLOSE.value = bool(int( os.environ.get("DEBUG_AUTOCLOSE", 0) ))
lib_logger = logging.getLogger("pypdfium2")
lib_logger.addHandler(logging.StreamHandler())
lib_logger.setLevel(logging.DEBUG)
pdfium.PdfUnspHandler().setup()
def parse_numtext(numtext):
# TODO enhancement: take count and verify page numbers
if not numtext:
return None
indices = []
for num_or_range in numtext.split(","):
if "-" in num_or_range:
start, end = num_or_range.split("-")
start = int(start) - 1
end = int(end) - 1
if start < end:
indices.extend( [i for i in range(start, end+1)] )
else:
indices.extend( [i for i in range(start, end-1, -1)] )
else:
indices.append(int(num_or_range) - 1)
return indices
def round_list(lst, n_digits):
if not lst:
return lst
result = [round(v, n_digits) for v in lst]
if isinstance(lst, tuple):
result = tuple(result)
return result
def add_input(parser, pages=True):
# TODO add option to open file with buffer/bytes strategy
parser.add_argument(
"input",
type = Path,
help = "Input PDF document",
)
parser.add_argument(
"--password",
help = "A password to unlock the PDF, if encrypted",
)
if pages:
parser.add_argument(
"--pages",
default = None,
type = parse_numtext,
help = "Page numbers and ranges to include",
)
def add_n_digits(parser):
parser.add_argument(
"--n-digits",
type = int,
default = 4,
help = "Number of digits to which coordinates/sizes shall be rounded",
)
def get_input(args, init_forms=False, **kwargs):
pdf = pdfium.PdfDocument(args.input, password=args.password, **kwargs)
if init_forms:
pdf.init_forms()
if "pages" in args and not args.pages:
args.pages = [i for i in range(len(pdf))]
return pdf
if sys.version_info >= (3, 9):
from argparse import BooleanOptionalAction
else:
# backport, adapted from argparse sources
class BooleanOptionalAction (argparse.Action):
def __init__(self, option_strings, dest, **kwargs):
_option_strings = []
for option_string in option_strings:
_option_strings.append(option_string)
if option_string.startswith('--'):
option_string = '--no-' + option_string[2:]
_option_strings.append(option_string)
super().__init__(option_strings=_option_strings, dest=dest, nargs=0, **kwargs)
def __call__(self, parser, namespace, values, option_string=None):
if option_string in self.option_strings:
setattr(namespace, self.dest, not option_string.startswith('--no-'))
def format_usage(self):
return ' | '.join(self.option_strings)
@@ -0,0 +1,51 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import pypdfium2._helpers as pdfium
# TODO? consider dotted access
from pypdfium2._cli._parsers import parse_numtext
def attach(parser):
parser.add_argument(
"inputs",
nargs = "+",
help = "Sequence of PDF files.",
)
parser.add_argument(
"--pages",
nargs = "+",
default = [],
help = "Sequence of page texts, definig the pages to include from each PDF. Use '_' as placeholder for all pages."
)
parser.add_argument(
"--passwords",
nargs = "+",
default = [],
help = "Passwords to unlock encrypted PDFs. Any placeholder may be used for non-encrypted documents.",
)
parser.add_argument(
"--output", "-o",
required = True,
help = "Target path for the output document",
)
def main(args):
args.pages = [None if p == "_" else parse_numtext(p) for p in args.pages]
for _ in range(len(args.inputs) - len(args.pages)):
args.pages.append(None)
for _ in range(len(args.inputs) - len(args.passwords)):
args.passwords.append(None)
dest_pdf = pdfium.PdfDocument.new()
index = 0
for in_path, pages, password in zip(args.inputs, args.pages, args.passwords):
src_pdf = pdfium.PdfDocument(in_path, password=password)
dest_pdf.import_pages(src_pdf, pages=pages)
index += len(src_pdf)
dest_pdf.save(args.output)
@@ -0,0 +1,88 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
from pathlib import Path
# TODO? consider dotted access
from pypdfium2._cli._parsers import (
add_input, get_input,
parse_numtext,
)
ACTION_LIST = "list"
ACTION_EXTRACT = "extract"
ACTION_EDIT = "edit"
def attach(parser): # hook
add_input(parser, pages=False)
subparsers = parser.add_subparsers(dest="action")
subparsers.add_parser(ACTION_LIST)
parser_extract = subparsers.add_parser(ACTION_EXTRACT)
parser_extract.add_argument(
"--numbers",
type = parse_numtext,
)
parser_extract.add_argument(
"--output-dir", "-o",
type = Path,
required = True,
)
parser_edit = subparsers.add_parser(ACTION_EDIT)
parser_edit.add_argument(
"--del-numbers", "-d",
type = parse_numtext,
)
parser_edit.add_argument(
"--add-files", "-a",
nargs = "+",
metavar = "F",
type = Path,
)
parser_edit.add_argument(
"--output", "-o",
type = Path,
required = True,
)
def main(args):
pdf = get_input(args)
n_attachments = pdf.count_attachments()
if args.action == ACTION_LIST:
for i in range(n_attachments):
attachment = pdf.get_attachment(i)
print(f"[{i+1}]", attachment.get_name())
elif args.action == ACTION_EXTRACT:
if not args.numbers:
args.numbers = range(n_attachments)
n_digits = len(str( max(args.numbers) + 1 ))
for i in args.numbers:
attachment = pdf.get_attachment(i)
name = attachment.get_name()
out_path = args.output_dir / ("%0*d_%s" % (n_digits, i+1, name))
out_path.write_bytes( attachment.get_data() )
elif args.action == ACTION_EDIT:
if args.del_numbers:
for i in sorted(args.del_numbers, reverse=True):
pdf.del_attachment(i)
if args.add_files:
for fp in args.add_files:
attachment = pdf.new_attachment(fp.name)
attachment.set_data( fp.read_bytes() )
pdf.save(args.output)
else:
assert False
@@ -0,0 +1,77 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
# TODO test-cover use_bitmap, format and render
import traceback
from pathlib import Path
import pypdfium2.raw as pdfium_c
import pypdfium2._helpers as pdfium
# TODO? consider dotted access
from pypdfium2._cli._parsers import add_input, get_input
def attach(parser):
add_input(parser, pages=True)
parser.add_argument(
"--output-dir", "-o",
required = True,
type = Path,
help = "Output directory to take the extracted images",
)
parser.add_argument(
"--max-depth",
type = int,
default = 2,
help = "Maximum recursion depth to consider when looking for page objects.",
)
parser.add_argument(
"--use-bitmap",
action = "store_true",
help = "Enforce the use of bitmaps rather than attempting a smart extraction of the image.",
)
parser.add_argument(
"--format",
help = "Image format to use when saving bitmaps. (Fallback if doing smart extraction.)",
)
parser.add_argument(
"--render",
action = "store_true",
help = "Whether to get rendered bitmaps, taking masks and transform matrices into account. (Fallback if doing smart extraction.)",
)
def main(args):
if not args.output_dir.is_dir():
raise NotADirectoryError(args.output_dir)
if args.use_bitmap and not args.format:
args.format = "png"
pdf = get_input(args)
n_pdigits = len(str( max(args.pages)+1 ))
for i in args.pages:
page = pdf[i]
images = page.get_objects(
filter = (pdfium_c.FPDF_PAGEOBJ_IMAGE, ),
max_depth = args.max_depth,
)
# not perfectly memory efficient, but we need image count for digit formatting
images = list(images)
n_idigits = len(str( len(images) ))
for j, image in enumerate(images):
stem = "%s_%0*d_%0*d" % (args.input.stem, n_pdigits, i+1, n_idigits, j+1)
prefix = args.output_dir / stem
try:
if args.use_bitmap:
pil_image = image.get_bitmap(render=args.render).to_pil()
pil_image.save(f"{prefix}.{args.format}")
else:
image.extract(prefix, fb_format=args.format, fb_render=args.render)
except pdfium.PdfiumError:
traceback.print_exc()
image.close()
@@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
# TODO? consider dotted access
from pypdfium2._cli._parsers import add_input, get_input
EXTRACT_RANGE = "range"
EXTRACT_BOUNDED = "bounded"
def attach(parser):
add_input(parser, pages=True)
parser.add_argument(
"--strategy",
default = EXTRACT_RANGE,
choices = (EXTRACT_RANGE, EXTRACT_BOUNDED),
help = "PDFium text extraction strategy (range, bounded).",
)
def main(args):
pdf = get_input(args)
sep = ""
for i in args.pages:
page = pdf[i]
textpage = page.get_textpage()
# TODO let caller pass in possible range/boundary parameters
if args.strategy == EXTRACT_RANGE:
text = textpage.get_text_range(force_this=True)
elif args.strategy == EXTRACT_BOUNDED:
text = textpage.get_text_bounded()
else:
assert False
print(sep + f"# Page {i+1}\n" + text)
sep = "\n"
@@ -0,0 +1,69 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
# TODO test-cover converting non-jpeg format
from pathlib import Path
import pypdfium2._helpers as pdfium
try:
import PIL.Image
except ImportError:
PIL = None
def attach(parser):
parser.add_argument(
"images",
nargs = "+",
help = "Input images",
type = Path,
)
parser.add_argument(
"--output", "-o",
required = True,
type = Path,
help = "Target path for the new PDF"
)
parser.add_argument(
"--inline",
action = "store_true",
help = "If JPEG, whether to use PDFium's inline loading function."
)
def main(args):
# Rudimentary image to PDF conversion (testing / proof of concept)
# Due to limitations in PDFium's public API, this function may be inefficient/lossy for non-JPEG input.
# The technically best available open-source tool for image to PDF conversion is probably img2pdf (although its code style can be regarded as displeasing).
# Development note: We are closing objects explicitly because loading JPEGs non-inline binds file handles to the PDF, which need to be released as soon as possible. Without this, we have already run into "OSError: Too many open files" while testing.
pdf = pdfium.PdfDocument.new()
for fp in args.images:
image_obj = pdfium.PdfImage.new(pdf)
# Simple check whether the file is a JPEG image - a better implementation could use mimetypes, python-magic, or PIL
if fp.suffix.lower() in (".jpg", ".jpeg"):
image_obj.load_jpeg(fp, inline=args.inline)
else:
pil_image = PIL.Image.open(fp)
bitmap = pdfium.PdfBitmap.from_pil(pil_image)
pil_image.close()
image_obj.set_bitmap(bitmap)
bitmap.close()
w, h = image_obj.get_size()
image_obj.set_matrix( pdfium.PdfMatrix().scale(w, h) )
page = pdf.new_page(w, h)
page.insert_obj(image_obj)
page.gen_content()
image_obj.close() # no-op
page.close()
pdf.save(args.output)
pdf.close()
@@ -0,0 +1,109 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
# TODO test-confirm filter and info params
from enum import Enum
import pypdfium2._helpers as pdfium
import pypdfium2.raw as pdfium_c
import pypdfium2.internal as pdfium_i
# TODO? consider dotted access
from pypdfium2._cli._parsers import (
add_input,
add_n_digits,
get_input,
round_list,
)
class InfoParams (Enum):
pos = 0
imageinfo = 1
def attach(parser):
add_input(parser, pages=True)
add_n_digits(parser)
# TODO think out strategy for choices (see https://github.com/python/cpython/issues/69247)
obj_types = list( pdfium_i.ObjectTypeToConst.keys() )
parser.add_argument(
"--filter",
nargs = "+",
metavar = "T",
choices = obj_types,
help = f"Object types to include. Choices: {obj_types}",
)
parser.add_argument(
"--max-depth",
type = int,
default = 2,
help = "Maximum recursion depth to consider when descending into Form XObjects.",
)
parser.add_argument(
"--info",
nargs = "*",
type = lambda s: InfoParams[s.lower()],
default = (InfoParams.pos, InfoParams.imageinfo),
help = "Object details to show (pos, imageinfo).",
)
def print_img_metadata(metadata, pad=""):
for attr in pdfium_c.FPDF_IMAGEOBJ_METADATA.__slots__:
value = getattr(metadata, attr)
if attr == "colorspace":
value = pdfium_i.ColorspaceToStr.get(value)
elif attr == "marked_content_id" and value == -1:
continue
print(pad + f"{attr}: {value}\n", end="")
def main(args):
pdf = get_input(args)
# if no filter is given, leave it at None (make a difference in case of unhandled object types)
if args.filter:
args.filter = [pdfium_i.ObjectTypeToConst[t] for t in args.filter]
show_pos = (InfoParams.pos in args.info)
show_imageinfo = (InfoParams.imageinfo in args.info)
total_count = 0
for i in args.pages:
page = pdf[i]
obj_searcher = page.get_objects(
filter = args.filter,
max_depth = args.max_depth,
)
preamble = f"# Page {i+1}\n"
count = 0
for obj in obj_searcher:
pad_0 = " " * obj.level
pad_1 = pad_0 + " "
print(preamble + pad_0 + pdfium_i.ObjectTypeToStr.get(obj.type))
if show_pos:
pos = round_list(obj.get_pos(), args.n_digits)
print(pad_1 + f"Position: {pos}")
# TODO? also call get_size() for coverage
if show_imageinfo and isinstance(obj, pdfium.PdfImage):
print(pad_1 + f"Filters: {obj.get_filters()}")
metadata = obj.get_metadata()
print_img_metadata(metadata, pad=pad_1)
count += 1
preamble = ""
if count > 0:
print(f"-> Count: {count}\n")
total_count += count
if total_count > 0:
print(f"-> Total count: {total_count}")
@@ -0,0 +1,59 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import pypdfium2.raw as pdfium_c
import pypdfium2.internal as pdfium_i
# TODO? consider dotted access
from pypdfium2._cli._parsers import (
add_input,
add_n_digits,
get_input,
round_list,
)
def attach(parser):
add_input(parser)
add_n_digits(parser)
def main(args):
pdf = get_input(args)
print(f"Page Count: {len(pdf)}")
print(f"PDF Version: {pdf.get_version() / 10}")
id_permanent = pdf.get_identifier(pdfium_c.FILEIDTYPE_PERMANENT)
id_changing = pdf.get_identifier(pdfium_c.FILEIDTYPE_CHANGING)
print(f"ID (permanent): {id_permanent}")
print(f"ID (changing): {id_changing}")
print(f"ID match? - {id_permanent == id_changing}")
print(f"Tagged? - {pdf.is_tagged()}")
pagemode = pdf.get_pagemode()
if pagemode != pdfium_c.PAGEMODE_USENONE:
print(f"Page Mode: {pdfium_i.PageModeToStr.get(pagemode)}")
formtype = pdf.get_formtype()
if formtype != pdfium_c.FORMTYPE_NONE:
print(f"Form Type: {pdfium_i.FormTypeToStr.get(formtype)}")
metadata = pdf.get_metadata_dict(skip_empty=True)
if len(metadata) > 0:
print("Metadata:")
for key, value in metadata.items():
print(f" {key}: {value}")
for i in args.pages:
print(f"\n# Page {i+1}")
page = pdf[i]
print(f"Size: {round_list(page.get_size(), args.n_digits)}")
print(f"Rotation: {page.get_rotation()}")
print(f"Bounding Box: {round_list(page.get_bbox(), args.n_digits)}")
for box_name in ("media", "crop", "bleed", "trim", "art"):
box = getattr(page, f"get_{box_name.lower()}box")(fallback_ok=False)
if box:
print(f"{box_name.capitalize()}Box: {round_list(box, args.n_digits)}")
@@ -0,0 +1,379 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import os
import math
import logging
import functools
from pathlib import Path
import multiprocessing as mp
import concurrent.futures as ft
try:
import cv2
except ImportError:
cv2 = None
import pypdfium2._helpers as pdfium
import pypdfium2.internal as pdfium_i
import pypdfium2.raw as pdfium_r
# TODO? consider dotted access
from pypdfium2._cli._parsers import (
add_input, get_input,
setup_logging,
BooleanOptionalAction,
)
logger = logging.getLogger(__name__)
def _bitmap_wrapper_foreign_simple(width, height, format, *args, **kwargs):
if format == pdfium_r.FPDFBitmap_BGRx:
use_alpha = False
elif format == pdfium_r.FPDFBitmap_BGRA:
use_alpha = True
else:
raise RuntimeError(f"Cannot create foreign_simple bitmap with bitmap type {pdfium_i.BitmapTypeToStr[format]}.")
return pdfium.PdfBitmap.new_foreign_simple(width, height, use_alpha, *args, **kwargs)
BitmapMakers = dict(
native = pdfium.PdfBitmap.new_native,
foreign = pdfium.PdfBitmap.new_foreign,
foreign_packed = functools.partial(pdfium.PdfBitmap.new_foreign, force_packed=True),
foreign_simple = _bitmap_wrapper_foreign_simple,
)
CsFields = ("path_fill", "path_stroke", "text_fill", "text_stroke")
ColorOpts = dict(metavar="C", nargs=4, type=int)
SampleTheme = dict(
# TODO improve colors - currently it's just some random ones to distinguish the different drawings
path_fill = (170, 100, 0, 255), # dark orange
path_stroke = (0, 150, 255, 255), # sky blue
text_fill = (255, 255, 255, 255), # white
text_stroke = (150, 255, 0, 255), # green
)
def attach(parser):
add_input(parser, pages=True)
parser.add_argument(
"--output", "-o",
type = Path,
required = True,
help = "Output directory where the serially numbered images shall be placed.",
)
parser.add_argument(
"--prefix",
help = "Custom prefix for the images. Defaults to the input filename's stem.",
)
parser.add_argument(
"--format", "-f",
default = "jpg",
type = str.lower,
help = "The image format to use.",
)
parser.add_argument(
"--engine",
dest = "engine_cls",
type = lambda k: {"pil": PILEngine, "numpy+cv2": NumpyCV2Engine}[k.lower()],
help = "The saver engine to use (pil, numpy+cv2)",
)
parser.add_argument(
"--scale",
default = 1,
type = float,
help = "Define the resolution of the output images. By default, one PDF point (1/72in) is rendered to 1x1 pixel. This factor scales the number of pixels that represent one point.",
)
parser.add_argument(
"--rotation",
default = 0,
type = int,
choices = (0, 90, 180, 270),
help = "Rotate pages by 90, 180 or 270 degrees.",
)
parser.add_argument(
"--fill-color",
help = "Color the bitmap will be filled with before rendering. It shall be given in RGBA format as a sequence of integers ranging from 0 to 255. Defaults to white.",
**ColorOpts,
)
parser.add_argument(
"--optimize-mode",
choices = ("lcd", "print"),
help = "The rendering optimisation mode. None if not given.",
)
parser.add_argument(
"--crop",
nargs = 4,
type = float,
default = (0, 0, 0, 0),
help = "Amount to crop from (left, bottom, right, top).",
)
parser.add_argument(
"--draw-annots",
action = BooleanOptionalAction,
default = True,
help = "Whether annotations may be shown (default: true).",
)
parser.add_argument(
"--draw-forms",
action = BooleanOptionalAction,
default = True,
help = "Whether forms may be shown (default: true).",
)
parser.add_argument(
"--no-antialias",
nargs = "+",
default = [],
choices = ("text", "image", "path"),
type = str.lower,
help = "Item types that shall not be smoothed.",
)
parser.add_argument(
"--force-halftone",
action = "store_true",
help = "Always use halftone for image stretching.",
)
bitmap = parser.add_argument_group(
title = "Bitmap options",
description = "Bitmap config, including pixel format.",
)
bitmap.add_argument(
"--bitmap-maker",
choices = BitmapMakers.keys(),
default = "native",
help = "The bitmap maker to use.",
type = str.lower,
)
bitmap.add_argument(
"--grayscale",
action = "store_true",
help = "Whether to render in grayscale mode (no colors).",
)
bitmap.add_argument(
"--byteorder",
dest = "rev_byteorder",
type = lambda v: {"bgr": False, "rgb": True}[v.lower()],
help = "Whether to use BGR or RGB byteorder (default: conditional).",
)
bitmap.add_argument(
"--x-channel",
dest = "prefer_bgrx",
action = BooleanOptionalAction,
help = "Whether to prefer BGRx/RGBx over BGR/RGB (default: conditional).",
)
parallel = parser.add_argument_group(
title = "Parallelization",
description = "Options for rendering with multiple processes.",
)
parallel.add_argument(
"--linear",
nargs = "?",
type = int,
const = math.inf,
help = "Render non-parallel if page count is less or equal to the specified value (default is conditional). If this flag is given without a value, then render linear regardless of document length.",
)
parallel.add_argument(
"--processes",
default = os.cpu_count(),
type = int,
help = "The maximum number of parallel rendering processes. Defaults to the number of CPU cores.",
)
parallel.add_argument(
"--parallel-strategy",
choices = ("spawn", "forkserver", "fork"),
default = "spawn",
type = str.lower,
help = "The process start method to use. ('fork' is discouraged due to stability issues.)",
)
parallel.add_argument(
"--parallel-lib",
choices = ("mp", "ft"),
default = "mp",
type = str.lower,
help = "The parallelization module to use (mp = multiprocessing, ft = concurrent.futures).",
)
parallel.add_argument(
"--parallel-map",
type = str.lower,
help = "The map function to use (backend specific, the default is an iterative map)."
)
color_scheme = parser.add_argument_group(
title = "Forced color scheme",
description = "Options for using pdfium's forced color scheme renderer. Deprecated, considered not useful.",
)
color_scheme.add_argument(
"--sample-theme",
action = "store_true",
help = "Use a dark background sample theme as base. Explicit color params override selectively."
)
color_scheme.add_argument(
"--path-fill",
**ColorOpts
)
color_scheme.add_argument(
"--path-stroke",
**ColorOpts
)
color_scheme.add_argument(
"--text-fill",
**ColorOpts
)
color_scheme.add_argument(
"--text-stroke",
**ColorOpts
)
color_scheme.add_argument(
"--fill-to-stroke",
action = "store_true",
help = "Only draw borders around fill areas using the `path_stroke` color, instead of filling with the `path_fill` color.",
)
class SavingEngine:
def __init__(self, path_parts):
self._path_parts = path_parts
def _get_path(self, i):
output_dir, prefix, n_digits, format = self._path_parts
return output_dir / f"{prefix}{i+1:0{n_digits}d}.{format}"
def __call__(self, bitmap, i):
out_path = self._get_path(i)
self._saving_hook(out_path, bitmap)
logger.info(f"Wrote page {i+1} as {out_path.name}")
class PILEngine (SavingEngine):
def _saving_hook(self, out_path, bitmap):
bitmap.to_pil().save(out_path)
class NumpyCV2Engine (SavingEngine):
def _saving_hook(self, out_path, bitmap):
cv2.imwrite(str(out_path), bitmap.to_numpy())
def _render_parallel_init(extra_init, input, password, may_init_forms, kwargs, engine):
if extra_init:
extra_init()
logger.info(f"Initializing data for process {os.getpid()}")
pdf = pdfium.PdfDocument(input, password=password, autoclose=True)
if may_init_forms:
pdf.init_forms()
global ProcObjs
ProcObjs = (pdf, kwargs, engine)
def _render_job(i, pdf, kwargs, engine):
# logger.info(f"Started page {i+1} ...")
page = pdf[i]
bitmap = page.render(**kwargs)
engine(bitmap, i)
def _render_parallel_job(i):
global ProcObjs; _render_job(i, *ProcObjs)
def main(args):
# TODO turn into a python-usable API yielding output paths as they are written
pdf = get_input(args, init_forms=args.draw_forms)
# TODO move to parsers?
pdf_len = len(pdf)
if not all(0 <= i < pdf_len for i in args.pages):
raise ValueError("Out-of-bounds page indices are prohibited.")
if len(args.pages) != len(set(args.pages)):
raise ValueError("Duplicate page indices are prohibited.")
if args.prefix is None:
args.prefix = f"{args.input.stem}_"
if args.fill_color is None:
args.fill_color = (0, 0, 0, 255) if args.sample_theme else (255, 255, 255, 255)
if args.linear is None:
args.linear = 6 if args.format == "jpg" else 3
# numpy+cv2 is much faster for PNG, and PIL faster for JPG, but this might simply be due to different encoding defaults
if args.engine_cls is None:
if cv2 != None and args.format == "png":
args.engine_cls = NumpyCV2Engine
else:
args.engine_cls = PILEngine
# PIL is faster with rev_byteorder and prefer_bgrx = True, as this achieves a natively supported pixel format. For numpy+cv2 there doesn't seem to be a difference.
if args.rev_byteorder is None:
args.rev_byteorder = args.engine_cls is PILEngine
if args.prefer_bgrx is None:
# PIL can't save BGRX as PNG
args.prefer_bgrx = args.engine_cls is PILEngine and args.format != "png"
cs_kwargs = dict()
if args.sample_theme:
cs_kwargs.update(**SampleTheme)
cs_kwargs.update(**{f: getattr(args, f) for f in CsFields if getattr(args, f)})
cs = pdfium.PdfColorScheme(**cs_kwargs) if len(cs_kwargs) > 0 else None
kwargs = dict(
scale = args.scale,
rotation = args.rotation,
crop = args.crop,
grayscale = args.grayscale,
fill_color = args.fill_color,
color_scheme = cs,
fill_to_stroke = args.fill_to_stroke,
optimize_mode = args.optimize_mode,
draw_annots = args.draw_annots,
may_draw_forms = args.draw_forms,
force_halftone = args.force_halftone,
rev_byteorder = args.rev_byteorder,
prefer_bgrx = args.prefer_bgrx,
bitmap_maker = BitmapMakers[args.bitmap_maker],
)
for type in args.no_antialias:
kwargs[f"no_smooth{type}"] = True
# TODO dump all args except password?
logger.info(f"{args.engine_cls.__name__}, Format: {args.format}, rev_byteorder: {args.rev_byteorder}, prefer_bgrx {args.prefer_bgrx}")
n_digits = len(str(pdf_len))
path_parts = (args.output, args.prefix, n_digits, args.format)
engine = args.engine_cls(path_parts)
if len(args.pages) <= args.linear:
logger.info("Linear rendering ...")
for i in args.pages:
_render_job(i, pdf, kwargs, engine)
else:
logger.info("Parallel rendering ...")
ctx = mp.get_context(args.parallel_strategy)
# TODO unify using mp.pool.Pool(context=...) ?
pool_backends = dict(
mp = (ctx.Pool, "imap"),
ft = (functools.partial(ft.ProcessPoolExecutor, mp_context=ctx), "map"),
)
pool_ctor, map_attr = pool_backends[args.parallel_lib]
if args.parallel_map:
map_attr = args.parallel_map
extra_init = (setup_logging if args.parallel_strategy in ("spawn", "forkserver") else None)
pool_kwargs = dict(
initializer = _render_parallel_init,
initargs = (extra_init, pdf._input, args.password, args.draw_forms, kwargs, engine),
)
n_procs = min(args.processes, len(args.pages))
with pool_ctor(n_procs, **pool_kwargs) as pool:
map_func = getattr(pool, map_attr)
for _ in map_func(_render_parallel_job, args.pages):
pass
@@ -0,0 +1,84 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
from enum import Enum
from pathlib import Path
import pypdfium2.raw as pdfium_c
import pypdfium2._helpers as pdfium
# TODO? consider dotted access
from pypdfium2._cli._parsers import add_input, get_input
class Units (Enum):
PT = 0
MM = 1
CM = 2
IN = 3
def units_to_pt(value, unit):
if unit is Units.PT:
return value
elif unit is Units.IN:
return value*72
elif unit is Units.CM:
return (value*72) / 2.54
elif unit is Units.MM:
return (value*72) / 25.4
else:
raise ValueError(f"Invalid unit type {unit}")
def attach(parser):
add_input(parser, pages=False)
parser.add_argument(
"--output", "-o",
required = True,
type = Path,
help = "Target path for the new document",
)
parser.add_argument(
"--rows", "-r",
type = int,
required = True,
help = "Number of rows (horizontal tiles)",
)
parser.add_argument(
"--cols", "-c",
type = int,
required = True,
help = "Number of columns (vertical tiles)",
)
# NOTE no short aliases for width and height since -h would confilct with argparse help
parser.add_argument(
"--width",
type = float,
required = True,
help = "Target width",
)
parser.add_argument(
"--height",
type = float,
required = True,
help = "Target height",
)
parser.add_argument(
"--unit", "-u",
default = Units.MM,
type = lambda string: Units[string.upper()],
help = "Unit for target width and height (pt, mm, cm, in)",
)
def main(args):
# Rudimentary page tiling, powered by pdfium
# A more sophisticated implementation could place XObjects rather than using PDFium's helper function, support merging and arranging on the fly, etc.
w = units_to_pt(args.width, args.unit)
h = units_to_pt(args.height, args.unit)
src_pdf = get_input(args)
raw_dest = pdfium_c.FPDF_ImportNPagesToOne(src_pdf, w, h, args.cols, args.rows)
dest_pdf = pdfium.PdfDocument(raw_dest)
dest_pdf.save(args.output)
@@ -0,0 +1,42 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import pypdfium2.internal as pdfium_i
# TODO? consider dotted access
from pypdfium2._cli._parsers import (
add_input,
add_n_digits,
get_input,
round_list,
)
def attach(parser):
add_input(parser, pages=False)
add_n_digits(parser)
parser.add_argument(
"--max-depth",
type = int,
default = 15,
help = "Maximum recursion depth to consider when parsing the table of contents",
)
def main(args):
pdf = get_input(args)
toc = pdf.get_toc(
max_depth = args.max_depth,
)
for item in toc:
state = "*" if item.n_kids == 0 else "-" if item.is_closed else "+"
target = "?" if item.page_index is None else item.page_index+1
print(
" " * item.level +
"[%s] %s -> %s # %s %s" % (
state, item.title, target,
pdfium_i.ViewmodeToStr.get(item.view_mode),
round_list(item.view_pos, args.n_digits),
)
)
@@ -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)}")
@@ -0,0 +1,44 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import atexit
import os, sys
import pypdfium2.raw as pdfium_c
import pypdfium2.internal as pdfium_i
def init_lib():
assert not pdfium_i.LIBRARY_AVAILABLE
if pdfium_i.DEBUG_AUTOCLOSE:
print("Initialize PDFium (auto)", file=sys.stderr)
# PDFium init API may change in the future: https://crbug.com/pdfium/1446
# NOTE Technically, FPDF_InitLibrary() would be sufficient for our purposes, but since it's formally marked for deprecation, don't use it to be on the safe side. Also, avoid experimental config versions that might not be promoted to stable.
config = pdfium_c.FPDF_LIBRARY_CONFIG(
version = 2,
m_pUserFontPaths = None,
m_pIsolate = None,
m_v8EmbedderSlot = 0,
# m_pPlatform = None, # v3
# m_RendererType = pdfium_c.FPDF_RENDERERTYPE_AGG, # v4
)
pdfium_c.FPDF_InitLibraryWithConfig(config)
pdfium_i.LIBRARY_AVAILABLE.value = True
def destroy_lib():
assert pdfium_i.LIBRARY_AVAILABLE
if pdfium_i.DEBUG_AUTOCLOSE:
# use os.write() rather than print() to avoid "reentrant call" exceptions on shutdown (see https://stackoverflow.com/q/75367828/15547292)
os.write(sys.stderr.fileno(), b"Destroy PDFium (auto)\n")
pdfium_c.FPDF_DestroyLibrary()
pdfium_i.LIBRARY_AVAILABLE.value = False
# Load pdfium
init_lib()
# Register an exit handler that will free pdfium
# Trust in Python to call exit handlers only after all objects have been finalized
atexit.register(destroy_lib)
@@ -0,0 +1,6 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
from pypdfium2.internal.bases import *
from pypdfium2.internal.consts import *
from pypdfium2.internal.utils import *
@@ -0,0 +1,108 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
__all__ = ("AutoCastable", "AutoCloseable", "DEBUG_AUTOCLOSE", "LIBRARY_AVAILABLE")
import os
import sys
import ctypes
import weakref
import logging
import uuid
logger = logging.getLogger(__name__)
# mutable bools
DEBUG_AUTOCLOSE = ctypes.c_bool(False)
LIBRARY_AVAILABLE = ctypes.c_bool(False) # set to true on library init
STATE_INVALID = -1
STATE_AUTO = 0
STATE_EXPLICIT = 1
STATE_BYPARENT = 2
class AutoCastable:
@property
def _as_parameter_(self):
return self.raw
def _close_template(close_func, raw, obj_repr, state, parent, *args, **kwargs):
if DEBUG_AUTOCLOSE:
desc = {STATE_AUTO: "auto", STATE_EXPLICIT: "explicit", STATE_BYPARENT: "by parent"}[state.value]
# use os.write() rather than print() to avoid "reentrant call" exceptions on shutdown (see https://stackoverflow.com/q/75367828/15547292)
os.write(sys.stderr.fileno(), f"Close ({desc}) {obj_repr}\n".encode())
if not LIBRARY_AVAILABLE:
os.write(sys.stderr.fileno(), f"-> Cannot close object, library is destroyed. This may cause a memory leak!\n".encode())
return
assert (parent is None) or not parent._tree_closed()
close_func(raw, *args, **kwargs)
class AutoCloseable (AutoCastable):
def __init__(self, close_func, *args, obj=None, needs_free=True, **kwargs):
# NOTE proactively prevent accidental double initialization
assert not hasattr(self, "_finalizer")
self._close_func = close_func
self._obj = self if obj is None else obj
self._uuid = uuid.uuid4()
self._ex_args = args
self._ex_kwargs = kwargs
self._autoclose_state = ctypes.c_int8(STATE_AUTO) # mutable int
self._finalizer = None
self._kids = []
if needs_free:
self._attach_finalizer()
def __repr__(self):
return f"<{type(self).__name__} uuid:{str(self._uuid)[:8]}>"
def _attach_finalizer(self):
# NOTE this function captures the value of the `parent` property at finalizer installation time - if it changes, detach the old finalizer and create a new one
assert self._finalizer is None
self._finalizer = weakref.finalize(self._obj, _close_template, self._close_func, self.raw, repr(self), self._autoclose_state, self.parent, *self._ex_args, **self._ex_kwargs)
def _detach_finalizer(self):
self._finalizer.detach()
self._finalizer = None
def _tree_closed(self):
if self.raw is None:
return True
if (self.parent is not None) and self.parent._tree_closed():
return True
return False
def _add_kid(self, k):
self._kids.append( weakref.ref(k) )
def close(self, _by_parent=False):
if not self.raw or not self._finalizer:
return False
for k_ref in self._kids:
k = k_ref()
if k and k.raw:
k.close(_by_parent=True)
self._autoclose_state.value = STATE_BYPARENT if _by_parent else STATE_EXPLICIT
self._finalizer()
self._autoclose_state.value = STATE_INVALID
self.raw = None
self._finalizer = None
self._kids.clear()
return True
@@ -0,0 +1,154 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import pypdfium2.raw as pdfium_c
from pypdfium2.version import PDFIUM_INFO
class _fallback_dict (dict):
def get(self, key, default_prefix="Unhandled constant"):
return dict.get(self, key, f"{default_prefix} {key}")
#: Convert a rotation value in degrees to a PDFium constant.
RotationToConst = {
0: 0,
90: 1,
180: 2,
270: 3,
}
#: Convert a PDFium rotation constant to a value in degrees. Inversion of :data:`.RotationToConst`.
RotationToDegrees = {v: k for k, v in RotationToConst.items()}
#: Get the number of channels for a PDFium bitmap format. (:attr:`FPDFBitmap_Unknown` is deliberately not handled.)
BitmapTypeToNChannels = {
pdfium_c.FPDFBitmap_Gray: 1,
pdfium_c.FPDFBitmap_BGR: 3,
pdfium_c.FPDFBitmap_BGRA: 4,
pdfium_c.FPDFBitmap_BGRx: 4,
}
#: Convert a PDFium bitmap format to string, assuming BGR byte order. (:attr:`FPDFBitmap_Unknown` is deliberately not handled.)
BitmapTypeToStr = {
pdfium_c.FPDFBitmap_Gray: "L",
pdfium_c.FPDFBitmap_BGR: "BGR",
pdfium_c.FPDFBitmap_BGRA: "BGRA",
pdfium_c.FPDFBitmap_BGRx: "BGRX",
}
#: Convert a PDFium bitmap format to string, assuming RGB byte order. (:attr:`FPDFBitmap_Unknown` is deliberately not handled.)
BitmapTypeToStrReverse = {
pdfium_c.FPDFBitmap_Gray: "L",
pdfium_c.FPDFBitmap_BGR: "RGB",
pdfium_c.FPDFBitmap_BGRA: "RGBA",
pdfium_c.FPDFBitmap_BGRx: "RGBX",
}
#: Convert a string to PDFium bitmap format, assuming BGR byte order. Inversion of :data:`BitmapTypeToStr`.
BitmapStrToConst = {v: k for k, v in BitmapTypeToStr.items()}
#: Convert a string to PDFium bitmap format, assuming RGB byte order. Inversion of :data:`BitmapTypeToStrReverse`.
BitmapStrReverseToConst = {v: k for k, v in BitmapTypeToStrReverse.items()}
#: Convert a PDFium form type (:attr:`FORMTYPE_*`) to string.
FormTypeToStr = _fallback_dict({
pdfium_c.FORMTYPE_NONE: "None",
pdfium_c.FORMTYPE_ACRO_FORM: "AcroForm",
pdfium_c.FORMTYPE_XFA_FULL: "XFA",
pdfium_c.FORMTYPE_XFA_FOREGROUND: "XFAF",
})
#: Convert a PDFium color space constant (:attr:`FPDF_COLORSPACE_*`) to string.
ColorspaceToStr = _fallback_dict({
pdfium_c.FPDF_COLORSPACE_UNKNOWN: "?",
pdfium_c.FPDF_COLORSPACE_DEVICEGRAY: "DeviceGray",
pdfium_c.FPDF_COLORSPACE_DEVICERGB: "DeviceRGB",
pdfium_c.FPDF_COLORSPACE_DEVICECMYK: "DeviceCMYK",
pdfium_c.FPDF_COLORSPACE_CALGRAY: "CalGray",
pdfium_c.FPDF_COLORSPACE_CALRGB: "CalRGB",
pdfium_c.FPDF_COLORSPACE_LAB: "Lab",
pdfium_c.FPDF_COLORSPACE_ICCBASED: "ICCBased",
pdfium_c.FPDF_COLORSPACE_SEPARATION: "Separation",
pdfium_c.FPDF_COLORSPACE_DEVICEN: "DeviceN",
pdfium_c.FPDF_COLORSPACE_INDEXED: "Indexed", # i. e. palettized
pdfium_c.FPDF_COLORSPACE_PATTERN: "Pattern",
})
#: Convert a PDFium view mode constant (:attr:`PDFDEST_VIEW_*`) to string.
ViewmodeToStr = _fallback_dict({
pdfium_c.PDFDEST_VIEW_UNKNOWN_MODE: "?",
pdfium_c.PDFDEST_VIEW_XYZ: "XYZ",
pdfium_c.PDFDEST_VIEW_FIT: "Fit",
pdfium_c.PDFDEST_VIEW_FITH: "FitH",
pdfium_c.PDFDEST_VIEW_FITV: "FitV",
pdfium_c.PDFDEST_VIEW_FITR: "FitR",
pdfium_c.PDFDEST_VIEW_FITB: "FitB",
pdfium_c.PDFDEST_VIEW_FITBH: "FitBH",
pdfium_c.PDFDEST_VIEW_FITBV: "FitBV",
})
#: Convert a PDFium object type constant (:attr:`FPDF_PAGEOBJ_*`) to string.
ObjectTypeToStr = _fallback_dict({
pdfium_c.FPDF_PAGEOBJ_UNKNOWN: "?",
pdfium_c.FPDF_PAGEOBJ_TEXT: "text",
pdfium_c.FPDF_PAGEOBJ_PATH: "path",
pdfium_c.FPDF_PAGEOBJ_IMAGE: "image",
pdfium_c.FPDF_PAGEOBJ_SHADING: "shading",
pdfium_c.FPDF_PAGEOBJ_FORM: "form",
})
#: Convert an object type string to a PDFium constant. Inversion of :data:`.ObjectTypeToStr`.
ObjectTypeToConst = {v: k for k, v in ObjectTypeToStr.items()}
#: Convert a PDFium page mode constant (:attr:`PAGEMODE_*`) to string.
PageModeToStr = _fallback_dict({
pdfium_c.PAGEMODE_UNKNOWN: "?",
pdfium_c.PAGEMODE_USENONE: "None",
pdfium_c.PAGEMODE_USEOUTLINES: "Outline",
pdfium_c.PAGEMODE_USETHUMBS: "Thumbnails",
pdfium_c.PAGEMODE_FULLSCREEN: "Full-screen",
pdfium_c.PAGEMODE_USEOC: "Layers",
pdfium_c.PAGEMODE_USEATTACHMENTS: "Attachments",
})
#: Convert a PDFium error constant (:attr:`FPDF_ERR_*`) to string.
ErrorToStr = _fallback_dict({
pdfium_c.FPDF_ERR_SUCCESS: "Success",
pdfium_c.FPDF_ERR_UNKNOWN: "Unknown error",
pdfium_c.FPDF_ERR_FILE: "File access error",
pdfium_c.FPDF_ERR_FORMAT: "Data format error",
pdfium_c.FPDF_ERR_PASSWORD: "Incorrect password error",
pdfium_c.FPDF_ERR_SECURITY: "Unsupported security scheme error",
pdfium_c.FPDF_ERR_PAGE: "Page not found or content error",
})
# known implication: causes eager evaluation of pdfium version
if "XFA" in PDFIUM_INFO.flags:
#: [V8/XFA builds only] Convert a PDFium XFA error constant (:attr:`FPDF_ERR_XFA*`) to string.
XFAErrorToStr = _fallback_dict({
pdfium_c.FPDF_ERR_XFALOAD: "Load error",
pdfium_c.FPDF_ERR_XFALAYOUT: "Layout error",
})
#: Convert a PDFium unsupported constant (:attr:`FPDF_UNSP_*`) to string.
UnsupportedInfoToStr = _fallback_dict({
pdfium_c.FPDF_UNSP_DOC_XFAFORM: "XFA form",
pdfium_c.FPDF_UNSP_DOC_PORTABLECOLLECTION: "Portable collection",
pdfium_c.FPDF_UNSP_DOC_ATTACHMENT: "Attachment (incomplete support)", # https://crbug.com/pdfium/1945
pdfium_c.FPDF_UNSP_DOC_SECURITY: "Security",
pdfium_c.FPDF_UNSP_DOC_SHAREDREVIEW: "Shared review",
pdfium_c.FPDF_UNSP_DOC_SHAREDFORM_ACROBAT: "Shared form (acrobat)",
pdfium_c.FPDF_UNSP_DOC_SHAREDFORM_FILESYSTEM: "Shared form (filesystem)",
pdfium_c.FPDF_UNSP_DOC_SHAREDFORM_EMAIL: "Shared form (email)",
pdfium_c.FPDF_UNSP_ANNOT_3DANNOT: "3D annotation",
pdfium_c.FPDF_UNSP_ANNOT_MOVIE: "Movie annotation",
pdfium_c.FPDF_UNSP_ANNOT_SOUND: "Sound annotation",
pdfium_c.FPDF_UNSP_ANNOT_SCREEN_MEDIA: "Screen media annotation",
pdfium_c.FPDF_UNSP_ANNOT_SCREEN_RICHMEDIA: "Screen rich media annotation",
pdfium_c.FPDF_UNSP_ANNOT_ATTACHMENT: "Attachment annotation",
pdfium_c.FPDF_UNSP_ANNOT_SIG: "Signature annotation",
})
@@ -0,0 +1,91 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import ctypes
import pypdfium2.raw as pdfium_c
def color_tohex(color, rev_byteorder):
if len(color) != 4:
raise ValueError("Color must consist of exactly 4 values.")
if not all(0 <= c <= 255 for c in color):
raise ValueError("Color value exceeds boundaries.")
# different color interpretation with FPDF_REVERSE_BYTE_ORDER might be a bug? at least it's not documented.
r, g, b, a = color
channels = (a, b, g, r) if rev_byteorder else (a, r, g, b)
c_color = 0
shift = 24
for c in channels:
c_color |= c << shift
shift -= 8
return c_color
def set_callback(struct, fname, callback):
setattr(struct, fname, type( getattr(struct, fname) )(callback))
def is_buffer(buf, spec="r"):
methods = []
assert set(spec).issubset( set("rw") )
if "r" in spec:
methods += ["seek", "tell", "read", "readinto"]
if "w" in spec:
methods += ["write"]
return all(callable(getattr(buf, a, None)) for a in methods)
class _buffer_reader:
def __init__(self, buffer):
self.buffer = buffer
def __call__(self, _, position, p_buf, size):
c_buf = ctypes.cast(p_buf, ctypes.POINTER(ctypes.c_char * size))
self.buffer.seek(position)
self.buffer.readinto(c_buf.contents)
return 1
class _buffer_writer:
def __init__(self, buffer):
self.buffer = buffer
def __call__(self, _, data, size):
block = ctypes.cast(data, ctypes.POINTER(ctypes.c_ubyte * size))
self.buffer.write(block.contents)
return 1
def get_bufreader(buffer):
file_len = buffer.seek(0, 2)
buffer.seek(0)
reader = pdfium_c.FPDF_FILEACCESS()
reader.m_FileLen = file_len
set_callback(reader, "m_GetBlock", _buffer_reader(buffer))
reader.m_Param = None
to_hold = (reader.m_GetBlock, )
return reader, to_hold
def get_bufwriter(buffer):
writer = pdfium_c.FPDF_FILEWRITE(version=1)
set_callback(writer, "WriteBlock", _buffer_writer(buffer))
return writer
def pages_c_array(pages):
if not pages:
return None, 0
count = len(pages)
c_array = (pdfium_c.FPDF_PAGE * count)(*[p.raw for p in pages])
return c_array, count
@@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
# platform files are managed in an own pypdfium2_raw module, cleanly separating raw bindings and helpers
from pypdfium2_raw.bindings import *
@@ -0,0 +1,11 @@
{
"major": 4,
"minor": 30,
"patch": 1,
"beta": null,
"n_commits": 0,
"hash": null,
"dirty": false,
"data_source": "git",
"is_editable": false
}
@@ -0,0 +1,222 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
__all__ = []
import sys
import json
import functools
from pathlib import Path
from types import MappingProxyType
import pypdfium2_raw
# TODO move to shared compat file
if sys.version_info < (3, 8):
def cached_property(func):
return property( functools.lru_cache(maxsize=1)(func) )
else:
cached_property = functools.cached_property
class _abc_version:
@cached_property
def _data(self):
with open(self._FILE, "r") as buf:
data = json.load(buf)
self._process_data(data)
return MappingProxyType(data)
def _process_data(self, data):
pass
def __getattr__(self, attr):
return self._data[attr]
def __setattr__(self, name, value):
raise AttributeError(f"Version class is immutable - assignment '{name} = {value}' not allowed")
def __repr__(self):
return self.version
@cached_property
def api_tag(self):
return tuple(self._data[k] for k in self._TAG_FIELDS)
def _craft_tag(self):
return ".".join(str(v) for v in self.api_tag)
def _craft_desc(self, extra=[]):
local_ver = []
if self.n_commits > 0:
local_ver += [str(self.n_commits), str(self.hash)]
local_ver += extra
desc = ""
if local_ver:
desc += "+" + ".".join(local_ver)
return desc
@cached_property
def version(self):
return self.tag + self.desc
class _version_pypdfium2 (_abc_version):
_FILE = Path(__file__).parent / "version.json"
_TAG_FIELDS = ("major", "minor", "patch")
@cached_property
def tag(self):
tag = self._craft_tag()
if self.beta is not None:
tag += f"b{self.beta}"
return tag
@cached_property
def desc(self):
extra = []
if self.dirty:
extra += ["dirty"]
desc = self._craft_desc(extra)
if self.data_source != "git":
desc += f":{self.data_source}"
if self.is_editable:
desc += "@editable"
return desc
class _version_pdfium (_abc_version):
_FILE = Path(pypdfium2_raw.__file__).parent / "version.json"
_TAG_FIELDS = ("major", "minor", "build", "patch")
def _process_data(self, data):
data["flags"] = tuple(data["flags"])
@cached_property
def tag(self):
return self._craft_tag()
@cached_property
def desc(self):
desc = self._craft_desc()
if self.flags:
desc += ":{%s}" % ",".join(self.flags)
if self.origin != "pdfium-binaries":
desc += f"@{self.origin}"
return desc
# TODO(future) add bindings info (e.g. ctypesgen version, reference/generated, runtime libdirs)
# Current API
PYPDFIUM_INFO = _version_pypdfium2()
PDFIUM_INFO = _version_pdfium()
__all__ += ["PYPDFIUM_INFO", "PDFIUM_INFO"]
# -----
# Deprecated API, to be removed with v5
# Known issue: causes eager evaluation of the new API's theoretically deferred properties.
V_PYPDFIUM2 = PYPDFIUM_INFO.version
V_LIBPDFIUM = str(PDFIUM_INFO.build)
V_BUILDNAME = PDFIUM_INFO.origin
V_PDFIUM_IS_V8 = "V8" in PDFIUM_INFO.flags # implies XFA
V_LIBPDFIUM_FULL = PDFIUM_INFO.version
__all__ += ["V_PYPDFIUM2", "V_LIBPDFIUM", "V_LIBPDFIUM_FULL", "V_BUILDNAME", "V_PDFIUM_IS_V8"]
# -----
# Docs
PYPDFIUM_INFO = PYPDFIUM_INFO
"""
pypdfium2 helpers version.
It is suggesed to compare against *api_tag* and possibly also *beta* (see below).
Parameters:
version (str):
Joined tag and desc, forming the full version.
tag (str):
Version ciphers joined as str, including possible beta. Corresponds to the latest release tag at install time.
desc (str):
Non-cipher descriptors represented as str.
api_tag (tuple[int]):
Version ciphers joined as tuple, excluding possible beta.
major (int):
Major cipher.
minor (int):
Minor cipher.
patch (int):
Patch cipher.
beta (int | None):
Beta cipher, or None if not a beta version.
n_commits (int):
Number of commits after tag at install time. 0 for release.
hash (str | None):
Hash of head commit if n_commits > 0, None otherwise.
dirty (bool):
True if there were uncommitted changes at install time, False otherwise.
data_source (str):
Source of this version info. Possible values:\n
- ``git``: Parsed from git describe. Always used if available. Highest accuracy.
- ``given``: Pre-supplied version file (e.g. packaged with sdist, or else created by caller).
- ``record``: Parsed from autorelease record. Implies that possible changes after tag are unknown.
is_editable (bool | None):
True for editable install, False otherwise. None if unknown.\n
If True, the version info is the one captured at install time. An arbitrary number of forward or reverse changes may have happened since. The actual current state is unknown.
"""
PDFIUM_INFO = PDFIUM_INFO
"""
PDFium version.
It is suggesed to compare against *build* (see below).
Parameters:
version (str):
Joined tag and desc, forming the full version.
tag (str):
Version ciphers joined as str.
desc (str):
Descriptors (origin, flags) represented as str.
api_tag (tuple[int]):
Version ciphers joined as tuple.
major (int):
Chromium major cipher.
minor (int):
Chromium minor cipher.
build (int):
Chromium/pdfium build cipher.
This value allows to uniquely identify the pdfium sources the binary was built from.
patch (int):
Chromium patch cipher.
n_commits (int):
Number of commits after tag at install time. 0 for tagged build commit.
hash (str | None):
Hash of head commit if n_commits > 0, None otherwise.
origin (str):
The pdfium binary's origin. Possible values:\n
- ``pdfium-binaries``: Compiled by bblanchon/pdfium-binaries, and bundled into pypdfium2.
- ``sourcebuild``: Provided by the caller (commonly compiled using pypdfium2's integrated build script), and bundled into pypdfium2.
- ``system``: Loaded from a standard system location using :func:`ctypes.util.find_library()`, or an explicit directory provided at setup time.
flags (tuple[str]):
Tuple of pdfium feature flags. Empty for default build. (V8, XFA) for pdfium-binaries V8 build.
"""
# -----