diff --git a/app/utils/pdf_export.py b/app/utils/pdf_export.py index e7549cd..13732c2 100644 --- a/app/utils/pdf_export.py +++ b/app/utils/pdf_export.py @@ -396,63 +396,38 @@ def _form_fields_section(form_fields, form_data, static_folder): story.append(HRFlowable(width='100%', thickness=0.75, color=C_BORDER, spaceAfter=4)) - # Build one Table row with cells sized by colSpan. - # Fields with no value are skipped unless they are text/textarea/label — - # those always appear so free-text notes are never suppressed. - # current_col tracks horizontal position (1-based) so gaps between fields - # are filled with invisible placeholder cells that preserve column alignment. - cells = [] - col_widths = [] + # Build a fixed 12-column table for this grid row. + # Each of the 12 grid columns gets exactly UNIT width. + # Fields occupy their designated columns via SPAN directives — + # this is the ReportLab equivalent of CSS grid-column. + # Unanswered fields are simply left as empty cells; no placeholder + # bookkeeping is needed because the table always has all 12 columns. + NCOLS = _GRID_COLS # 12 + cells_12 = [Paragraph('', STYLES['FieldValue']) for _ in range(NCOLS)] + spans = [] # SPAN TableStyle directives has_content = False - current_col = 1 - - # Returns True if the field should be hidden from the PDF. - def _skip(ftype, val): - if ftype == 'rating': - return not str(val).isdigit() or int(val) == 0 - if ftype == 'image': - img_path = os.path.join(static_folder, val) if val else '' - return not val or not os.path.exists(img_path) - if ftype == 'signature': - return not val or not str(val).startswith('data:') - if ftype == 'checkbox': - # stored as 'true'/'false' by _collect_form_responses - return val not in ('yes', 'true') - if ftype == 'pass_fail': - return not val - if ftype == 'checkbox_group': - return not val or not isinstance(val, list) or len(val) == 0 - if ftype == 'table': - return not val or not isinstance(val, list) or len(val) == 0 - # All remaining input types (text, textarea, number, date, - # email, radio, select, and any future types) - return not val for field in fields_in_row: ftype = field.get('type', '') - col = field.get('col', current_col) - col_span = field.get('colSpan', 1) + col = max(1, int(field.get('col', 1))) # 1-indexed + col_span = max(1, int(field.get('colSpan', 1))) + ci = col - 1 # 0-indexed start + ci_end = min(ci + col_span - 1, NCOLS - 1) # 0-indexed end cell_w = UNIT * col_span - # Fill horizontal gap before this field with an invisible spacer - if col > current_col: - gap_w = UNIT * (col - current_col) - cells.append(Paragraph('', STYLES['FieldValue'])) - col_widths.append(gap_w) - current_col = col + col_span + if col_span > 1: + spans.append(('SPAN', (ci, 0), (ci_end, 0))) # ── Inline label (free-standing text element) ───────────────────── if ftype == 'label': fs_map = {'small': 8, 'normal': 9, 'large': 11, 'x-large': 13} fs = fs_map.get(field.get('font_size', 'normal'), 9) fw = 'Helvetica-Bold' if field.get('font_weight') == 'bold' else 'Helvetica' - p = Paragraph( + cells_12[ci] = Paragraph( field.get('text_content', ''), ParagraphStyle('li', fontName=fw, fontSize=fs, textColor=C_DARK, leading=fs + 3), ) - cells.append(p) - col_widths.append(cell_w) has_content = True continue @@ -460,20 +435,31 @@ def _form_fields_section(form_fields, form_data, static_folder): val = form_data.get(fid, '') lbl = field.get('label', '') - # ── Skip fields with no meaningful value ────────────────────────── + # ── Skip fields with no meaningful value — leave cell empty ──────── + def _skip(ft, v): + if ft == 'rating': + return not str(v).isdigit() or int(v) == 0 + if ft == 'image': + ip = os.path.join(static_folder, v) if v else '' + return not v or not os.path.exists(ip) + if ft == 'signature': + return not v or not str(v).startswith('data:') + if ft == 'checkbox': + return v not in ('yes', 'true') + if ft == 'pass_fail': + return not v + if ft in ('checkbox_group', 'table'): + return not v or not isinstance(v, list) or len(v) == 0 + return not v + if _skip(ftype, val): - # Keep a placeholder so subsequent fields stay in column position - cells.append(Paragraph('', STYLES['FieldValue'])) - col_widths.append(cell_w) - continue + continue # cell stays empty; SPAN ensures correct column width lbl_p = Paragraph(lbl, STYLES['FieldLabel']) # ── Value content ──────────────────────────────────────────────── if ftype == 'rating': score_int = int(val) - # Use ★ for both filled and unfilled — ☆ is unsupported in - # Helvetica and renders as a solid box. Grey colour for unfilled. filled_stars = '' + ('★' * score_int) + '' empty_stars = ('' + ('★' * (5 - score_int)) + '' if score_int < 5 else '') @@ -482,13 +468,8 @@ def _form_fields_section(form_fields, form_data, static_folder): 'rv', fontName='Helvetica', fontSize=9, leading=12)) elif ftype == 'image': - # File existence already verified in the skip gate above img_path = os.path.join(static_folder, val) try: - # 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, @@ -496,10 +477,7 @@ def _form_fields_section(form_fields, form_data, static_folder): height=1.0 * inch, kind='proportional') except Exception: - # Could not load image — insert placeholder to preserve column position - cells.append(Paragraph('', STYLES['FieldValue'])) - col_widths.append(cell_w) - continue + continue # leave cell empty elif ftype == 'signature': val_p = Paragraph('[Signature captured]', STYLES['FieldValue']) @@ -509,7 +487,7 @@ def _form_fields_section(form_fields, form_data, static_folder): elif ftype == 'pass_fail': is_pass = str(val).lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant') - colour = C_GREEN if is_pass else C_RED + colour = C_GREEN if is_pass else C_RED val_p = Paragraph( f'{str(val)}', STYLES['FieldValue'], @@ -535,8 +513,7 @@ def _form_fields_section(form_fields, form_data, static_folder): ])) else: - # text, textarea, number, date, email, radio, select - disp = str(val) if val else '' + disp = str(val) if val else '' val_p = Paragraph(disp, STYLES['FieldValue']) # ── Wrap into label-over-value form box ────────────────────────── @@ -553,22 +530,23 @@ def _form_fields_section(form_fields, form_data, static_folder): ('BACKGROUND', (0, 1), (0, 1), colors.HexColor('#f8fafc')), ])) - cells.append(box) - col_widths.append(cell_w) - has_content = True + cells_12[ci] = box + has_content = True # Skip the entire row if no field had meaningful content if not has_content: continue - row_tbl = Table([cells], colWidths=col_widths, hAlign='LEFT') - row_tbl.setStyle(TableStyle([ - ('VALIGN', (0, 0), (-1, -1), 'TOP'), - ('LEFTPADDING', (0, 0), (-1, -1), 2), - ('RIGHTPADDING', (0, 0), (-1, -1), 2), - ('TOPPADDING', (0, 0), (-1, -1), 2), - ('BOTTOMPADDING',(0, 0), (-1, -1), 2), - ])) + row_tbl = Table([cells_12], colWidths=[UNIT] * NCOLS, hAlign='LEFT') + row_tbl.setStyle(TableStyle( + [ + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 2), + ('RIGHTPADDING', (0, 0), (-1, -1), 2), + ('TOPPADDING', (0, 0), (-1, -1), 2), + ('BOTTOMPADDING',(0, 0), (-1, -1), 2), + ] + spans + )) story.append(row_tbl) return story