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