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:
166
.gitignore
vendored
Normal file
166
.gitignore
vendored
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Data
|
||||||
|
data/
|
||||||
|
Ghostscript/
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
flask_session/
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
|
||||||
|
# PEP 582
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Project-specific
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
BIN
Ghostscript.zip
Normal file
BIN
Ghostscript.zip
Normal file
Binary file not shown.
56
README.md
Normal file
56
README.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# LabelExtractor (Честный ЗНАК / CRPT)
|
||||||
|
|
||||||
|
Утилита для автоматизированного извлечения кодов маркировки (DataMatrix) и массовой генерации готовых к печати PDF-этикеток (формат 58x40 мм) на основе данных из Excel-шаблона. Решение оптимизировано для работы с системой «Честный ЗНАК» и создания этикеток для термопринтеров.
|
||||||
|
|
||||||
|
## Ключевые возможности
|
||||||
|
|
||||||
|
* **Распознавание DataMatrix:** Точное чтение кодов из изображений с помощью `zxing-cpp` с сохранением непечатаемых спецсимволов GS1 (включая FNC1 / ASCII 29) посредством кодирования в Base64.
|
||||||
|
* **Интеграция с Excel:** Парсинг атрибутов товара (GTIN, EAN, Описание, Артикул, Цвет, Размер, Организация) из Excel-шаблонов с использованием `pandas`. Чтение строго по позициям колонок обеспечивает защиту от изменения заголовков.
|
||||||
|
* **PDF Генерация:** Автоматическая сборка промышленных макетов этикеток (включая логотипы EAC, Честный ЗНАК, штрихкоды Code128 и DataMatrix) с помощью `reportlab`.
|
||||||
|
* **Умное форматирование текста:** Автоматический перенос строк (Word Wrap), адаптивное уменьшение размера шрифта и умное усечение текста многоточием (при выходе за границы AABB).
|
||||||
|
* **Портативность:** Использование встроенной версии Ghostscript (`gswin32c`) для рендеринга EPS и штрихкодов без необходимости сложной системной настройки.
|
||||||
|
|
||||||
|
## Требования и установка
|
||||||
|
|
||||||
|
Проект написан на Python 3. Установите необходимые зависимости из `requirements.txt`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Зависимости:**
|
||||||
|
* `pillow` — работа с изображениями.
|
||||||
|
* `zxing-cpp` — быстрое и надежное чтение штрихкодов.
|
||||||
|
* `reportlab` — генерация векторных PDF-файлов.
|
||||||
|
* `pandas` & `openpyxl` — чтение и обработка Excel-таблиц.
|
||||||
|
* `treepoem` — генерация штрихкодов (Code128, DataMatrix).
|
||||||
|
|
||||||
|
*Примечание: Убедитесь, что архив `Ghostscript.zip` распакован в папку `Ghostscript` в корне проекта для корректной работы `treepoem` и обработки EPS-файлов.*
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
* `build_pdf.py` — Главный модуль и оркестратор бизнес-логики. Считывает базу данных Excel, группирует входные коды по GTIN, рендерит штрихкоды и генерирует готовые PDF-документы.
|
||||||
|
* `read_image.py` — Утилита для извлечения байтов DataMatrix из картинок и перевода их в безопасный Base64 формат.
|
||||||
|
* `render_eps.py` — Скрипт-конвертер EPS изображений в PNG с использованием портативного Ghostscript.
|
||||||
|
* `Resources/` — Папка с графическими ассетами (логотипы) и исходным файлом `ШАблон для загрузки этикеток.xlsx`.
|
||||||
|
* `data/` — Рабочая директория (содержит входные коды, картинки и генерируемые PDF в папке `output_pdfs`).
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
1. Подготовьте описания ваших товаров в файле `Resources/ШАблон для загрузки этикеток.xlsx`.
|
||||||
|
2. Подготовьте список КМ (кодов маркировки), предварительно закодировав сырые байты DataMatrix в формат Base64. (Для получения Base64 из изображений можно использовать скрипт `read_image.py`).
|
||||||
|
3. Запустите процесс генерации PDF:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python build_pdf.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Готовые многостраничные PDF-файлы (сгруппированные по GTIN) будут сохранены в директории `data/output_pdfs/`.
|
||||||
|
|
||||||
|
## Архитектура решения
|
||||||
|
|
||||||
|
Код разбит на несколько логических слоев:
|
||||||
|
1. **Domain Layer:** Описание структур данных (`LabelData`).
|
||||||
|
2. **Data & Asset Layer:** Функции генерации и трансформации изображений и штрихкодов (`render_code128`, `create_datamatrix_in_memory`). Изображения генерируются без сглаживания (`NEAREST`) для идеальной печати на термопринтерах.
|
||||||
|
3. **Presentation Layer:** Низкоуровневая отрисовка PDF-холста через `reportlab`, позиционирование блоков и алгоритмы подгонки текста (`draw_label_page`, `place_text`).
|
||||||
|
4. **Business Logic Layer:** Оркестрация батчевой обработки данных (`process_batch`). Чтение тяжелых файлов происходит один раз для экономии ресурсов I/O диска.
|
||||||
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")
|
||||||
|
)
|
||||||
36
read_image.py
Normal file
36
read_image.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import zxingcpp as zxing_cpp
|
||||||
|
from PIL import Image
|
||||||
|
import base64
|
||||||
|
|
||||||
|
def read_datamatrix_zxing(file_path: str) -> str:
|
||||||
|
# Открываем изображение через Pillow
|
||||||
|
img = Image.open(file_path)
|
||||||
|
|
||||||
|
# Читаем штрих-код.
|
||||||
|
# zxing_cpp.read_barcodes умеет работать напрямую с объектами PIL
|
||||||
|
results = zxing_cpp.read_barcodes(img)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
print("Коды не найдены на изображении.")
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# Берем первый найденный код
|
||||||
|
result = results[0]
|
||||||
|
|
||||||
|
# Извлекаем именно байты (важно для непечатаемых символов GS/FNC1)
|
||||||
|
raw_bytes = result.bytes
|
||||||
|
|
||||||
|
# Кодируем байты в Base64
|
||||||
|
# b64encode возвращает bytes, поэтому делаем .decode('ascii') для получения строки
|
||||||
|
b64_string = base64.b64encode(raw_bytes).decode('ascii')
|
||||||
|
|
||||||
|
return b64_string
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
image_path = "data/output.png"
|
||||||
|
data = read_datamatrix_zxing(image_path)
|
||||||
|
|
||||||
|
if data:
|
||||||
|
print(f"Успешно прочитано: {data}")
|
||||||
|
else:
|
||||||
|
print("Код не распознан")
|
||||||
25
render_eps.py
Normal file
25
render_eps.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import os
|
||||||
|
import PIL.EpsImagePlugin
|
||||||
|
|
||||||
|
# 1. ПОРТАТИВНОСТЬ: Вычисляем путь относительно этого скрипта
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
gs_path = os.path.join(BASE_DIR, 'Ghostscript', 'bin')
|
||||||
|
|
||||||
|
# Добавляем в PATH
|
||||||
|
os.environ['PATH'] = gs_path + os.pathsep + os.environ.get('PATH', '')
|
||||||
|
|
||||||
|
# 2. ХАК: Принудительно заставляем Pillow использовать 32-битную версию
|
||||||
|
# Мы устанавливаем имя бинарника ДО того, как Pillow начнет его искать
|
||||||
|
PIL.EpsImagePlugin.gs_binary = "gswin32c"
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
file_path = './data/d46349f7-148a-4301-b6b5-f9a3c70fdf19_04639970975115_2000.eps'
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(file_path)
|
||||||
|
img.load(scale=10)
|
||||||
|
img.save('./data/output.png', 'PNG')
|
||||||
|
print("Успех! Файл сохранен в ./data/output.png")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Опять ошибка: {e}")
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pillow
|
||||||
|
zxing-cpp
|
||||||
|
reportlab
|
||||||
|
pandas
|
||||||
|
treepoem
|
||||||
|
openpyxl
|
||||||
BIN
resources/eac-conformity-mark-seeklogo.png
Normal file
BIN
resources/eac-conformity-mark-seeklogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
BIN
resources/logo-CHZ-grey.png
Normal file
BIN
resources/logo-CHZ-grey.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
resources/ШАблон для загрузки этикеток.xlsx
Normal file
BIN
resources/ШАблон для загрузки этикеток.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user