pharmacy-pos-qr-system/backend/samples/barcode_print.py
시골약사 b4de6ff791 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>
2026-01-23 16:36:41 +09:00

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 '실패'}")