pharmacy-pos-qr-system/backend/samples/product_label.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

1368 lines
53 KiB
Python

# print_label.py
from PIL import Image, ImageDraw, ImageFont
import io
import logging
from brother_ql.raster import BrotherQLRaster
from brother_ql.conversion import convert
from brother_ql.backends.helpers import send
import datetime # 날짜 처리를 위해 추가
import pytz
import re
import qrcode
import json
# 프린터 설정
PRINTER_IP = "192.168.0.121" # QL-710W 프린터의 IP 주소
PRINTER_MODEL = "QL-710W"
LABEL_TYPE = "29" # 29mm 연속 출력 용지
# 로깅 설정
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(message)s')
# KOR 시간대 설정
KOR_TZ = pytz.timezone('Asia/Seoul')
def format_total_amount(dosage, frequency, duration):
"""
1회 복용량, 복용 횟수, 총 복용 일수를 기반으로 총량을 계산하고,
1/4 단위로 반올림하거나 그대로 반환합니다.
Parameters:
dosage (float): 1회 복용량 (소수 넷째 자리까지 포함 가능)
frequency (int): 1일 복용 횟수
duration (int): 복용 일수
Returns:
str: 포맷팅된 총량 문자열
"""
if frequency > 0 and duration > 0:
# 1일 복용량 = 1회 복용량 * 1일 복용 횟수
daily_dosage = dosage * frequency
# 총량 = 1일 복용량 * 총 복용 일수
total_amount = daily_dosage * duration
# 1회 복용량이 소수 넷째 자리까지 있는 경우 1/4 단위로 반올림
if round(dosage, 4) != round(dosage, 3): # 소수 넷째 자리 여부 확인
total_amount = round(total_amount * 4) / 4
# 정수인 경우 소수점 없이 표시, 소수가 있는 경우 둘째 자리까지 표시
return str(int(total_amount)) if total_amount.is_integer() else f"{total_amount:.2f}".rstrip('0').rstrip('.')
return "0" # 복용 횟수나 복용 일수가 0인 경우
def format_dosage(dosage):
"""
1회 복용량을 포맷팅합니다.
Parameters:
dosage (float): 1회 복용량
Returns:
str: 포맷팅된 복용량 문자열
"""
if dosage.is_integer():
return str(int(dosage))
else:
# 최대 4자리 소수까지 표시, 불필요한 0 제거
return f"{dosage:.4f}".rstrip('0').rstrip('.')
def format_converted_total(dosage, frequency, duration, conversion_factor):
"""
총량을 계산하고 환산계수를 곱한 변환된 총량을 포맷팅합니다.
Parameters:
dosage (float): 1회 복용량 (소수 넷째 자리까지 포함 가능)
frequency (int): 1일 복용 횟수
duration (int): 복용 일수
conversion_factor (float): 환산계수
Returns:
str: 변환된 총량을 포함한 포맷팅된 문자열
"""
if frequency > 0 and duration > 0 and conversion_factor is not None:
total_amount = dosage * frequency * duration
if round(dosage, 4) != round(dosage, 3):
total_amount = round(total_amount * 4) / 4
converted_total = total_amount * conversion_factor
if converted_total.is_integer():
return str(int(converted_total))
else:
return f"{converted_total:.2f}".rstrip('0').rstrip('.')
return None
def draw_scissor_border(draw, width, height, edge_size=5, steps=230):
"""
라벨 이미지의 테두리에 톱니 모양의 절취선을 그립니다.
Parameters:
draw (ImageDraw.Draw): 이미지에 그리기 위한 Draw 객체
width (int): 라벨 너비
height (int): 라벨 높이
edge_size (int): 톱니 크기
steps (int): 톱니 반복 횟수
"""
top_points = []
step_x = width / (steps * 2)
for i in range(steps * 2 + 1):
x = i * step_x
y = 0 if i % 2 == 0 else edge_size
top_points.append((int(x), int(y)))
draw.line(top_points, fill="black", width=2)
bottom_points = []
for i in range(steps * 2 + 1):
x = i * step_x
y = height if i % 2 == 0 else height - edge_size
bottom_points.append((int(x), int(y)))
draw.line(bottom_points, fill="black", width=2)
left_points = []
step_y = height / (steps * 2)
for i in range(steps * 2 + 1):
y = i * step_y
x = 0 if i % 2 == 0 else edge_size
left_points.append((int(x), int(y)))
draw.line(left_points, fill="black", width=2)
right_points = []
for i in range(steps * 2 + 1):
y = i * step_y
x = width if i % 2 == 0 else width - edge_size
right_points.append((int(x), int(y)))
draw.line(right_points, fill="black", width=2)
def split_med_name(med_name):
"""
약품 이름을 표시용 이름과 시그니처 정보로 분리합니다.
Parameters:
med_name (str): 약품 이름
Returns:
tuple: (표시용 약품 이름, 시그니처 정보, 분리 여부)
"""
units = ['mg', 'g', 'ml', '%']
pattern = r'(\d+(?:\.\d+)?(?:/\d+(?:\.\d+)?)*)\s*(' + '|'.join(units) + r')(?:/(' + '|'.join(units) + r'))?$'
korean_only = re.fullmatch(r'[가-힣]+', med_name) is not None
korean_and_num_eng = re.fullmatch(r'[가-힣a-zA-Z0-9/\.]+', med_name) is not None and not korean_only
med_name_display = med_name
signature_info = "청 춘 약 국"
split_occurred = False
if korean_only:
if len(med_name) >= 10:
match = re.search(pattern, med_name)
if match and match.start() >= 10:
med_name_display = med_name[:match.start()].strip()
signature_info = match.group(1) + match.group(2) + (f"/{match.group(3)}" if match.group(3) else "")
split_occurred = True
else:
med_name_display = med_name[:10]
# else 그대로 사용
elif korean_and_num_eng:
if len(med_name) >= 13:
match = re.search(pattern, med_name)
if match:
med_name_display = med_name[:match.start()].strip()
signature_info = match.group(1) + match.group(2) + (f"/{match.group(3)}" if match.group(3) else "")
split_occurred = True
else:
med_name_display = med_name[:12]
return med_name_display, signature_info, split_occurred
def should_left_align(med_name_display):
"""
약품 이름의 길이와 구성을 기반으로 좌측 정렬 여부를 결정합니다.
Parameters:
med_name_display (str): 분리된 약품 이름 표시 부분
Returns:
bool: 좌측 정렬 여부
"""
korean_only = re.fullmatch(r'[가-힣]+', med_name_display) is not None
korean_and_num_eng = re.fullmatch(r'[가-힣a-zA-Z0-9/\.]+', med_name_display) is not None and not korean_only
if korean_only and len(med_name_display) >= 10: # 10글자부터 좌측정렬 (한글단독)
return True
if korean_and_num_eng and len(med_name_display) >= 13: # 13글자부터 좌측정렬 (한글+숫자+영문)
return True
return False
def create_label_image(patient_name, med_name, add_info, frequency, dosage, duration,
formulation_type, main_ingredient_code, dosage_form, administration_route,
label_name, unit, conversion_factor=None, storage_condition="실온보관",
custom_dosage_instruction=""):
"""
라벨 이미지를 생성합니다.
Parameters:
patient_name (str): 환자 이름
med_name (str): 약품 이름
add_info (str): 약품 효능 정보
frequency (int): 복용 횟수
dosage (float): 복용량
duration (int): 복용 일수
formulation_type (str): 제형 타입
main_ingredient_code (str): 주성분 코드
dosage_form (str): 복용 형태
administration_route (str): 투여 경로
label_name (str): 라벨 명칭
unit (str): 복용 단위
conversion_factor (float, optional): 환산계수
storage_condition (str, optional): 보관 조건
custom_dosage_instruction (str, optional): 커스텀 용법 텍스트
Returns:
PIL.Image: 생성된 라벨 이미지
"""
# 라벨 이미지 설정
label_width = 306 # 29mm 용지에 해당하는 너비 픽셀 수 (300 dpi 기준)
label_height = 380 # 라벨 높이를 380으로 확장하여 추가 정보 포함 (Glabel 기준 380 적당)
image = Image.new("1", (label_width, label_height), "white")
draw = ImageDraw.Draw(image)
# 폰트 설정 (여기서 폰트를 규정하고 요소에서 불러서 사용)
font_path = "/root/project/react_cclabel/backend/fonts/malgunbd.ttf"
try:
patient_name_font = ImageFont.truetype(font_path, 44)
drug_name_font = ImageFont.truetype(font_path, 32)
info_font = ImageFont.truetype(font_path, 30)
signature_font = ImageFont.truetype(font_path, 32)
print_date_font = ImageFont.truetype(font_path, 20) # 조제일 폰트 추가
additional_info_font = ImageFont.truetype(font_path, 27) # 추가 정보 폰트
storage_condition_font = ImageFont.truetype(font_path, 27) # 보관 조건 폰트
except IOError:
patient_name_font = ImageFont.load_default()
drug_name_font = ImageFont.load_default()
info_font = ImageFont.load_default()
signature_font = ImageFont.load_default()
print_date_font = ImageFont.load_default() # 조제일 폰트 기본값 사용
additional_info_font = ImageFont.load_default() # 추가 정보 폰트 기본값 사용
storage_condition_font = ImageFont.load_default() # 보관 조건 폰트 기본값 사용
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
# 중앙 정렬된 텍스트 출력 함수
def draw_centered_text(draw, text, y, font, max_width=None):
if not text:
return y
lines = []
if max_width:
words = re.findall(r'\S+', text)
current_line = ""
for word in words:
test_line = f"{current_line} {word}".strip()
bbox = draw.textbbox((0, 0), test_line, font=font)
w = bbox[2] - bbox[0]
if w <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
else:
lines = [text]
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(((label_width - w) / 2, y), line, font=font, fill="black")
y += h + 5
return y
def draw_left_aligned_text(draw, text, y, font, max_width=None):
if not text:
return y
lines = []
if max_width:
words = re.findall(r'\S+', text)
current_line = ""
for word in words:
test_line = f"{current_line} {word}".strip()
bbox = draw.textbbox((0, 0), test_line, font=font)
w = bbox[2] - bbox[0]
if w <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
else:
lines = [text]
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text((10, y), line, font=font, fill="black")
y += h + 5
return y
y_position = 10
y_position = draw_centered_text(draw, " ".join(patient_name), y_position, patient_name_font, max_width=label_width - 40)
med_name_display, signature_info, split_occurred = split_med_name(med_name)
if should_left_align(med_name_display):
y_position = draw_left_aligned_text(draw, med_name_display, y_position, drug_name_font, max_width=label_width - 40)
y_position = draw_centered_text(draw, f"({add_info})", y_position, info_font, max_width=label_width - 40)
else:
y_position = draw_centered_text(draw, med_name_display, y_position, drug_name_font, max_width=label_width - 40)
y_position = draw_centered_text(draw, f"({add_info})", y_position, info_font, max_width=label_width - 40)
if dosage and frequency and duration and unit:
formatted_dosage = format_dosage(dosage)
daily_dosage = dosage * frequency
total_amount = daily_dosage * duration
if round(dosage, 4) != round(dosage, 3):
total_amount = round(total_amount * 4) / 4
formatted_total_amount = str(int(total_amount)) if total_amount.is_integer() else f"{total_amount:.2f}".rstrip('0').rstrip('.')
converted_total = format_converted_total(dosage, frequency, duration, conversion_factor)
if converted_total is not None:
total_label = f"{formatted_total_amount}{unit}/{duration}일분({converted_total})"
else:
total_label = f"{formatted_total_amount}{unit}/{duration}일분"
y_position = draw_centered_text(draw, total_label, y_position, additional_info_font, max_width=label_width - 40)
box_height = 70
box_margin = 10
box_width = label_width - 40
box_x1 = (label_width - box_width) // 2
box_x2 = box_x1 + box_width
box_y1 = y_position + box_margin
box_y2 = box_y1 + box_height
draw.rectangle([box_x1, box_y1, box_x2, box_y2], outline="black", width=2)
box_padding = 10
line_spacing = 5
box_text1 = f"{formatted_dosage}{unit}"
text1_size = info_font.getbbox(box_text1)
text1_height = text1_size[3] - text1_size[1]
frequency_text = custom_dosage_instruction.strip()
if not frequency_text:
if frequency == 1:
frequency_text = "아침"
elif frequency == 2:
frequency_text = "아침, 저녁"
elif frequency == 3:
frequency_text = "아침, 점심, 저녁"
text2_height = 0
if frequency_text:
text2_size = info_font.getbbox(frequency_text)
text2_height = text2_size[3] - text2_size[1]
total_text_height = text1_height + line_spacing + text2_height
center_y = (box_y1 + box_y2) // 2
adjustment = 7
start_y = center_y - (total_text_height // 2) - adjustment
y_temp = draw_centered_text(draw, box_text1, start_y, info_font, max_width=box_width)
if frequency_text:
text2_y = y_temp + line_spacing
draw_centered_text(draw, frequency_text, text2_y, info_font, max_width=box_width)
y_position = box_y2 + box_margin
if storage_condition:
storage_condition_text = f"{storage_condition}"
y_position = draw_centered_text(draw, storage_condition_text, y_position, storage_condition_font, max_width=label_width - 40)
print_date_text = f"조제일 : {datetime.datetime.now(KOR_TZ).strftime('%Y-%m-%d')}"
bbox = draw.textbbox((0, 0), print_date_text, font=print_date_font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
print_date_x = (label_width - w) / 2
print_date_y = label_height - h - 70
draw.text((print_date_x, print_date_y), print_date_text, font=print_date_font, fill="black")
signature_text = signature_info if signature_info else "청 춘 약 국"
margin_val = int(0.1 * label_width)
box_width_sig = label_width - 2 * margin_val
try:
bbox = draw.textbbox((0, 0), signature_text, font=signature_font)
w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1]
scale_factor = box_width_sig / w_sig if w_sig != 0 else 1
scaled_font_size = max(1, int(22 * scale_factor))
scaled_font = ImageFont.truetype(font_path, scaled_font_size)
except IOError:
scaled_font = ImageFont.load_default()
logging.warning("시그니처 폰트 로드 실패. 기본 폰트 사용.")
bbox = draw.textbbox((0, 0), signature_text, font=scaled_font)
w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1]
# 테두리 여백 설정
padding_top = int(h_sig * 0.1) # 위쪽 여백: 텍스트 높이의 10%
padding_bottom = int(h_sig * 0.5) # 아래쪽 여백: 텍스트 높이의 50%
padding_sides = int(h_sig * 0.2) # 좌우 여백: 텍스트 높이의 20%
# 테두리 좌표 계산
box_x = (label_width - w_sig) / 2 - padding_sides
box_y = label_height - h_sig - padding_top - padding_bottom - 10
box_x2 = box_x + w_sig + 2 * padding_sides
box_y2 = box_y + h_sig + padding_top + padding_bottom
draw.rectangle([(box_x, box_y), (box_x2, box_y2)], outline="black")
draw.text(((label_width - w_sig) / 2, box_y + padding_top), signature_text, font=scaled_font, fill="black")
draw_scissor_border(draw, label_width, label_height, edge_size=10, steps=20)
return image
def print_label(patient_name, med_name, add_info, frequency, dosage, duration,
formulation_type, main_ingredient_code, dosage_form, administration_route, label_name,
unit=None, conversion_factor=None, storage_condition="실온보관", custom_dosage_instruction=""):
"""
라벨 이미지를 생성하여 프린터로 인쇄합니다.
Parameters:
patient_name (str): 환자 이름
med_name (str): 약품 이름
add_info (str): 약품 효능 정보
frequency (int): 복용 횟수
dosage (float): 복용량
duration (int): 복용 일수
formulation_type (str): 제형 타입
main_ingredient_code (str): 주성분 코드
dosage_form (str): 복용 형태
administration_route (str): 투여 경로
label_name (str): 라벨 명칭
unit (str, optional): 복용 단위
conversion_factor (float, optional): 환산계수
storage_condition (str, optional): 보관 조건
custom_dosage_instruction (str, optional): 커스텀 용법 텍스트
"""
try:
if not unit:
if "캡슐" in med_name:
unit = "캡슐"
elif "" in med_name:
unit = ""
elif "시럽" in med_name:
unit = "ml"
elif "과립" in med_name or "시럽" in med_name:
unit = "g"
else:
unit = ""
label_image = create_label_image(
patient_name=patient_name,
med_name=med_name,
add_info=add_info,
frequency=frequency,
dosage=dosage,
duration=duration,
formulation_type=formulation_type,
main_ingredient_code=main_ingredient_code,
dosage_form=dosage_form,
administration_route=administration_route,
label_name=label_name,
unit=unit,
conversion_factor=conversion_factor,
storage_condition=storage_condition,
custom_dosage_instruction=custom_dosage_instruction
)
image_stream = io.BytesIO()
label_image.save(image_stream, format="PNG")
image_stream.seek(0)
from brother_ql.raster import BrotherQLRaster
from brother_ql.conversion import convert
from brother_ql.backends.helpers import send
qlr = BrotherQLRaster(PRINTER_MODEL)
instructions = convert(
qlr=qlr,
images=[Image.open(image_stream)],
label=LABEL_TYPE,
rotate="0",
threshold=70.0,
dither=False,
compress=False,
lq=True,
red=False
)
send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100")
logging.info(f"라벨 인쇄 성공: 환자={patient_name}, 약품={med_name}, 커스텀 용법={custom_dosage_instruction}")
print(f"[SUCCESS] 라벨 인쇄 성공: 환자={patient_name}, 약품={med_name}, 커스텀 용법={custom_dosage_instruction}")
except Exception as e:
logging.error(f"라벨 인쇄 실패: {e}")
print(f"[ERROR] 라벨 인쇄 실패: {e}")
def print_custom_image(pil_image):
"""
PIL 이미지를 받아 Brother QL 프린터로 인쇄합니다.
Parameters:
pil_image (PIL.Image): 인쇄할 이미지
"""
try:
logging.info(f"이미지 모드: {pil_image.mode}")
if pil_image.mode in ('RGBA', 'LA'):
logging.info("알파 채널 있음 (RGBA 또는 LA 모드)")
elif pil_image.mode == 'P' and 'transparency' in pil_image.info:
logging.info("알파 채널 있음 (팔레트 모드, transparency 키 확인됨)")
else:
logging.info("알파 채널 없음")
pil_image = pil_image.rotate(90, expand=True)
width, height = pil_image.size
new_height = int((306 / width) * height)
pil_image = pil_image.resize((306, new_height), Image.LANCZOS)
if pil_image.mode in ('RGBA', 'LA') or (pil_image.mode == 'P' and 'transparency' in pil_image.info):
background = Image.new("RGB", pil_image.size, "white")
background.paste(pil_image, mask=pil_image.split()[-1])
pil_image = background
image_stream = io.BytesIO()
pil_image.convert('1').save(image_stream, format="PNG")
image_stream.seek(0)
from brother_ql.raster import BrotherQLRaster
from brother_ql.conversion import convert
from brother_ql.backends.helpers import send
# Brother QL 프린터로 전송
qlr = BrotherQLRaster(PRINTER_MODEL)
instructions = convert(
qlr=qlr,
images=[Image.open(image_stream)],
label=LABEL_TYPE,
rotate="0", # 라벨 회전 없음
threshold=70.0, # 흑백 변환 임계값
dither=False,
compress=False,
lq=True, # 저화질 인쇄 옵션
red=False
)
send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100")
logging.info("커스텀 이미지 인쇄 성공")
print("[SUCCESS] 커스텀 이미지 인쇄 성공")
except Exception as e:
logging.error(f"커스텀 이미지 인쇄 실패: {e}")
print(f"[ERROR] 커스텀 이미지 인쇄 실패: {e}")
if __name__ == "__main__":
# 인터랙티브 메뉴를 통해 샘플 인쇄 선택
samples = {
"1": {
"patient_name": "이영희",
"med_name": "아모크라정375mg",
"add_info": "고혈압",
"frequency": 1,
"dosage": 375.0,
"duration": 30,
"formulation_type": "정제",
"main_ingredient_code": "AMO375",
"dosage_form": "경구",
"administration_route": "경구",
"label_name": "고혈압용",
"unit": None,
"conversion_factor": 1.0,
"storage_condition": "실온보관",
"custom_dosage_instruction": ""
},
"2": {
"patient_name": "박지성",
"med_name": "삼남아세트아미노펜정500mg",
"add_info": "통증 완화",
"frequency": 2,
"dosage": 500.0,
"duration": 5,
"formulation_type": "정제",
"main_ingredient_code": "MED001", # 예시용
"dosage_form": "경구",
"administration_route": "경구",
"label_name": "통증용",
"unit": None,
"conversion_factor": 1.0,
"storage_condition": "서늘한 곳에 보관",
"custom_dosage_instruction": ""
},
"3": {
"patient_name": "최민수",
"med_name": "세레타이드125에보할러",
"add_info": "알레르기 치료",
"frequency": 3,
"dosage": 125.0,
"duration": 10,
"formulation_type": "정제",
"main_ingredient_code": "SER125",
"dosage_form": "흡입",
"administration_route": "흡입",
"label_name": "알레르기용",
"unit": None,
"conversion_factor": 1.0,
"storage_condition": "냉장보관",
"custom_dosage_instruction": ""
},
"4": {
"patient_name": "최민수",
"med_name": "트윈스타정40/5mg",
"add_info": "혈압 조절",
"frequency": 2,
"dosage": 40.0,
"duration": 10,
"formulation_type": "정제",
"main_ingredient_code": "TW40",
"dosage_form": "경구",
"administration_route": "경구",
"label_name": "고혈압용",
"unit": None,
"conversion_factor": 1.0,
"storage_condition": "실온보관",
"custom_dosage_instruction": ""
},
"5": {
"patient_name": "최우주",
"med_name": "오셀타원현탁용분말6mg/mL",
"add_info": "오셀타미",
"frequency": 2,
"dosage": 4.0,
"duration": 5,
"formulation_type": "현탁용분말",
"main_ingredient_code": "358907ASS",
"dosage_form": "SS",
"administration_route": "A",
"label_name": "오셀타원현탁용분말6mg/mL",
"unit": "ml",
"conversion_factor": 0.126,
"storage_condition": "실온및(냉장)",
"custom_dosage_instruction": ""
},
"6": {
"patient_name": "최우주",
"med_name": "어린이타이레놀현탁액",
"add_info": "해열,진통제",
"frequency": 3,
"dosage": 3.0,
"duration": 3,
"formulation_type": "현탁액",
"main_ingredient_code": "101330ASS",
"dosage_form": "SS",
"administration_route": "A",
"label_name": "어린이타이레놀현탁액",
"unit": "ml",
"conversion_factor": None,
"storage_condition": "실온보관",
"custom_dosage_instruction": ""
}
}
print("=======================================")
print(" 라벨 인쇄 샘플 선택 ")
print("=======================================")
for key, sample in samples.items():
print(f"{key}: {sample['patient_name']} / {sample['med_name']} / {sample['add_info']}")
print("q: 종료")
choice = input("인쇄할 샘플 번호를 선택하세요: ").strip()
while choice.lower() != 'q':
if choice in samples:
sample = samples[choice]
print(f"선택한 샘플: {sample['patient_name']} / {sample['med_name']}")
print_label(
patient_name=sample["patient_name"],
med_name=sample["med_name"],
add_info=sample["add_info"],
frequency=sample["frequency"],
dosage=sample["dosage"],
duration=sample["duration"],
formulation_type=sample["formulation_type"],
main_ingredient_code=sample["main_ingredient_code"],
dosage_form=sample["dosage_form"],
administration_route=sample["administration_route"],
label_name=sample["label_name"],
unit=sample["unit"],
conversion_factor=sample["conversion_factor"],
storage_condition=sample["storage_condition"],
custom_dosage_instruction=sample["custom_dosage_instruction"]
)
else:
print("올바른 번호를 입력하세요.")
choice = input("인쇄할 샴플 번호를 선택하세요 (종료하려면 q 입력): ").strip()
# ==================================================================================
# 약품 QR 라벨 인쇄 기능 (신규 추가)
# ==================================================================================
# 목적: 약품 검색 후 바코드 기반 QR 코드, 약품명, 판매가격이 포함된 라벨 인쇄
# 기존 처방전 라벨 인쇄 기능과 완전히 분리된 독립 기능
# ==================================================================================
def create_drug_qr_label(drug_name, barcode, sale_price, drug_code=None):
"""
약품 QR 라벨 이미지 생성
Parameters:
drug_name (str): 약품명
barcode (str): 바코드 (QR 코드로 변환)
sale_price (float): 판매가격
drug_code (str, optional): 약품 코드 (바코드가 없을 때 대체)
Returns:
PIL.Image: 생성된 라벨 이미지
라벨 구조 (3단):
┌─────────────────┐
│ [QR CODE] │ ← 바코드 기반 QR 코드 (상단)
├─────────────────┤
│ 약품명 │ ← 중앙 정렬 (중간)
├─────────────────┤
│ ₩12,000 │ ← 판매가격 (하단)
└─────────────────┘
"""
# 라벨 크기 설정 (29mm 용지 기준)
label_width = 306
label_height = 380
image = Image.new("1", (label_width, label_height), "white")
draw = ImageDraw.Draw(image)
# 폰트 설정
font_path = "/srv/for-windows/person-lookup-web/prescription_monitoring/malgunbd.ttf"
try:
drug_name_font = ImageFont.truetype(font_path, 32)
price_font = ImageFont.truetype(font_path, 36)
label_font = ImageFont.truetype(font_path, 24)
except IOError:
drug_name_font = ImageFont.load_default()
price_font = ImageFont.load_default()
label_font = ImageFont.load_default()
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
# 바코드가 없으면 약품 코드 사용
qr_data = barcode if barcode else (drug_code if drug_code else "NO_BARCODE")
# QR 코드 생성
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=4,
border=1,
)
qr.add_data(qr_data)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white")
# QR 코드 크기 조정 및 배치 (상단 영역) - 크기 축소
qr_size = 130 # 180 → 130으로 축소
qr_img = qr_img.resize((qr_size, qr_size), Image.LANCZOS)
qr_x = (label_width - qr_size) // 2
qr_y = 15 # 상단 여백 약간 줄임
# QR 코드를 메인 이미지에 붙이기
if qr_img.mode != '1':
qr_img = qr_img.convert('1')
image.paste(qr_img, (qr_x, qr_y))
# 약품명 그리기 (QR 코드 아래)
y_position = qr_y + qr_size + 10 # 간격 줄임
# 약품명이 길면 2줄로 나누기 (괄호 기준 자동 분리)
def draw_wrapped_text(draw, text, y, font, max_width):
"""
약품명을 2줄로 표시하되, 괄호가 있으면 괄호 앞뒤로 자동 분리
예시:
- "안텔민뽀삐(5kg이하)" → ["안텔민뽀삐", "(5kg이하)"]
- "타이레놀정500mg" → 폭 기준으로 자동 분리
"""
import re
lines = []
# 괄호가 있으면 괄호 기준으로 먼저 분리
if '(' in text and ')' in text:
# 괄호 앞부분과 괄호 포함 뒷부분으로 분리
match = re.match(r'^(.+?)(\(.+\))$', text)
if match:
main_part = match.group(1).strip()
bracket_part = match.group(2).strip()
# 첫 줄이 max_width를 초과하면 글자 단위로 자르기
bbox = draw.textbbox((0, 0), main_part, font=font)
w = bbox[2] - bbox[0]
if w <= max_width:
# 괄호 앞 부분이 한 줄에 들어감
lines.append(main_part)
lines.append(bracket_part)
else:
# 괄호 앞 부분이 너무 길면 글자 단위로 자르기
chars = list(main_part)
current_line = ""
for char in chars:
test_line = current_line + char
bbox = draw.textbbox((0, 0), test_line, font=font)
w = bbox[2] - bbox[0]
if w <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = char
if current_line:
lines.append(current_line)
# 괄호 부분 추가 (최대 2줄이므로 1개만)
if len(lines) < 2:
lines.append(bracket_part)
else:
# 괄호 패턴 매칭 실패 시 기존 로직
lines = _split_text_by_width(draw, text, font, max_width)
else:
# 괄호가 없으면 폭 기준으로 자동 분리
lines = _split_text_by_width(draw, text, font, max_width)
# 최대 2줄만 표시
lines = lines[:2]
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(((label_width - w) / 2, y), line, font=font, fill="black")
y += h + 5
return y
def _split_text_by_width(draw, text, font, max_width):
"""폭 기준으로 텍스트를 여러 줄로 분리 (헬퍼 함수)"""
chars = list(text)
lines = []
current_line = ""
for char in chars:
test_line = current_line + char
bbox = draw.textbbox((0, 0), test_line, font=font)
w = bbox[2] - bbox[0]
if w <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = char
if current_line:
lines.append(current_line)
return lines
y_position = draw_wrapped_text(draw, drug_name, y_position, drug_name_font, label_width - 40)
# 약간의 여백
y_position += 8
# 가격 그리기 (약품명 바로 아래)
if sale_price > 0:
price_text = f"{int(sale_price):,}"
else:
price_text = "가격 미정"
bbox = draw.textbbox((0, 0), price_text, font=price_font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(((label_width - w) / 2, y_position), price_text, font=price_font, fill="black")
y_position += h + 15 # 가격 다음 여백
# 구분선 그리기 (가격과 청춘약국 사이)
line_margin = 30
draw.line([(line_margin, y_position), (label_width - line_margin, y_position)], fill="black", width=2)
y_position += 12
# 청춘약국 서명 (하단에 고정하지 않고 가격 아래 배치)
signature_text = "청 춘 약 국"
bbox = draw.textbbox((0, 0), signature_text, font=label_font)
w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1]
# 테두리 박스 그리기
padding = 10
box_x = (label_width - w_sig) / 2 - padding
box_y = y_position - padding
box_x2 = box_x + w_sig + 2 * padding
box_y2 = box_y + h_sig + 2 * padding
draw.rectangle([(box_x, box_y), (box_x2, box_y2)], outline="black", width=2)
draw.text(((label_width - w_sig) / 2, y_position), signature_text, font=label_font, fill="black")
# 절취선 테두리
draw_scissor_border(draw, label_width, label_height, edge_size=10, steps=20)
return image
def print_drug_qr_label(drug_name, barcode, sale_price, drug_code=None):
"""
약품 QR 라벨 인쇄 실행
Parameters:
drug_name (str): 약품명
barcode (str): 바코드
sale_price (float): 판매가격
drug_code (str, optional): 약품 코드
"""
try:
label_image = create_drug_qr_label(drug_name, barcode, sale_price, drug_code)
# 이미지를 메모리 스트림으로 변환
image_stream = io.BytesIO()
label_image.save(image_stream, format="PNG")
image_stream.seek(0)
# Brother QL 프린터로 전송
qlr = BrotherQLRaster(PRINTER_MODEL)
instructions = convert(
qlr=qlr,
images=[Image.open(image_stream)],
label=LABEL_TYPE,
rotate="0",
threshold=70.0,
dither=False,
compress=False,
lq=True,
red=False
)
send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100")
logging.info(f"약품 QR 라벨 인쇄 성공: 약품={drug_name}, 바코드={barcode}, 가격={sale_price}")
print(f"[SUCCESS] 약품 QR 라벨 인쇄 성공: {drug_name}")
return True
except Exception as e:
logging.error(f"약품 QR 라벨 인쇄 실패: {e}")
print(f"[ERROR] 약품 QR 라벨 인쇄 실패: {e}")
return False
# ==================================================================================
# 약품 QR 라벨 인쇄 기능 끝
# ==================================================================================
# ==================================================================================
# 상품 라벨 인쇄 기능 (v2.0 - 효과/용법/꿀팁 포함)
# ==================================================================================
def create_product_label(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
상품 라벨 이미지 생성
Parameters:
drug_name (str): 약품명 (필수)
effect (str): 효과 (예: "치통약")
dosage_instruction (str): 용법 (예: "2정 또는 3정 복용...")
usage_tip (str): 복용 꿀팁 (예: "이튼돌과 함께...")
Returns:
PIL.Image: 생성된 라벨 이미지 (306x400px, mode='1')
"""
# 라벨 크기 설정
label_width = 306
label_height = 400
image = Image.new("1", (label_width, label_height), "white")
draw = ImageDraw.Draw(image)
# 폰트 설정
font_path = "/srv/for-windows/person-lookup-web/prescription_monitoring/malgunbd.ttf"
try:
drug_name_font = ImageFont.truetype(font_path, 32)
label_font = ImageFont.truetype(font_path, 24)
tip_font = ImageFont.truetype(font_path, 22)
signature_font = ImageFont.truetype(font_path, 24)
except IOError:
drug_name_font = ImageFont.load_default()
label_font = ImageFont.load_default()
tip_font = ImageFont.load_default()
signature_font = ImageFont.load_default()
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
# 좌측 정렬 텍스트 출력 함수
def draw_left_aligned_text(draw, text, y, font, max_width=266, max_lines=4):
if not text:
return y
lines = []
current_line = ""
# 텍스트를 줄바꿈 기준으로 먼저 분리
text_lines = text.split('\n')
for text_line in text_lines:
# 각 줄을 글자 단위로 처리
for char in text_line:
test_line = current_line + char
bbox = draw.textbbox((0, 0), test_line, font=font)
w = bbox[2] - bbox[0]
if w <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = char
# 줄 끝에서 현재 라인 추가
if current_line:
lines.append(current_line)
current_line = ""
# 최대 줄 수 제한
lines = lines[:max_lines]
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
h = bbox[3] - bbox[1]
draw.text((20, y), line, font=font, fill="black")
y += h + 3
return y
# 중앙 정렬 텍스트 출력 함수
def draw_centered_text(draw, text, y, font, max_width=286, max_lines=2):
if not text:
return y
lines = []
current_line = ""
for char in text:
test_line = current_line + char
bbox = draw.textbbox((0, 0), test_line, font=font)
w = bbox[2] - bbox[0]
if w <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = char
if current_line:
lines.append(current_line)
lines = lines[:max_lines]
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(((label_width - w) / 2, y), line, font=font, fill="black")
y += h + 5
return y
y_position = 15
# 1. 약품명 (중앙 정렬, 최대 2줄)
y_position = draw_centered_text(draw, drug_name, y_position, drug_name_font, max_width=286, max_lines=2)
y_position += 10
# 구분선
draw.line([(30, y_position), (276, y_position)], fill="black", width=2)
y_position += 10
# 2. 효과 (좌측 정렬, 최대 2줄)
if effect:
effect_text = f"효과: {effect}"
y_position = draw_left_aligned_text(draw, effect_text, y_position, label_font, max_width=266, max_lines=2)
y_position += 8
# 3. 용법 (좌측 정렬, 최대 4줄)
if dosage_instruction:
dosage_text = f"용법:\n{dosage_instruction}"
y_position = draw_left_aligned_text(draw, dosage_text, y_position, label_font, max_width=266, max_lines=4)
y_position += 8
# 구분선
if usage_tip:
draw.line([(30, y_position), (276, y_position)], fill="black", width=2)
y_position += 10
# 4. 복용 꿀팁 (좌측 정렬, 최대 4줄)
if usage_tip:
tip_text = f"💡 복용 꿀팁:\n{usage_tip}"
y_position = draw_left_aligned_text(draw, tip_text, y_position, tip_font, max_width=266, max_lines=4)
y_position += 12
# 구분선
draw.line([(30, y_position), (276, y_position)], fill="black", width=2)
y_position += 15
# 5. 청춘약국 서명 (하단 고정, 테두리 박스)
signature_text = "청 춘 약 국"
bbox = draw.textbbox((0, 0), signature_text, font=signature_font)
w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1]
padding = 10
box_x = (label_width - w_sig) / 2 - padding
box_y = label_height - h_sig - 40
box_x2 = box_x + w_sig + 2 * padding
box_y2 = box_y + h_sig + 2 * padding
draw.rectangle([(box_x, box_y), (box_x2, box_y2)], outline="black", width=2)
draw.text(((label_width - w_sig) / 2, box_y + padding), signature_text, font=signature_font, fill="black")
# 6. 절취선 테두리
draw_scissor_border(draw, label_width, label_height, edge_size=10, steps=20)
return image
def print_product_label(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
상품 라벨 인쇄 실행
Parameters:
drug_name (str): 약품명
effect (str): 효과
dosage_instruction (str): 용법
usage_tip (str): 복용 꿀팁
Returns:
bool: 인쇄 성공 여부
"""
try:
label_image = create_product_label(drug_name, effect, dosage_instruction, usage_tip)
# 이미지를 메모리 스트림으로 변환
image_stream = io.BytesIO()
label_image.save(image_stream, format="PNG")
image_stream.seek(0)
# Brother QL 프린터로 전송
qlr = BrotherQLRaster(PRINTER_MODEL)
instructions = convert(
qlr=qlr,
images=[Image.open(image_stream)],
label=LABEL_TYPE,
rotate="0",
threshold=70.0,
dither=False,
compress=False,
lq=True,
red=False
)
send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100")
logging.info(f"상품 라벨 인쇄 성공: {drug_name}")
print(f"[SUCCESS] 상품 라벨 인쇄 성공: {drug_name}")
return True
except Exception as e:
logging.error(f"상품 라벨 인쇄 실패: {e}")
print(f"[ERROR] 상품 라벨 인쇄 실패: {e}")
return False
def create_product_label_wide(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
가로형 와이드 상품 라벨 이미지 생성 (800 x 306px)
레이아웃 구조:
- 효능: 중앙 상단에 크게 강조
- 약품명: 오른쪽 여백 공간에 배치
- 용법: 왼쪽 하단에 크게
- 약국명: 오른쪽 하단에 크게
Args:
drug_name (str): 약품명
effect (str): 효능
dosage_instruction (str): 복용 방법
usage_tip (str): 사용 팁
Returns:
PIL.Image: 가로형 와이드 라벨 이미지 (800 x 306px, mode='1')
"""
try:
# 1. 캔버스 생성 (가로로 긴 형태, 고정 800px)
width = 800
height = 306 # Brother QL 29mm 용지 폭
img = Image.new('1', (width, height), 1) # 흰색 배경
draw = ImageDraw.Draw(img)
# 2. 폰트 로드 (새로운 레이아웃에 맞게 최적화)
font_path = "/srv/for-windows/person-lookup-web/prescription_monitoring/malgunbd.ttf"
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:
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:
font_dosage_adjusted = ImageFont.truetype(font_path, 50) # 더 큰 폰트
else:
font_dosage_adjusted = font_dosage # 기본 폰트 (40pt)
if dosage_instruction:
# 대괄호로 묶인 부분을 별도 줄로 분리
import re
# [텍스트] 패턴을 찾아서 줄바꿈으로 치환
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"가로형 와이드 라벨 이미지 생성 성공: {drug_name}")
return img
except Exception as e:
logging.error(f"가로형 와이드 라벨 이미지 생성 실패: {e}")
raise
def print_product_label_wide(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
가로형 와이드 상품 라벨 인쇄
이미지를 90도 회전하여 Brother QL 프린터로 전송
Args:
drug_name (str): 약품명
effect (str): 효능
dosage_instruction (str): 복용 방법
usage_tip (str): 사용 팁
Returns:
bool: 성공 여부
"""
try:
# 1. 가로형 라벨 이미지 생성
label_img = create_product_label_wide(drug_name, effect, dosage_instruction, usage_tip)
# 2. 이미지 90도 회전 (Brother QL이 세로 방향 기준이므로)
# 시계 반대방향 90도 회전
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', # 29mm 용지
rotate='0', # 이미 회전했으므로 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] 가로형 와이드 상품 라벨 인쇄 성공: {drug_name}")
print(f"[SUCCESS] 가로형 와이드 상품 라벨 인쇄 성공: {drug_name}")
return True
except Exception as e:
logging.error(f"[ERROR] 가로형 와이드 라벨 인쇄 실패: {e}")
print(f"[ERROR] 가로형 와이드 라벨 인쇄 실패: {e}")
return False
# ==================================================================================
# 상품 라벨 인쇄 기능 끝
# ==================================================================================