"""

    AUTHOR:

        Khushal P Soonderji

    DATE:

        Tuesday, 13th Aug., 2024

    OBJECTIVE:

        To generate custom PDFs.

    REFERENCES:

        N/A

    DOWNLOADS:

        N/A

"""


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                   IMPORT                                                      ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# To make sibling directories accessible for imports:
import sys
sys.path.append(".")
sys.path.append("..")

# For conversion to PDF:
from PyPDF2 import PdfMerger, PdfReader, PdfWriter, Transformation

# For barcode and QR code generation:
import xml.etree.ElementTree as ET
from barcode import Code128
from barcode.writer import ImageWriter, SVGWriter
import segno
from PIL import Image, ExifTags, ImageFilter

# System-level activities:
import os
import io
import inspect
import contextlib

# For calculations:
import math

# For PDF generation:
import fitz
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm, cm, inch
from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import Paragraph
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.utils import ImageReader
from reportlab.graphics import renderSVG

# My utils:
from utils_v2.string import json
from utils_v2.system import files
from utils_v2.string import regex

# For debugging:
from icecream import IceCreamDebugger
import traceback

# For random string generation:
import random
import string

# To validate the font file:
from fontTools import ttLib

# To make http calls:
import requests
import httpx

# To work with base64 coding:
import base64


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                           MACROS / ONE-TIME INIT                                              ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# To make async API calls:
max_connections = 5
limits = httpx.Limits(
    max_keepalive_connections = max_connections,
    max_connections = max_connections,
    keepalive_expiry = 3600
)
http_client = httpx.AsyncClient(
    limits = limits,
    follow_redirects = False,
    timeout = httpx.Timeout(None)
)

# Headers for downloading files:
HEADERS_DOWNLOAD = {
    "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:129.0) Gecko/20100101 Firefox/129.0",
    "Accept": "*/*",
    "Accept-Encoding": "gzip, deflate"
}


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                VARIABLES                                                      ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# --- Nothing Yet


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                   CLASSES                                                     ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


class PDFMaker:

    def __init__(
            self,
            width,
            height,
            unit = "mm",
            dpi = 72,
            debug = True,
            debug_prefix = "PDF (M) | ",
            raise_exception = True,
            **kwargs
    ):
        
        """
        IMPORTANT: To keep things intuitively oriented (left-to-right, and top-to-bottom), the internal bottom-up
        coordinates have been flipped mathematically. All measurements start from the top-left corner. Moreover, I have
        ensured that all numbers follow the same units. So, if you set your unit to "inch", everything will be measured
        in the same unit. I hope this creates maximum consistency for you.
        A NOTE ON IMAGES: All images will be measured in pixels when operating on them directly (like cropping and
        resizing), but will be measured in the 'unit' of the canvas when placing it in the PDF.
        A NOTE OF FONT SIZES: Font sizes (and their line spacings) are measured in 'points'. As per the standard, there
        are 72 'points' in one inch. While everything else in this utility uses the unit of measurement defined in the
        'unit' param, fonts need to be specified in 'points' to maintain compatibility with HTML rendering engines to
        work with well-formatted paragraphs.
        WARNING: I STRONGLY RECOMMEND NOT USING DPI AS A SETTING. THE PDF FORMAT ITSELF DOESN'T HAVE ANY PROVISION FOR
        PIXEL DENSITY. THIS SETTING HAS BEEN MADE AVAILABLE FOR PLACEBO EFFECT WHEN CLIENTS ASK FOR VERY HIGH RESOLUTION
        FILES WITHOUT WANTING TO UNDERSTAND ANY TECHNICALITY. USE WITH CAUTION.
        :param width: [int|float] The width of the page (in the specified unit).
        :param height: [int|float] The height of the page (in the specified unit).
        :param unit: [str] The unit ("mm", "cm", "inch") to use.
        :param dpi: [int|float] The pixel density. AVOID USING THIS.
        :param debug: [bool] Whether, or not, you want to print debugging messages. Can be changed on the fly.
        :param debug_prefix: [str] The prefix to show in debugging messages.
        :param raise_exception: If set to True, any failure will immediately stop the PDF building process. If set to
            False, the PDF building will continue by ignoring failed steps. Set to True by default for an all-or-nothing
            output behaviour.
        :param kwargs: Just a placeholder, does nothing for now (20240904).
        """

        # For debugging:
        self.__printer = IceCreamDebugger(prefix = debug_prefix, includeContext = True)
        if not debug: self.__printer.disable()

        # Note down the exception-handling choice:
        self.__raise_exception = raise_exception

        # We pick the unit from the user's choice:
        self.__unit = {
            "mm": mm,
            "cm": cm,
            "inch": inch
        }[unit]

        # To know the canvas size multipliers, we need a few parameters:
        # 01. The DPI Factor is the multiplier to apply to achieve the target DPI considering a default DPI of 72,
        # 02. The Unit Factor is the multiplier to apply to the unit to achieve a high-res result.
        self.__dpi_factor = (dpi / 72.0)
        self.__unit_factor = self.__unit * self.__dpi_factor

        # Now we create the canvas:
        self.__pdf_in_ram = io.BytesIO()
        self.__canvas_width = width * self.__unit_factor
        self.__canvas_height = height * self.__unit_factor
        self.__canvas = canvas.Canvas(
            self.__pdf_in_ram,
            pagesize = (self.__canvas_width, self.__canvas_height)
        )

    def enable_debug(self):

        """
        To enable the debugging text in the terminal.
        :return: None.
        """

        self.__printer.enable()

    def disable_debug(self):

        """
        To disable the debugging text in the terminal.
        :return: None.
        """

        self.__printer.disable()

    # ┏┓┓        ┓  ┳┳ •┓• •
    # ┗┓┣┓┏┓┏┓┏┓┏┫  ┃┃╋┓┃┓╋┓┏┓┏
    # ┗┛┛┗┗┻┛ ┗ ┗┻  ┗┛┗┗┗┗┗┗┗ ┛

    @staticmethod
    async def download_from_url(url, follow_redirects = False):

        """
        Download a file from a URL.
        :param url: The URL to downloads the file from.
        :param follow_redirects: Whether, or not, to follow along with any redirects when downloading the file.
        :return: The downloaded file or None.
        """

        file = io.BytesIO((await http_client.get(
            url,
            headers = HEADERS_DOWNLOAD,
            follow_redirects = follow_redirects
        )).content)
        file.seek(0)
        return file

    @staticmethod
    def read_to_ram(file_path):

        """
        Reads a file into a BytesIO object in RAM.
        NOTE: The method doesn't itself verify if the file is a PDF.
        :param file_path: The path to the file on disk.
        :return: The file in a BytesIO object.
        """

        with open(file_path, "rb") as file:
            file_data = file.read()
        file_in_ram = io.BytesIO(file_data)
        file_in_ram.seek(0)
        return file_in_ram

    def __parse_color(self, input_color):

        """
        The reportlab 'reportlab' needs the colour to be specified in an array of RGS values where each value is a
        number in the range 0-1.
        :param input_color: Either a hex string or an array of numbers.
        :return: An array that can be used by 'reportlab'.
        """

        # Start by assuming failure:
        processed_color = None

        # If the input color is a string, we convert from hex to an array of hex codes:
        if isinstance(input_color, str):
            input_color = input_color.lstrip("#")
            color_tuple = (
                input_color[0:2].upper(),
                input_color[2:4].upper(),
                input_color[4:6].upper()
            )
            input_color = color_tuple

        # If the input color is a list/tuple of hex-strings:
        if isinstance(input_color, (list, tuple)):
            try:
                if all(isinstance(item, str) for item in input_color):
                    processed_color = [int(item, 16) / 255.0 for item in input_color]
                elif all(isinstance(item, (float, int)) for item in input_color):
                    processed_color = [max(min(item, 255.0), 0.0) / 255.0 for item in input_color]
            except:
                if self.__raise_exception: raise

        # Done here:
        return processed_color

    def __parse_x(self, *args):

        """
        To adjust the horizontal coordinates for the canvas.
        :param args: As many horizontal coordinates as you would like to adjust.
        :return: The adjusted coordinates.
        """

        if len(args) == 0: return None
        parsed_x = [x * self.__unit_factor for x in args]
        parsed_x = [x if x >= 0 else x + self.__canvas_width for x in parsed_x]
        return tuple(parsed_x) if len(parsed_x) > 1 else parsed_x[0]

    def __parse_y(self, *args, flip = False):

        """
        To adjust the vertical coordinates for the canvas.
        :param args: As many vertical coordinates as you would like to adjust.
        :param flip: To convert from bottom-up system to top-down system.
        :return: The adjusted coordinates.
        """

        if len(args) == 0: return None
        parsed_y = [y * self.__unit_factor for y in args]
        parsed_y = [y if y >= 0 else y + self.__canvas_height for y in parsed_y]
        if flip: parsed_y = [self.__canvas_height - y for y in parsed_y]
        return tuple(parsed_y) if len(parsed_y) > 1 else parsed_y[0]

    def __parse_font_size(self, *args):

        """
        Adjust the font size.
        :param args: The font sizes to adjust
        :return: The adjusted font-size.
        """

        if len(args) == 0: return None
        parsed_font_sizes = args
        return parsed_font_sizes if len(parsed_font_sizes) > 1 else parsed_font_sizes[0]

    @staticmethod
    def _parse_coordinates_for_rotation(
            original_x,
            original_y,
            angle
    ):

        """
        Adjusts the x and y coordinates to compensate for the effect of a rotation operation.
        :param original_x: The x coordinate before applying rotation.
        :param original_y: The y coordinate before applying rotation.
        :param angle: The angle (in degrees) by which the rotation will be applied.
        :return: The adjusted x and y coordinates that compensate for the effect of the canvas's rotation.
        """

        # Get the angle in radians:
        angle_radians = math.radians(angle)
        angle_cos = math.cos(angle_radians)
        angle_sin = math.sin(angle_radians)

        # Compute the inverse rotation:
        adjusted_x = original_x * angle_cos + original_y * angle_sin
        adjusted_y = -original_x * angle_sin + original_y * angle_cos

        # Done here:
        return adjusted_x, adjusted_y

    # ┳             ┏┓           •
    # ┃┏┓┏╋┏┓┏┓┏┏┓  ┃┃┏┓┏┓┏┓┏┓┏┓╋┓┏┓┏
    # ┻┛┗┛┗┗┻┛┗┗┗   ┣┛┛ ┗┛┣┛┗ ┛ ┗┗┗ ┛
    #               ┛

    @property
    def canvas(self):

        """
        Use this to fetch just the canvas with all the inputs applied to it.
        :return: The canvas (page) that is being used currently in the instance.
        """

        return self.__canvas

    @property
    def unit_factor(self):

        """
        The scaling factor after taking into consideration the physical unit of measurement being use (e.g.: "mm").
        :return: The unit-factor.
        """

        return self.__unit_factor

    @property
    def dpi_factor(self):

        """
        The scaling factor derived from comparing the default DPI of 72 and a custom DPI specified when creating the
        instance. Since the PDF format doesn't have a native DPI configuration, this scaling factor will help in
        adjusting the sizes of whatever you wish to add to the canvas.
        :return: The DPI scaling factor.
        """

        return self.__dpi_factor

    @property
    def shape(self):

        """
        an array of the dimensions (width, height) of the canvas.
        :return: The width and height of the canvas.
        """

        return self.__canvas_width, self.__canvas_height

    # ┏┓           ┏┓  ┏┓•┓
    # ┃ ┏┓┏┓┓┏┏┓┏  ┣╋  ┣ ┓┃┏┓
    # ┗┛┗┻┛┗┗┛┗┻┛  ┗┻  ┻ ┗┗┗

    def save(self, path: str = None):

        """
        Save the Canvas as a PDF either to RAM or a file on disk.
        :param path: The path you want to save the PDF to on disk. If not specified, the PDF will be saved to RAM and
            returned as a BytesIO object.
        :return: A BytesIO object, or True/False if a path is supplied and the file gets saved.
        """

        # Save the changes to the file in RAM.
        self.__canvas.save()
        self.__pdf_in_ram.seek(0)

        # If the user has not specified a path, return the buffer:
        if path is None: return self.__pdf_in_ram

        # In case a path was specified,
        # We try to save the file in the specified path:
        try:
            with open(path, "wb") as file: file.write(self.__pdf_in_ram.getvalue())
        except Exception as exception:
            self.__printer(exception)
            if self.__raise_exception: raise
            return False

    def next_page(self):

        """
        To start working on the next page.
        The library that is being used doesn't support moving back and forth between pages in any random order, it,
        instead, needs to go in a sequential fashion from one page to the next.
        :return: None.
        """

        self.__canvas.showPage()

    # ┏┓
    # ┣ ┏┓┏┓╋┏
    # ┻ ┗┛┛┗┗┛

    @staticmethod
    async def register_font_from_url(name, url, follow_redirects = False):

        """
        Download a font from a URL and then register it for use.
        :param name: The name you would later refer to the font by.
        :param url: The URL to downloads the font from.
        :param follow_redirects: Whether, or not, to follow along with any redirects when downloading the file.
        :return: True if registered, else False.
        """

        try: return PDFMaker.register_font(
            name,
            await PDFMaker.download_from_url(url, follow_redirects = follow_redirects)
        )
        except Exception as exception:
            return False

    @staticmethod
    def register_fonts_from_directory(directory):

        """
        Register all the fonts from a directory to be used later in the PDF.
        :param directory: The directory that has the TTF files to register.
        :return: A dict describing the fonts that were registered.
        """

        # List out all the files in the folder:
        all_files = files.list_files(directory, full_path = True)

        # Keep only TTF files and make a name-to-path map:
        font_map = {
            os.path.split(file)[-1].split(".")[0].lower(): file
            for file in all_files if file.lower().rstrip().endswith(".ttf")
        }

        # Register the custom fonts:
        registered_fonts = []
        for font_name, font_path in font_map.items():
            if PDFMaker.register_font(font_name, font_path):
                registered_fonts.append({"name": font_name, "file": font_path})

        # Done here:
        return registered_fonts

    @staticmethod
    def register_font(name, file):

        """
        Register one TTF font from either a BytesIO object or a path on the disk.
        :param name: The name of the font as you would like to use when invoking it.
        :param file: The font file (TTF format).
        :return: True if registered, else False.
        """

        try:

            if isinstance(file, io.BytesIO): file.seek(0)
            pdfmetrics.registerFont(TTFont(name, file))
            return True

        except Exception as exception:
            return False

    @staticmethod
    def get_font_height(font, size, count_ascent = True, count_descent = True):

        """
        Compute the height of the font for the given size.
        :param font: [str] The name of the font (as registered).
        :param size: [int|float] The size of the font (in points).
        :param count_ascent: [bool] Whether, or not, you would like to consider the ascent of the font.
        :param count_descent: [bool] Whether, or not, you would like to consider the descent of the font.
        :return: The height of the font in points.
        """

        # Get font's information and compute the height:
        ascent, descent = pdfmetrics.getAscentDescent(font, size)
        if not count_ascent: ascent = 0.0
        if not count_descent: descent = 0.0
        height = ascent - descent
        return height

    @staticmethod
    def font_is_registered(font):

        """
        Checks if a particular font is available for use.
        :param font: [str] The name of the font to check.
        :return: True if registered, else False
        """

        return font in pdfmetrics.getRegisteredFontNames()

    @staticmethod
    def get_first_registered_font():

        """
        Returns the name of the first registered font. Useful for cases like those when your font fails to load and you
        need a default to fall back on.
        :return: The name of the first font that has been registered.
        """

        return pdfmetrics.getRegisteredFontNames()[0]

    @staticmethod
    def list_registered_fonts():

        """
        Returns the list of names of all the registered fonts.
        :return: The list of names of the fonts that have been registered.
        """

        return pdfmetrics.getRegisteredFontNames()

    @staticmethod
    def is_valid_ttf(file):

        """
        Checks if a font file is valid and usable or not.
        :param file: [str|io.BytesIO] The file to check.
        :return: True if valid, else False.
        """

        if isinstance(file, io.BytesIO): file.seek(0)
        try: font = ttLib.TTFont(file)["head"].fontRevision
        except Exception as exception: return False
        return True

    # ┳
    # ┃┏┳┓┏┓┏┓┏┓┏
    # ┻┛┗┗┗┻┗┫┗ ┛
    #        ┛

    @staticmethod
    def make_barcode_image(
            data,
            bar_width = 2,
            bar_height = 125,
            border = 10,
            bar_color = "#000000",
            background_color = "#FFFFFF",
            dpi = 300,
            format = "png",
            as_pil = True
    ):

        """
        Makes a barcode in Code128 format.
        :param data: [str] The data to encode in the barcode.
        :param bar_width: [int] The base bar-module width in pixels.
        :param bar_height: [int] The bar-module height in pixels.
        :param border: [int] The size of the border around all sides of the barcode in pixels.
        :param bar_color: [str] The colour of the bars as a hex string.
        :param background_color: [str] The colour of the background as a hex string.
        :param dpi: [int] The pixel density to note in the image. Leave it to default for most use cases.
        :param format: [str] The file format to save the image in.
        :param as_pil: [bool] If True, a PIL object will be returned, else a PNG file will be returned in RAM (BytesIO).
        :return: Either a PIL object or a PNG file in RAM.
        """

        # Create the barcode in RAM:
        barcode_in_ram = io.BytesIO()
        barcode_options = {
            "module_width": (bar_width / dpi) * 25.4,
            "module_height": (bar_height / dpi) * 25.4,
            "quiet_zone": 0,
            "foreground": bar_color,
            "background": background_color,
            "format": format,
            "dpi": dpi
        }
        Code128(str(data), writer = ImageWriter()).write(barcode_in_ram, options = barcode_options)
        barcode_in_ram.seek(0)

        # Remove the rasterized text at the bottom, and create a border of the desired size:
        barcode_image = Image.open(barcode_in_ram, formats = [format])
        width, height = barcode_image.size
        barcode_image = barcode_image.crop((
            0,
            int(dpi * 0.0393701),
            width,
            int(dpi * 0.0393701) + bar_height
        ))
        width, height = barcode_image.size
        background_image = Image.new(
            mode = "RGB",
            size = (width + border + border, height + border + border),
            color = background_color
        )
        background_image.paste(barcode_image, (border, border))
        barcode_image = background_image

        # If the user has asked for a PIL object:
        if as_pil: return barcode_image

        # Else, ave the PNG in ram:
        barcode_in_ram = io.BytesIO()
        barcode_image.save(barcode_in_ram, format = "png")
        barcode_in_ram.seek(0)

        # Done:
        return barcode_in_ram

    @staticmethod
    def make_qr_image(
            data,
            scale = 5,
            border = 2,
            foreground_color = "#000000",
            background_color = "#FFFFFF",
            border_color = "#FFFFFF",
            background_art = None,
            format = "png",
            as_pil = True
    ):

        """
        Create a QR code.
        :param data: [str] The data that you want to encode in the QR code.
        :param scale: [int] The size (in pixels) for each tiny block in the QR code.
        :param border: [int] The size (in multiples of scale) of the quiet zone.
        :param foreground_color: [str] The color of the foreground, a.k.a. the dark color.
        :param background_color: [str] The color of the background, a.k.a. the light color.
        :param border_color: [str] The color of the quiet zone.
        :param background_art: [io.BytesIO|PIL.Image] Any image that you would like to feed into the background instead
            of a plain QR code. This can be an animated GIF image as well.
        :param format: [str] The type of output file that you want.
        :param as_pil: [bool] If True, a PIL object will be returned, else a PNG file will be returned in RAM (BytesIO).
        :return: Either a PIL object or a PNG file in RAM.
        """

        qr_code = segno.make_qr(str(data))
        params = {
            "scale": scale,
            "border": border,
            "dark": foreground_color,
            "light": background_color,
            "quiet_zone": border_color,
            "kind": format
        }

        qr_image = io.BytesIO()

        if background_art is not None:
            if isinstance(background_art, Image.Image):
                buffer = io.BytesIO()
                background_art.save(buffer, format = format)
                buffer.seek(0)
                background_art = buffer
            params["background"] = background_art
            params["target"] = qr_image
            qr_code.to_artistic(**params)

        else:
            params["out"] = qr_image
            qr_code.save(**params)

        qr_image.seek(0)
        if as_pil: qr_image = Image.open(qr_image, formats = [format])
        return qr_image

    async def image_from_url(self, url, follow_redirects = False, as_pil = True, format = "png"):

        """
        Asynchronously downloads an image and returns it as a PIL object.
        Use this instead of just passing the URL to 'draw_image' for better efficiency.
        :param url: [str] The URL to downloads the image from.
        :param follow_redirects: [bool] Whether, or not, to follow redirect URLs when downloading the file.
        :param as_pil: [bool] If True, a PIL object will be returned, else a PNG file will be returned in RAM (BytesIO).
        :param format: [str] The type of output file that you want. Not applicable for PIL objects.
        :return: The image as a PIL object or as a file in RAM.
        """

        try:

            pil_image = Image.open(await PDFMaker.download_from_url(url, follow_redirects = follow_redirects))
            if as_pil: return pil_image
            image_in_memory = io.BytesIO()
            pil_image.save(image_in_memory, format = format)
            image_in_memory.seek(0)
            return image_in_memory

        except Exception as exception:
            self.__printer(exception)
            if self.__raise_exception: raise

    def image_from_base64(self, data, as_pil = True):

        """
        Converts a base64 string to an image.
        :param data: [str] The base-64 representation of the image data.
        :param as_pil: [bool] If True, a PIL object will be returned, else a PNG file will be returned in RAM (BytesIO).
        :return: The image as a PIL object or as a file in RAM.
        """

        try:

            image = io.BytesIO(base64.b64decode(data))
            image.seek(0)
            if not as_pil: return image
            image = Image.open(image)
            return image

        except Exception as exception:
            if self.__raise_exception: raise

    @staticmethod
    def crop_image_to_aspect_ratio(image, target_aspect_ratio, anchor = "c"):

        """
        Shaves off pixels from an image to achieve the target aspect ratio.
        :param image: [PIL.Image] The image to work on, held in a PIL object.
        :param target_aspect_ratio: [int|float] The aspect ratio you want to achieve.
        :param anchor: [str] Which part of the image you want to retain. Imagine it like a compass - "nw", "n", "ne",
            "e", "se", "s", "sw", "w", and "c".
        :return: The adjusted image.
        """

        # Get the existing figures:
        width, height = image.size
        original_aspect_ratio = width / height

        # Compute the new dimensions:
        if original_aspect_ratio > target_aspect_ratio:
            scaling_factor = target_aspect_ratio / original_aspect_ratio
            new_width, new_height = int(width * scaling_factor), height
        else:
            scaling_factor = original_aspect_ratio / target_aspect_ratio
            new_width, new_height = width, int(height * scaling_factor)

        # Now we figure out the start and end points of cropping:
        match anchor.strip().lower():
            case "nw":
                start_x = 0
                start_y = 0
            case "n":
                start_x = int(abs(new_width - width) / 2.0)
                start_y = 0
            case "ne":
                start_x = int(abs(new_width - width))
                start_y = 0
            case "e":
                start_x = int(abs(new_width - width))
                start_y = int(abs(new_height - height) / 2.0)
            case "se":
                start_x = int(abs(new_width - width))
                start_y = int(abs(new_height - height))
            case "s":
                start_x = int(abs(new_width - width) / 2.0)
                start_y = int(abs(new_height - height))
            case "sw":
                start_x = 0
                start_y = int(abs(new_height - height))
            case "w":
                start_x = 0
                start_y = int(abs(new_height - height) / 2.0)
            case _:
                start_x = int(abs(new_width - width) / 2.0)
                start_y = int(abs(new_height - height) / 2.0)

        end_x = start_x + new_width
        end_y = start_y + new_height

        # And we finally crop and return the image:
        return image.crop((start_x, start_y, end_x, end_y))

    @staticmethod
    def crop_image(image, start_x, start_y, end_x, end_y):

        """
        Crops an image.
        :param image: [PIL.Image] The image to work on, held in a PIL object.
        :param start_x: [int|float] The left coordinate (in the same unit as the dimension of the canvas).
        :param start_y: [int|float] The top coordinate (in the same unit as the dimension of the canvas).
        :param end_x: [int|float] The right coordinate (in the same unit as the dimension of the canvas).
        :param end_y: [int|float] The bottom coordinate (in the same unit as the dimension of the canvas).
        :return: The cropped image.
        """

        return image.crop((start_x, start_y, end_x, end_y))

    @staticmethod
    def resize_image_to_aspect_ratio(image, target_aspect_ratio):

        """
        Resizes the image (by stretching and squishing) till it fits the target aspect ratio.
        :param image: [PIL.Image] The image to adjust.
        :param target_aspect_ratio: [int|float] The aspect ratio that you want to achieve.
        :return: The adjusted image.
        """

        # Get the existing figures:
        width, height = image.size
        original_aspect_ratio = width / height

        # Compute the new dimensions:
        if original_aspect_ratio > target_aspect_ratio:
            new_width, new_height = width, int(width / target_aspect_ratio)
        else:
            new_width, new_height = int(height * target_aspect_ratio), height

        # Resize and return the image:
        return image.resize((new_width, new_height))

    @staticmethod
    def resize_image(image, width, height):

        """
        Resizes an image to the specified width and height.
        :param image: [PIL.Image] The image as a PIL object.
        :param width: [int|float] The new width.
        :param height: [int|float] The new height.
        :return: The resized image.
        """

        return image.resize((width, height))

    @staticmethod
    def fit_image_to_aspect_ratio(
            image: Image,
            target_aspect_ratio: float,
            blur_strength = 0.01,
            alpha: float = 0.8
    ):

        """
        Fits an image to any aspect ratio by adding a blurred background to it.
        :param image: [PIL.Image] The image to adjust.
        :param target_aspect_ratio: [int|float] The aspect ratio that you want to achieve.
        :param blur_strength: How intense the blur must be.
        :param alpha: The opacity of the blurred background.
        :return: The adjusted image.
        """

        # Get the existing figures:
        width, height = image.size
        original_aspect_ratio = width / height

        # Compute the new dimensions:
        if original_aspect_ratio > target_aspect_ratio:
            new_width, new_height = width, int(width / target_aspect_ratio)
            scaling_factor = new_height / height
            paste_x = 0
            paste_y = int((new_height - height) / 2)
        else:
            new_width, new_height = int(height * target_aspect_ratio), height
            scaling_factor = new_width / width
            paste_x = int((new_width - width) / 2)
            paste_y = 0

        # Prepare the background image:
        # bg_image = image.copy().resize((new_width, new_height))
        bg_image = image.copy().resize((int(width * scaling_factor), int(height * scaling_factor)))
        bg_image = bg_image.filter(ImageFilter.GaussianBlur(radius = int((new_width + new_height) / 2) * blur_strength))
        bg_image = PDFMaker.crop_image_to_aspect_ratio(bg_image, target_aspect_ratio)

        # darken the background a little:
        bg_image = Image.blend(
            bg_image,
            Image.new("RGB", bg_image.size, (0, 0, 0)),
            alpha = 1.0 - alpha
        )

        # Paste the original image on top of the background:
        bg_image.paste(image, (paste_x, paste_y))

        # Resize and return the image:
        return bg_image

    @staticmethod
    def adjust_image_orientation_from_exif(image):

        """
        Rotates the image as per the instructions in the EXIF tags.
        :param image: The PIL object that holds the image data.
        :return: The adjusted image.
        """

        # Guard clause to return if the attribute is not even found:
        if not hasattr(image, "getexif"): return image

        # Else we start extracting EXIF data:
        exif = image.getexif()
        if exif is None: return image

        # We look for the tag that indicates orientation:
        # ORIENTATION CODES:
        # 1: Normal (Landscape)
        # 2: Flipped horizontally
        # 3: Upside down (Reverse Landscape)
        # 4: Flipped vertically
        # 5: Rotated 90° clockwise and flipped horizontally
        # 6: Rotated 90° clockwise (Portrait)
        # 7: Rotated 90° counter-clockwise and flipped horizontally
        # 8: Rotated 90° counter-clockwise (Reverse Portrait)
        orientation = exif.get(274, 0)
        if orientation == 1: pass
        elif orientation == 2: image = image.transpose(Image.FLIP_LEFT_RIGHT)
        elif orientation == 3: image = image.rotate(180, expand = True)
        elif orientation == 4: image = image.rotate(180, expand = True).transpose(Image.FLIP_LEFT_RIGHT)
        elif orientation == 5: image = image.rotate(270, expand = True).transpose(Image.FLIP_LEFT_RIGHT)
        elif orientation == 6: image = image.rotate(270, expand = True)
        elif orientation == 7: image = image.rotate(90, expand = True).transpose(Image.FLIP_LEFT_RIGHT)
        elif orientation == 8: image = image.rotate(90, expand = True)

        # Done here:
        return image

    # ┏┓┳┓┏
    # ┃┃┃┃╋
    # ┣┛┻┛┛

    def draw_line(
            self,
            start_x,
            start_y,
            end_x,
            end_y,
            thickness = 1.0,
            color = (0, 0, 0),
            alpha = 1.0
    ):

        """
        Draw a line in the PDF page.
        :param start_x: [int|float] The left coordinate.
        :param start_y: [int|float] The top coordinate.
        :param end_x: [int|float] The right coordinate.
        :param end_y: [int|float] The bottom coordinate.
        :param thickness: [int|float] The thickness of the line (in the same unit as the canvas's dimensions).
        :param color: [str] The RGB color in an array.
        :param alpha: [int|float] The opacity of the line. 1 is 100% and 0 is 0%
        :return: True or False based on the success of the operation.
        """

        # Handle color:
        color = self.__parse_color(color)

        # Adjust the dimensions:
        thickness *= self.__unit_factor
        start_x, end_x = self.__parse_x(start_x, end_x)
        start_y, end_y = self.__parse_y(start_y, end_y, flip = True)

        try:

            self.__canvas.setLineWidth(thickness)
            self.__canvas.setStrokeColorRGB(color[0], color[1], color[2], alpha)
            self.__canvas.line(
                start_x,
                start_y,
                end_x,
                end_y
            )
            return True

        # In case something goes wrong:
        except Exception as exception:
            self.__printer(exception)
            if self.__raise_exception: raise
            return False

    def draw_circle(
            self,
            x,
            y,
            radius,
            fill_color = (255, 255, 255),
            fill_alpha = 0.0,
            stroke_color = (0, 0, 0),
            stroke_alpha = 1.0,
            thickness = 1.0
    ):

        """
        Draw a circle in the PDF page.
        :param x: [int|float] The horizontal coordinate of the center of the circle.
        :param y: [int|float] The vertical coordinate of the center of the circle.
        :param radius: [int|float] The radius of the circle.
        :param fill_color: [str] The color-array (0-255) or hex string to define the inner fill color of the rectangle.
        :param fill_alpha: [int|float] The opacity of the inner fill color.
        :param stroke_color: [str] The color-array (0-255) or hex string to define the outline color of the rectangle.
        :param stroke_alpha: [int|float] The opacity of the outline color.
        :param thickness: [int|float] The thickness of the line (in the same unit as the canvas's dimensions).
        :return: True or False based on the success of the operation.
        """

        try:

            # Handle color:
            fill_color = self.__parse_color(fill_color)
            stroke_color = self.__parse_color(stroke_color)

            # Adjust the dimensions:
            x = self.__parse_x(x)
            y = self.__parse_y(y, flip = True)
            thickness *= self.__unit_factor
            radius *= self.__unit_factor

            # Draw the circle here:
            self.__canvas.setLineWidth(thickness)
            self.__canvas.setFillColorRGB(fill_color[0], fill_color[1], fill_color[2])
            self.__canvas.setFillAlpha(fill_alpha)
            self.__canvas.setStrokeColorRGB(stroke_color[0], stroke_color[1], stroke_color[2])
            self.__canvas.setStrokeAlpha(stroke_alpha)
            self.__canvas.circle(x, y, radius, stroke = 1, fill = 1)

        # In case something goes wrong:
        except Exception as exception:
            self.__printer(exception)
            if self.__raise_exception: raise
            return False

    def draw_rect(
            self,
            start_x,
            start_y,
            end_x,
            end_y,
            radius = 0.0,
            fill_color = (255, 255, 255),
            fill_alpha = 0.0,
            stroke_color = (0, 0, 0),
            stroke_alpha = 1.0,
            thickness = 1.0,
    ):
        
        """
        Draws a rectangle on the page.
        :param start_x: [int|float] The left coordinate.
        :param start_y: [int|float] The top coordinate.
        :param end_x: [int|float] The right coordinate.
        :param end_y: [int|float] The bottom coordinate.
        :param radius: [int|float] The corner radius.
        :param fill_color: [str] The color-array (0-255) or hex string to define the inner fill color of the rectangle.
        :param fill_alpha: [int|float] The opacity of the inner fill color.
        :param stroke_color: [str] The color-array (0-255) or hex string to define the outline color of the rectangle.
        :param stroke_alpha: [int|float] The opacity of the outline color.
        :param thickness: [int|float] The line thickness of the outline (in the same unit as the canvas's dimensions).
        :return: True or False based on the success of the operation.
        """

        try:

            # Handle color:
            fill_color = self.__parse_color(fill_color)
            stroke_color = self.__parse_color(stroke_color)

            # Adjust the dimensions:
            thickness *= self.__unit_factor
            start_x, end_x = self.__parse_x(start_x, end_x)
            start_y, end_y = self.__parse_y(start_y, end_y, flip = False)

            # Compute the width and height desired by the user:
            width = end_x - start_x
            height = end_y - start_y

            # Draw the rectangle here:
            self.__canvas.setLineWidth(thickness)
            self.__canvas.setFillColorRGB(fill_color[0], fill_color[1], fill_color[2])
            self.__canvas.setFillAlpha(fill_alpha)
            self.__canvas.setStrokeColorRGB(stroke_color[0], stroke_color[1], stroke_color[2])
            self.__canvas.setStrokeAlpha(stroke_alpha)
            self.__canvas.roundRect(
                start_x,
                self.__canvas_height - start_y - height,
                width,
                height,
                radius * self.__unit_factor,
                stroke = 1,
                fill = 1
            )

            return True

        # In case something goes wrong:
        except Exception as exception:
            self.__printer(exception)
            if self.__raise_exception: raise
            return False

    def make_paragraph_style(
            self,
            font_name = "Helvetica",
            font_size = 12,
            font_color = (0, 0, 0),
            line_spacing = 12,
            border_size = 0.0,
            border_color = (255, 255, 255),
            align = "left",
            style_name = "CustomStyle"
    ):

        """
        Creates a text style for using in a paragraph.
        :param font_name: [str] The name of the font. Has to be registered.
        :param font_size: [int|float] The size that you want the font to be printed in.
        :param font_color: [str] The color-array (0-255) or hex string that you want the font to be printed in.
        :param line_spacing: [int|float] The line spacing to apply.
        :param border_size: [int|float] The thickness of the border.
        :param border_color: [str] The color-array (0-255) or hex string of the color you wan the border to be in.
        :param align: [str] Text alignment ("left", "center", or "right").
        :param style_name: [str] A name that you would like to give this style.
        :return: The paragraph style.
        """

        # Make adjustments:
        font_color = self.__parse_color(font_color)
        font_size = self.__parse_font_size(font_size)
        border_color = self.__parse_color(border_color)
        border_size *= self.__unit_factor

        return ParagraphStyle(
            name = style_name,
            fontName = font_name,
            fontSize = font_size,
            textColor = (font_color[0], font_color[1], font_color[2]),
            alignment = {
                "left": 0,
                "center": 1,
                "right": 2
            }[align],
            leading = line_spacing * font_size,
            spaceBefore = 0,
            spaceAfter = 0,
            borderWidth = border_size,
            borderColor = border_color
        )

    def write_paragraph(
            self,
            text,
            start_x,
            start_y,
            end_x,
            end_y,
            alpha = 1.0,
            style = None
    ):

        """
        To write text into a bounding box in the PDF.
        This can be either plaintext or an HTML string.
        :param text: [str] The text to type in the bounding box.
        :param start_x: [int|float] The left coordinate (in the same unit as used while setting up the canvas).
        :param start_y: [int|float] The top coordinate (in the same unit as used while setting up the canvas).
        :param end_x: [int|float] The right coordinate (in the same unit as used while setting up the canvas).
        :param end_y: [int|float] The bottom coordinate (in the same unit as used while setting up the canvas).
        :param alpha: [int|float] The opacity of the text. 1 is 100% and 0 is 0%
        :param style: The paragraph-style generated by using "make_paragraph_style" method. Avoid using this if you are
            passing in an HTML string.
        :return: True or False based on the success of the operation. You will get a False even if the text flows out of
            the bounding box (which will cause it to not render).
        """

        try:

            # Adjust the dimensions:
            start_x, end_x = self.__parse_x(start_x, end_x)
            start_y, end_y = self.__parse_y(start_y, end_y, flip = False)

            # Compute the width and height desired by the user:
            width = end_x - start_x
            height = end_y - start_y

            # Make the paragraph:
            paragraph = Paragraph(text, style = style)

            # Wrap the paragraph to compute the needed dimensions:
            needed_width, needed_height = paragraph.wrap(width, height)

            # Adjust the y coordinates (for top-down system):
            start_y = self.__canvas_height - start_y - needed_height

            # Write the text to the canvas if the text fits.
            # Return true in case of success, else false:
            if width >= needed_width and height >= needed_height:
                self.__canvas.setFillAlpha(alpha)
                paragraph.drawOn(self.__canvas, start_x, start_y, _sW = 0)
                return True
            else: return False

        # In case something goes wrong:
        except Exception as exception:
            self.__printer(exception)
            if self.__raise_exception: raise
            return False

    def write_string(
            self,
            text,
            x,
            y,
            font = "Helvetica",
            size = 12,
            color = (0, 0, 0),
            alpha = 1.0,
            align = "left",
            angle = 0
    ):

        """
        Writes a string onto the canvas.
        :param text: [str] The text to be written.
        :param x: [int|float] The horizontal coordinate (of the baseline).
        :param y: [int|float] The vertical coordinate (of the baseline).
        :param font: [str] The name of the font to be used.
        :param size: [int|float] The size of the font to be used (in points).
        :param color: [str] The color-array (0-255) or hex string that you want the font to be printed in.
        :param alpha: [int|float] The opacity of the text. 1 is 100% and 0 is 0%
        :param align: [str] The alignment to use ("left", "center", "right").
        :param angle: The angle at which you want the text to be printed in.
        :return: True or False based on the success of the operation.
        """

        try:

            # Handle the color
            color = self.__parse_color(color)

            # Adjust the dimensions:
            x = self.__parse_x(x)
            y = self.__parse_y(y, flip = True)
            size = self.__parse_font_size(size)

            # Adjust the canvas's values:
            self.__canvas.setFont(font, size)
            self.__canvas.setFillColorRGB(color[0], color[1], color[2])
            self.__canvas.setFillAlpha(alpha)

            # Handle angular adjustments:
            self.__canvas.rotate(angle)
            x, y = self._parse_coordinates_for_rotation(
                original_x = x,
                original_y = y,
                angle = angle
            )

            # Adjust the font coordinates as per the angle:
            text_width = self.__canvas.stringWidth(text, fontName = font, fontSize = size)
            if align == "center": x -= text_width / 2
            elif align == "right": x -= text_width

            # Draw the string:
            self.__canvas.drawString(x, y, text)

            # Reset angular adjustments:
            self.__canvas.rotate(-angle)

            # Return with success if nothing broke till here:
            return True

        # In case something goes wrong:
        except Exception as exception:
            self.__printer(exception)
            if self.__raise_exception: raise
            return False

    def draw_image(
            self,
            image,
            start_x,
            start_y,
            end_x,
            end_y,
            crop = False,
            crop_anchor = "c",
            alpha = 1.0,
            stroke_color = (0, 0, 0),
            stroke_alpha = 0.0,
            thickness = 1.0,
            identifier: str = None,
            format = "jpeg"
    ):

        """
        Draw an image onto the PDF page in the specified bounding box. The image can be drawn in either "fit" mode where
        the image is scaled down to fit in the bounding box, or in "fill" mode where the image is cropped to fill up the
        space in the bounding box by matching their aspec ratios.
        :param image: The image data as a PIL object or a valid URL or a file path. NOTE: If given as a URL, the image
            will be downloaded synchronously.
        :param start_x: [int|float] The left coordinate.
        :param start_y: [int|float] The top coordinate.
        :param end_x: [int|float] The right coordinate.
        :param end_y: [int|float] The bottom coordinate.
        :param crop: [bool] Set to True for "fill" mode, False for "fit" mode.
        :param crop_anchor: [str] If cropping is to be performed, what should the anchor be for cropping. Refer to the
            description in 'crop_image_to_aspect_ratio'.
        :param alpha: [int|float] The opacity of the image. 1 is 100% and 0 is 0%
        :param stroke_color: [str] The color-array (0-255) or hex string to define the outline color of the rectangle.
        :param stroke_alpha: [int|float] The opacity of the outline color.
        :param thickness: [int|float] The line thickness of the outline (in the same unit as the canvas's dimensions).
        :param identifier: [str] A unique name for the image.
        :param format: The file format of the image (when it will be embedded in the PDF).
        :return: True or False based on the success of the operation.
        """

        try:

            # Cleaning:
            format = format.strip().lower().split(".")[-1]

            # Handle color:
            stroke_color = self.__parse_color(stroke_color)

            # The identifier can allow us to use the same image multiple times in a PDF file.
            # If the identifier is not provided, we give the file a random identifier.
            if identifier is None: identifier = "".join([random.choice(string.ascii_lowercase) for _ in range(8)])

            # Adjust the dimensions:
            start_x, end_x = self.__parse_x(start_x, end_x)
            start_y, end_y = self.__parse_y(start_y, end_y, flip = True)

            # Compute the width and height desired by the user:
            width = end_x - start_x
            height = end_y - start_y

            # In case the image is passed as a URL, we try to downloads it and open it as a PIL object:
            if isinstance(image, str):
                if image.startswith("https://") or image.startswith("http://"):
                    image = Image.open(io.BytesIO(requests.get(image).content))
                else: image = Image.open(image)

            # The image can have orientation data in its EXIF tags.
            # If so, we adjust for it so that reportlab can work with it properly:
            image = self.adjust_image_orientation_from_exif(image)

            # If the image needs to be cropped, we compute the aspect ratio and get the image adjusted:
            if crop: image = self.crop_image_to_aspect_ratio(
                image,
                abs(end_x - start_x) / abs(end_y - start_y),
                anchor = crop_anchor
            )

            # Else we add a blurred background to the image:
            else: image = self.fit_image_to_aspect_ratio(
                image,
                abs(end_x - start_x) / abs(end_y - start_y),
                blur_strength = 0.1
            )

            # We set the image opacity and outline settings here:
            self.__canvas.setFillAlpha(alpha)
            self.__canvas.setLineWidth(thickness * self.__unit_factor)
            self.__canvas.setStrokeColorRGB(stroke_color[0], stroke_color[1], stroke_color[2])
            self.__canvas.setStrokeAlpha(stroke_alpha)

            # Draw the image onto the canvas:
            if (
                    image.mode == "P" or
                    format in ["jpeg", "jpg"]
            ):
                image = image.convert("RGB")
            image_buffer = io.BytesIO()
            image.save(image_buffer, format = format)
            image_buffer.seek(0)
            self.__canvas.drawImage(
                ImageReader(image_buffer, ident = identifier),
                start_x,
                start_y,
                width = width,
                height = height,
                preserveAspectRatio = True,
                mask = "auto",
                anchor = "c",
                showBoundary = True
            )

            return True

        # In case something goes wrong:
        except Exception as exception:
            self.__printer(exception)
            if self.__raise_exception: raise
            return False

    # def draw_svg(
    #         self,
    #         svg,
    #         start_x,
    #         start_y,
    #         end_x,
    #         end_y
    # ):
    #
    #     renderSVG.draw(svg, sta)

    # ┏┓┳┓┏  ┏┳┓    ┓
    # ┃┃┃┃╋   ┃ ┏┓┏┓┃┏
    # ┣┛┻┛┛   ┻ ┗┛┗┛┗┛

    @staticmethod
    def make_thumbnails(
            pdf_file,
            pages = None,
            scale = 1.0,
            as_pil = True,
            format = "png"
    ):

        """
        Makes thumbnails of pages of the PDF and returns them
        :param pdf_file: The PDF file whose pages must be converted to thumbnails.
        :param pages: The list of page nos. to make thumbnails of. If not specified, all pages will be converted.
        :param scale: The scaling multiplier, applied to both axes, to change the output size.
        :param as_pil: If True, the output will be an array of PIL objects, else the output will be an array of
            file-like BytesIO objects.
        :param format: The file format of the output image.
        :return: The array of thumbnails, either as PIL objects, or as BytesIO objects.
        """

        # Open the PDF, and ensure that we have the pages numbers:
        if isinstance(pdf_file, io.BytesIO):
            pdf_file.seek(0)
            pdf_document = fitz.open(stream = pdf_file)
            pdf_file.seek(0)
        else: pdf_document = fitz.open(pdf_file)
        if pages is None: pages = list(range(len(pdf_document)))

        # Iterate over the pages and make the thumbnails:
        thumbnails = []
        for page_no in pages:
            img = pdf_document.load_page(page_no).get_pixmap(matrix = fitz.Matrix(scale, scale))
            img = io.BytesIO(img.tobytes(format))
            if as_pil: img = Image.open(img, formats = [format])
            thumbnails.append(img)

        # Done here:
        return thumbnails

    @staticmethod
    def join_from_ram(pdf_files, raise_exception = True):

        """
        Joins a set of PDF files held in RAM (as BytesIO objects).
        :param pdf_files: The list of PDF files (as BytesIO objects).
        :param raise_exception: Set to True for an all-or-nothing process.
        :return: The joined PDF file (as a BytesIo object).
        """

        try:

            # Create a file in RAM that will hold the merged PDF:
            joined_pdf_in_ram = io.BytesIO()

            # Merge the files into one:
            merger = PdfMerger()
            for pdf_file in pdf_files: merger.append(pdf_file)
            merger.write(joined_pdf_in_ram)
            merger.close()

            # Done here:
            joined_pdf_in_ram.seek(0)
            return joined_pdf_in_ram

        # In case something goes wrong:
        except Exception as exception:
            if raise_exception: raise
            return None

    @staticmethod
    def grid_from_ram(
            pdf_files,
            input_width,
            input_height,
            output_width,
            output_height,
            cutting_width,
            cutting_height,
            unit = "mm",
            dpi = 72
    ):

        """
        Lays out a set of input PDFs into a larger pdf in a grid.
        NOTE: The 'unit' and 'dpi' choice will be used for both (input and output).
        WARNING: I STRONGLY RECOMMEND NOT USING DPI AS A SETTING. THE PDF FORMAT ITSELF DOESN'T HAVE ANY PROVISION FOR
        PIXEL DENSITY. THIS SETTING HAS BEEN MADE AVAILABLE FOR PLACEBO EFFECT WHEN CLIENTS ASK FOR VERY HIGH RESOLUTION
        FILES WITHOUT WANTING TO UNDERSTAND ANY TECHNICALITY. USE WITH CAUTION.
        :param pdf_files: The list of PDF files (as BytesIO objects) to lay onto the larger canvas.
        :param input_width: The width of the input PDFs.
        :param input_height: The height of the input PDFs
        :param output_width: The width of the large PDF on which the smaller PDFs will be laid out.
        :param output_height: The height of the large PDF on which the smaller PDFs will be laid out.
        :param cutting_width: The horizontal buffer to leave between the grid.
        :param cutting_height: The vertical buffer to leave between the grid.
        :param unit: The unit to use (common to the input and output PDFs).
        :param dpi: The pixel density to consider (common to the input and output PDFs).
        :return: The PDF grid.
        """

        # Create the base PDF:
        base_canvas = PDFMaker(
            width = output_width,
            height = output_height,
            unit = unit,
            dpi = dpi
        )
        base_canvas.draw_line(0, 1, 1, 0, alpha = 0.0)
        base_pdf = base_canvas.save()

        # Adjust all units to match the pixel density adjustments:
        input_width *= base_canvas.unit_factor
        input_height *= base_canvas.unit_factor
        output_width *= base_canvas.unit_factor
        output_height *= base_canvas.unit_factor
        cutting_width *= base_canvas.unit_factor
        cutting_height *= base_canvas.unit_factor

        # Calculate how many input pdfs will fit and the page count needed:
        per_row = math.floor((output_width - cutting_width) / (input_width + cutting_width))
        per_column = math.floor((output_height - cutting_height) / (input_height + cutting_height))
        per_page = per_row * per_column
        output_page_count = math.ceil(len(pdf_files) / per_page)

        # Calculate the starting point on the page from where the pasting will start:
        page_start_x = (output_width - (per_row * (input_width + cutting_width)) + cutting_width) / 2.0
        page_start_y = (output_height - (per_column * (input_height + cutting_height)) + cutting_height) / 2.0

        # We make a writer object to write pages to the output file,
        # and we make a list that will hold the individual output pages:
        grid_pdf = PdfWriter()

        # We create each needed page:
        for page_number in range(output_page_count):

            # Open the base PDF and create a page here:
            grid_page = PdfReader(base_pdf).pages[0]

            # Shortlist the input pdfs to be pasted on this page:
            starting_offset = page_number * per_page
            ending_offset = starting_offset + per_page
            ending_offset = min(ending_offset, len(pdf_files))
            pdf_files_for_page = pdf_files[starting_offset:ending_offset]

            # We paste all the input PDFs one-by-one:
            for index, input_pdf in enumerate(pdf_files_for_page):

                # Compute the coordinates to paste on:
                row_number = math.floor(index / per_row)
                column_number = index if index < per_row else index % per_row
                paste_x = page_start_x + (column_number * input_width) + (column_number * cutting_width)
                paste_y = page_start_y + (row_number * input_height) + (row_number * cutting_height)

                # The default system for the reportlab is bottom-up,
                # we convert to top-down for intuitive operations:
                paste_y = base_canvas.shape[1] - paste_y - input_height

                # Open th smaller PDF and paste it on those coordinates:
                input_page = PdfReader(input_pdf).pages[0]
                input_page.add_transformation(Transformation().translate(
                    tx = paste_x,
                    ty = paste_y
                ), expand = True)
                grid_page.merge_page(input_page)

            # Add the grid page to the grid PDF:
            grid_pdf.add_page(grid_page)

        # Join and return the result:
        grid_pdf_in_ram = io.BytesIO()
        grid_pdf.write(grid_pdf_in_ram)
        return grid_pdf_in_ram


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                               MAIN PROGRAM                                                    ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


if __name__ == "__main__":

    import asyncio

    async def main():

        my_pdf = PDFMaker(width = 210, height = 297)
        # my_image = await my_pdf.image_from_url(r"https://images.pexels.com/photos/28973930/pexels-photo-28973930/free-photo-of-historic-saigon-central-post-office-architecture.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1")
        # my_image = await my_pdf.image_from_url(r"")
        # my_image = await my_pdf.image_from_url(r"")
        my_image = await my_pdf.image_from_url(r"https://images.pexels.com/photos/18317748/pexels-photo-18317748/free-photo-of-peoples-committee-of-ho-chi-minh.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1")
        # my_image.show()
        blurred_image = PDFMaker.fit_image_to_aspect_ratio(my_image, 1.49)
        blurred_image.show()

    asyncio.run(main())
