Files
label-extractor/build_pdf.py
gkonoplya 93b28b1ea2 Add initial project structure and core functionality for label extraction and PDF generation
- Created .gitignore to exclude unnecessary files and directories.
- Implemented build_pdf.py for generating PDF labels from Excel data, including barcode rendering.
- Added read_image.py for extracting DataMatrix codes from images using zxing-cpp.
- Introduced render_eps.py for converting EPS images to PNG format with Ghostscript.
- Updated README.md with project overview, features, installation instructions, and usage guidelines.
- Included requirements.txt for dependency management.
- Added resources for logos and sample Excel template.
- Compressed Ghostscript binaries into Ghostscript.zip for easy integration.
2026-02-21 11:46:24 +03:00

418 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import base64
from collections import defaultdict
from dataclasses import dataclass
import pandas as pd
from PIL import Image
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.utils import ImageReader
from reportlab.platypus import Paragraph
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
from read_image import read_datamatrix_zxing
import render_eps
import treepoem
# ---------------------------------------------------------
# 1. СТРУКТУРЫ ДАННЫХ (DOMAIN LAYER)
# ---------------------------------------------------------
@dataclass
class LabelData:
gtin: str
ean: str
description: str
article: str
color: str
size: str
organization: str
page_num: int = 0
@classmethod
def from_excel_row(cls, row: pd.Series):
"""
Парсит строку DataFrame по строго заданным ПОЗИЦИЯМ колонок (индексам).
Игнорирует названия заголовков в Excel-файле для защиты от их случайного изменения.
Ожидаемый порядок колонок:
0: GTIN (14)
1: EAN (13)
2: Описание (200)
3: Артикул (20)
4: Цвет (20)
5: Размер (15)
6: Организация (100)
"""
def extract_by_index_and_limit(col_index: int, limit: int) -> str:
# Безопасное извлечение по индексу: если колонок меньше, чем мы ожидаем, вернем пустоту
if col_index >= len(row):
return ''
val = str(row.iloc[col_index])
# Очистка от мусора парсера pandas
if val.lower() == 'nan' or not val.strip():
return ''
return val[:limit]
return cls(
gtin=extract_by_index_and_limit(0, 14), # Колонка 1 (индекс 0)
ean=extract_by_index_and_limit(1, 13), # Колонка 2 (индекс 1)
description=extract_by_index_and_limit(2, 200), # Колонка 3 (индекс 2)
article=extract_by_index_and_limit(3, 20), # Колонка 4 (индекс 3)
color=extract_by_index_and_limit(4, 20), # Колонка 5 (индекс 4)
size=extract_by_index_and_limit(5, 15), # Колонка 6 (индекс 5)
organization=extract_by_index_and_limit(6, 100) # Колонка 7 (индекс 6)
)
# ---------------------------------------------------------
# 2. РАБОТА С ДАННЫМИ И ИЗОБРАЖЕНИЯМИ (DATA & ASSET LAYER)
# ---------------------------------------------------------
def load_png(file_path: str):
img = Image.open(file_path)
img = img.convert("RGBA")
img = img.resize((img.width * 5, img.height * 5), Image.Resampling.NEAREST)
return ImageReader(img)
def render_code128(text: str) -> ImageReader:
"""Генерирует Code128 штрихкод через Treepoem и Ghostscript (без новых зависимостей)."""
# Защита от пустых или невалидных строк, чтобы предотвратить сбой Ghostscript
if not text or str(text).lower() == 'nan':
text = "0000000000000"
img = treepoem.generate_barcode(
barcode_type='code128',
data=str(text)
)
# Очищаем картинку для термопринтера (масштабируем без сглаживания)
img = img.resize((img.width * 5, img.height * 5), Image.Resampling.NEAREST)
return ImageReader(img)
def extract_key_from_base64(b64_data: str) -> tuple[bytes, str]:
"""Возвращает сырые байты (для рендера) и 14-значный ключ (GTIN) для группировки/поиска."""
raw_bytes = base64.b64decode(b64_data)
raw_string = raw_bytes.decode('utf-8', errors='ignore')
# Срез с 3 по 16-й символ, как вы описывали ранее
search_key = raw_string[2:16]
return raw_bytes, search_key
def create_datamatrix_in_memory(raw_bytes: bytes) -> ImageReader:
"""Генерирует DataMatrix через Treepoem и Ghostscript (без Си-зависимостей)."""
# treepoem напрямую и безопасно передает сырые байты (raw_bytes) в Ghostscript,
# сохраняя все непечатаемые спецсимволы GS1 (ASCII 29)
img = treepoem.generate_barcode(
barcode_type='datamatrix',
data=raw_bytes
)
# Очищаем картинку для термопринтера (убираем сглаживание)
img = img.resize((img.width * 10, img.height * 10), Image.Resampling.NEAREST)
return ImageReader(img)
# ---------------------------------------------------------
# 3. РЕНДЕРИНГ PDF (PRESENTATION LAYER)
# ---------------------------------------------------------
def init_pdf_font():
"""Регистрирует системный шрифт с поддержкой кириллицы (Regular и Bold)."""
font_name = "Arial"
# Обычный шрифт
if os.path.exists("C:\\Windows\\Fonts\\arial.ttf"):
pdfmetrics.registerFont(TTFont('Arial', 'C:\\Windows\\Fonts\\arial.ttf'))
else:
pdfmetrics.registerFont(TTFont('Arial', 'Helvetica'))
# Жирный шрифт
if os.path.exists("C:\\Windows\\Fonts\\arialbd.ttf"):
pdfmetrics.registerFont(TTFont('Arial-Bold', 'C:\\Windows\\Fonts\\arialbd.ttf'))
else:
pdfmetrics.registerFont(TTFont('Arial-Bold', 'Helvetica-Bold'))
return font_name
def place_text(c: canvas.Canvas, text: str, font_name: str,
x: float, y: float, width: float, height: float,
font_size: float, font_min = 3, auto_resize = False, font_type: str = 'regular', align: str = 'left'):
"""
Размещает текст внутри AABB с автоматическим переносом строк (Word Wrap).
Если текст не помещается по высоте в заданный AABB, он автоматически обрезается
с добавлением многоточия '...'.
"""
if not text or str(text).lower() == 'nan' or not str(text).strip():
return
current_font = f"{font_name}-Bold" if font_type == 'bold' else font_name
# Возвращаем РОДНЫЕ константы ReportLab
if align == 'center':
reportlab_align = TA_CENTER
elif align == 'right':
reportlab_align = TA_RIGHT
else:
reportlab_align = TA_LEFT
# Создаем стиль для абзаца
style = ParagraphStyle(
name='CustomStyle',
fontName=current_font,
fontSize=font_size,
leading=font_size ,
alignment=reportlab_align, # type: ignore
)
# 1. Алгоритм усечения текста (Truncation)
original_text = str(text)
current_text = original_text
while True:
# Заменяем явные переносы на HTML-тег для ReportLab
text_for_pdf = current_text.replace('\n', '<br/>')
p = Paragraph(text_for_pdf, style)
# Вычисляем реальные размеры
actual_width, actual_height = p.wrap(width, height)
# Если текст поместился по высоте, или от него осталась только одна буква
if actual_height <= height or len(current_text) <= 3:
break
#Если не помещается, стараемся сначала уменьшить размер шрифта
if style.fontSize > font_min and auto_resize:
style.fontSize -= 0.1
style.leading = style.fontSize
continue
# Если не поместился, откусываем часть с конца и добавляем многоточие
# Откусываем по 3 символа за итерацию для скорости
if current_text.endswith('...'):
current_text = current_text[:-6] + '...'
else:
current_text = current_text[:-3] + '...'
# 2. Вертикальное выравнивание
# Размещаем параграф так, чтобы он был отцентрирован по вертикали внутри бокса
y_draw = y + (height - actual_height) / 2.0
# Запасная защита (если даже один символ не влезает в бокс по высоте)
if actual_height > height:
y_draw = y + height - actual_height
# 3. Отрисовка
p.drawOn(c, x, y_draw)
# =========================================================
# ЛАЙФХАК ДЛЯ ОТЛАДКИ (можете закомментировать после настройки)
# =========================================================
#c.setStrokeColorRGB(0.8, 0.8, 0.8) # Светло-серая линия
#c.rect(x, y, width, height)
def draw_label_page(c: canvas.Canvas, font_name: str, dm_image: ImageReader, label_data: LabelData, readable_km: str):
"""Отрисовывает ОДНУ страницу по промышленному макету (левая и правая зоны)."""
label_width = 58 * mm
label_height = 40 * mm
# =========================================================
# 1. ЛЕВАЯ ПАНЕЛЬ (DataMatrix + КМ)
# =========================================================
# DataMatrix (крупный квадрат слева сверху)
img_size = 23 * mm
img_x = 2 * mm
img_y = label_height - img_size - 2 * mm # Верхний левый угол
c.drawImage(dm_image, img_x, img_y, width=img_size, height=img_size)
# Код Маркировки в 2 строки
# Если мы ранее добавили пробел в КМ, Paragraph сам перенесет его.
# Но для надежности мы принудительно заменяем пробел на тег новой строки:
km_two_lines = readable_km.replace(' ', '\n')
km_box_h = 5 * mm
km_box_y = img_y - km_box_h
place_text(c, km_two_lines, font_name,
x=img_x, y=km_box_y, width=img_size, height=km_box_h,
font_size=5, font_min = 3, font_type='regular', align='center')
CHZ_image_h = 5 * mm
CHZ_image_y = km_box_y - CHZ_image_h
CHZ_width_mm = 15
#c.drawBoundary(c, img_x, CHZ_image_y, width=10 * mm, height=CHZ_image_h)
c.drawImage(load_png('resources/logo-CHZ-grey.png'), img_x, CHZ_image_y, width=CHZ_width_mm * mm, height=CHZ_image_h, mask='auto')
eac_image_h = CHZ_image_h
eac_image_x = img_x + (CHZ_width_mm + 2) * mm
eac_image_y = CHZ_image_y
c.drawImage(load_png('resources/eac-conformity-mark-seeklogo.png'), eac_image_x, eac_image_y, width=eac_image_h, height=CHZ_image_h, mask='auto')
page_num_h = 2 * mm
page_num_y = CHZ_image_y - page_num_h - 0.3 * mm
place_text(c, f'{label_data.page_num}', font_name,
x=img_x, y=page_num_y, width=10 * mm, height=page_num_h,
font_size=8, font_min = 3, font_type='bold', align='center')
#c.drawBoundary(c, img_x, page_num_y, width=10 * mm, height=page_num_h)
# Ниже на левой панели у вас останется место под логотипы ЧЗ, EAC и №1
# ... (резерв места от y=0 до y=10мм)
# =========================================================
# 2. ПРАВАЯ ПАНЕЛЬ (Сборка AABB снизу вверх)
# =========================================================
right_x = img_x + img_size + 2 * mm
right_w = label_width - right_x - 2 * mm # Около 29 мм
# 7. EAN Текст (самый низ)
ean_text_h = 2.5 * mm
ean_text_y = 1 * mm
place_text(c, f"{label_data.ean}", font_name,
x=right_x, y=ean_text_y, width=right_w, height=ean_text_h,
font_size=6, font_type='regular', align='center', auto_resize=True)
# 6. EAN Штрихкод (Серый прямоугольник-заглушка)
ean_barcode_h = 9 * mm
ean_barcode_y = ean_text_y + ean_text_h
code128_image = render_code128(label_data.ean)
c.drawImage(code128_image, right_x, ean_barcode_y, width=right_w, height=ean_barcode_h)
ean_barcode_h += 0.5
# 5. Артикул (одна строка, жирно)
art_h = 2 * mm
art_y = ean_barcode_y + ean_barcode_h
place_text(c, f"арт.: {label_data.article}", font_name,
x=right_x, y=art_y, width=right_w, height=art_h,
font_size=5, font_type='bold', align='center')
# 4. Размер (одна строка, жирно)
size_h = 2 * mm
size_y = art_y + art_h
place_text(c, f"размер: {label_data.size}", font_name,
x=right_x, y=size_y, width=right_w, height=size_h,
font_size=5, font_type='bold', align='center')
# 3. Цвет (одна строка, жирно)
color_h = 2 * mm
color_y = size_y + size_h
place_text(c, f"цвет: {label_data.color}", font_name,
x=right_x, y=color_y, width=right_w, height=color_h,
font_size=5, font_type='bold', align='center')
# 2. Описание (Крупно, жирно, центр, занимает всё среднее пространство)
desc_h = 16 * mm
desc_y = color_y + color_h
place_text(c, f"{label_data.description}", font_name,
x=right_x, y=desc_y, width=right_w, height=desc_h,
font_size=7, font_type='bold', align='center', auto_resize=True)
# 1. Организация (На самом верху, мелко)
org_h = 3 * mm
org_y = desc_y + desc_h
place_text(c, f"{label_data.organization}", font_name,
x=right_x, y=org_y, width=right_w, height=org_h,
font_size=4.5, font_type='bold', align='center', auto_resize=True)
c.showPage()
# ---------------------------------------------------------
# 4. ОРКЕСТРАТОР ПАЙПЛАЙНА (BUSINESS LOGIC)
# ---------------------------------------------------------
def process_batch(base64_codes: list[str], excel_path: str, output_dir: str):
"""
Группирует КМ по GTIN, читает структуру из Excel один раз
и собирает многостраничные PDF документы.
"""
if not os.path.exists(output_dir):
os.makedirs(output_dir)
# 1. Читаем базу данных (Excel) один раз, чтобы не дергать диск в цикле
print(f"Чтение Excel-шаблона из: {excel_path}...")
df = pd.read_excel(excel_path)
df_str = df.astype(str) # Сразу приводим к строкам для быстрого поиска
# 2. Группируем входящие коды (КМ) по GTIN / Ключу
# Структура: { '1234567890123': [(raw_bytes_1, b64_1), (raw_bytes_2, b64_2)] }
grouped_codes = defaultdict(list)
for b64 in base64_codes:
try:
raw_bytes, search_key = extract_key_from_base64(b64)
grouped_codes[search_key].append(raw_bytes)
except Exception as e:
print(f"Ошибка парсинга кода {b64[:10]}... : {e}")
font_name = init_pdf_font()
# 3. Оркестрация: 1 GTIN = 1 Многостраничный PDF файл
for search_key, raw_bytes_list in grouped_codes.items():
print(f"\nНачинаем сборку PDF для GTIN: {search_key} (кодов: {len(raw_bytes_list)})")
# 3.1. Ищем структуру для этого GTIN в таблице
mask = df_str.apply(lambda row: row.str.contains(search_key).any(), axis=1)
matched_rows = df[mask]
if matched_rows.empty:
print(f" [ПРОПУСК] GTIN {search_key} не найден в Excel. Этикетки не сгенерированы.")
continue
target_row = matched_rows.iloc[0]
# Загружаем лимитированную структуру (Dataclass)
label_data = LabelData.from_excel_row(target_row)
# 3.2. Создаем холст PDF для группы
pdf_path = os.path.join(output_dir, f"Labels_{search_key}.pdf")
# Размер страницы 58х40 мм
c = canvas.Canvas(pdf_path, pagesize=(58*mm, 40*mm))
# 3.3. Рендерим каждую этикетку как отдельную страницу в документе
for i, raw_bytes in enumerate(raw_bytes_list):
label_data.page_num = i + 1
# Рендер DM полностью в оперативной памяти (без I/O диска)
dm_image = create_datamatrix_in_memory(raw_bytes)
# Декодируем сырые байты в читаемую строку (игнорируя непечатаемый GS/FNC1)
# и берем первые 31 символ (обычно это: 01 + 14 GTIN + 21 + 13 Серийник)
readable_km = raw_bytes.decode('utf-8', errors='ignore')[:31]
# Вставляем спасительный пробел после 16-го символа
readable_km = readable_km[:16] + ' ' + readable_km[16:]
# Рисуем страницу, передавая ПОЛНЫЙ КОД МАРКИРОВКИ вместо короткого search_key
draw_label_page(c, font_name, dm_image, label_data, readable_km)
# Сохраняем и закрываем сгенерированный PDF документ
c.save()
print(f" -> Сохранен файл: {pdf_path}")
if __name__ == "__main__":
# Тестовый пример оркестрации:
base_dir = os.path.dirname(os.path.abspath(__file__))
image_path = "data/output.png"
data = read_datamatrix_zxing(image_path)
# Замените своими боевыми Base64 строками
mock_base64_list = [
read_datamatrix_zxing(image_path),
read_datamatrix_zxing(image_path)
]
process_batch(
base64_codes=mock_base64_list,
excel_path=os.path.join(base_dir, "resources", "ШАблон для загрузки этикеток.xlsx"),
output_dir=os.path.join(base_dir, "data", "output_pdfs")
)