From 74fd9f7ce63548e570de36be85afc05196f6e177 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 27 Apr 2026 17:24:58 -0400 Subject: [PATCH] 04/27 Updated inspection PDF export with compress, low images to reduce file sizes --- app/utils/pdf_export.py | 59 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/app/utils/pdf_export.py b/app/utils/pdf_export.py index cef0449..fc642a3 100644 --- a/app/utils/pdf_export.py +++ b/app/utils/pdf_export.py @@ -17,6 +17,12 @@ import os import json from datetime import datetime +try: + from PIL import Image as PILImage + _PIL_AVAILABLE = True +except ImportError: + _PIL_AVAILABLE = False + from reportlab.lib.pagesizes import letter from reportlab.lib import colors from reportlab.lib.units import inch @@ -94,6 +100,51 @@ def _build_styles(): STYLES = _build_styles() +# ── Image compression helper ────────────────────────────────────────────────── + +# Max pixel dimension for any side of an image embedded in the PDF. +# Phone cameras produce 12–48 MP images; 800px is plenty for a printed report. +_IMG_MAX_PX = 800 +# JPEG quality for re-encoded images (0–95). 55 is visually acceptable for +# a printed report and reduces a typical phone photo by ~90% vs the original. +_IMG_JPEG_QUALITY = 55 + + +def _compress_image(src_path: str) -> io.BytesIO | None: + """ + Load an image from disk, resize it to fit within _IMG_MAX_PX on any side, + re-encode as JPEG at _IMG_JPEG_QUALITY, and return a BytesIO buffer. + + Returns None if PIL is unavailable or the image cannot be processed, + signalling the caller to fall back to the original file path. + """ + if not _PIL_AVAILABLE: + return None + try: + img = PILImage.open(src_path) + + # Strip transparency — JPEG does not support alpha channels. + # Convert palette / RGBA / LA → RGB before saving. + if img.mode in ('RGBA', 'LA', 'P'): + background = PILImage.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + # Resize proportionally so neither dimension exceeds _IMG_MAX_PX. + img.thumbnail((_IMG_MAX_PX, _IMG_MAX_PX), PILImage.LANCZOS) + + buf = io.BytesIO() + img.save(buf, format='JPEG', quality=_IMG_JPEG_QUALITY, optimize=True) + buf.seek(0) + return buf + except Exception: + return None + + # ── Header / footer callbacks ───────────────────────────────────────────────── def _on_page(canvas, doc, title, generated_at): @@ -417,7 +468,13 @@ def _form_fields_section(form_fields, form_data, static_folder): # File existence already verified in the skip gate above img_path = os.path.join(static_folder, val) try: - val_p = RLImage(img_path, + # Attempt to compress the image before embedding. + # _compress_image() resizes to _IMG_MAX_PX and re-encodes + # as JPEG at _IMG_JPEG_QUALITY — typically 85–95% smaller + # than the original phone photo. + compressed = _compress_image(img_path) + img_src = compressed if compressed is not None else img_path + val_p = RLImage(img_src, width=min(cell_w - 12, 1.4 * inch), height=1.0 * inch, kind='proportional')