diff --git a/app/routes/inspections.py b/app/routes/inspections.py
index 057727c..48eaddf 100644
--- a/app/routes/inspections.py
+++ b/app/routes/inspections.py
@@ -448,6 +448,17 @@ def export_pdf(inspection_id):
static_folder = os.path.join(current_app.root_path, 'static')
+ # Log image field values to help diagnose missing-photo issues in PDF export
+ for field in form_fields:
+ if field.get('type') == 'image':
+ fid = str(field.get('id', ''))
+ val = form_data.get(fid, '')
+ resolved = os.path.join(static_folder, val) if val else ''
+ current_app.logger.info(
+ 'PDF export image field | fid=%s | val=%r | exists=%s | resolved=%r',
+ fid, val, os.path.exists(resolved) if resolved else False, resolved
+ )
+
pdf_bytes = generate_inspection_pdf(
inspection = inspection,
form_fields = form_fields,
diff --git a/app/utils/pdf_export.py b/app/utils/pdf_export.py
index c9900c9..9d88712 100644
--- a/app/utils/pdf_export.py
+++ b/app/utils/pdf_export.py
@@ -234,8 +234,13 @@ def _score_banner(inspection):
# ── Form fields section ───────────────────────────────────────────────────────
def _star_string(score, max_stars=5):
+ """Return a star string using only the filled ★ glyph.
+ Hollow ☆ is not supported by Helvetica and renders as a box.
+ Unselected stars are rendered as grey ★ via ReportLab XML markup.
+ Returns a plain string for canvas use; use _star_markup() for Paragraph.
+ """
filled = min(int(score), max_stars)
- return '★' * filled + '☆' * (max_stars - filled)
+ return '★' * filled + '★' * (max_stars - filled)
_SKIP_TYPES = {'button_submit', 'button_print', 'button_email'}
@@ -285,7 +290,7 @@ def _form_fields_section(form_fields, form_data, static_folder):
# 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'}
+ _ALWAYS_SHOW_PRE = {'label'}
def _row_has_content(fields_in_row):
for f in fields_in_row:
@@ -295,12 +300,13 @@ def _form_fields_section(form_fields, form_data, static_folder):
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' and v in ('yes','true'): return True
if ft == 'checkbox_group'and isinstance(v, list) and v: return True
- if ft == 'image' and v: return True
+ if ft == 'image' and v and os.path.exists(os.path.join(static_folder, 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
+ if ft in ('text','textarea') and v: return True
return False
# Build a set of section-trigger row numbers that have visible content somewhere
@@ -344,9 +350,6 @@ def _form_fields_section(form_fields, form_data, static_folder):
cells = []
col_widths = []
- # 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)
@@ -370,57 +373,55 @@ def _form_fields_section(form_fields, form_data, static_folder):
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:
+ # ── Skip fields with no meaningful value ──────────────────────────
+ # Returns True if the field should be hidden from the PDF.
+ def _skip(ftype, val):
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
+ 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 == '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
+
+ if _skip(ftype, val):
+ continue
lbl_p = Paragraph(lbl, STYLES['FieldLabel'])
# ── Value content ────────────────────────────────────────────────
if ftype == 'rating':
score_int = int(val)
- stars = (f'{"★" * score_int}'
- f'{"☆" * (5 - score_int)}'
- f' {score_int}/5')
+ # 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 '')
+ stars = filled_stars + empty_stars + f' {score_int}/5'
val_p = Paragraph(stars, ParagraphStyle(
'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)
- 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:
- val_p = Paragraph(
- 'File not found',
- STYLES['FieldValue'])
+ try:
+ val_p = RLImage(img_path,
+ width=min(cell_w - 12, 1.4 * inch),
+ height=1.0 * inch,
+ kind='proportional')
+ except Exception:
+ # Could not load image — skip this cell entirely
+ continue
elif ftype == 'signature':
val_p = Paragraph('[Signature captured]', STYLES['FieldValue'])