Заметки · 13.03.2026

Склеить и сохранить

Этот пост носит малоприкладной характер и будет интересен исключительно в виде заметки о том, как ещё можно обойти работу в прикладном ПО, потратив час на автоматизацию очередной рутинной задачи.

Помнится, где-то здесь я уже ковырялся в дебрях автоматизации распечатки увесистой пачки jpg. Но тогда я разрабатывал скрипт на PowerShell (тег). Здесь же всю работу я осуществил на Python.

К сабжу привела ситуация, когда в сетевой папке накопилась целая куча сканированных и импортированных из разных приложений чеков. Часть из них представляла собой парный набор, которые было бы правильнее объединить в один документ (желательно) формата А4 для более упорядоченного хранения и последующей распечатки. В контексте этого поста конечный документ я назвал чек-листом (не путать с контрольным листом).

Спустя пару часов файлового аудита, обработки графической составляющей изображений для повышения читаемости и приведения в порядок имён файлов (об этом чуть позже) — я приступил к написанию основного скрипта.

Логика творения такова: скрипту в качестве аргументов должны быть переданы два изображения формата jpg и имя выходного файла в формате pdf. Под капотом должна срабатывать магия с формированием листа А4, разделённого на две колонки. В каждой колонке должно располагаться одно изображение. Важным моментом здесь является масштабирование изображений, чтобы избежать последующего наложения. Завершается весь этот праздник сохранением итогового документа.

Для работы с аргументами я обратился к модулю argparse. Про рутину написания параметров можно забыть, просто указав всё, что нужно в соответствующих директивах:

parser = argparse.ArgumentParser(description=f"{title} | {version}")
parser.add_argument("-i", "--images", required=True, nargs='+', help="Список путей к файлам изображений, которые нужно объединить.")
parser.add_argument("-o", "--output", required=True, help="Имя выходного файла для сохранения страницы (с расширением .jpg или .pdf).")
args = parser.parse_args()

# Вызов функции создания страницы #
create_page(args.images, args.output)

Из этого фрагмента видно, что я объявил для скрипта два параметра:

i/--images и -o/--output

Они оба являются обязательными, оба имеют описание для вывода помощи. Параметр images принимает множественные значения.

Параметр help (-h) argparse формирует сам, основываясь на описании добавленных параметров. Вывод помощи выглядит так:

usage: createchecklist.py [-h] -i IMAGES [IMAGES ...] -o OUTPUT

Скрипт для формирования чек-листов | 1.0.1.2

options:
  -h, --help            show this help message and exit
  -i IMAGES [IMAGES ...], --images IMAGES [IMAGES ...]
     Список путей к файлам изображений, которые нужно объединить.
  -o OUTPUT, --output OUTPUT
     Имя выходного файла для сохранения страницы (с расширением .jpg или .pdf).

Если вызвать скрипт без параметров — в терминал упадёт краткая справка и соответствующая ошибка:

usage: createchecklist.py [-h] -i IMAGES [IMAGES ...] -o OUTPUT
createchecklist.py: error: the following arguments are required: -i/--images, -o/--output

Ну и, наконец, сам скрипт createchecklist.py:

from PIL import Image
import argparse
import os
import sys

# Заголовок скрипта #
title = "Скрипт для формирования чек-листов"

# Версия скрипта #
version = "1.0.1.2"

# Размеры страницы A4 в пикселях (при 300 DPI) #
PAGE_WIDTH = 2480  # 21 см
PAGE_HEIGHT = 3508  # 29.7 см
MARGIN = 118  # 1 см в пикселях

# Ширина колонки с учетом отступов #
COLUMN_WIDTH = (PAGE_WIDTH - 3 * MARGIN) // 2

# Максимальная высота для изображения #
MAX_HEIGHT = PAGE_HEIGHT - 2 * MARGIN

# Функция создания страницы #
def create_page(image_files, output_file):
    # Проверка существования файлов изображений #
    for file in image_files:
        if not os.path.isfile(file):
            print(f"Файл не найден: {file}")
            sys.exit(1)

    # Создаем новое изображение для страницы #
    page = Image.new("RGB", (PAGE_WIDTH, PAGE_HEIGHT), "white")

    images = []
    for file in image_files:
        image = Image.open(file)
        images.append(image)

    # Переменные для размещения изображений #
    y_offset = MARGIN
    max_col_height = 0

    for index, image in enumerate(images):
        image.thumbnail((COLUMN_WIDTH, MAX_HEIGHT))
        
        # Определяем позицию для размещения изображения #
        # Левая колонка #
        if index % 2 == 0:
            x_offset = MARGIN
        # Правая колонка #
        # Просчет положения правой колонки #
        else:
            x_offset = PAGE_WIDTH - MARGIN - image.width
        
        # Размещение изображения на странице
        page.paste(image, (x_offset, y_offset))
        
        # Обновление максимальной высоты для колонки
        # Если это левое изображение #
        if index % 2 == 0:
            max_col_height = max(max_col_height, image.height)
        else:  
            # Если это правое изображение, переходим на новую строку #
            y_offset += max_col_height
            max_col_height = 0

    # Если количество изображений нечётное, нам нужно обработать последнее изображение #
    if len(images) % 2 != 0:
        y_offset += max_col_height

    # Сохраняем итоговое изображение в указанном формате #
    file_extension = os.path.splitext(output_file)[-1].lower()
    if file_extension in ('.jpg', '.jpeg'):
        page.save(output_file, "JPEG")
    elif file_extension == '.pdf':
        page.save(output_file, "PDF")
    else:
        print(f"Неподдерживаемый формат файла: {file_extension}. Используйте .jpg или .pdf.")
        sys.exit(1)

# Основная функция #
def main():
    # Парсинг аргументов #
    parser = argparse.ArgumentParser(description=f"{title} | {version}")
    parser.add_argument("-i", "--images", required=True, nargs='+', help="Список путей к файлам изображений, которые нужно объединить.")
    parser.add_argument("-o", "--output", required=True, help="Имя выходного файла для сохранения страницы (с расширением .jpg или .pdf).")
    args = parser.parse_args()
    # Вызов функции создания страницы #
    create_page(args.images, args.output)

# Точка входа #
if __name__ == "__main__":
    main()

В основной функции скрипта create_page заложен принцип формирования чек-листа с учётом размещения не более двух изображений на листе, учитывая масштабирование и дальнейшее расположение.

Дальше хотелось бы упомянуть принцип именования файлов и автоматизацию запуска. Для автоматизации я написал ещё один скрипт и назвал его batch.py.

Принцип именования изображений был организован так:

Общее название - Номер чека (максимальное значение 2)
Например, "Закупка от 2024.12.01 - 01"

За разделитель я взял символ «-». Теперь оставалось разместить в папке пачку изображений и запустить скрипт batch.py:

import os
import subprocess
from collections import defaultdict

# Функция поиска jpg файлов #
def find_jpg_files():
    return [f for f in os.listdir('.') if f.lower().endswith('.jpg')]

# Группировка файлов #
def group_files_by_name(files):
    groups = defaultdict(list)
    for file in files:
        # Имя до символа "-" #
        base_name = file.split('-')[0].strip()
        groups[base_name].append(file)
    # Оставить только группы с 2 и более файлами #
    return {k: v for k, v in groups.items() if len(v) > 1}

# Основная функция обработки файлов #
def process_files():
    jpg_files = find_jpg_files()
    grouped_files = group_files_by_name(jpg_files)

    # Если есть группы файлов >=2, обрабатываем их #
    if grouped_files:
        for base_name, files in grouped_files.items():
            output_file = f"{base_name}.pdf"
            # Берем только первые два файла #
            input_files = " ".join(files[:2])
            # Вызов скрипта для создания страницы #
            subprocess.run(['python', 'createchecklist.py', '-i', *files[:2], '-o', output_file])
            
    # Если групп нет, но есть хотя бы один jpg файл, обрабатываем его #
    elif jpg_files:
        # Берем первый файл в списке #
        single_file = jpg_files[0]
        # Имя выходного файла #
        output_file = f"{single_file.split('.')[0]}.pdf"
        # Вызов скрипта для создания страницы #
        subprocess.run(['python', 'createchecklist.py', '-i', single_file, '-o', output_file])
    else:
        print("Не найдено файлов .jpg в текущей директории.")

# Точка входа #
if __name__ == "__main__":
    process_files()

В итоговом варианте получаются файлы pdf именованные по первой части имени парных файлов до символа «-». Всё, что расположено в имени после «-» — отбрасывается. Основываясь на примере, приведённом выше, имя файла будет таковым: «Закупка от 2024.12.01.pdf». В самом же файле будут содержаться два чека, расположенные на одном листе в две колонки.