feat: 통합 테스트 및 샘플 코드 추가
- 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>
This commit is contained in:
372
backend/samples/barcode_print.py
Normal file
372
backend/samples/barcode_print.py
Normal file
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
바코드 스캔 시 간단한 라벨 자동 출력 모듈
|
||||
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 '실패'}")
|
||||
Reference in New Issue
Block a user