- test_integration.py: QR 토큰 생성 및 라벨 테스트 - samples/barcode_print.py: Brother QL 프린터 예제 - samples/barcode_reader_gui.py: 바코드 리더 GUI 참고 코드 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
373 lines
14 KiB
Python
373 lines
14 KiB
Python
"""
|
|
바코드 스캔 시 간단한 라벨 자동 출력 모듈
|
|
Brother QL-810W 프린터용
|
|
"""
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from brother_ql.raster import BrotherQLRaster
|
|
from brother_ql.conversion import convert
|
|
from brother_ql.backends.helpers import send
|
|
import os
|
|
import logging
|
|
import re
|
|
from datetime import datetime
|
|
import glob
|
|
|
|
# 프린터 설정
|
|
PRINTER_IP = "192.168.0.168"
|
|
PRINTER_PORT = 9100
|
|
PRINTER_MODEL = "QL-810W"
|
|
LABEL_TYPE = "29"
|
|
|
|
# 로깅 설정
|
|
logging.basicConfig(level=logging.INFO, format='[BARCODE_PRINT] %(levelname)s: %(message)s')
|
|
|
|
|
|
def normalize_medication_name(med_name):
|
|
"""
|
|
약품명 정제 (print_label.py의 함수 복사)
|
|
- 괄호 제거
|
|
- 밀리그램 → mg 변환
|
|
- 대괄호 제거
|
|
|
|
Args:
|
|
med_name: 약품명
|
|
|
|
Returns:
|
|
str: 정제된 약품명
|
|
"""
|
|
if not med_name:
|
|
return med_name
|
|
|
|
# 대괄호 및 내용 제거
|
|
med_name = re.sub(r'\[.*?\]', '', med_name)
|
|
med_name = re.sub(r'\[.*$', '', med_name)
|
|
|
|
# 소괄호 및 내용 제거
|
|
med_name = re.sub(r'\(.*?\)', '', med_name)
|
|
med_name = re.sub(r'\(.*$', '', med_name)
|
|
|
|
# 언더스코어 뒤 내용 제거
|
|
med_name = re.sub(r'_.*$', '', med_name)
|
|
|
|
# 밀리그램 변환
|
|
med_name = re.sub(r'밀리그램|밀리그람|미리그램|미리그람', 'mg', med_name)
|
|
|
|
# 마이크로그램 변환
|
|
med_name = re.sub(r'마이크로그램|마이크로그람', 'μg', med_name)
|
|
|
|
# 그램 변환 (단, mg/μg로 이미 변환된 것은 제외)
|
|
med_name = re.sub(r'(?<!m)(?<!μ)그램|그람', 'g', med_name)
|
|
|
|
# 공백 정리
|
|
med_name = re.sub(r'\s+', ' ', med_name).strip()
|
|
|
|
return med_name
|
|
|
|
|
|
def create_wide_label(goods_name, sale_price):
|
|
"""
|
|
가로형 와이드 라벨 이미지 생성 (product_label.py 기반)
|
|
|
|
- 크기: 800 x 306px (Brother QL 29mm 가로형)
|
|
- 하드코딩: 효능 "치통/진통제", 용법, 사용팁
|
|
- 동적: 약품명만 스캔된 값 사용
|
|
|
|
Args:
|
|
goods_name: 약품명 (스캔된 실제 약품명)
|
|
sale_price: 판매가 (사용하지 않음, 호환성 유지)
|
|
|
|
Returns:
|
|
PIL.Image: 생성된 가로형 라벨 이미지 (800x306px, mode='1')
|
|
"""
|
|
try:
|
|
# 1. 캔버스 생성 (가로로 긴 형태)
|
|
width = 800
|
|
height = 306 # Brother QL 29mm 용지 폭
|
|
|
|
img = Image.new('1', (width, height), 1) # 흰색 배경
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# 2. 폰트 로드
|
|
font_path = os.path.join(os.path.dirname(__file__), "fonts", "malgunbd.ttf")
|
|
try:
|
|
font_effect = ImageFont.truetype(font_path, 72) # 효능 (매우 크게!)
|
|
font_drugname = ImageFont.truetype(font_path, 36) # 약품명 (중간)
|
|
font_dosage = ImageFont.truetype(font_path, 50) # 용법 (크게, 사용팁 없으므로)
|
|
font_pharmacy = ImageFont.truetype(font_path, 32) # 약국명 (크게)
|
|
font_small = ImageFont.truetype(font_path, 26) # 사용팁
|
|
except IOError:
|
|
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
|
|
font_effect = ImageFont.load_default()
|
|
font_drugname = ImageFont.load_default()
|
|
font_dosage = ImageFont.load_default()
|
|
font_pharmacy = ImageFont.load_default()
|
|
font_small = ImageFont.load_default()
|
|
|
|
# 3. 하드코딩 데이터
|
|
effect = "치통/진통제"
|
|
dosage_instruction = "1캡슐 또는 2캡슐 복용, 1일 최대 5캡슐 [다른 NSAID와 복용시 약사와 상담]"
|
|
usage_tip = "식후 복용 권장"
|
|
|
|
# 4. 약품명 정제
|
|
goods_name = normalize_medication_name(goods_name)
|
|
|
|
# 5. 레이아웃 시작
|
|
x_margin = 25
|
|
|
|
# 효능 - 중앙 상단에 크게 (매우 강조!)
|
|
effect_bbox = draw.textbbox((0, 0), effect, font=font_effect)
|
|
effect_width = effect_bbox[2] - effect_bbox[0]
|
|
effect_x = (width - effect_width) // 2
|
|
|
|
# 굵게 표시 (offset)
|
|
for offset in [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]:
|
|
draw.text((effect_x + offset[0], 20 + offset[1]), effect, font=font_effect, fill=0)
|
|
|
|
# 약품명 - "치통/진통제 (약품명)" 형식으로 오른쪽에 표시
|
|
# 괄호 형식으로 표시
|
|
drugname_text = f"({goods_name})"
|
|
|
|
# 효능 텍스트 끝 위치 계산
|
|
effect_end_x = effect_x + effect_width + 30 # 효능 끝에서 30px 여백
|
|
|
|
# 동적 폰트 크기 조정 (박스 안에 들어오도록)
|
|
max_drugname_width = width - effect_end_x - 50 # 오른쪽 여백 50px
|
|
drugname_font_size = 48 # 초기 폰트 크기 (크게 시작)
|
|
|
|
while drugname_font_size > 20: # 최소 20pt까지 축소
|
|
font_drugname_dynamic = ImageFont.truetype(font_path, drugname_font_size)
|
|
drugname_bbox = draw.textbbox((0, 0), drugname_text, font=font_drugname_dynamic)
|
|
drugname_width = drugname_bbox[2] - drugname_bbox[0]
|
|
|
|
if drugname_width <= max_drugname_width:
|
|
break
|
|
drugname_font_size -= 2 # 2pt씩 축소
|
|
|
|
# 효능과 같은 Y 위치 (중앙 정렬)
|
|
drugname_height = drugname_bbox[3] - drugname_bbox[1]
|
|
drugname_y = 20 + (72 - drugname_height) // 2
|
|
draw.text((effect_end_x, drugname_y), drugname_text, font=font_drugname_dynamic, fill=0)
|
|
|
|
# 용법 - 왼쪽 하단에 크게 표시 (동적 폰트 크기 조정)
|
|
y = 120 # 효능 아래부터 시작
|
|
|
|
if dosage_instruction:
|
|
# 대괄호로 묶인 부분을 별도 줄로 분리
|
|
dosage_text = re.sub(r'\s*(\[.*?\])\s*', r'\n\1\n', dosage_instruction)
|
|
|
|
# 여러 줄 처리
|
|
dosage_lines = []
|
|
|
|
# 줄바꿈으로 먼저 분리
|
|
text_parts = dosage_text.split('\n')
|
|
|
|
for part in text_parts:
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
|
|
# 대괄호로 묶인 부분은 그대로 한 줄로
|
|
if part.startswith('[') and part.endswith(']'):
|
|
dosage_lines.append(part)
|
|
# 일반 텍스트는 그대로 추가 (폰트 크기로 조정)
|
|
else:
|
|
dosage_lines.append(part)
|
|
|
|
# 동적 폰트 크기 조정 (박스 안에 들어오도록)
|
|
max_dosage_width = width - x_margin - 50 # 좌우 여백
|
|
dosage_font_size = 50 # 초기 폰트 크기
|
|
|
|
# 가장 긴 줄을 기준으로 폰트 크기 조정
|
|
longest_line = max(dosage_lines, key=len) if dosage_lines else ""
|
|
test_line = f"□ {longest_line}"
|
|
|
|
while dosage_font_size > 30: # 최소 30pt까지 축소
|
|
font_dosage_dynamic = ImageFont.truetype(font_path, dosage_font_size)
|
|
test_bbox = draw.textbbox((0, 0), test_line, font=font_dosage_dynamic)
|
|
test_width = test_bbox[2] - test_bbox[0]
|
|
|
|
if test_width <= max_dosage_width:
|
|
break
|
|
dosage_font_size -= 2 # 2pt씩 축소
|
|
|
|
# 첫 줄에 체크박스 추가
|
|
if dosage_lines:
|
|
first_line = f"□ {dosage_lines[0]}"
|
|
draw.text((x_margin, y), first_line, font=font_dosage_dynamic, fill=0)
|
|
|
|
# 줄 간격 조정 (폰트 크기에 비례)
|
|
line_spacing = int(dosage_font_size * 1.2)
|
|
y += line_spacing
|
|
|
|
# 나머지 줄
|
|
for line in dosage_lines[1:]:
|
|
# 대괄호로 묶인 줄은 들여쓰기 없이
|
|
indent = 0 if (line.startswith('[') and line.endswith(']')) else 30
|
|
draw.text((x_margin + indent, y), line, font=font_dosage_dynamic, fill=0)
|
|
y += line_spacing + 2
|
|
|
|
# 사용팁 (체크박스 + 텍스트)
|
|
if usage_tip and y < height - 60:
|
|
tip_text = f"□ {usage_tip}"
|
|
# 길면 축약
|
|
if len(tip_text) > 55:
|
|
tip_text = tip_text[:52] + "..."
|
|
draw.text((x_margin, y), tip_text, font=font_small, fill=0)
|
|
|
|
# 약국명 - 오른쪽 하단에 크게 (테두리 박스)
|
|
sign_text = "청춘약국"
|
|
sign_bbox = draw.textbbox((0, 0), sign_text, font=font_pharmacy)
|
|
sign_width = sign_bbox[2] - sign_bbox[0]
|
|
sign_height = sign_bbox[3] - sign_bbox[1]
|
|
|
|
# 패딩 설정
|
|
sign_padding_lr = 10 # 좌우 패딩
|
|
sign_padding_top = 5 # 상단 패딩
|
|
sign_padding_bottom = 10 # 하단 패딩
|
|
|
|
sign_x = width - sign_width - x_margin - 10 - sign_padding_lr
|
|
sign_y = height - 55 # 위치 고정
|
|
|
|
# 테두리 박스 그리기
|
|
box_x1 = sign_x - sign_padding_lr
|
|
box_y1 = sign_y - sign_padding_top
|
|
box_x2 = sign_x + sign_width + sign_padding_lr
|
|
box_y2 = sign_y + sign_height + sign_padding_bottom
|
|
draw.rectangle([box_x1, box_y1, box_x2, box_y2], outline=0, width=2)
|
|
|
|
# 약국명 텍스트 (굵게)
|
|
for offset in [(0, 0), (1, 0), (0, 1), (1, 1)]:
|
|
draw.text((sign_x + offset[0], sign_y + offset[1]), sign_text, font=font_pharmacy, fill=0)
|
|
|
|
# 테두리 (가위선 스타일)
|
|
for i in range(3):
|
|
draw.rectangle([5 + i, 5 + i, width - 5 - i, height - 5 - i], outline=0)
|
|
|
|
logging.info(f"가로형 와이드 라벨 이미지 생성 성공: {goods_name}")
|
|
return img
|
|
|
|
except Exception as e:
|
|
logging.error(f"가로형 와이드 라벨 이미지 생성 실패: {e}")
|
|
raise
|
|
|
|
|
|
def cleanup_old_preview_files(max_files=10):
|
|
"""
|
|
임시 미리보기 파일 정리 (최대 개수 초과 시 오래된 파일 삭제)
|
|
|
|
Args:
|
|
max_files: 유지할 최대 파일 개수
|
|
"""
|
|
try:
|
|
temp_dir = os.path.join(os.path.dirname(__file__), "temp")
|
|
if not os.path.exists(temp_dir):
|
|
return
|
|
|
|
# label_preview_*.png 파일 목록 가져오기
|
|
preview_files = glob.glob(os.path.join(temp_dir, "label_preview_*.png"))
|
|
|
|
# 파일 개수가 max_files를 초과하면 오래된 파일 삭제
|
|
if len(preview_files) > max_files:
|
|
# 생성 시간 기준으로 정렬 (오래된 순)
|
|
preview_files.sort(key=os.path.getmtime)
|
|
|
|
# 초과된 파일 삭제
|
|
files_to_delete = preview_files[:len(preview_files) - max_files]
|
|
for file_path in files_to_delete:
|
|
try:
|
|
os.remove(file_path)
|
|
logging.info(f"오래된 미리보기 파일 삭제: {file_path}")
|
|
except Exception as e:
|
|
logging.warning(f"파일 삭제 실패: {file_path} - {e}")
|
|
|
|
except Exception as e:
|
|
logging.warning(f"미리보기 파일 정리 실패: {e}")
|
|
|
|
|
|
def print_barcode_label(goods_name, sale_price, preview_mode=False):
|
|
"""
|
|
바코드 스캔 시 가로형 와이드 라벨 출력 또는 미리보기
|
|
|
|
Args:
|
|
goods_name: 약품명
|
|
sale_price: 판매가 (호환성 유지용, 내부 미사용)
|
|
preview_mode: True = 이미지 파일 경로 반환, False = 프린터 전송
|
|
|
|
Returns:
|
|
preview_mode=True: (성공 여부, 이미지 파일 경로)
|
|
preview_mode=False: 성공 여부 (bool)
|
|
"""
|
|
try:
|
|
logging.info(f"가로형 와이드 라벨 {'미리보기' if preview_mode else '출력'} 시작: {goods_name}")
|
|
|
|
# 1. 가로형 라벨 이미지 생성
|
|
label_image = create_wide_label(goods_name, sale_price)
|
|
|
|
# 2. 미리보기 모드: PNG 파일로 저장
|
|
if preview_mode:
|
|
# temp 디렉터리 생성
|
|
temp_dir = os.path.join(os.path.dirname(__file__), "temp")
|
|
os.makedirs(temp_dir, exist_ok=True)
|
|
|
|
# 파일명 생성 (타임스탬프 포함)
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
filename = f"label_preview_{timestamp}.png"
|
|
file_path = os.path.join(temp_dir, filename)
|
|
|
|
# PNG로 저장 (회전하지 않은 가로형 이미지)
|
|
label_image.save(file_path, "PNG")
|
|
|
|
logging.info(f"미리보기 이미지 저장 완료: {file_path}")
|
|
|
|
# 오래된 파일 정리
|
|
cleanup_old_preview_files(max_files=10)
|
|
|
|
return True, file_path
|
|
|
|
# 3. 실제 인쇄 모드: 프린터로 전송
|
|
else:
|
|
# 이미지 90도 회전 (시계 반대방향)
|
|
# Brother QL은 세로 방향 기준이므로 가로형 이미지를 회전
|
|
label_image_rotated = label_image.rotate(90, expand=True)
|
|
|
|
logging.info(f"이미지 회전 완료: {label_image_rotated.size}")
|
|
|
|
# Brother QL Raster 객체 생성
|
|
qlr = BrotherQLRaster(PRINTER_MODEL)
|
|
|
|
# PIL 이미지를 Brother QL 형식으로 변환
|
|
instructions = convert(
|
|
qlr=qlr,
|
|
images=[label_image_rotated],
|
|
label=LABEL_TYPE,
|
|
rotate="0", # 이미 회전했으므로 0
|
|
threshold=70.0, # 흑백 변환 임계값
|
|
dither=False, # 디더링 비활성화 (선명한 텍스트)
|
|
compress=False, # 압축 비활성화
|
|
red=False, # 흑백 전용
|
|
dpi_600=False,
|
|
hq=True, # 고품질 모드
|
|
cut=True # 자동 절단
|
|
)
|
|
|
|
# 프린터로 전송
|
|
printer_identifier = f"tcp://{PRINTER_IP}:{PRINTER_PORT}"
|
|
send(instructions, printer_identifier=printer_identifier)
|
|
|
|
logging.info(f"가로형 와이드 라벨 출력 성공: {goods_name}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logging.error(f"가로형 와이드 라벨 {'미리보기' if preview_mode else '출력'} 실패: {e}")
|
|
if preview_mode:
|
|
return False, None
|
|
else:
|
|
return False
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# 테스트 코드
|
|
test_result = print_barcode_label("타이레놀정500mg", 3000.0)
|
|
print(f"테스트 결과: {'성공' if test_result else '실패'}")
|