pharmacy-pos-qr-system/backend/utils/otc_label_printer.py
thug0bin 76a4280ebd feat: OTC 용법 라벨 시스템 구현
DB:
- otc_label_presets 테이블 추가 (SQLite)
- 바코드 기준 오버라이드 데이터 저장

Backend:
- utils/otc_label_printer.py: 라벨 이미지 생성 + Brother QL-810W 출력
- API: CRUD + 미리보기 렌더링 + MSSQL 약품 검색

Frontend:
- /admin/otc-labels: 관리 페이지
- 실시간 미리보기
- 저장된 프리셋 목록
- 바코드/이름 검색 → 프리셋 편집 → 인쇄
2026-03-02 17:00:47 +09:00

284 lines
9.9 KiB
Python

"""
OTC 용법 라벨 출력 모듈
Brother QL-810W 프린터용 가로형 와이드 라벨 생성 및 출력
기반: person-lookup-web-local/print_label.py
"""
from PIL import Image, ImageDraw, ImageFont
import logging
import re
from pathlib import Path
# 프린터 설정 (QL-810W)
PRINTER_IP = "192.168.0.109"
PRINTER_MODEL = "QL-810W"
LABEL_TYPE = "29" # 29mm 연속 출력 용지
# 폰트 경로 (Windows/Linux 크로스 플랫폼)
FONT_PATHS = [
"C:/Windows/Fonts/malgunbd.ttf", # Windows
"/srv/person-lookup-web-local/pop_maker/fonts/malgunbd.ttf", # Linux
"/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf", # Linux 대체
]
def get_font_path():
"""사용 가능한 폰트 경로 반환"""
for path in FONT_PATHS:
if Path(path).exists():
return path
return None
def create_otc_label_image(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
OTC 용법 라벨 이미지 생성 (800 x 306px)
레이아웃:
- 효능: 중앙 상단에 크게 강조 (72pt)
- 약품명: 오른쪽 중간 (36pt)
- 용법: 왼쪽 하단 체크박스 (40pt)
- 약국명: 오른쪽 하단 테두리 박스 (32pt)
Args:
drug_name (str): 약품명
effect (str): 효능
dosage_instruction (str): 복용 방법
usage_tip (str): 사용 팁
Returns:
PIL.Image: 가로형 와이드 라벨 이미지 (800 x 306px, 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 = get_font_path()
try:
font_effect = ImageFont.truetype(font_path, 72) # 효능 (매우 크게!)
font_drugname = ImageFont.truetype(font_path, 36) # 약품명 (중간)
font_dosage = ImageFont.truetype(font_path, 40) # 용법 (크게)
font_pharmacy = ImageFont.truetype(font_path, 32) # 약국명 (크게)
font_small = ImageFont.truetype(font_path, 26) # 사용팁
except (IOError, TypeError):
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()
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
# 3. 레이아웃
x_margin = 25
# 효능 - 중앙 상단에 크게 (매우 강조!)
if effect:
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_bbox = draw.textbbox((0, 0), drug_name, font=font_drugname)
drugname_width = drugname_bbox[2] - drugname_bbox[0]
drugname_x = width - drugname_width - 30 # 오른쪽에서 30px 여백
drugname_y = 195
draw.text((drugname_x, drugname_y), drug_name, font=font_drugname, fill=0)
# 용법 - 왼쪽 하단에 크게 표시
y = 120 # 효능 아래부터 시작
# 사용팁이 없으면 복용방법을 더 크게
if not usage_tip:
try:
font_dosage_adjusted = ImageFont.truetype(font_path, 50)
except:
font_dosage_adjusted = font_dosage
else:
font_dosage_adjusted = font_dosage
if dosage_instruction:
# 대괄호로 묶인 부분을 별도 줄로 분리
dosage_text = re.sub(r'\s*(\[.*?\])\s*', r'\n\1\n', dosage_instruction)
# 여러 줄 처리
max_chars_per_line = 32
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)
elif len(part) > max_chars_per_line:
words = part.split()
current_line = ""
for word in words:
if len(current_line + word) <= max_chars_per_line:
current_line += word + " "
else:
if current_line:
dosage_lines.append(current_line.strip())
current_line = word + " "
if current_line:
dosage_lines.append(current_line.strip())
else:
dosage_lines.append(part)
# 첫 줄에 체크박스 추가
if dosage_lines:
first_line = f"{dosage_lines[0]}"
draw.text((x_margin, y), first_line, font=font_dosage_adjusted, fill=0)
line_spacing = 60 if not usage_tip else 50
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_adjusted, 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)
# 5. 테두리 (가위선 스타일)
for i in range(3):
draw.rectangle([5 + i, 5 + i, width - 5 - i, height - 5 - i], outline=0)
logging.info(f"OTC 라벨 이미지 생성 성공: {drug_name}")
return img
except Exception as e:
logging.error(f"OTC 라벨 이미지 생성 실패: {e}")
raise
def print_otc_label(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
OTC 용법 라벨을 Brother QL-810W 프린터로 출력
Args:
drug_name (str): 약품명
effect (str): 효능
dosage_instruction (str): 복용 방법
usage_tip (str): 사용 팁
Returns:
bool: 성공 여부
"""
try:
from brother_ql.raster import BrotherQLRaster
from brother_ql.conversion import convert
from brother_ql.backends.helpers import send
# 1. 라벨 이미지 생성
label_img = create_otc_label_image(drug_name, effect, dosage_instruction, usage_tip)
# 2. 이미지 90도 회전 (Brother QL이 세로 방향 기준이므로)
label_img_rotated = label_img.rotate(90, expand=True)
logging.info(f"이미지 회전 완료: {label_img_rotated.size}")
# 3. Brother QL 프린터로 전송
qlr = BrotherQLRaster(PRINTER_MODEL)
instructions = convert(
qlr=qlr,
images=[label_img_rotated],
label='29',
rotate='0',
threshold=70.0,
dither=False,
compress=False,
red=False,
dpi_600=False,
hq=True,
cut=True
)
send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100")
logging.info(f"[SUCCESS] OTC 용법 라벨 인쇄 성공: {drug_name}")
return True
except ImportError:
logging.error("brother_ql 라이브러리가 설치되지 않았습니다.")
return False
except Exception as e:
logging.error(f"[ERROR] OTC 용법 라벨 인쇄 실패: {e}")
return False
def generate_preview_image(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
미리보기용 PNG 이미지 생성 (Base64 인코딩)
Args:
drug_name (str): 약품명
effect (str): 효능
dosage_instruction (str): 복용 방법
usage_tip (str): 사용 팁
Returns:
str: Base64 인코딩된 PNG 이미지 (data:image/png;base64,... 형태)
"""
import base64
from io import BytesIO
try:
# 라벨 이미지 생성
label_img = create_otc_label_image(drug_name, effect, dosage_instruction, usage_tip)
# RGB로 변환 (1-bit → RGB)
label_img_rgb = label_img.convert('RGB')
# PNG로 인코딩
buffer = BytesIO()
label_img_rgb.save(buffer, format='PNG')
buffer.seek(0)
# Base64 인코딩
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return f"data:image/png;base64,{img_base64}"
except Exception as e:
logging.error(f"미리보기 이미지 생성 실패: {e}")
return None