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