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 import html import click from pathlib import Path # --------------------------------------------------------- # 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 * 5, img.height * 5), 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 = html.escape(current_text) 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 = 4 * 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 = 3 * 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=20 * mm, height=page_num_h, font_size=8, font_min = 3, font_type='bold', align='left') #c.drawBoundary(c, img_x, page_num_y, width=20 * 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 = 3 * 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 = 8 * 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 = 3 * 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=7, font_type='bold', align='center', auto_resize=True) # 4. Размер (одна строка, жирно) size_h = 3 * 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=7, font_type='bold', align='center', auto_resize=True) # 3. Цвет (одна строка, жирно) color_h = 3 * 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=7, font_type='bold', align='center', auto_resize=True) # 2. Описание (Крупно, жирно, центр, занимает всё среднее пространство) desc_h = 13 * 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() total_files = len(grouped_codes) # 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"{label_data.article}_{label_data.size}_{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) print(' ', end='\r') print(f'Обработана страница {label_data.page_num} из {len(raw_bytes_list)}', end='\r') # Сохраняем и закрываем сгенерированный PDF документ c.save() total_files -= 1 click.echo(f" -> Сохранен файл: {pdf_path} \n Осталось {total_files}") @click.command(help="Генерирует PDF-этикетки на основе извлеченных Base64-кодов и Excel-шаблона.") @click.argument('codes_xlsx', type=click.Path(exists=True, dir_okay=False, path_type=Path)) @click.argument('template_xlsx', type=click.Path(exists=True, dir_okay=False, path_type=Path)) @click.argument('output_dir', type=click.Path(file_okay=False, writable=True, path_type=Path)) def cli(codes_xlsx: Path, template_xlsx: Path, output_dir: Path): """ Создает многостраничные PDF-файлы для термопринтера. CODES_XLSX: Путь к XLSX-файлу со списком кодов (структура: Текст / Base64). TEMPLATE_XLSX: Путь к XLSX-файлу с описанием товаров (GTIN, Артикул, Цвет и т.д.). OUTPUT_DIR: Папка, куда будут сохранены готовые PDF-файлы. """ click.echo(f"Подготовка к генерации PDF...") click.echo(f"Файл с кодами: {codes_xlsx.name}") click.echo(f"Файл шаблона: {template_xlsx.name}") try: # 1. Читаем файл с извлеченными кодами codes_df = pd.read_excel(codes_xlsx, engine='openpyxl') # Проверяем наличие нужной колонки if "Base64" not in codes_df.columns: click.secho("Ошибка: В файле кодов отсутствует колонка 'Base64'.", fg="red") return # Извлекаем все коды в список строк base64_list = codes_df["Base64"].dropna().astype(str).tolist() if not base64_list: click.secho("Предупреждение: Список Base64 кодов пуст.", fg="yellow") return click.echo(f"Успешно загружено {len(base64_list)} кодов из {codes_xlsx.name}.") # 2. Создаем выходную директорию (если её нет) # exist_ok=True предотвращает ошибку, если папка уже существует output_dir.mkdir(parents=True, exist_ok=True) # 3. Запускаем основной оркестратор # process_batch ожидает пути в виде строк (str), поэтому оборачиваем Path в str() process_batch( base64_codes=base64_list, excel_path=str(template_xlsx), output_dir=str(output_dir) ) click.secho(f"Генерация успешно завершена! Файлы сохранены в: {output_dir.absolute()}", fg="green") except Exception as e: click.secho(f"Критическая ошибка при генерации PDF: {e}", fg="red") if __name__ == "__main__": cli() # Left priamry for testing reasons! # test_str = Path('c:/Python/CRPT/LabelExtractor/data/штучка_на_печать.xlsx') # template_path = Path('c:/Python/CRPT/LabelExtractor/data/Шаблон для загрузки этикеток.xlsx') # output_path = Path('c:/Python/CRPT/LabelExtractor/data/output/units') # cli(test_str,template_path, output_path)