Initial commit of Roster

This commit is contained in:
Media
2025-06-28 01:12:46 -05:00
commit c23d3a7676
4367 changed files with 890519 additions and 0 deletions
@@ -0,0 +1,2 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
@@ -0,0 +1,117 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import os
import sys
import argparse
import logging
from pathlib import Path
import pypdfium2._helpers as pdfium
import pypdfium2.internal as pdfium_i
def setup_logging():
pdfium_i.DEBUG_AUTOCLOSE.value = bool(int( os.environ.get("DEBUG_AUTOCLOSE", 0) ))
lib_logger = logging.getLogger("pypdfium2")
lib_logger.addHandler(logging.StreamHandler())
lib_logger.setLevel(logging.DEBUG)
pdfium.PdfUnspHandler().setup()
def parse_numtext(numtext):
# TODO enhancement: take count and verify page numbers
if not numtext:
return None
indices = []
for num_or_range in numtext.split(","):
if "-" in num_or_range:
start, end = num_or_range.split("-")
start = int(start) - 1
end = int(end) - 1
if start < end:
indices.extend( [i for i in range(start, end+1)] )
else:
indices.extend( [i for i in range(start, end-1, -1)] )
else:
indices.append(int(num_or_range) - 1)
return indices
def round_list(lst, n_digits):
if not lst:
return lst
result = [round(v, n_digits) for v in lst]
if isinstance(lst, tuple):
result = tuple(result)
return result
def add_input(parser, pages=True):
# TODO add option to open file with buffer/bytes strategy
parser.add_argument(
"input",
type = Path,
help = "Input PDF document",
)
parser.add_argument(
"--password",
help = "A password to unlock the PDF, if encrypted",
)
if pages:
parser.add_argument(
"--pages",
default = None,
type = parse_numtext,
help = "Page numbers and ranges to include",
)
def add_n_digits(parser):
parser.add_argument(
"--n-digits",
type = int,
default = 4,
help = "Number of digits to which coordinates/sizes shall be rounded",
)
def get_input(args, init_forms=False, **kwargs):
pdf = pdfium.PdfDocument(args.input, password=args.password, **kwargs)
if init_forms:
pdf.init_forms()
if "pages" in args and not args.pages:
args.pages = [i for i in range(len(pdf))]
return pdf
if sys.version_info >= (3, 9):
from argparse import BooleanOptionalAction
else:
# backport, adapted from argparse sources
class BooleanOptionalAction (argparse.Action):
def __init__(self, option_strings, dest, **kwargs):
_option_strings = []
for option_string in option_strings:
_option_strings.append(option_string)
if option_string.startswith('--'):
option_string = '--no-' + option_string[2:]
_option_strings.append(option_string)
super().__init__(option_strings=_option_strings, dest=dest, nargs=0, **kwargs)
def __call__(self, parser, namespace, values, option_string=None):
if option_string in self.option_strings:
setattr(namespace, self.dest, not option_string.startswith('--no-'))
def format_usage(self):
return ' | '.join(self.option_strings)
@@ -0,0 +1,51 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import pypdfium2._helpers as pdfium
# TODO? consider dotted access
from pypdfium2._cli._parsers import parse_numtext
def attach(parser):
parser.add_argument(
"inputs",
nargs = "+",
help = "Sequence of PDF files.",
)
parser.add_argument(
"--pages",
nargs = "+",
default = [],
help = "Sequence of page texts, definig the pages to include from each PDF. Use '_' as placeholder for all pages."
)
parser.add_argument(
"--passwords",
nargs = "+",
default = [],
help = "Passwords to unlock encrypted PDFs. Any placeholder may be used for non-encrypted documents.",
)
parser.add_argument(
"--output", "-o",
required = True,
help = "Target path for the output document",
)
def main(args):
args.pages = [None if p == "_" else parse_numtext(p) for p in args.pages]
for _ in range(len(args.inputs) - len(args.pages)):
args.pages.append(None)
for _ in range(len(args.inputs) - len(args.passwords)):
args.passwords.append(None)
dest_pdf = pdfium.PdfDocument.new()
index = 0
for in_path, pages, password in zip(args.inputs, args.pages, args.passwords):
src_pdf = pdfium.PdfDocument(in_path, password=password)
dest_pdf.import_pages(src_pdf, pages=pages)
index += len(src_pdf)
dest_pdf.save(args.output)
@@ -0,0 +1,88 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
from pathlib import Path
# TODO? consider dotted access
from pypdfium2._cli._parsers import (
add_input, get_input,
parse_numtext,
)
ACTION_LIST = "list"
ACTION_EXTRACT = "extract"
ACTION_EDIT = "edit"
def attach(parser): # hook
add_input(parser, pages=False)
subparsers = parser.add_subparsers(dest="action")
subparsers.add_parser(ACTION_LIST)
parser_extract = subparsers.add_parser(ACTION_EXTRACT)
parser_extract.add_argument(
"--numbers",
type = parse_numtext,
)
parser_extract.add_argument(
"--output-dir", "-o",
type = Path,
required = True,
)
parser_edit = subparsers.add_parser(ACTION_EDIT)
parser_edit.add_argument(
"--del-numbers", "-d",
type = parse_numtext,
)
parser_edit.add_argument(
"--add-files", "-a",
nargs = "+",
metavar = "F",
type = Path,
)
parser_edit.add_argument(
"--output", "-o",
type = Path,
required = True,
)
def main(args):
pdf = get_input(args)
n_attachments = pdf.count_attachments()
if args.action == ACTION_LIST:
for i in range(n_attachments):
attachment = pdf.get_attachment(i)
print(f"[{i+1}]", attachment.get_name())
elif args.action == ACTION_EXTRACT:
if not args.numbers:
args.numbers = range(n_attachments)
n_digits = len(str( max(args.numbers) + 1 ))
for i in args.numbers:
attachment = pdf.get_attachment(i)
name = attachment.get_name()
out_path = args.output_dir / ("%0*d_%s" % (n_digits, i+1, name))
out_path.write_bytes( attachment.get_data() )
elif args.action == ACTION_EDIT:
if args.del_numbers:
for i in sorted(args.del_numbers, reverse=True):
pdf.del_attachment(i)
if args.add_files:
for fp in args.add_files:
attachment = pdf.new_attachment(fp.name)
attachment.set_data( fp.read_bytes() )
pdf.save(args.output)
else:
assert False
@@ -0,0 +1,77 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
# TODO test-cover use_bitmap, format and render
import traceback
from pathlib import Path
import pypdfium2.raw as pdfium_c
import pypdfium2._helpers as pdfium
# TODO? consider dotted access
from pypdfium2._cli._parsers import add_input, get_input
def attach(parser):
add_input(parser, pages=True)
parser.add_argument(
"--output-dir", "-o",
required = True,
type = Path,
help = "Output directory to take the extracted images",
)
parser.add_argument(
"--max-depth",
type = int,
default = 2,
help = "Maximum recursion depth to consider when looking for page objects.",
)
parser.add_argument(
"--use-bitmap",
action = "store_true",
help = "Enforce the use of bitmaps rather than attempting a smart extraction of the image.",
)
parser.add_argument(
"--format",
help = "Image format to use when saving bitmaps. (Fallback if doing smart extraction.)",
)
parser.add_argument(
"--render",
action = "store_true",
help = "Whether to get rendered bitmaps, taking masks and transform matrices into account. (Fallback if doing smart extraction.)",
)
def main(args):
if not args.output_dir.is_dir():
raise NotADirectoryError(args.output_dir)
if args.use_bitmap and not args.format:
args.format = "png"
pdf = get_input(args)
n_pdigits = len(str( max(args.pages)+1 ))
for i in args.pages:
page = pdf[i]
images = page.get_objects(
filter = (pdfium_c.FPDF_PAGEOBJ_IMAGE, ),
max_depth = args.max_depth,
)
# not perfectly memory efficient, but we need image count for digit formatting
images = list(images)
n_idigits = len(str( len(images) ))
for j, image in enumerate(images):
stem = "%s_%0*d_%0*d" % (args.input.stem, n_pdigits, i+1, n_idigits, j+1)
prefix = args.output_dir / stem
try:
if args.use_bitmap:
pil_image = image.get_bitmap(render=args.render).to_pil()
pil_image.save(f"{prefix}.{args.format}")
else:
image.extract(prefix, fb_format=args.format, fb_render=args.render)
except pdfium.PdfiumError:
traceback.print_exc()
image.close()
@@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
# TODO? consider dotted access
from pypdfium2._cli._parsers import add_input, get_input
EXTRACT_RANGE = "range"
EXTRACT_BOUNDED = "bounded"
def attach(parser):
add_input(parser, pages=True)
parser.add_argument(
"--strategy",
default = EXTRACT_RANGE,
choices = (EXTRACT_RANGE, EXTRACT_BOUNDED),
help = "PDFium text extraction strategy (range, bounded).",
)
def main(args):
pdf = get_input(args)
sep = ""
for i in args.pages:
page = pdf[i]
textpage = page.get_textpage()
# TODO let caller pass in possible range/boundary parameters
if args.strategy == EXTRACT_RANGE:
text = textpage.get_text_range(force_this=True)
elif args.strategy == EXTRACT_BOUNDED:
text = textpage.get_text_bounded()
else:
assert False
print(sep + f"# Page {i+1}\n" + text)
sep = "\n"
@@ -0,0 +1,69 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
# TODO test-cover converting non-jpeg format
from pathlib import Path
import pypdfium2._helpers as pdfium
try:
import PIL.Image
except ImportError:
PIL = None
def attach(parser):
parser.add_argument(
"images",
nargs = "+",
help = "Input images",
type = Path,
)
parser.add_argument(
"--output", "-o",
required = True,
type = Path,
help = "Target path for the new PDF"
)
parser.add_argument(
"--inline",
action = "store_true",
help = "If JPEG, whether to use PDFium's inline loading function."
)
def main(args):
# Rudimentary image to PDF conversion (testing / proof of concept)
# Due to limitations in PDFium's public API, this function may be inefficient/lossy for non-JPEG input.
# The technically best available open-source tool for image to PDF conversion is probably img2pdf (although its code style can be regarded as displeasing).
# Development note: We are closing objects explicitly because loading JPEGs non-inline binds file handles to the PDF, which need to be released as soon as possible. Without this, we have already run into "OSError: Too many open files" while testing.
pdf = pdfium.PdfDocument.new()
for fp in args.images:
image_obj = pdfium.PdfImage.new(pdf)
# Simple check whether the file is a JPEG image - a better implementation could use mimetypes, python-magic, or PIL
if fp.suffix.lower() in (".jpg", ".jpeg"):
image_obj.load_jpeg(fp, inline=args.inline)
else:
pil_image = PIL.Image.open(fp)
bitmap = pdfium.PdfBitmap.from_pil(pil_image)
pil_image.close()
image_obj.set_bitmap(bitmap)
bitmap.close()
w, h = image_obj.get_size()
image_obj.set_matrix( pdfium.PdfMatrix().scale(w, h) )
page = pdf.new_page(w, h)
page.insert_obj(image_obj)
page.gen_content()
image_obj.close() # no-op
page.close()
pdf.save(args.output)
pdf.close()
@@ -0,0 +1,109 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
# TODO test-confirm filter and info params
from enum import Enum
import pypdfium2._helpers as pdfium
import pypdfium2.raw as pdfium_c
import pypdfium2.internal as pdfium_i
# TODO? consider dotted access
from pypdfium2._cli._parsers import (
add_input,
add_n_digits,
get_input,
round_list,
)
class InfoParams (Enum):
pos = 0
imageinfo = 1
def attach(parser):
add_input(parser, pages=True)
add_n_digits(parser)
# TODO think out strategy for choices (see https://github.com/python/cpython/issues/69247)
obj_types = list( pdfium_i.ObjectTypeToConst.keys() )
parser.add_argument(
"--filter",
nargs = "+",
metavar = "T",
choices = obj_types,
help = f"Object types to include. Choices: {obj_types}",
)
parser.add_argument(
"--max-depth",
type = int,
default = 2,
help = "Maximum recursion depth to consider when descending into Form XObjects.",
)
parser.add_argument(
"--info",
nargs = "*",
type = lambda s: InfoParams[s.lower()],
default = (InfoParams.pos, InfoParams.imageinfo),
help = "Object details to show (pos, imageinfo).",
)
def print_img_metadata(metadata, pad=""):
for attr in pdfium_c.FPDF_IMAGEOBJ_METADATA.__slots__:
value = getattr(metadata, attr)
if attr == "colorspace":
value = pdfium_i.ColorspaceToStr.get(value)
elif attr == "marked_content_id" and value == -1:
continue
print(pad + f"{attr}: {value}\n", end="")
def main(args):
pdf = get_input(args)
# if no filter is given, leave it at None (make a difference in case of unhandled object types)
if args.filter:
args.filter = [pdfium_i.ObjectTypeToConst[t] for t in args.filter]
show_pos = (InfoParams.pos in args.info)
show_imageinfo = (InfoParams.imageinfo in args.info)
total_count = 0
for i in args.pages:
page = pdf[i]
obj_searcher = page.get_objects(
filter = args.filter,
max_depth = args.max_depth,
)
preamble = f"# Page {i+1}\n"
count = 0
for obj in obj_searcher:
pad_0 = " " * obj.level
pad_1 = pad_0 + " "
print(preamble + pad_0 + pdfium_i.ObjectTypeToStr.get(obj.type))
if show_pos:
pos = round_list(obj.get_pos(), args.n_digits)
print(pad_1 + f"Position: {pos}")
# TODO? also call get_size() for coverage
if show_imageinfo and isinstance(obj, pdfium.PdfImage):
print(pad_1 + f"Filters: {obj.get_filters()}")
metadata = obj.get_metadata()
print_img_metadata(metadata, pad=pad_1)
count += 1
preamble = ""
if count > 0:
print(f"-> Count: {count}\n")
total_count += count
if total_count > 0:
print(f"-> Total count: {total_count}")
@@ -0,0 +1,59 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import pypdfium2.raw as pdfium_c
import pypdfium2.internal as pdfium_i
# TODO? consider dotted access
from pypdfium2._cli._parsers import (
add_input,
add_n_digits,
get_input,
round_list,
)
def attach(parser):
add_input(parser)
add_n_digits(parser)
def main(args):
pdf = get_input(args)
print(f"Page Count: {len(pdf)}")
print(f"PDF Version: {pdf.get_version() / 10}")
id_permanent = pdf.get_identifier(pdfium_c.FILEIDTYPE_PERMANENT)
id_changing = pdf.get_identifier(pdfium_c.FILEIDTYPE_CHANGING)
print(f"ID (permanent): {id_permanent}")
print(f"ID (changing): {id_changing}")
print(f"ID match? - {id_permanent == id_changing}")
print(f"Tagged? - {pdf.is_tagged()}")
pagemode = pdf.get_pagemode()
if pagemode != pdfium_c.PAGEMODE_USENONE:
print(f"Page Mode: {pdfium_i.PageModeToStr.get(pagemode)}")
formtype = pdf.get_formtype()
if formtype != pdfium_c.FORMTYPE_NONE:
print(f"Form Type: {pdfium_i.FormTypeToStr.get(formtype)}")
metadata = pdf.get_metadata_dict(skip_empty=True)
if len(metadata) > 0:
print("Metadata:")
for key, value in metadata.items():
print(f" {key}: {value}")
for i in args.pages:
print(f"\n# Page {i+1}")
page = pdf[i]
print(f"Size: {round_list(page.get_size(), args.n_digits)}")
print(f"Rotation: {page.get_rotation()}")
print(f"Bounding Box: {round_list(page.get_bbox(), args.n_digits)}")
for box_name in ("media", "crop", "bleed", "trim", "art"):
box = getattr(page, f"get_{box_name.lower()}box")(fallback_ok=False)
if box:
print(f"{box_name.capitalize()}Box: {round_list(box, args.n_digits)}")
@@ -0,0 +1,379 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import os
import math
import logging
import functools
from pathlib import Path
import multiprocessing as mp
import concurrent.futures as ft
try:
import cv2
except ImportError:
cv2 = None
import pypdfium2._helpers as pdfium
import pypdfium2.internal as pdfium_i
import pypdfium2.raw as pdfium_r
# TODO? consider dotted access
from pypdfium2._cli._parsers import (
add_input, get_input,
setup_logging,
BooleanOptionalAction,
)
logger = logging.getLogger(__name__)
def _bitmap_wrapper_foreign_simple(width, height, format, *args, **kwargs):
if format == pdfium_r.FPDFBitmap_BGRx:
use_alpha = False
elif format == pdfium_r.FPDFBitmap_BGRA:
use_alpha = True
else:
raise RuntimeError(f"Cannot create foreign_simple bitmap with bitmap type {pdfium_i.BitmapTypeToStr[format]}.")
return pdfium.PdfBitmap.new_foreign_simple(width, height, use_alpha, *args, **kwargs)
BitmapMakers = dict(
native = pdfium.PdfBitmap.new_native,
foreign = pdfium.PdfBitmap.new_foreign,
foreign_packed = functools.partial(pdfium.PdfBitmap.new_foreign, force_packed=True),
foreign_simple = _bitmap_wrapper_foreign_simple,
)
CsFields = ("path_fill", "path_stroke", "text_fill", "text_stroke")
ColorOpts = dict(metavar="C", nargs=4, type=int)
SampleTheme = dict(
# TODO improve colors - currently it's just some random ones to distinguish the different drawings
path_fill = (170, 100, 0, 255), # dark orange
path_stroke = (0, 150, 255, 255), # sky blue
text_fill = (255, 255, 255, 255), # white
text_stroke = (150, 255, 0, 255), # green
)
def attach(parser):
add_input(parser, pages=True)
parser.add_argument(
"--output", "-o",
type = Path,
required = True,
help = "Output directory where the serially numbered images shall be placed.",
)
parser.add_argument(
"--prefix",
help = "Custom prefix for the images. Defaults to the input filename's stem.",
)
parser.add_argument(
"--format", "-f",
default = "jpg",
type = str.lower,
help = "The image format to use.",
)
parser.add_argument(
"--engine",
dest = "engine_cls",
type = lambda k: {"pil": PILEngine, "numpy+cv2": NumpyCV2Engine}[k.lower()],
help = "The saver engine to use (pil, numpy+cv2)",
)
parser.add_argument(
"--scale",
default = 1,
type = float,
help = "Define the resolution of the output images. By default, one PDF point (1/72in) is rendered to 1x1 pixel. This factor scales the number of pixels that represent one point.",
)
parser.add_argument(
"--rotation",
default = 0,
type = int,
choices = (0, 90, 180, 270),
help = "Rotate pages by 90, 180 or 270 degrees.",
)
parser.add_argument(
"--fill-color",
help = "Color the bitmap will be filled with before rendering. It shall be given in RGBA format as a sequence of integers ranging from 0 to 255. Defaults to white.",
**ColorOpts,
)
parser.add_argument(
"--optimize-mode",
choices = ("lcd", "print"),
help = "The rendering optimisation mode. None if not given.",
)
parser.add_argument(
"--crop",
nargs = 4,
type = float,
default = (0, 0, 0, 0),
help = "Amount to crop from (left, bottom, right, top).",
)
parser.add_argument(
"--draw-annots",
action = BooleanOptionalAction,
default = True,
help = "Whether annotations may be shown (default: true).",
)
parser.add_argument(
"--draw-forms",
action = BooleanOptionalAction,
default = True,
help = "Whether forms may be shown (default: true).",
)
parser.add_argument(
"--no-antialias",
nargs = "+",
default = [],
choices = ("text", "image", "path"),
type = str.lower,
help = "Item types that shall not be smoothed.",
)
parser.add_argument(
"--force-halftone",
action = "store_true",
help = "Always use halftone for image stretching.",
)
bitmap = parser.add_argument_group(
title = "Bitmap options",
description = "Bitmap config, including pixel format.",
)
bitmap.add_argument(
"--bitmap-maker",
choices = BitmapMakers.keys(),
default = "native",
help = "The bitmap maker to use.",
type = str.lower,
)
bitmap.add_argument(
"--grayscale",
action = "store_true",
help = "Whether to render in grayscale mode (no colors).",
)
bitmap.add_argument(
"--byteorder",
dest = "rev_byteorder",
type = lambda v: {"bgr": False, "rgb": True}[v.lower()],
help = "Whether to use BGR or RGB byteorder (default: conditional).",
)
bitmap.add_argument(
"--x-channel",
dest = "prefer_bgrx",
action = BooleanOptionalAction,
help = "Whether to prefer BGRx/RGBx over BGR/RGB (default: conditional).",
)
parallel = parser.add_argument_group(
title = "Parallelization",
description = "Options for rendering with multiple processes.",
)
parallel.add_argument(
"--linear",
nargs = "?",
type = int,
const = math.inf,
help = "Render non-parallel if page count is less or equal to the specified value (default is conditional). If this flag is given without a value, then render linear regardless of document length.",
)
parallel.add_argument(
"--processes",
default = os.cpu_count(),
type = int,
help = "The maximum number of parallel rendering processes. Defaults to the number of CPU cores.",
)
parallel.add_argument(
"--parallel-strategy",
choices = ("spawn", "forkserver", "fork"),
default = "spawn",
type = str.lower,
help = "The process start method to use. ('fork' is discouraged due to stability issues.)",
)
parallel.add_argument(
"--parallel-lib",
choices = ("mp", "ft"),
default = "mp",
type = str.lower,
help = "The parallelization module to use (mp = multiprocessing, ft = concurrent.futures).",
)
parallel.add_argument(
"--parallel-map",
type = str.lower,
help = "The map function to use (backend specific, the default is an iterative map)."
)
color_scheme = parser.add_argument_group(
title = "Forced color scheme",
description = "Options for using pdfium's forced color scheme renderer. Deprecated, considered not useful.",
)
color_scheme.add_argument(
"--sample-theme",
action = "store_true",
help = "Use a dark background sample theme as base. Explicit color params override selectively."
)
color_scheme.add_argument(
"--path-fill",
**ColorOpts
)
color_scheme.add_argument(
"--path-stroke",
**ColorOpts
)
color_scheme.add_argument(
"--text-fill",
**ColorOpts
)
color_scheme.add_argument(
"--text-stroke",
**ColorOpts
)
color_scheme.add_argument(
"--fill-to-stroke",
action = "store_true",
help = "Only draw borders around fill areas using the `path_stroke` color, instead of filling with the `path_fill` color.",
)
class SavingEngine:
def __init__(self, path_parts):
self._path_parts = path_parts
def _get_path(self, i):
output_dir, prefix, n_digits, format = self._path_parts
return output_dir / f"{prefix}{i+1:0{n_digits}d}.{format}"
def __call__(self, bitmap, i):
out_path = self._get_path(i)
self._saving_hook(out_path, bitmap)
logger.info(f"Wrote page {i+1} as {out_path.name}")
class PILEngine (SavingEngine):
def _saving_hook(self, out_path, bitmap):
bitmap.to_pil().save(out_path)
class NumpyCV2Engine (SavingEngine):
def _saving_hook(self, out_path, bitmap):
cv2.imwrite(str(out_path), bitmap.to_numpy())
def _render_parallel_init(extra_init, input, password, may_init_forms, kwargs, engine):
if extra_init:
extra_init()
logger.info(f"Initializing data for process {os.getpid()}")
pdf = pdfium.PdfDocument(input, password=password, autoclose=True)
if may_init_forms:
pdf.init_forms()
global ProcObjs
ProcObjs = (pdf, kwargs, engine)
def _render_job(i, pdf, kwargs, engine):
# logger.info(f"Started page {i+1} ...")
page = pdf[i]
bitmap = page.render(**kwargs)
engine(bitmap, i)
def _render_parallel_job(i):
global ProcObjs; _render_job(i, *ProcObjs)
def main(args):
# TODO turn into a python-usable API yielding output paths as they are written
pdf = get_input(args, init_forms=args.draw_forms)
# TODO move to parsers?
pdf_len = len(pdf)
if not all(0 <= i < pdf_len for i in args.pages):
raise ValueError("Out-of-bounds page indices are prohibited.")
if len(args.pages) != len(set(args.pages)):
raise ValueError("Duplicate page indices are prohibited.")
if args.prefix is None:
args.prefix = f"{args.input.stem}_"
if args.fill_color is None:
args.fill_color = (0, 0, 0, 255) if args.sample_theme else (255, 255, 255, 255)
if args.linear is None:
args.linear = 6 if args.format == "jpg" else 3
# numpy+cv2 is much faster for PNG, and PIL faster for JPG, but this might simply be due to different encoding defaults
if args.engine_cls is None:
if cv2 != None and args.format == "png":
args.engine_cls = NumpyCV2Engine
else:
args.engine_cls = PILEngine
# PIL is faster with rev_byteorder and prefer_bgrx = True, as this achieves a natively supported pixel format. For numpy+cv2 there doesn't seem to be a difference.
if args.rev_byteorder is None:
args.rev_byteorder = args.engine_cls is PILEngine
if args.prefer_bgrx is None:
# PIL can't save BGRX as PNG
args.prefer_bgrx = args.engine_cls is PILEngine and args.format != "png"
cs_kwargs = dict()
if args.sample_theme:
cs_kwargs.update(**SampleTheme)
cs_kwargs.update(**{f: getattr(args, f) for f in CsFields if getattr(args, f)})
cs = pdfium.PdfColorScheme(**cs_kwargs) if len(cs_kwargs) > 0 else None
kwargs = dict(
scale = args.scale,
rotation = args.rotation,
crop = args.crop,
grayscale = args.grayscale,
fill_color = args.fill_color,
color_scheme = cs,
fill_to_stroke = args.fill_to_stroke,
optimize_mode = args.optimize_mode,
draw_annots = args.draw_annots,
may_draw_forms = args.draw_forms,
force_halftone = args.force_halftone,
rev_byteorder = args.rev_byteorder,
prefer_bgrx = args.prefer_bgrx,
bitmap_maker = BitmapMakers[args.bitmap_maker],
)
for type in args.no_antialias:
kwargs[f"no_smooth{type}"] = True
# TODO dump all args except password?
logger.info(f"{args.engine_cls.__name__}, Format: {args.format}, rev_byteorder: {args.rev_byteorder}, prefer_bgrx {args.prefer_bgrx}")
n_digits = len(str(pdf_len))
path_parts = (args.output, args.prefix, n_digits, args.format)
engine = args.engine_cls(path_parts)
if len(args.pages) <= args.linear:
logger.info("Linear rendering ...")
for i in args.pages:
_render_job(i, pdf, kwargs, engine)
else:
logger.info("Parallel rendering ...")
ctx = mp.get_context(args.parallel_strategy)
# TODO unify using mp.pool.Pool(context=...) ?
pool_backends = dict(
mp = (ctx.Pool, "imap"),
ft = (functools.partial(ft.ProcessPoolExecutor, mp_context=ctx), "map"),
)
pool_ctor, map_attr = pool_backends[args.parallel_lib]
if args.parallel_map:
map_attr = args.parallel_map
extra_init = (setup_logging if args.parallel_strategy in ("spawn", "forkserver") else None)
pool_kwargs = dict(
initializer = _render_parallel_init,
initargs = (extra_init, pdf._input, args.password, args.draw_forms, kwargs, engine),
)
n_procs = min(args.processes, len(args.pages))
with pool_ctor(n_procs, **pool_kwargs) as pool:
map_func = getattr(pool, map_attr)
for _ in map_func(_render_parallel_job, args.pages):
pass
@@ -0,0 +1,84 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
from enum import Enum
from pathlib import Path
import pypdfium2.raw as pdfium_c
import pypdfium2._helpers as pdfium
# TODO? consider dotted access
from pypdfium2._cli._parsers import add_input, get_input
class Units (Enum):
PT = 0
MM = 1
CM = 2
IN = 3
def units_to_pt(value, unit):
if unit is Units.PT:
return value
elif unit is Units.IN:
return value*72
elif unit is Units.CM:
return (value*72) / 2.54
elif unit is Units.MM:
return (value*72) / 25.4
else:
raise ValueError(f"Invalid unit type {unit}")
def attach(parser):
add_input(parser, pages=False)
parser.add_argument(
"--output", "-o",
required = True,
type = Path,
help = "Target path for the new document",
)
parser.add_argument(
"--rows", "-r",
type = int,
required = True,
help = "Number of rows (horizontal tiles)",
)
parser.add_argument(
"--cols", "-c",
type = int,
required = True,
help = "Number of columns (vertical tiles)",
)
# NOTE no short aliases for width and height since -h would confilct with argparse help
parser.add_argument(
"--width",
type = float,
required = True,
help = "Target width",
)
parser.add_argument(
"--height",
type = float,
required = True,
help = "Target height",
)
parser.add_argument(
"--unit", "-u",
default = Units.MM,
type = lambda string: Units[string.upper()],
help = "Unit for target width and height (pt, mm, cm, in)",
)
def main(args):
# Rudimentary page tiling, powered by pdfium
# A more sophisticated implementation could place XObjects rather than using PDFium's helper function, support merging and arranging on the fly, etc.
w = units_to_pt(args.width, args.unit)
h = units_to_pt(args.height, args.unit)
src_pdf = get_input(args)
raw_dest = pdfium_c.FPDF_ImportNPagesToOne(src_pdf, w, h, args.cols, args.rows)
dest_pdf = pdfium.PdfDocument(raw_dest)
dest_pdf.save(args.output)
@@ -0,0 +1,42 @@
# SPDX-FileCopyrightText: 2024 geisserml <[email protected]>
# SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
import pypdfium2.internal as pdfium_i
# TODO? consider dotted access
from pypdfium2._cli._parsers import (
add_input,
add_n_digits,
get_input,
round_list,
)
def attach(parser):
add_input(parser, pages=False)
add_n_digits(parser)
parser.add_argument(
"--max-depth",
type = int,
default = 15,
help = "Maximum recursion depth to consider when parsing the table of contents",
)
def main(args):
pdf = get_input(args)
toc = pdf.get_toc(
max_depth = args.max_depth,
)
for item in toc:
state = "*" if item.n_kids == 0 else "-" if item.is_closed else "+"
target = "?" if item.page_index is None else item.page_index+1
print(
" " * item.level +
"[%s] %s -> %s # %s %s" % (
state, item.title, target,
pdfium_i.ViewmodeToStr.get(item.view_mode),
round_list(item.view_pos, args.n_digits),
)
)