# 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 # ================================================================================== # 상품 라벨 인쇄 기능 끝 # ==================================================================================