Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| from io import BytesIO | |
| import math | |
| from pathlib import Path | |
| import re | |
| from typing import Dict, Iterable, List, Optional, Tuple | |
| from reportlab.lib import colors | |
| from reportlab.lib.pagesizes import A4 | |
| from reportlab.lib.units import mm | |
| from reportlab.lib.utils import ImageReader | |
| from PIL import Image | |
| from reportlab.pdfgen import canvas | |
| from .session_store import SessionStore | |
| PDF_IMAGE_TARGET_DPI = 120 | |
| PDF_IMAGE_JPEG_QUALITY = 70 | |
| PDF_IMAGE_MAX_LONG_EDGE_PX = 1800 | |
| PDF_IMAGE_PNG_PRESERVE_MAX_PX = 640 | |
| def _has_template_content(template: dict | None) -> bool: | |
| if not template: | |
| return False | |
| for value in template.values(): | |
| if isinstance(value, str) and value.strip(): | |
| return True | |
| if value not in (None, ""): | |
| return True | |
| return False | |
| def _chunk(items: List[str], size: int) -> List[List[str]]: | |
| if not items: | |
| return [] | |
| return [items[i : i + size] for i in range(0, len(items), size)] | |
| def _safe_text(value: Optional[str]) -> str: | |
| return (value or "").strip() | |
| def _wrap_lines( | |
| pdf: canvas.Canvas, | |
| text: str, | |
| width: float, | |
| max_lines: Optional[int], | |
| font: str, | |
| size: int, | |
| ) -> List[str]: | |
| if not text: | |
| return [] | |
| pdf.setFont(font, size) | |
| words = text.split() | |
| lines: List[str] = [] | |
| current: List[str] = [] | |
| for word in words: | |
| if pdf.stringWidth(word, font, size) > width: | |
| if current: | |
| lines.append(" ".join(current)) | |
| current = [] | |
| if max_lines is not None and len(lines) >= max_lines: | |
| break | |
| remaining = word | |
| while remaining and (max_lines is None or len(lines) < max_lines): | |
| cut = len(remaining) | |
| while cut > 1 and pdf.stringWidth(remaining[:cut], font, size) > width: | |
| cut -= 1 | |
| if cut <= 1: | |
| cut = 1 | |
| lines.append(remaining[:cut]) | |
| remaining = remaining[cut:] | |
| continue | |
| test = " ".join(current + [word]) | |
| if pdf.stringWidth(test, font, size) <= width or not current: | |
| current.append(word) | |
| else: | |
| lines.append(" ".join(current)) | |
| current = [word] | |
| if max_lines is not None and len(lines) >= max_lines: | |
| current = [] | |
| break | |
| if current and (max_lines is None or len(lines) < max_lines): | |
| lines.append(" ".join(current)) | |
| return lines | |
| def _draw_wrapped( | |
| pdf: canvas.Canvas, | |
| text: str, | |
| x: float, | |
| y: float, | |
| width: float, | |
| leading: float, | |
| max_lines: Optional[int], | |
| font: str, | |
| size: int, | |
| ) -> float: | |
| lines = _wrap_lines(pdf, text, width, max_lines, font, size) | |
| if not lines: | |
| return y | |
| pdf.setFont(font, size) | |
| for line in lines: | |
| pdf.drawString(x, y, line) | |
| y -= leading | |
| return y | |
| def _truncate_to_width( | |
| pdf: canvas.Canvas, | |
| text: str, | |
| width: float, | |
| font: str, | |
| size: float, | |
| ) -> str: | |
| if pdf.stringWidth(text, font, size) <= width: | |
| return text | |
| ellipsis = "..." | |
| if pdf.stringWidth(ellipsis, font, size) >= width: | |
| return "" | |
| cut = len(text) | |
| while cut > 0: | |
| candidate = f"{text[:cut].rstrip()}{ellipsis}" | |
| if pdf.stringWidth(candidate, font, size) <= width: | |
| return candidate | |
| cut -= 1 | |
| return "" | |
| def _fit_wrapped_text( | |
| pdf: canvas.Canvas, | |
| text: str, | |
| width: float, | |
| font: str, | |
| preferred_size: float, | |
| min_size: float = 7.0, | |
| ) -> Tuple[List[str], float, float]: | |
| size = float(preferred_size) | |
| while size >= min_size: | |
| lines = _wrap_lines(pdf, text, width, None, font, size) | |
| if not lines: | |
| return [], size, max(9.0, size + 1.0) | |
| if max(pdf.stringWidth(line, font, size) for line in lines) <= width + 0.1: | |
| leading = max(9.0, size + 1.0) | |
| return lines, size, leading | |
| size -= 0.5 | |
| lines = _wrap_lines(pdf, text, width, None, font, min_size) | |
| safe_lines = [ | |
| _truncate_to_width(pdf, line, width, font, min_size) for line in lines | |
| ] | |
| leading = max(9.0, min_size + 1.0) | |
| return safe_lines, min_size, leading | |
| def _draw_centered_block( | |
| pdf: canvas.Canvas, | |
| lines: List[str], | |
| x_center: float, | |
| box_bottom: float, | |
| box_height: float, | |
| leading: float, | |
| font: str, | |
| size: int, | |
| ) -> None: | |
| if not lines: | |
| return | |
| pdf.setFont(font, size) | |
| block_h = len(lines) * leading | |
| start_y = box_bottom + (box_height + block_h) / 2 - leading | |
| y = start_y | |
| for line in lines: | |
| pdf.drawCentredString(x_center, y, line) | |
| y -= leading | |
| def _draw_label_value( | |
| pdf: canvas.Canvas, | |
| label: str, | |
| value: str, | |
| x: float, | |
| y: float, | |
| label_font: str, | |
| value_font: str, | |
| label_size: int, | |
| value_size: int, | |
| label_color: colors.Color, | |
| value_color: colors.Color, | |
| ) -> float: | |
| pdf.setFillColor(label_color) | |
| pdf.setFont(label_font, label_size) | |
| pdf.drawString(x, y, label) | |
| y -= label_size + 1 | |
| pdf.setFillColor(value_color) | |
| pdf.setFont(value_font, value_size) | |
| pdf.drawString(x, y, value or "-") | |
| return y | |
| def _draw_label_value_centered( | |
| pdf: canvas.Canvas, | |
| label: str, | |
| value: str, | |
| x_center: float, | |
| y: float, | |
| label_font: str, | |
| value_font: str, | |
| label_size: int, | |
| value_size: int, | |
| label_color: colors.Color, | |
| value_color: colors.Color, | |
| max_width: float, | |
| max_lines: int, | |
| leading: float, | |
| ) -> int: | |
| pdf.setFillColor(label_color) | |
| pdf.setFont(label_font, label_size) | |
| pdf.drawCentredString(x_center, y, label) | |
| y -= label_size + 1 | |
| pdf.setFillColor(value_color) | |
| pdf.setFont(value_font, value_size) | |
| lines = _wrap_lines(pdf, value or "-", max_width, max_lines, value_font, value_size) | |
| if not lines: | |
| lines = ["-"] | |
| for line in lines: | |
| pdf.drawCentredString(x_center, y, line) | |
| y -= leading | |
| return len(lines) | |
| def _badge_style(value: str, scale: dict) -> tuple[str, colors.Color, colors.Color]: | |
| raw = (value or "").strip() | |
| key = raw.upper() | |
| match = re.match(r"^([0-9]|[XM])", key) | |
| if match: | |
| key = match.group(1) | |
| tone = scale.get(key) | |
| if not tone: | |
| return (raw or ""), colors.HexColor("#f9fafb"), colors.HexColor("#374151") | |
| return f"{key} {tone['label']}", tone["bg"], tone["text"] | |
| def _resolve_logo_path(store: SessionStore, session: dict, raw: str) -> Optional[Path]: | |
| value = _safe_text(raw) | |
| if not value: | |
| return None | |
| def normalize_lookup(value_raw: str) -> str: | |
| return re.sub(r"[^a-z0-9]", "", str(value_raw or "").strip().lower()) | |
| uploads = (session.get("uploads") or {}).get("photos") or [] | |
| lookup_key = normalize_lookup(value) | |
| for item in uploads: | |
| if value == (item.get("id") or ""): | |
| path = store.resolve_upload_path(session, item.get("id")) | |
| if path and path.exists(): | |
| return path | |
| name = item.get("name") or "" | |
| if not name: | |
| continue | |
| stem = name.rsplit(".", 1)[0] | |
| if lookup_key in {normalize_lookup(name), normalize_lookup(stem)}: | |
| path = store.resolve_upload_path(session, item.get("id")) | |
| if path and path.exists(): | |
| return path | |
| return None | |
| def _draw_image_fit( | |
| pdf: canvas.Canvas, | |
| image_path: Path, | |
| x: float, | |
| y: float, | |
| width: float, | |
| height: float, | |
| image_cache: Optional[Dict[Tuple[str, int, int], Tuple[ImageReader, int, int, BytesIO]]] = None, | |
| ) -> bool: | |
| def _resample_filter() -> int: | |
| resampling = getattr(Image, "Resampling", None) | |
| if resampling is not None: | |
| return int(resampling.LANCZOS) | |
| return int(Image.LANCZOS) | |
| def _prepare_reader() -> Optional[Tuple[ImageReader, int, int]]: | |
| if image_cache is None: | |
| return None | |
| key = ( | |
| str(image_path.resolve()), | |
| max(1, int(round(width))), | |
| max(1, int(round(height))), | |
| ) | |
| cached = image_cache.get(key) | |
| if cached: | |
| return cached[0], cached[1], cached[2] | |
| try: | |
| with Image.open(image_path) as source: | |
| source.load() | |
| image = source.copy() | |
| except Exception: | |
| return None | |
| if image.width <= 0 or image.height <= 0: | |
| return None | |
| resample = _resample_filter() | |
| target_w_px = max(1, int(round(width * PDF_IMAGE_TARGET_DPI / 72.0))) | |
| target_h_px = max(1, int(round(height * PDF_IMAGE_TARGET_DPI / 72.0))) | |
| image.thumbnail((target_w_px, target_h_px), resample) | |
| long_edge = max(image.width, image.height) | |
| if long_edge > PDF_IMAGE_MAX_LONG_EDGE_PX: | |
| ratio = PDF_IMAGE_MAX_LONG_EDGE_PX / float(long_edge) | |
| image = image.resize( | |
| ( | |
| max(1, int(round(image.width * ratio))), | |
| max(1, int(round(image.height * ratio))), | |
| ), | |
| resample, | |
| ) | |
| suffix = image_path.suffix.lower() | |
| buffer = BytesIO() | |
| try: | |
| if suffix == ".png" and max(image.width, image.height) <= PDF_IMAGE_PNG_PRESERVE_MAX_PX: | |
| image.save(buffer, format="PNG", optimize=True) | |
| else: | |
| if image.mode in ("RGBA", "LA", "P"): | |
| alpha_source = image.convert("RGBA") | |
| flattened = Image.new("RGB", alpha_source.size, (255, 255, 255)) | |
| flattened.paste(alpha_source, mask=alpha_source.split()[-1]) | |
| image = flattened | |
| elif image.mode not in ("RGB", "L"): | |
| image = image.convert("RGB") | |
| image.save( | |
| buffer, | |
| format="JPEG", | |
| quality=PDF_IMAGE_JPEG_QUALITY, | |
| optimize=True, | |
| progressive=True, | |
| ) | |
| buffer.seek(0) | |
| reader = ImageReader(buffer) | |
| iw, ih = reader.getSize() | |
| image_cache[key] = (reader, iw, ih, buffer) | |
| return reader, iw, ih | |
| except Exception: | |
| buffer.close() | |
| return None | |
| prepared = _prepare_reader() | |
| if prepared: | |
| reader, iw, ih = prepared | |
| else: | |
| reader = None | |
| iw = 0 | |
| ih = 0 | |
| if reader is None: | |
| try: | |
| reader = ImageReader(str(image_path)) | |
| iw, ih = reader.getSize() | |
| except Exception: | |
| try: | |
| img = Image.open(image_path) | |
| img.load() | |
| if img.mode not in ("RGB", "L"): | |
| img = img.convert("RGB") | |
| reader = ImageReader(img) | |
| iw, ih = reader.getSize() | |
| except Exception: | |
| try: | |
| pdf.drawImage( | |
| str(image_path), | |
| x, | |
| y, | |
| width, | |
| height, | |
| preserveAspectRatio=True, | |
| mask="auto", | |
| ) | |
| return True | |
| except Exception: | |
| return False | |
| if iw <= 0 or ih <= 0: | |
| return False | |
| scale = min(width / iw, height / ih) | |
| draw_w = iw * scale | |
| draw_h = ih * scale | |
| draw_x = x + (width - draw_w) / 2 | |
| draw_y = y + (height - draw_h) / 2 | |
| pdf.drawImage( | |
| reader, | |
| draw_x, | |
| draw_y, | |
| draw_w, | |
| draw_h, | |
| preserveAspectRatio=True, | |
| mask="auto", | |
| ) | |
| return True | |
| def render_report_pdf( | |
| store: SessionStore, | |
| session: dict, | |
| sections_or_pages: List[dict], | |
| output_path: Path, | |
| ) -> Path: | |
| width, height = A4 | |
| margin = 10 * mm | |
| header_h = 26 * mm | |
| footer_h = 12 * mm | |
| gap = 4 * mm | |
| photo_col_gap = 6 * mm | |
| photo_row_gap = 6 * mm | |
| photos_header_gap = 8 * mm | |
| min_photo_cell_w = 80 * mm | |
| min_photo_cell_h = 60 * mm | |
| two_col_cell_w = (width - 2 * margin - photo_col_gap) / 2 | |
| columns_for_photos = 2 if two_col_cell_w >= min_photo_cell_w else 1 | |
| gray_50 = colors.HexColor("#f9fafb") | |
| gray_200 = colors.HexColor("#e5e7eb") | |
| gray_500 = colors.HexColor("#6b7280") | |
| gray_700 = colors.HexColor("#374151") | |
| gray_800 = colors.HexColor("#1f2937") | |
| gray_900 = colors.HexColor("#111827") | |
| gray_600 = colors.HexColor("#4b5563") | |
| amber_50 = colors.HexColor("#fffbeb") | |
| amber_300 = colors.HexColor("#fcd34d") | |
| amber_800 = colors.HexColor("#92400e") | |
| emerald_50 = colors.HexColor("#ecfdf5") | |
| emerald_800 = colors.HexColor("#065f46") | |
| blue_50 = colors.HexColor("#eff6ff") | |
| blue_300 = colors.HexColor("#93c5fd") | |
| blue_800 = colors.HexColor("#1e40af") | |
| blue_200 = colors.HexColor("#bfdbfe") | |
| blue_900 = colors.HexColor("#1e3a8a") | |
| purple_200 = colors.HexColor("#e9d5ff") | |
| purple_800 = colors.HexColor("#6b21a8") | |
| green_100 = colors.HexColor("#dcfce7") | |
| green_200 = colors.HexColor("#bbf7d0") | |
| yellow_100 = colors.HexColor("#fef9c3") | |
| yellow_200 = colors.HexColor("#fef08a") | |
| orange_200 = colors.HexColor("#fed7aa") | |
| red_200 = colors.HexColor("#fecaca") | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| pdf = canvas.Canvas(str(output_path), pagesize=A4, pageCompression=1) | |
| image_cache: Dict[Tuple[str, int, int], Tuple[ImageReader, int, int, BytesIO]] = {} | |
| uploads = (session.get("uploads") or {}).get("photos") or [] | |
| by_id = {item.get("id"): item for item in uploads if item.get("id")} | |
| content_top = height - margin - header_h - gap | |
| content_bottom = margin + footer_h + gap | |
| photo_area_height_photos = max(0, content_top - photos_header_gap - content_bottom) | |
| max_rows_photos = max( | |
| 1, | |
| int((photo_area_height_photos + photo_row_gap) // (min_photo_cell_h + photo_row_gap)), | |
| ) | |
| max_photos_photos = max(1, max_rows_photos * columns_for_photos) | |
| sections: List[dict] | |
| if sections_or_pages and isinstance(sections_or_pages[0], dict) and "pages" in sections_or_pages[0]: | |
| sections = sections_or_pages | |
| else: | |
| sections = [{"id": "section-1", "title": "Section 1", "pages": sections_or_pages or []}] | |
| print_pages: List[dict] = [] | |
| for section_index, section in enumerate(sections): | |
| section_title = _safe_text(section.get("title")) or f"Section {section_index + 1}" | |
| section_pages = section.get("pages") or [] | |
| for page_index, page in enumerate(section_pages): | |
| template = page.get("template") or {} | |
| figure_caption = _safe_text(template.get("figure_caption")) | |
| base_variant = ( | |
| (page.get("variant") or "").strip().lower() if isinstance(page, dict) else "" | |
| ) | |
| if base_variant not in ("full", "photos"): | |
| base_variant = "full" if page_index == 0 else "photos" | |
| photo_ids = page.get("photo_ids") or [] | |
| photo_entries = [] | |
| for pid in photo_ids: | |
| item = by_id.get(pid) | |
| if not item: | |
| continue | |
| path = store.resolve_upload_path(session, pid) | |
| if path and path.exists(): | |
| label = figure_caption or _safe_text(item.get("name") or path.name) | |
| photo_entries.append({"path": path, "label": label}) | |
| if base_variant == "photos": | |
| chunks = _chunk(photo_entries, max_photos_photos) or [[]] | |
| for chunk in chunks: | |
| print_pages.append( | |
| { | |
| "page_index": page_index, | |
| "template": template, | |
| "photos": chunk, | |
| "variant": "photos", | |
| "section_index": section_index, | |
| "section_title": section_title, | |
| } | |
| ) | |
| else: | |
| first_chunk = photo_entries[:2] | |
| remainder = photo_entries[2:] | |
| chunks = [first_chunk] | |
| if remainder: | |
| chunks.extend(_chunk(remainder, max_photos_photos)) | |
| for chunk_index, chunk in enumerate(chunks): | |
| variant = "full" if chunk_index == 0 else "photos" | |
| print_pages.append( | |
| { | |
| "page_index": page_index, | |
| "template": template, | |
| "photos": chunk, | |
| "variant": variant, | |
| "section_index": section_index, | |
| "section_title": section_title, | |
| } | |
| ) | |
| if not print_pages: | |
| print_pages = [ | |
| { | |
| "page_index": 0, | |
| "template": {}, | |
| "photos": [], | |
| "variant": "full", | |
| "section_index": 0, | |
| "section_title": "Section 1", | |
| } | |
| ] | |
| total_pages = len(print_pages) | |
| repo_root = Path(__file__).resolve().parents[3] | |
| logo_candidates = [ | |
| repo_root / "frontend" / "public" / "assets" / "prosento-logo.png", | |
| repo_root / "frontend" / "dist" / "assets" / "prosento-logo.png", | |
| repo_root / "server" / "assets" / "prosento-logo.png", | |
| ] | |
| default_logo = next((path for path in logo_candidates if path.exists()), None) | |
| for output_index, payload in enumerate(print_pages): | |
| template = payload["template"] | |
| photos = payload["photos"] | |
| variant = payload["variant"] | |
| doc_number = _safe_text(template.get("document_no")) or _safe_text( | |
| session.get("document_no") | |
| ) | |
| if not doc_number and session.get("id"): | |
| doc_number = f"REP-{session['id'][:8].upper()}" | |
| section_index = payload.get("section_index") | |
| section_title = _safe_text(payload.get("section_title")) | |
| section_label = "" | |
| if isinstance(section_index, int): | |
| base_label = f"Section {section_index + 1}" | |
| if section_title and section_title != base_label: | |
| section_label = f"{base_label} - {section_title}" | |
| else: | |
| section_label = base_label | |
| header_y = height - margin | |
| content_top = header_y - header_h - gap | |
| content_bottom = margin + footer_h + gap | |
| content_height = content_top - content_bottom | |
| # Header | |
| logo_w = 40 * mm | |
| logo_h = 20 * mm | |
| logo_x = margin | |
| logo_y = header_y - (header_h + logo_h) / 2 | |
| logo_drawn = False | |
| if default_logo: | |
| logo_drawn = _draw_image_fit( | |
| pdf, | |
| default_logo, | |
| logo_x, | |
| logo_y, | |
| logo_w, | |
| logo_h, | |
| image_cache=image_cache, | |
| ) | |
| if not logo_drawn: | |
| pdf.setStrokeColor(colors.red) | |
| pdf.setLineWidth(1) | |
| pdf.rect(logo_x, logo_y, logo_w, logo_h, stroke=1, fill=0) | |
| pdf.setFillColor(colors.red) | |
| pdf.setFont("Helvetica-Bold", 9) | |
| pdf.drawString(logo_x + 2, logo_y + logo_h / 2 - 3, "LOGO MISSING") | |
| else: | |
| pdf.setStrokeColor(gray_200) | |
| pdf.setLineWidth(0.5) | |
| pdf.rect(logo_x, logo_y, logo_w, logo_h, stroke=1, fill=0) | |
| client_logo = _resolve_logo_path(store, session, template.get("company_logo", "")) | |
| client_logo_w = 40 * mm | |
| client_logo_h = 20 * mm | |
| client_logo_x = width - margin - client_logo_w | |
| client_logo_y = header_y - (header_h + client_logo_h) / 2 | |
| if client_logo: | |
| _draw_image_fit( | |
| pdf, | |
| client_logo, | |
| client_logo_x, | |
| client_logo_y, | |
| client_logo_w, | |
| client_logo_h, | |
| image_cache=image_cache, | |
| ) | |
| else: | |
| pdf.setStrokeColor(gray_200) | |
| pdf.rect(client_logo_x, client_logo_y, client_logo_w, client_logo_h, stroke=1, fill=0) | |
| pdf.setFillColor(gray_500) | |
| pdf.setFont("Helvetica", 8) | |
| pdf.drawCentredString( | |
| client_logo_x + client_logo_w / 2, | |
| client_logo_y + client_logo_h / 2 + 2, | |
| "Company Logo", | |
| ) | |
| pdf.drawCentredString( | |
| client_logo_x + client_logo_w / 2, | |
| client_logo_y + client_logo_h / 2 - 6, | |
| "not found", | |
| ) | |
| pdf.setFillColor(gray_900) | |
| pdf.setFont("Helvetica-Bold", 13) | |
| pdf.drawCentredString( | |
| width / 2, | |
| header_y - header_h / 2 + 2 * mm, | |
| doc_number or "Document No", | |
| ) | |
| pdf.setStrokeColor(gray_200) | |
| pdf.line(margin, header_y - header_h + 3 * mm, width - margin, header_y - header_h + 3 * mm) | |
| y = content_top | |
| if variant == "full": | |
| # Observations and Findings | |
| pdf.setFillColor(gray_800) | |
| pdf.setFont("Helvetica-Bold", 12) | |
| pdf.drawString(margin, y, "Observations and Findings") | |
| pdf.setStrokeColor(gray_200) | |
| pdf.line(margin, y - 2, width - margin, y - 2) | |
| y -= photos_header_gap | |
| ref = _safe_text(template.get("reference")) | |
| area = _safe_text(template.get("area")) | |
| location = _safe_text(template.get("functional_location")) | |
| item_desc = _safe_text(template.get("item_description")) | |
| required_action = _safe_text(template.get("required_action")) | |
| total_w = width - 2 * margin | |
| col_w = total_w / 3 | |
| col_centers = [ | |
| margin + col_w / 2, | |
| margin + col_w * 1.5, | |
| margin + col_w * 2.5, | |
| ] | |
| label_size = 9 | |
| value_size = 10 | |
| value_gap = 1.5 * mm | |
| row_gap = 3 * mm | |
| leading = 11 | |
| row_y = y | |
| line_counts = [] | |
| line_counts.append( | |
| _draw_label_value_centered( | |
| pdf, | |
| "Ref", | |
| ref, | |
| col_centers[0], | |
| row_y, | |
| "Helvetica", | |
| "Helvetica-Bold", | |
| label_size, | |
| value_size, | |
| gray_500, | |
| gray_900, | |
| col_w - 4 * mm, | |
| 2, | |
| leading, | |
| ) | |
| ) | |
| line_counts.append( | |
| _draw_label_value_centered( | |
| pdf, | |
| "Area", | |
| area, | |
| col_centers[1], | |
| row_y, | |
| "Helvetica", | |
| "Helvetica-Bold", | |
| label_size, | |
| value_size, | |
| gray_500, | |
| gray_900, | |
| col_w - 4 * mm, | |
| 2, | |
| leading, | |
| ) | |
| ) | |
| line_counts.append( | |
| _draw_label_value_centered( | |
| pdf, | |
| "Location", | |
| location, | |
| col_centers[2], | |
| row_y, | |
| "Helvetica", | |
| "Helvetica-Bold", | |
| label_size, | |
| value_size, | |
| gray_500, | |
| gray_900, | |
| col_w - 4 * mm, | |
| 2, | |
| leading, | |
| ) | |
| ) | |
| y = row_y - (label_size + value_gap + leading * max(1, max(line_counts))) - row_gap | |
| category = _safe_text(template.get("category")) | |
| priority = _safe_text(template.get("priority")) | |
| cat_scale = { | |
| "0": {"label": "(100% Original Strength)", "bg": green_100, "text": colors.HexColor("#166534")}, | |
| "1": {"label": "(100% Original Strength)", "bg": green_200, "text": colors.HexColor("#166534")}, | |
| "2": {"label": "(95-100% Original Strength)", "bg": yellow_100, "text": colors.HexColor("#854d0e")}, | |
| "3": {"label": "(75-95% Original Strength)", "bg": yellow_200, "text": colors.HexColor("#854d0e")}, | |
| "4": {"label": "(50-75% Original Strength)", "bg": orange_200, "text": colors.HexColor("#9a3412")}, | |
| "5": {"label": "(<50% Original Strength)", "bg": red_200, "text": colors.HexColor("#991b1b")}, | |
| } | |
| pr_scale = { | |
| "1": {"label": "(Immediate)", "bg": red_200, "text": colors.HexColor("#991b1b")}, | |
| "2": {"label": "(1 Year)", "bg": orange_200, "text": colors.HexColor("#9a3412")}, | |
| "3": {"label": "(3 Years)", "bg": green_200, "text": colors.HexColor("#166534")}, | |
| "X": {"label": "(At Use)", "bg": purple_200, "text": purple_800}, | |
| "M": {"label": "(Monitor)", "bg": blue_200, "text": blue_900}, | |
| } | |
| cat_text, cat_bg, cat_text_color = _badge_style(category, cat_scale) | |
| pr_text, pr_bg, pr_text_color = _badge_style(priority, pr_scale) | |
| badge_h = 10 * mm | |
| y -= 1 * mm | |
| pdf.setFillColor(gray_500) | |
| pdf.setFont("Helvetica", 9) | |
| # Make badge width adapt to text length while keeping both badges centered. | |
| badge_font = "Helvetica-Bold" | |
| badge_size = 10 | |
| badge_padding = 6 * mm | |
| min_badge_w = 28 * mm | |
| max_badge_w = 85 * mm | |
| content_w = width - 2 * margin | |
| cat_text_w = pdf.stringWidth(cat_text, badge_font, badge_size) + badge_padding * 2 | |
| pr_text_w = pdf.stringWidth(pr_text, badge_font, badge_size) + badge_padding * 2 | |
| cat_w = max(min_badge_w, min(max_badge_w, cat_text_w)) | |
| pr_w = max(min_badge_w, min(max_badge_w, pr_text_w)) | |
| badge_gap = 12 * mm | |
| total_badge_w = cat_w + pr_w + badge_gap | |
| if total_badge_w > content_w: | |
| badge_gap = max(4 * mm, content_w - (cat_w + pr_w)) | |
| total_badge_w = cat_w + pr_w + badge_gap | |
| if total_badge_w > content_w: | |
| available = max(20 * mm, content_w - badge_gap) | |
| scale = available / max(cat_w + pr_w, 1) | |
| cat_w *= scale | |
| pr_w *= scale | |
| total_badge_w = cat_w + pr_w + badge_gap | |
| start_x = (width - total_badge_w) / 2 | |
| cat_x = start_x | |
| pr_x = start_x + cat_w + badge_gap | |
| cat_label_x = cat_x + cat_w / 2 | |
| pr_label_x = pr_x + pr_w / 2 | |
| label_y = y | |
| pdf.drawCentredString(cat_label_x, label_y, "Category") | |
| pdf.drawCentredString(pr_label_x, label_y, "Priority") | |
| y -= 10 * mm | |
| pdf.setFillColor(cat_bg) | |
| pdf.setStrokeColor(gray_200) | |
| pdf.roundRect(cat_x, y - 2, cat_w, badge_h, 2 * mm, stroke=1, fill=1) | |
| pdf.setFillColor(cat_text_color) | |
| pdf.setFont(badge_font, badge_size) | |
| pdf.drawCentredString(cat_label_x, y - 2 + badge_h / 2 - 3, cat_text) | |
| pdf.setFillColor(pr_bg) | |
| pdf.roundRect(pr_x, y - 2, pr_w, badge_h, 2 * mm, stroke=1, fill=1) | |
| pdf.setFillColor(pr_text_color) | |
| pdf.drawCentredString(pr_label_x, y - 2 + badge_h / 2 - 3, pr_text) | |
| y -= 8 * mm | |
| condition = item_desc | |
| action = required_action | |
| pdf.setFillColor(gray_500) | |
| pdf.setFont("Helvetica", 9) | |
| pdf.drawCentredString( | |
| margin + (width - 2 * margin) / 2, y, "Condition Description" | |
| ) | |
| y -= 3 * mm | |
| pdf.setFillColor(gray_50) | |
| pdf.setStrokeColor(gray_200) | |
| cond_lines, cond_font_size, cond_leading = _fit_wrapped_text( | |
| pdf, | |
| condition or "", | |
| width - 2 * margin - 8 * mm, | |
| "Helvetica-Bold", | |
| 10.0, | |
| ) | |
| cond_h = max(10 * mm, (len(cond_lines) or 1) * cond_leading + 4 * mm) | |
| cond_bottom = y - cond_h | |
| pdf.rect(margin, cond_bottom, width - 2 * margin, cond_h, stroke=1, fill=1) | |
| pdf.setLineWidth(2) | |
| pdf.line(margin, cond_bottom, margin, y) | |
| pdf.setLineWidth(1) | |
| pdf.setFillColor(gray_700) | |
| text_center_x = margin + (width - 2 * margin) / 2 | |
| _draw_centered_block( | |
| pdf, | |
| cond_lines, | |
| text_center_x, | |
| cond_bottom, | |
| cond_h, | |
| cond_leading, | |
| "Helvetica-Bold", | |
| cond_font_size, | |
| ) | |
| y = cond_bottom - 4 * mm | |
| pdf.setFillColor(gray_500) | |
| pdf.setFont("Helvetica", 9) | |
| pdf.drawCentredString( | |
| margin + (width - 2 * margin) / 2, y, "Required Action" | |
| ) | |
| y -= 3 * mm | |
| pdf.setFillColor(gray_50) | |
| pdf.setStrokeColor(gray_200) | |
| action_lines, action_font_size, action_leading = _fit_wrapped_text( | |
| pdf, | |
| action or "", | |
| width - 2 * margin - 8 * mm, | |
| "Helvetica-Bold", | |
| 10.0, | |
| ) | |
| action_h = max(10 * mm, (len(action_lines) or 1) * action_leading + 4 * mm) | |
| action_bottom = y - action_h | |
| pdf.rect(margin, action_bottom, width - 2 * margin, action_h, stroke=1, fill=1) | |
| pdf.setLineWidth(2) | |
| pdf.line(margin, action_bottom, margin, y) | |
| pdf.setLineWidth(1) | |
| pdf.setFillColor(gray_700) | |
| text_center_x = margin + (width - 2 * margin) / 2 | |
| _draw_centered_block( | |
| pdf, | |
| action_lines, | |
| text_center_x, | |
| action_bottom, | |
| action_h, | |
| action_leading, | |
| "Helvetica-Bold", | |
| action_font_size, | |
| ) | |
| y = action_bottom - 4 * mm | |
| else: | |
| pdf.setFillColor(gray_800) | |
| pdf.setFont("Helvetica-Bold", 12) | |
| pdf.drawString(margin, y, "Photo Documentation (continued)") | |
| pdf.setStrokeColor(gray_200) | |
| pdf.line(margin, y - 2, width - margin, y - 2) | |
| y -= photos_header_gap | |
| if variant == "full": | |
| y -= 2 * mm | |
| pdf.setFillColor(gray_800) | |
| pdf.setFont("Helvetica-Bold", 12) | |
| pdf.drawString(margin, y, "Photo Documentation") | |
| pdf.setStrokeColor(gray_200) | |
| pdf.line(margin, y - 2, width - margin, y - 2) | |
| y -= 8 * mm | |
| photo_area_top = y | |
| photo_area_height = max(0, photo_area_top - content_bottom) | |
| if photos: | |
| columns = 1 if len(photos) == 1 else 2 | |
| rows = math.ceil(len(photos) / columns) | |
| cell_w = (width - 2 * margin - (columns - 1) * photo_col_gap) / columns | |
| cell_h = (photo_area_height - (rows - 1) * photo_row_gap) / rows | |
| for idx, photo in enumerate(photos): | |
| photo_path = photo["path"] | |
| label = photo.get("label") or photo_path.name | |
| row = idx // columns | |
| col = idx % columns | |
| x = margin + col * (cell_w + photo_col_gap) | |
| y = photo_area_top - (row + 1) * cell_h - row * photo_row_gap | |
| pdf.setStrokeColor(gray_200) | |
| pdf.setFillColor(gray_50) | |
| pdf.roundRect(x, y, cell_w, cell_h, 3 * mm, stroke=1, fill=1) | |
| caption_text = label or "" | |
| caption_font = "Helvetica" | |
| caption_size = 9 | |
| caption_leading = 10 | |
| caption_lines = _wrap_lines( | |
| pdf, | |
| caption_text, | |
| cell_w - 6 * mm, | |
| 2, | |
| caption_font, | |
| caption_size, | |
| ) | |
| caption_h = max(6 * mm, max(1, len(caption_lines)) * caption_leading) | |
| image_y = y + caption_h + 2 * mm | |
| image_h = cell_h - caption_h - 6 * mm | |
| if image_h < 10 * mm: | |
| image_h = 10 * mm | |
| _draw_image_fit( | |
| pdf, | |
| photo_path, | |
| x + 2 * mm, | |
| image_y, | |
| cell_w - 4 * mm, | |
| image_h, | |
| image_cache=image_cache, | |
| ) | |
| if caption_lines: | |
| pdf.setFillColor(gray_500) | |
| _draw_centered_block( | |
| pdf, | |
| caption_lines, | |
| x + cell_w / 2, | |
| y + 2 * mm, | |
| caption_h, | |
| caption_leading, | |
| caption_font, | |
| caption_size, | |
| ) | |
| else: | |
| pdf.setFont("Helvetica", 11) | |
| pdf.drawString(margin, photo_area_top - 10 * mm, "No photos selected.") | |
| # Footer | |
| footer_y = margin | |
| pdf.setFillColor(gray_500) | |
| pdf.setFont("Helvetica-Bold", 10) | |
| pdf.drawCentredString(width / 2, footer_y + 8 * mm, "RepEx Inspection Job Sheet") | |
| pdf.setFont("Helvetica", 9) | |
| pdf.drawCentredString( | |
| width / 2, | |
| footer_y + 4 * mm, | |
| "Prosento - (c) 2026 All Rights Reserved", | |
| ) | |
| page_line = f"Page {output_index + 1} of {total_pages}" | |
| if section_label: | |
| page_line = f"{section_label} - {page_line}" | |
| pdf.drawCentredString(width / 2, footer_y + 1 * mm, page_line) | |
| pdf.showPage() | |
| pdf.save() | |
| return output_path | |