from __future__ import annotations from io import BytesIO from pathlib import Path from types import SimpleNamespace from app.models.mix_calculator import MixCalculatorSession class MixCalculatorPdfUnavailableError(RuntimeError): pass def _fmt_number(value: float, digits: int = 2) -> str: return f"{value:.{digits}f}" def _fractional_bag_warning(session_record: MixCalculatorSession) -> str | None: rounded_bags = round(session_record.total_bags) if abs(session_record.total_bags - rounded_bags) < 1e-9: return None return ( f"Batch size {session_record.batch_size_kg:g}kg produces {session_record.total_bags:.2f} bags " f"for {session_record.product_unit_of_measure}. This is not a whole-bag quantity." ) def _coerce_pdf_source(source): if isinstance(source, dict): lines = [SimpleNamespace(**line) if isinstance(line, dict) else line for line in source.get("lines", [])] return SimpleNamespace(**{**source, "lines": lines}) return source def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> bytes: session_record = _coerce_pdf_source(session_record) try: from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.utils import ImageReader from reportlab.pdfbase.pdfmetrics import stringWidth from reportlab.pdfgen import canvas except ModuleNotFoundError as exc: raise MixCalculatorPdfUnavailableError( "PDF generation is unavailable because 'reportlab' is not installed. " "Install backend dependencies again to enable PDF export." ) from exc page_width, page_height = A4 margin = 26 gutter = 10 content_width = page_width - (margin * 2) page_top = page_height - 40 palette = { "page": colors.HexColor("#FFFFFF"), "line": colors.HexColor("#000000"), "muted": colors.HexColor("#000000"), "text": colors.HexColor("#000000"), "warning_bg": colors.HexColor("#FFFFFF"), "warning_text": colors.HexColor("#000000"), } logo_path = Path(__file__).resolve().parents[3] / "frontend" / "static" / "logo-hsf.png" def clamp(value: float, minimum: float, maximum: float) -> float: return max(minimum, min(maximum, value)) def fit_text(value: str, font_name: str, font_size: float, max_width: float) -> str: if stringWidth(value, font_name, font_size) <= max_width: return value ellipsis = "..." available = max_width - stringWidth(ellipsis, font_name, font_size) trimmed = value while trimmed and stringWidth(trimmed, font_name, font_size) > available: trimmed = trimmed[:-1] return f"{trimmed.rstrip()}{ellipsis}" if trimmed else ellipsis def wrap_text(value: str, font_name: str, font_size: float, max_width: float, max_lines: int) -> list[str]: words = value.split() if not words: return [] lines: list[str] = [] current = words[0] for word in words[1:]: candidate = f"{current} {word}" if stringWidth(candidate, font_name, font_size) <= max_width: current = candidate else: lines.append(current) current = word if len(lines) == max_lines - 1: break if len(lines) < max_lines: lines.append(current) remaining_words = words[len(" ".join(lines).split()) :] if remaining_words and lines: lines[-1] = fit_text(f"{lines[-1]} {' '.join(remaining_words)}", font_name, font_size, max_width) return lines[:max_lines] def draw_box(pdf: canvas.Canvas, x: float, y_top: float, width: float, height: float): pdf.setFillColor(palette["page"]) pdf.setStrokeColor(palette["line"]) pdf.setLineWidth(1) pdf.rect(x, y_top - height, width, height, fill=1, stroke=1) def draw_label_value_card( pdf: canvas.Canvas, x: float, y_top: float, width: float, height: float, label: str, value: str, subtitle: str | None = None, value_font_size: float = 14, ): draw_box(pdf, x, y_top, width, height) inset_x = x + 14 label_y = y_top - 16 pdf.setFillColor(palette["muted"]) pdf.setFont("Helvetica-Bold", 7.5) pdf.drawString(inset_x, label_y, label.upper()) value_y = y_top - 38 pdf.setFillColor(palette["text"]) pdf.setFont("Helvetica-Bold", value_font_size) pdf.drawString(inset_x, value_y, fit_text(value, "Helvetica-Bold", value_font_size, width - 28)) if subtitle: pdf.setFillColor(palette["muted"]) pdf.setFont("Helvetica", 8) pdf.drawString(inset_x, y_top - height + 14, fit_text(subtitle, "Helvetica", 8, width - 28)) buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) session_number = getattr(session_record, "session_number", None) document_title = ( f"{session_number} - {session_record.product_name}" if session_number else f"Mix Calculator - {session_record.product_name}" ) pdf.setTitle(document_title) pdf.setAuthor("Lean 101 Clients") pdf.setFillColor(palette["page"]) pdf.rect(0, 0, page_width, page_height, stroke=0, fill=1) current_y = page_top mix_date_label = f"{session_record.mix_date.day} {session_record.mix_date.strftime('%B %Y')}" if logo_path.exists(): logo_source = str(logo_path) try: from PIL import Image logo_source = Image.open(logo_path).convert("L") except ModuleNotFoundError: pass logo_reader = ImageReader(logo_source) logo_width, logo_height = logo_reader.getSize() aspect_ratio = logo_height / max(logo_width, 1) draw_width = 108 draw_height = draw_width * aspect_ratio pdf.drawImage( logo_reader, margin, current_y - draw_height, width=draw_width, height=draw_height, preserveAspectRatio=True, mask="auto", ) current_y -= draw_height + 20 pdf.setFillColor(palette["text"]) pdf.setFont("Helvetica-Bold", 15) pdf.drawString(margin, current_y, "Calculated Output") current_y -= 16 pdf.setFillColor(palette["muted"]) pdf.setFont("Helvetica", 10) pdf.drawString(margin, current_y, "Snapshot of the scaled raw material requirements.") current_y -= 20 stat_height = 66 stat_width = (content_width - (gutter * 2)) / 3 draw_label_value_card( pdf, margin, current_y, stat_width, stat_height, "Total kg", _fmt_number(session_record.total_kg), "Scaled batch size", value_font_size=18, ) draw_label_value_card( pdf, margin + stat_width + gutter, current_y, stat_width, stat_height, "Total bags", _fmt_number(session_record.total_bags), session_record.product_unit_of_measure, value_font_size=18, ) draw_label_value_card( pdf, margin + ((stat_width + gutter) * 2), current_y, stat_width, stat_height, "Prepared by", session_record.prepared_by_name, mix_date_label, value_font_size=10.5, ) current_y -= stat_height + 12 detail_height = 52 detail_width = (content_width - gutter) / 2 draw_label_value_card( pdf, margin, current_y, detail_width, detail_height, "Client", session_record.client_name, value_font_size=12, ) draw_label_value_card( pdf, margin + detail_width + gutter, current_y, detail_width, detail_height, "Mix", session_record.product_name, value_font_size=12, ) current_y -= detail_height + 8 draw_label_value_card( pdf, margin, current_y, detail_width, detail_height, "Formula source", session_record.mix_name, value_font_size=11, ) draw_label_value_card( pdf, margin + detail_width + gutter, current_y, detail_width, detail_height, "Unit size", f"{_fmt_number(session_record.product_unit_size_kg)}kg", value_font_size=12, ) current_y -= detail_height + 10 warning = _fractional_bag_warning(session_record) note_lines: list[str] = [] warning_lines: list[str] = [] strip_height = 0 if session_record.notes: note_lines = wrap_text(session_record.notes.replace("\n", " "), "Helvetica", 7.5, content_width - 28, 2) strip_height += 30 if warning: warning_lines = wrap_text(warning, "Helvetica", 7.5, content_width - 28, 2) strip_height += 30 if strip_height: strip_height += 6 table_header_height = 24 table_bottom_padding = 12 table_top = current_y available_table_height = table_top - margin - strip_height - table_header_height - table_bottom_padding row_count = max(len(session_record.lines), 1) row_height = clamp(available_table_height / row_count, 16, 32) table_font_size = clamp(row_height * 0.44, 8.5, 12.5) table_height = table_header_height + (row_height * row_count) table_bottom = table_top - table_height pdf.setFillColor(palette["muted"]) pdf.setFont("Helvetica-Bold", 8.5) pdf.drawString(margin + 4, table_top - 7, "RAW MATERIAL") pdf.drawString(margin + content_width - 190, table_top - 7, "REQUIRED KG") pdf.setStrokeColor(palette["line"]) pdf.setLineWidth(0.8) pdf.line(margin, table_top - table_header_height, margin + content_width, table_top - table_header_height) left_col_x = margin + 6 right_col_x = margin + content_width - 190 y_cursor = table_top - table_header_height for line in session_record.lines: y_cursor -= row_height pdf.setStrokeColor(palette["line"]) pdf.setLineWidth(0.6) pdf.line(margin, y_cursor, margin + content_width, y_cursor) text_y = y_cursor + (row_height / 2) - (table_font_size * 0.35) pdf.setFillColor(palette["text"]) pdf.setFont("Helvetica-Bold", table_font_size) pdf.drawString( left_col_x, text_y, fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210), ) pdf.setFont("Helvetica", table_font_size) pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg)}kg") strip_y = table_bottom - 6 if note_lines: note_height = 24 if len(note_lines) == 1 else 30 pdf.setFillColor(palette["page"]) pdf.setStrokeColor(palette["line"]) pdf.rect(margin, strip_y - note_height, content_width, note_height, fill=1, stroke=1) pdf.setFillColor(palette["muted"]) pdf.setFont("Helvetica-Bold", 7) pdf.drawString(margin + 10, strip_y - 10, "NOTES") pdf.setFillColor(palette["text"]) pdf.setFont("Helvetica", 7.5) for idx, text in enumerate(note_lines): pdf.drawString(margin + 10, strip_y - 20 - (idx * 8), text) strip_y -= note_height + 6 if warning_lines: warning_height = 24 if len(warning_lines) == 1 else 30 pdf.setFillColor(palette["warning_bg"]) pdf.setStrokeColor(palette["line"]) pdf.rect(margin, strip_y - warning_height, content_width, warning_height, fill=1, stroke=1) pdf.setFillColor(palette["warning_text"]) pdf.setFont("Helvetica-Bold", 7) pdf.drawString(margin + 10, strip_y - 10, "WARNING") pdf.setFont("Helvetica", 7.5) for idx, text in enumerate(warning_lines): pdf.drawString(margin + 10, strip_y - 20 - (idx * 8), text) pdf.showPage() pdf.save() return buffer.getvalue()