0bb9553150
git-subtree-dir: utils_v2 git-subtree-split: 398cbc6f75cdfb8ce0cca27898c3854540a208eb
1643 lines
62 KiB
Python
1643 lines
62 KiB
Python
"""
|
|
|
|
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 download 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 download 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 download 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 download 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())
|