Prosento_RepEx / server /app /services /pdf_reportlab.py
ChristopherJKoen's picture
Update template sizing/box wrapping fixes
dd94ad9
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