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.
This commit is contained in:
418
build_pdf.py
Normal file
418
build_pdf.py
Normal file
@@ -0,0 +1,418 @@
|
||||
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")
|
||||
)
|
||||
Reference in New Issue
Block a user