Initial commit of Roster
This commit is contained in:
@@ -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),
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user