Feb 02 2026: change export file format
This commit is contained in:
+176
-110
@@ -189,6 +189,7 @@ def _meta_table(inspection):
|
|||||||
('RIGHTPADDING', (0, 0), (-1, -1), 8),
|
('RIGHTPADDING', (0, 0), (-1, -1), 8),
|
||||||
('TOPPADDING', (0, 0), (-1, -1), 5),
|
('TOPPADDING', (0, 0), (-1, -1), 5),
|
||||||
('BOTTOMPADDING',(0, 0), (-1, -1), 5),
|
('BOTTOMPADDING',(0, 0), (-1, -1), 5),
|
||||||
|
# Label rows get a lighter text treatment (already in STYLES['MetaLabel'])
|
||||||
('TEXTCOLOR', (0, 0), (-1, -1), C_DARK),
|
('TEXTCOLOR', (0, 0), (-1, -1), C_DARK),
|
||||||
]))
|
]))
|
||||||
return tbl
|
return tbl
|
||||||
@@ -237,137 +238,202 @@ def _star_string(score, max_stars=5):
|
|||||||
return '★' * filled + '☆' * (max_stars - filled)
|
return '★' * filled + '☆' * (max_stars - filled)
|
||||||
|
|
||||||
|
|
||||||
_SKIP_TYPES = {'label', 'section', 'button_submit', 'button_print', 'button_email'}
|
_SKIP_TYPES = {'button_submit', 'button_print', 'button_email'}
|
||||||
|
_GRID_COLS = 12 # matches the web UI 12-column grid
|
||||||
|
|
||||||
|
|
||||||
def _form_fields_section(form_fields, form_data, static_folder):
|
def _form_fields_section(form_fields, form_data, static_folder):
|
||||||
"""Render all answered form fields as a two-column table per row."""
|
"""
|
||||||
|
Reconstruct the web UI grid layout in PDF form.
|
||||||
|
|
||||||
|
Fields are grouped by their grid row number. Within each row the fields
|
||||||
|
are placed side-by-side with column widths proportional to their colSpan.
|
||||||
|
Section headings (type='section') are rendered as full-width banners
|
||||||
|
between groups of rows, exactly as they appear in the web UI.
|
||||||
|
"""
|
||||||
story = []
|
story = []
|
||||||
story.append(Paragraph('Inspection Results', STYLES['SectionHead']))
|
story.append(Paragraph('Inspection Results', STYLES['SectionHead']))
|
||||||
story.append(HRFlowable(width='100%', thickness=1, color=C_BORDER,
|
story.append(HRFlowable(width='100%', thickness=1, color=C_BORDER, spaceAfter=6))
|
||||||
spaceAfter=6))
|
|
||||||
|
|
||||||
current_section = None
|
FULL_W = letter[0] - 1.3 * inch # usable page width
|
||||||
rows = [] # list of [label_para, value_para] pairs
|
UNIT = FULL_W / _GRID_COLS # width of one grid column unit
|
||||||
|
|
||||||
def _flush_rows():
|
# ── Group fields by row number, preserving original order ────────────────
|
||||||
if not rows:
|
from collections import defaultdict, OrderedDict
|
||||||
return []
|
rows_map = OrderedDict() # row_num -> [field, ...]
|
||||||
out = []
|
sections = {} # row_num -> section label that precedes this row
|
||||||
w = letter[0] - 1.3 * inch
|
|
||||||
for label_p, value_p in rows:
|
|
||||||
tbl = Table([[label_p, value_p]],
|
|
||||||
colWidths=[w * 0.38, w * 0.62])
|
|
||||||
tbl.setStyle(TableStyle([
|
|
||||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
||||||
('LEFTPADDING', (0, 0), (-1, -1), 6),
|
|
||||||
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
|
||||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
|
||||||
('BOTTOMPADDING',(0, 0), (-1, -1), 4),
|
|
||||||
('LINEBELOW', (0, 0), (-1, 0), 0.25, C_BORDER),
|
|
||||||
]))
|
|
||||||
out.append(tbl)
|
|
||||||
rows.clear()
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
pending_section = None
|
||||||
for field in form_fields:
|
for field in form_fields:
|
||||||
ftype = field.get('type', '')
|
ftype = field.get('type', '')
|
||||||
fid = str(field.get('id', ''))
|
if ftype in ('button_submit', 'button_print', 'button_email'):
|
||||||
val = form_data.get(fid, '')
|
continue
|
||||||
|
|
||||||
if ftype == 'section':
|
if ftype == 'section':
|
||||||
story.extend(_flush_rows())
|
pending_section = field.get('label', '')
|
||||||
|
continue
|
||||||
|
|
||||||
|
row_num = field.get('row', 0)
|
||||||
|
if row_num not in rows_map:
|
||||||
|
rows_map[row_num] = []
|
||||||
|
if pending_section is not None:
|
||||||
|
sections[row_num] = pending_section
|
||||||
|
pending_section = None
|
||||||
|
|
||||||
|
rows_map[row_num].append(field)
|
||||||
|
|
||||||
|
# ── Render row by row ─────────────────────────────────────────────────────
|
||||||
|
for row_num, fields_in_row in rows_map.items():
|
||||||
|
|
||||||
|
# Emit section banner if one precedes this row
|
||||||
|
if row_num in sections:
|
||||||
story.append(Spacer(1, 6))
|
story.append(Spacer(1, 6))
|
||||||
story.append(Paragraph(field.get('label', ''),
|
sec_label = sections[row_num]
|
||||||
ParagraphStyle('SecH', fontName='Helvetica-Bold',
|
# Bold letter prefix (e.g. "A.", "B.") + rest of label
|
||||||
fontSize=9.5, textColor=C_BLUE,
|
story.append(Paragraph(
|
||||||
spaceBefore=6, spaceAfter=2,
|
sec_label,
|
||||||
borderPad=3,
|
ParagraphStyle('SecBanner', fontName='Helvetica-Bold',
|
||||||
backColor=colors.HexColor('#eff6ff'))))
|
fontSize=10, textColor=C_DARK,
|
||||||
story.append(HRFlowable(width='100%', thickness=0.5,
|
spaceBefore=8, spaceAfter=3),
|
||||||
color=C_BLUE, spaceAfter=4))
|
))
|
||||||
continue
|
story.append(HRFlowable(width='100%', thickness=0.75,
|
||||||
|
color=C_BORDER, spaceAfter=4))
|
||||||
|
|
||||||
if ftype in _SKIP_TYPES:
|
# Build one Table row with cells sized by colSpan
|
||||||
continue
|
cells = []
|
||||||
|
col_widths = []
|
||||||
|
has_content = False
|
||||||
|
|
||||||
label = field.get('label', fid)
|
for field in fields_in_row:
|
||||||
label_p = Paragraph(label, STYLES['FieldLabel'])
|
ftype = field.get('type', '')
|
||||||
|
col_span = field.get('colSpan', 1)
|
||||||
|
cell_w = UNIT * col_span
|
||||||
|
|
||||||
# ── Format value by type ──
|
if ftype == 'label':
|
||||||
if ftype == 'rating':
|
fs_map = {'small': 8, 'normal': 9, 'large': 11, 'x-large': 13}
|
||||||
score_int = int(val) if str(val).isdigit() else 0
|
fs = fs_map.get(field.get('font_size', 'normal'), 9)
|
||||||
if score_int > 0:
|
fw = 'Helvetica-Bold' if field.get('font_weight') == 'bold' else 'Helvetica'
|
||||||
val_text = f'{_star_string(score_int)} ({score_int}/5)'
|
p = Paragraph(
|
||||||
else:
|
field.get('text_content', ''),
|
||||||
val_text = '<i><font color="#94a3b8">Not rated</font></i>'
|
ParagraphStyle('li', fontName=fw, fontSize=fs,
|
||||||
value_p = Paragraph(val_text, STYLES['FieldValue'])
|
textColor=C_DARK, leading=fs + 3),
|
||||||
|
)
|
||||||
elif ftype == 'checkbox':
|
cells.append(p)
|
||||||
val_text = ('Yes' if val == 'yes' else 'No')
|
col_widths.append(cell_w)
|
||||||
value_p = Paragraph(val_text, STYLES['FieldValue'])
|
has_content = True
|
||||||
|
|
||||||
elif ftype == 'checkbox_group':
|
|
||||||
if val and isinstance(val, list):
|
|
||||||
val_text = ', '.join(val)
|
|
||||||
else:
|
|
||||||
val_text = '<i><font color="#94a3b8">None selected</font></i>'
|
|
||||||
value_p = Paragraph(val_text, STYLES['FieldValue'])
|
|
||||||
|
|
||||||
elif ftype == 'image':
|
|
||||||
if val:
|
|
||||||
img_path = os.path.join(static_folder, val)
|
|
||||||
if os.path.exists(img_path):
|
|
||||||
try:
|
|
||||||
max_w, max_h = 2.5 * inch, 1.8 * inch
|
|
||||||
img = RLImage(img_path, width=max_w, height=max_h,
|
|
||||||
kind='proportional')
|
|
||||||
rows.append([label_p, img])
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
value_p = Paragraph('<i><font color="#94a3b8">No photo</font></i>',
|
|
||||||
STYLES['FieldValue'])
|
|
||||||
|
|
||||||
elif ftype == 'signature':
|
|
||||||
# Base64 signatures can't be embedded inline easily — note presence only
|
|
||||||
if val and str(val).startswith('data:'):
|
|
||||||
val_text = '[Signature captured]'
|
|
||||||
else:
|
|
||||||
val_text = '<i><font color="#94a3b8">No signature</font></i>'
|
|
||||||
value_p = Paragraph(val_text, 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] + [[row.get(h, '') for h in headers] for row in val]
|
|
||||||
inner = Table(tbl_data)
|
|
||||||
inner.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),
|
|
||||||
]))
|
|
||||||
rows.append([label_p, inner])
|
|
||||||
continue
|
continue
|
||||||
value_p = Paragraph('<i><font color="#94a3b8">No data</font></i>',
|
|
||||||
|
fid = str(field.get('id', ''))
|
||||||
|
val = form_data.get(fid, '')
|
||||||
|
lbl = field.get('label', '')
|
||||||
|
|
||||||
|
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'<font color="#f59e0b">{"★" * score_int}'
|
||||||
|
f'{"☆" * (5 - score_int)}</font>'
|
||||||
|
f' <font color="#64748b">{score_int}/5</font>')
|
||||||
|
else:
|
||||||
|
stars = '<font color="#94a3b8"><i>Not rated</i></font>'
|
||||||
|
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(
|
||||||
|
'<i><font color="#94a3b8">Photo error</font></i>',
|
||||||
STYLES['FieldValue'])
|
STYLES['FieldValue'])
|
||||||
|
else:
|
||||||
|
val_p = Paragraph(
|
||||||
|
'<i><font color="#94a3b8">No photo</font></i>',
|
||||||
|
STYLES['FieldValue'])
|
||||||
|
else:
|
||||||
|
val_p = Paragraph(
|
||||||
|
'<i><font color="#94a3b8">No photo</font></i>',
|
||||||
|
STYLES['FieldValue'])
|
||||||
|
|
||||||
else:
|
elif ftype == 'signature':
|
||||||
# text, textarea, number, date, email, radio, select
|
val_p = Paragraph(
|
||||||
disp = str(val) if val else '—'
|
'[Signature captured]' if (val and str(val).startswith('data:'))
|
||||||
value_p = Paragraph(disp, STYLES['FieldValue'])
|
else '<i><font color="#94a3b8">No signature</font></i>',
|
||||||
|
STYLES['FieldValue'])
|
||||||
|
|
||||||
if not val and ftype not in ('rating',):
|
elif ftype == 'checkbox':
|
||||||
# Skip entirely empty non-rated fields to keep the PDF concise
|
val_p = Paragraph('Yes' if val == 'yes' else 'No',
|
||||||
|
STYLES['FieldValue'])
|
||||||
|
|
||||||
|
elif ftype == 'checkbox_group':
|
||||||
|
val_p = Paragraph(
|
||||||
|
', '.join(val) if (val and isinstance(val, list))
|
||||||
|
else '<i><font color="#94a3b8">None</font></i>',
|
||||||
|
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(
|
||||||
|
'<i><font color="#94a3b8">No data</font></i>',
|
||||||
|
STYLES['FieldValue'])
|
||||||
|
|
||||||
|
else:
|
||||||
|
disp = str(val) if val else ''
|
||||||
|
val_p = Paragraph(disp, STYLES['FieldValue'])
|
||||||
|
|
||||||
|
# ── Wrap into label-over-value form box ──────────────────────────
|
||||||
|
box = Table([[lbl_p], [val_p]], colWidths=[cell_w - 4])
|
||||||
|
box.setStyle(TableStyle([
|
||||||
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||||
|
('LEFTPADDING', (0, 0), (-1, -1), 5),
|
||||||
|
('RIGHTPADDING', (0, 0), (-1, -1), 5),
|
||||||
|
('TOPPADDING', (0, 0), (0, 0), 2),
|
||||||
|
('BOTTOMPADDING',(0, 0), (0, 0), 1),
|
||||||
|
('TOPPADDING', (0, 1), (0, 1), 3),
|
||||||
|
('BOTTOMPADDING',(0, 1), (0, 1), 4),
|
||||||
|
('BOX', (0, 1), (0, 1), 0.5, C_BORDER),
|
||||||
|
('BACKGROUND', (0, 1), (0, 1), colors.HexColor('#f8fafc')),
|
||||||
|
]))
|
||||||
|
|
||||||
|
cells.append(box)
|
||||||
|
col_widths.append(cell_w)
|
||||||
|
has_content = True
|
||||||
|
|
||||||
|
if not cells:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rows.append([label_p, value_p])
|
row_tbl = Table([cells], colWidths=col_widths)
|
||||||
|
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),
|
||||||
|
]))
|
||||||
|
story.append(row_tbl)
|
||||||
|
|
||||||
story.extend(_flush_rows())
|
|
||||||
return story
|
return story
|
||||||
|
|
||||||
|
|
||||||
@@ -519,4 +585,4 @@ def generate_inspection_pdf(inspection, form_fields, form_data, issues,
|
|||||||
story.append(sig_tbl)
|
story.append(sig_tbl)
|
||||||
|
|
||||||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
Reference in New Issue
Block a user