- 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>
957 lines
40 KiB
Python
957 lines
40 KiB
Python
# print_label.py
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import io
|
|
import os
|
|
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
|
|
|
|
# 프린터 기본 설정 (프린터 정보가 전달되지 않을 때 사용)
|
|
DEFAULT_PRINTER_IP = "192.168.0.121" # QL-710W 프린터의 IP 주소
|
|
DEFAULT_PRINTER_MODEL = "QL-710W"
|
|
DEFAULT_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 normalize_medication_name(med_name):
|
|
"""
|
|
약품 이름을 정제하여 라벨에 표시할 형태로 변환
|
|
- 괄호 안 내용 제거: "디오탄정80밀리그램(발사르탄)" → "디오탄정80mg"
|
|
- 밀리그램 계열: "밀리그램", "밀리그람", "미리그램" → "mg"
|
|
- 마이크로그램 계열: "마이크로그램", "마이크로그람" → "μg"
|
|
- 그램 계열: "그램", "그람" → "g"
|
|
- 대괄호 제거: "[애엽이소프]" → ""
|
|
"""
|
|
if not med_name:
|
|
return med_name
|
|
|
|
# 1. 대괄호 및 내용 제거 (예: "오티렌F정[애엽이소프]" → "오티렌F정")
|
|
med_name = re.sub(r'\[.*?\]', '', med_name) # 완전한 대괄호 쌍 제거
|
|
med_name = re.sub(r'\[.*$', '', med_name) # 여는 괄호부터 끝까지 제거
|
|
|
|
# 2. 소괄호 및 내용 제거 (예: "디오탄정80밀리그램(발사르탄)" → "디오탄정80밀리그램")
|
|
med_name = re.sub(r'\(.*?\)', '', med_name) # 완전한 소괄호 쌍 제거
|
|
med_name = re.sub(r'\(.*$', '', med_name) # 여는 괄호부터 끝까지 제거
|
|
|
|
# 2-1. 언더스코어 뒤 내용 제거 (예: "리피토정10mg_(10.85mg/1정)" → "리피토정10mg")
|
|
med_name = re.sub(r'_.*$', '', med_name) # 언더스코어부터 끝까지 제거
|
|
|
|
# 3. 밀리그램 변환 (숫자와 함께 있는 경우 포함)
|
|
# "80밀리그램" → "80mg", "밀리그램" → "mg"
|
|
med_name = re.sub(r'밀리그램|밀리그람|미리그램|미리그람', 'mg', med_name)
|
|
|
|
# 4. 마이크로그램 변환
|
|
med_name = re.sub(r'마이크로그램|마이크로그람', 'μg', med_name)
|
|
|
|
# 5. 그램 변환 (단, mg/μg로 이미 변환된 것은 제외)
|
|
med_name = re.sub(r'(?<!m)(?<!μ)그램|그람', 'g', med_name)
|
|
|
|
# 6. 공백 정리 (연속된 공백을 하나로)
|
|
med_name = re.sub(r'\s+', ' ', med_name).strip()
|
|
|
|
return med_name
|
|
|
|
|
|
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: 생성된 라벨 이미지
|
|
"""
|
|
# 약품 이름 정제 (밀리그램 → mg 등)
|
|
med_name = normalize_medication_name(med_name)
|
|
|
|
# 라벨 이미지 설정
|
|
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 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "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
|
|
|
|
def draw_fitted_single_line(draw, text, y, font, max_width, min_font_size=24):
|
|
"""
|
|
텍스트를 1줄로 강제 표시하되, 폰트 크기 자동 축소 → 잘라내기 순으로 처리
|
|
|
|
Parameters:
|
|
draw: ImageDraw.Draw 객체
|
|
text (str): 표시할 텍스트
|
|
y (int): Y 좌표
|
|
font: 기본 폰트
|
|
max_width (int): 최대 너비
|
|
min_font_size (int): 최소 폰트 크기 (기본값: 24)
|
|
|
|
Returns:
|
|
int: 다음 줄의 Y 좌표
|
|
"""
|
|
if not text:
|
|
return y
|
|
|
|
font_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts", "malgunbd.ttf")
|
|
original_font_size = 30 # info_font 기본 크기
|
|
|
|
# 1단계: 폰트 크기 자동 축소 (30px → 24px)
|
|
for font_size in range(original_font_size, min_font_size - 1, -1):
|
|
try:
|
|
test_font = ImageFont.truetype(font_path, font_size)
|
|
except IOError:
|
|
test_font = ImageFont.load_default()
|
|
|
|
bbox = draw.textbbox((0, 0), text, font=test_font)
|
|
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
|
|
if w <= max_width:
|
|
# 크기가 맞으면 중앙 정렬로 그리기
|
|
draw.text(((label_width - w) / 2, y), text, font=test_font, fill="black")
|
|
return y + h + 5
|
|
|
|
# 2단계: 최소 폰트에도 안 맞으면 텍스트 잘라내기
|
|
try:
|
|
final_font = ImageFont.truetype(font_path, min_font_size)
|
|
except IOError:
|
|
final_font = ImageFont.load_default()
|
|
|
|
original_text = text
|
|
while len(text) > 3:
|
|
ellipsized = text + "..."
|
|
bbox = draw.textbbox((0, 0), ellipsized, font=final_font)
|
|
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
|
|
if w <= max_width:
|
|
draw.text(((label_width - w) / 2, y), ellipsized, font=final_font, fill="black")
|
|
return y + h + 5
|
|
|
|
text = text[:-1]
|
|
|
|
# 최악의 경우: "..." 만 표시
|
|
bbox = draw.textbbox((0, 0), "...", font=final_font)
|
|
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
draw.text(((label_width - w) / 2, y), "...", font=final_font, fill="black")
|
|
return y + h + 5
|
|
|
|
# 환자 이름 처리: 한글은 띄워쓰기, 영문은 동적 크기 조정
|
|
is_korean_name = all(ord('가') <= ord(char) <= ord('힣') or char.isspace() for char in patient_name if char.strip())
|
|
|
|
if is_korean_name:
|
|
# 한글 이름: 한 글자씩 띄우기 (기존 방식)
|
|
y_position = 10
|
|
formatted_patient_name = " ".join(patient_name)
|
|
y_position = draw_centered_text(draw, formatted_patient_name, y_position, patient_name_font, max_width=label_width - 40)
|
|
else:
|
|
# 영문 이름: 1줄에 맞추기 위해 폰트 크기 동적 조정 (44px → 최소 28px)
|
|
max_width_for_name = label_width - 40
|
|
min_font_size_1line = 28 # 1줄일 때 최소 폰트
|
|
min_font_size_2line = 20 # 2줄일 때 최소 폰트 (18px → 20px)
|
|
original_font_size = 44
|
|
|
|
# 폰트 크기를 줄여가며 1줄에 맞는 크기 찾기
|
|
fitted_font = patient_name_font
|
|
for font_size in range(original_font_size, min_font_size_1line - 1, -1):
|
|
try:
|
|
test_font = ImageFont.truetype(font_path, font_size)
|
|
except IOError:
|
|
test_font = ImageFont.load_default()
|
|
|
|
bbox = draw.textbbox((0, 0), patient_name, font=test_font)
|
|
w = bbox[2] - bbox[0]
|
|
|
|
if w <= max_width_for_name:
|
|
fitted_font = test_font
|
|
break
|
|
|
|
# 28px에도 안 맞으면 띄어쓰기 기준으로 2줄 처리 (22px 최소 폰트로)
|
|
bbox = draw.textbbox((0, 0), patient_name, font=fitted_font)
|
|
w = bbox[2] - bbox[0]
|
|
|
|
if w > max_width_for_name and ' ' in patient_name:
|
|
# 2줄 처리: 29px ~ 20px 범위에서 동적 조정
|
|
y_position = 5 # 2줄일 때는 위에서 시작
|
|
|
|
max_font_size_2line = 29 # 2줄일 때 최대 폰트
|
|
fitted_font_2line = patient_name_font
|
|
|
|
for font_size in range(max_font_size_2line, min_font_size_2line - 1, -1):
|
|
try:
|
|
test_font = ImageFont.truetype(font_path, font_size)
|
|
except IOError:
|
|
test_font = ImageFont.load_default()
|
|
|
|
# 각 줄이 라벨 너비에 맞는지 확인
|
|
words = patient_name.split(' ')
|
|
line1 = words[0]
|
|
line2 = ' '.join(words[1:]) if len(words) > 1 else ''
|
|
|
|
bbox1 = draw.textbbox((0, 0), line1, font=test_font)
|
|
w1 = bbox1[2] - bbox1[0]
|
|
|
|
bbox2 = draw.textbbox((0, 0), line2, font=test_font)
|
|
w2 = bbox2[2] - bbox2[0]
|
|
|
|
if w1 <= max_width_for_name and w2 <= max_width_for_name:
|
|
fitted_font_2line = test_font
|
|
break
|
|
|
|
# 띄어쓰기로 분리하여 2줄로 처리
|
|
words = patient_name.split(' ')
|
|
line1 = words[0]
|
|
line2 = ' '.join(words[1:]) if len(words) > 1 else ''
|
|
|
|
# 첫 번째 줄
|
|
bbox1 = draw.textbbox((0, 0), line1, font=fitted_font_2line)
|
|
w1, h1 = bbox1[2] - bbox1[0], bbox1[3] - bbox1[1]
|
|
draw.text(((label_width - w1) / 2, y_position), line1, font=fitted_font_2line, fill="black")
|
|
y_position += h1 + 1 # 줄 간격 축소 (2 → 1)
|
|
|
|
# 두 번째 줄
|
|
if line2:
|
|
bbox2 = draw.textbbox((0, 0), line2, font=fitted_font_2line)
|
|
w2, h2 = bbox2[2] - bbox2[0], bbox2[3] - bbox2[1]
|
|
draw.text(((label_width - w2) / 2, y_position), line2, font=fitted_font_2line, fill="black")
|
|
y_position += h2 + 5
|
|
else:
|
|
# 1줄로 표시: 한글과 동일한 위치에서 시작
|
|
y_position = 10
|
|
h = bbox[3] - bbox[1]
|
|
draw.text(((label_width - w) / 2, y_position), patient_name, font=fitted_font, fill="black")
|
|
y_position += h + 5
|
|
|
|
# 약품명 시작 위치 고정 (이름 길이와 관계없이 일정한 위치 보장)
|
|
DRUG_NAME_START_Y = 60 # 약품명 시작 위치를 y=60으로 고정
|
|
if y_position < DRUG_NAME_START_Y:
|
|
y_position = DRUG_NAME_START_Y
|
|
|
|
# 약품명 정제 (괄호 제거, 단위 변환 등)
|
|
med_name = normalize_medication_name(med_name)
|
|
|
|
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_fitted_single_line(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_fitted_single_line(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 = 75 # 70 → 75로 증가 (하단 여백 확보)
|
|
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 = "아침, 점심, 저녁"
|
|
elif frequency == 4:
|
|
frequency_text = "아침, 점심, 저녁, 취침"
|
|
|
|
# 4회 복용일 때는 작은 폰트 사용 (30px -> 24px)
|
|
frequency_font = info_font
|
|
if frequency == 4 and not custom_dosage_instruction.strip():
|
|
try:
|
|
frequency_font = ImageFont.truetype(font_path, 24)
|
|
except IOError:
|
|
frequency_font = info_font
|
|
|
|
text2_height = 0
|
|
if frequency_text:
|
|
text2_size = frequency_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, frequency_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)
|
|
|
|
# === 동적 레이아웃 시스템 ===
|
|
# 상단 컨텐츠의 최종 y_position과 하단 고정 영역의 공간을 계산하여 겹침 방지
|
|
|
|
# 1. 시그니처 텍스트 및 크기 계산
|
|
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]
|
|
|
|
# 2. 조제일 텍스트 크기 계산
|
|
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)
|
|
date_w, date_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
|
|
# 3. 하단 고정 영역 필요 공간 계산
|
|
# 기본 패딩 설정
|
|
padding_top = int(h_sig * 0.1)
|
|
padding_bottom = int(h_sig * 0.5)
|
|
padding_sides = int(h_sig * 0.2)
|
|
|
|
# 시그니처 박스 높이
|
|
signature_box_height = h_sig + padding_top + padding_bottom
|
|
|
|
# 조제일과 시그니처 사이 간격
|
|
date_signature_gap = 5
|
|
|
|
# 하단 고정 영역 전체 높이 (조제일 + 간격 + 시그니처 + 하단 여백)
|
|
bottom_fixed_height = date_h + date_signature_gap + signature_box_height + 10
|
|
|
|
# 4. 충돌 감지 및 조정
|
|
# 상단 컨텐츠 하단 (y_position) + 최소 여백(2px)
|
|
content_bottom = y_position + 2
|
|
|
|
# 하단 고정 영역 시작점
|
|
bottom_fixed_start = label_height - bottom_fixed_height
|
|
|
|
# 겹침 여부 확인 (10px 이상 겹칠 때만 조정)
|
|
overlap = content_bottom - bottom_fixed_start
|
|
|
|
if overlap > 10:
|
|
# 겹침 발생! 조제일 제거로 공간 확보
|
|
logging.info(f"레이아웃 충돌 감지: {overlap}px 겹침. 조제일 제거로 조정.")
|
|
|
|
# 조제일 없이 하단 고정 영역 재계산
|
|
bottom_fixed_height = signature_box_height + 10
|
|
bottom_fixed_start = label_height - bottom_fixed_height
|
|
|
|
# 조제일 표시 안 함
|
|
show_date = False
|
|
|
|
# 여전히 겹치면 시그니처 패딩 축소
|
|
overlap = content_bottom - bottom_fixed_start
|
|
if overlap > 0:
|
|
logging.info(f"추가 충돌 감지: {overlap}px 겹침. 시그니처 패딩 축소.")
|
|
padding_top = max(2, int(h_sig * 0.05))
|
|
padding_bottom = max(2, int(h_sig * 0.2))
|
|
signature_box_height = h_sig + padding_top + padding_bottom
|
|
bottom_fixed_height = signature_box_height + 5
|
|
bottom_fixed_start = label_height - bottom_fixed_height
|
|
else:
|
|
# 여유 공간 충분
|
|
show_date = True
|
|
|
|
# 5. 조제일 그리기 (공간이 충분한 경우에만)
|
|
if show_date:
|
|
print_date_x = (label_width - date_w) / 2
|
|
print_date_y = label_height - bottom_fixed_height
|
|
draw.text((print_date_x, print_date_y), print_date_text, font=print_date_font, fill="black")
|
|
|
|
# 시그니처는 조제일 아래에 배치
|
|
signature_y_start = print_date_y + date_h + date_signature_gap
|
|
else:
|
|
# 시그니처는 하단 고정 시작점에 배치
|
|
signature_y_start = bottom_fixed_start
|
|
|
|
# 6. 시그니처 박스 그리기
|
|
box_x = (label_width - w_sig) / 2 - padding_sides
|
|
box_y = signature_y_start
|
|
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="",
|
|
printer_ip=None, printer_model=None, label_type=None):
|
|
"""
|
|
라벨 이미지를 생성하여 프린터로 인쇄합니다.
|
|
|
|
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): 커스텀 용법 텍스트
|
|
printer_ip (str, optional): 프린터 IP 주소 (기본값: DEFAULT_PRINTER_IP)
|
|
printer_model (str, optional): 프린터 모델 (기본값: DEFAULT_PRINTER_MODEL)
|
|
label_type (str, optional): 라벨 타입 (기본값: DEFAULT_LABEL_TYPE)
|
|
"""
|
|
try:
|
|
# 프린터 설정 적용 (전달되지 않으면 기본값 사용)
|
|
printer_ip = printer_ip or DEFAULT_PRINTER_IP
|
|
printer_model = printer_model or DEFAULT_PRINTER_MODEL
|
|
label_type = label_type or DEFAULT_LABEL_TYPE
|
|
|
|
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, printer_ip=None, printer_model=None, label_type=None):
|
|
"""
|
|
PIL 이미지를 받아 Brother QL 프린터로 인쇄합니다.
|
|
|
|
Parameters:
|
|
pil_image (PIL.Image): 인쇄할 이미지
|
|
printer_ip (str, optional): 프린터 IP 주소 (기본값: DEFAULT_PRINTER_IP)
|
|
printer_model (str, optional): 프린터 모델 (기본값: DEFAULT_PRINTER_MODEL)
|
|
label_type (str, optional): 라벨 타입 (기본값: DEFAULT_LABEL_TYPE)
|
|
"""
|
|
try:
|
|
# 프린터 설정 적용 (전달되지 않으면 기본값 사용)
|
|
printer_ip = printer_ip or DEFAULT_PRINTER_IP
|
|
printer_model = printer_model or DEFAULT_PRINTER_MODEL
|
|
label_type = label_type or DEFAULT_LABEL_TYPE
|
|
|
|
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() |