feat: OTC 용법 라벨 시스템 구현
DB: - otc_label_presets 테이블 추가 (SQLite) - 바코드 기준 오버라이드 데이터 저장 Backend: - utils/otc_label_printer.py: 라벨 이미지 생성 + Brother QL-810W 출력 - API: CRUD + 미리보기 렌더링 + MSSQL 약품 검색 Frontend: - /admin/otc-labels: 관리 페이지 - 실시간 미리보기 - 저장된 프리셋 목록 - 바코드/이름 검색 → 프리셋 편집 → 인쇄
This commit is contained in:
283
backend/utils/otc_label_printer.py
Normal file
283
backend/utils/otc_label_printer.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user