05/26 Fix exported PDF form layout broken 3

This commit is contained in:
2026-05-26 14:59:57 -04:00
parent 6286f95e1c
commit 0767bcaeee
+41 -63
View File
@@ -396,63 +396,38 @@ def _form_fields_section(form_fields, form_data, static_folder):
story.append(HRFlowable(width='100%', thickness=0.75, story.append(HRFlowable(width='100%', thickness=0.75,
color=C_BORDER, spaceAfter=4)) color=C_BORDER, spaceAfter=4))
# Build one Table row with cells sized by colSpan. # Build a fixed 12-column table for this grid row.
# Fields with no value are skipped unless they are text/textarea/label — # Each of the 12 grid columns gets exactly UNIT width.
# those always appear so free-text notes are never suppressed. # Fields occupy their designated columns via SPAN directives —
# current_col tracks horizontal position (1-based) so gaps between fields # this is the ReportLab equivalent of CSS grid-column.
# are filled with invisible placeholder cells that preserve column alignment. # Unanswered fields are simply left as empty cells; no placeholder
cells = [] # bookkeeping is needed because the table always has all 12 columns.
col_widths = [] NCOLS = _GRID_COLS # 12
cells_12 = [Paragraph('', STYLES['FieldValue']) for _ in range(NCOLS)]
spans = [] # SPAN TableStyle directives
has_content = False 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: for field in fields_in_row:
ftype = field.get('type', '') ftype = field.get('type', '')
col = field.get('col', current_col) col = max(1, int(field.get('col', 1))) # 1-indexed
col_span = field.get('colSpan', 1) 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 cell_w = UNIT * col_span
# Fill horizontal gap before this field with an invisible spacer if col_span > 1:
if col > current_col: spans.append(('SPAN', (ci, 0), (ci_end, 0)))
gap_w = UNIT * (col - current_col)
cells.append(Paragraph('', STYLES['FieldValue']))
col_widths.append(gap_w)
current_col = col + col_span
# ── Inline label (free-standing text element) ───────────────────── # ── Inline label (free-standing text element) ─────────────────────
if ftype == 'label': if ftype == 'label':
fs_map = {'small': 8, 'normal': 9, 'large': 11, 'x-large': 13} fs_map = {'small': 8, 'normal': 9, 'large': 11, 'x-large': 13}
fs = fs_map.get(field.get('font_size', 'normal'), 9) fs = fs_map.get(field.get('font_size', 'normal'), 9)
fw = 'Helvetica-Bold' if field.get('font_weight') == 'bold' else 'Helvetica' fw = 'Helvetica-Bold' if field.get('font_weight') == 'bold' else 'Helvetica'
p = Paragraph( cells_12[ci] = Paragraph(
field.get('text_content', ''), field.get('text_content', ''),
ParagraphStyle('li', fontName=fw, fontSize=fs, ParagraphStyle('li', fontName=fw, fontSize=fs,
textColor=C_DARK, leading=fs + 3), textColor=C_DARK, leading=fs + 3),
) )
cells.append(p)
col_widths.append(cell_w)
has_content = True has_content = True
continue continue
@@ -460,20 +435,31 @@ def _form_fields_section(form_fields, form_data, static_folder):
val = form_data.get(fid, '') val = form_data.get(fid, '')
lbl = field.get('label', '') 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): if _skip(ftype, val):
# Keep a placeholder so subsequent fields stay in column position continue # cell stays empty; SPAN ensures correct column width
cells.append(Paragraph('', STYLES['FieldValue']))
col_widths.append(cell_w)
continue
lbl_p = Paragraph(lbl, STYLES['FieldLabel']) lbl_p = Paragraph(lbl, STYLES['FieldLabel'])
# ── Value content ──────────────────────────────────────────────── # ── Value content ────────────────────────────────────────────────
if ftype == 'rating': if ftype == 'rating':
score_int = int(val) 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 = '<font color="#f59e0b">' + ('' * score_int) + '</font>' filled_stars = '<font color="#f59e0b">' + ('' * score_int) + '</font>'
empty_stars = ('<font color="#d1d5db">' + ('' * (5 - score_int)) + '</font>' empty_stars = ('<font color="#d1d5db">' + ('' * (5 - score_int)) + '</font>'
if score_int < 5 else '') 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)) 'rv', fontName='Helvetica', fontSize=9, leading=12))
elif ftype == 'image': elif ftype == 'image':
# File existence already verified in the skip gate above
img_path = os.path.join(static_folder, val) img_path = os.path.join(static_folder, val)
try: 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 8595% smaller
# than the original phone photo.
compressed = _compress_image(img_path) compressed = _compress_image(img_path)
img_src = compressed if compressed is not None else img_path img_src = compressed if compressed is not None else img_path
val_p = RLImage(img_src, val_p = RLImage(img_src,
@@ -496,10 +477,7 @@ def _form_fields_section(form_fields, form_data, static_folder):
height=1.0 * inch, height=1.0 * inch,
kind='proportional') kind='proportional')
except Exception: except Exception:
# Could not load image — insert placeholder to preserve column position continue # leave cell empty
cells.append(Paragraph('', STYLES['FieldValue']))
col_widths.append(cell_w)
continue
elif ftype == 'signature': elif ftype == 'signature':
val_p = Paragraph('[Signature captured]', STYLES['FieldValue']) val_p = Paragraph('[Signature captured]', STYLES['FieldValue'])
@@ -535,7 +513,6 @@ def _form_fields_section(form_fields, form_data, static_folder):
])) ]))
else: 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']) val_p = Paragraph(disp, STYLES['FieldValue'])
@@ -553,22 +530,23 @@ def _form_fields_section(form_fields, form_data, static_folder):
('BACKGROUND', (0, 1), (0, 1), colors.HexColor('#f8fafc')), ('BACKGROUND', (0, 1), (0, 1), colors.HexColor('#f8fafc')),
])) ]))
cells.append(box) cells_12[ci] = box
col_widths.append(cell_w)
has_content = True has_content = True
# Skip the entire row if no field had meaningful content # Skip the entire row if no field had meaningful content
if not has_content: if not has_content:
continue continue
row_tbl = Table([cells], colWidths=col_widths, hAlign='LEFT') row_tbl = Table([cells_12], colWidths=[UNIT] * NCOLS, hAlign='LEFT')
row_tbl.setStyle(TableStyle([ row_tbl.setStyle(TableStyle(
[
('VALIGN', (0, 0), (-1, -1), 'TOP'), ('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 2), ('LEFTPADDING', (0, 0), (-1, -1), 2),
('RIGHTPADDING', (0, 0), (-1, -1), 2), ('RIGHTPADDING', (0, 0), (-1, -1), 2),
('TOPPADDING', (0, 0), (-1, -1), 2), ('TOPPADDING', (0, 0), (-1, -1), 2),
('BOTTOMPADDING',(0, 0), (-1, -1), 2), ('BOTTOMPADDING',(0, 0), (-1, -1), 2),
])) ] + spans
))
story.append(row_tbl) story.append(row_tbl)
return story return story