diff --git a/app/utils/pdf_export.py b/app/utils/pdf_export.py
index c2f8e5f..3bdb65f 100644
--- a/app/utils/pdf_export.py
+++ b/app/utils/pdf_export.py
@@ -281,14 +281,54 @@ def _form_fields_section(form_fields, form_data, static_folder):
rows_map[row_num].append(field)
+ # ── Pre-compute which rows have at least one visible field ───────────────
+ # A row is visible if it contains a text/textarea/label field, OR any other
+ # field type that has a non-empty value. This is used to suppress section
+ # banners whose every subordinate row is empty.
+ _ALWAYS_SHOW_PRE = {'text', 'textarea', 'label'}
+
+ def _row_has_content(fields_in_row):
+ for f in fields_in_row:
+ ft = f.get('type', '')
+ fid = str(f.get('id', ''))
+ v = form_data.get(fid, '')
+ if ft in _ALWAYS_SHOW_PRE:
+ return True
+ if ft == 'rating' and str(v).isdigit() and int(v) > 0: return True
+ if ft == 'checkbox' and v == 'yes': return True
+ if ft == 'checkbox_group'and isinstance(v, list) and v: return True
+ if ft == 'image' and v: return True
+ if ft == 'signature' and v and str(v).startswith('data:'): return True
+ if ft == 'table' and isinstance(v, list) and v: return True
+ if ft in ('number','date','email','radio','select') and v: return True
+ return False
+
+ # Build a set of section-trigger row numbers that have visible content somewhere
+ # in their subordinate rows (from their row_num up to the next section's row_num).
+ section_row_nums = sorted(sections.keys())
+ all_row_nums = list(rows_map.keys())
+
+ def _section_has_visible_rows(sec_row_num):
+ idx = section_row_nums.index(sec_row_num)
+ next_sec = section_row_nums[idx + 1] if idx + 1 < len(section_row_nums) else None
+ for rn in all_row_nums:
+ if rn < sec_row_num:
+ continue
+ if next_sec is not None and rn >= next_sec:
+ break
+ if _row_has_content(rows_map[rn]):
+ return True
+ return False
+
# ── Render row by row ─────────────────────────────────────────────────────
for row_num, fields_in_row in rows_map.items():
- # Emit section banner if one precedes this row
+ # Emit section banner only if its subordinate rows have visible content
if row_num in sections:
+ if not _section_has_visible_rows(row_num):
+ continue # skip the banner — all rows beneath it are empty
story.append(Spacer(1, 6))
sec_label = sections[row_num]
- # Bold letter prefix (e.g. "A.", "B.") + rest of label
story.append(Paragraph(
sec_label,
ParagraphStyle('SecBanner', fontName='Helvetica-Bold',
@@ -298,16 +338,21 @@ 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
+ # 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.
cells = []
col_widths = []
- has_content = False
+
+ # Types that are always shown regardless of value
+ _ALWAYS_SHOW = {'text', 'textarea', 'label'}
for field in fields_in_row:
ftype = field.get('type', '')
col_span = field.get('colSpan', 1)
cell_w = UNIT * col_span
+ # ── 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)
@@ -319,87 +364,91 @@ def _form_fields_section(form_fields, form_data, static_folder):
)
cells.append(p)
col_widths.append(cell_w)
- has_content = True
continue
fid = str(field.get('id', ''))
val = form_data.get(fid, '')
lbl = field.get('label', '')
+ # ── Skip fields with no value unless type is always-shown ─────────
+ if ftype not in _ALWAYS_SHOW:
+ if ftype == 'rating':
+ if not str(val).isdigit() or int(val) == 0:
+ continue
+ elif ftype == 'checkbox':
+ if val != 'yes':
+ continue
+ elif ftype == 'checkbox_group':
+ if not val or not isinstance(val, list) or len(val) == 0:
+ continue
+ elif ftype == 'image':
+ if not val:
+ continue
+ elif ftype == 'signature':
+ if not val or not str(val).startswith('data:'):
+ continue
+ elif ftype == 'table':
+ if not val or not isinstance(val, list) or len(val) == 0:
+ continue
+ elif ftype in ('number', 'date', 'email', 'radio', 'select'):
+ if not val:
+ continue
+
lbl_p = Paragraph(lbl, STYLES['FieldLabel'])
# ── Value content ────────────────────────────────────────────────
if ftype == 'rating':
- score_int = int(val) if str(val).isdigit() else 0
- if score_int > 0:
- stars = (f'{"★" * score_int}'
- f'{"☆" * (5 - score_int)}'
- f' {score_int}/5')
- else:
- stars = 'Not rated'
+ score_int = int(val)
+ stars = (f'{"★" * score_int}'
+ f'{"☆" * (5 - score_int)}'
+ f' {score_int}/5')
val_p = Paragraph(stars, ParagraphStyle(
'rv', fontName='Helvetica', fontSize=9, leading=12))
elif ftype == 'image':
- if val:
- img_path = os.path.join(static_folder, val)
- if os.path.exists(img_path):
- try:
- val_p = RLImage(img_path,
- width=min(cell_w - 12, 1.4 * inch),
- height=1.0 * inch,
- kind='proportional')
- except Exception:
- val_p = Paragraph(
- 'Photo error',
- STYLES['FieldValue'])
- else:
+ img_path = os.path.join(static_folder, val)
+ if os.path.exists(img_path):
+ try:
+ val_p = RLImage(img_path,
+ width=min(cell_w - 12, 1.4 * inch),
+ height=1.0 * inch,
+ kind='proportional')
+ except Exception:
val_p = Paragraph(
- 'No photo',
+ 'Photo error',
STYLES['FieldValue'])
else:
val_p = Paragraph(
- 'No photo',
+ 'File not found',
STYLES['FieldValue'])
elif ftype == 'signature':
- val_p = Paragraph(
- '[Signature captured]' if (val and str(val).startswith('data:'))
- else 'No signature',
- STYLES['FieldValue'])
+ val_p = Paragraph('[Signature captured]', STYLES['FieldValue'])
elif ftype == 'checkbox':
- val_p = Paragraph('Yes' if val == 'yes' else 'No',
- STYLES['FieldValue'])
+ val_p = Paragraph('Yes', STYLES['FieldValue'])
elif ftype == 'checkbox_group':
- val_p = Paragraph(
- ', '.join(val) if (val and isinstance(val, list))
- else 'None',
- STYLES['FieldValue'])
+ val_p = Paragraph(', '.join(val), STYLES['FieldValue'])
elif ftype == 'table':
- if val and isinstance(val, list) and len(val) > 0:
- headers = field.get('col_headers',
- list(val[0].keys()) if val else [])
- tbl_data = ([headers] +
- [[r.get(h, '') for h in headers] for r in val])
- val_p = Table(tbl_data)
- val_p.setStyle(TableStyle([
- ('BACKGROUND', (0, 0), (-1, 0), C_LIGHT),
- ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
- ('FONTSIZE', (0, 0), (-1, -1), 7),
- ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
- ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
- ('TOPPADDING', (0, 0), (-1, -1), 2),
- ('BOTTOMPADDING', (0, 0), (-1, -1), 2),
- ]))
- else:
- val_p = Paragraph(
- 'No data',
- STYLES['FieldValue'])
+ headers = field.get('col_headers',
+ list(val[0].keys()) if val else [])
+ tbl_data = ([headers] +
+ [[r.get(h, '') for h in headers] for r in val])
+ val_p = Table(tbl_data)
+ val_p.setStyle(TableStyle([
+ ('BACKGROUND', (0, 0), (-1, 0), C_LIGHT),
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
+ ('FONTSIZE', (0, 0), (-1, -1), 7),
+ ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
+ ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
+ ('TOPPADDING', (0, 0), (-1, -1), 2),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 2),
+ ]))
else:
+ # text, textarea, number, date, email, radio, select
disp = str(val) if val else ''
val_p = Paragraph(disp, STYLES['FieldValue'])
@@ -419,8 +468,8 @@ def _form_fields_section(form_fields, form_data, static_folder):
cells.append(box)
col_widths.append(cell_w)
- has_content = True
+ # Skip the entire row if nothing made it through the filter
if not cells:
continue