Files
LT_Janitorial_Quality_Control/app/utils/pdf_export.py
T

1233 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
pdf_export.py
-------------
Generates a professional PDF report for a completed inspection.
Uses ReportLab Platypus (flow-based layout) so the document repaginates
cleanly regardless of how many form fields or issues exist.
Public API
----------
generate_inspection_pdf(inspection, form_fields, form_data, issues,
static_folder) -> bytes
"""
import io
import os
import json
from datetime import datetime
try:
from PIL import Image as PILImage
_PIL_AVAILABLE = True
except ImportError:
_PIL_AVAILABLE = False
from reportlab.lib.pagesizes import letter, landscape
from reportlab.lib import colors
from reportlab.lib.units import inch
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable, KeepTogether, Image as RLImage
)
# ── Colour palette (matches the web UI) ──────────────────────────────────────
C_DARK = colors.HexColor('#1a1d23')
C_BLUE = colors.HexColor('#2563eb')
C_SLATE = colors.HexColor('#64748b')
C_LIGHT = colors.HexColor('#f1f5f9')
C_BORDER = colors.HexColor('#e2e8f0')
C_GREEN = colors.HexColor('#16a34a')
C_YELLOW = colors.HexColor('#d97706')
C_RED = colors.HexColor('#dc2626')
C_WHITE = colors.white
SEVERITY_COLORS = {
'critical': C_RED,
'high': C_RED,
'medium': C_YELLOW,
'low': C_SLATE,
}
STATUS_COLORS = {
'completed': C_GREEN,
'flagged': C_RED,
'in_progress': C_YELLOW,
}
# ── Style sheet ───────────────────────────────────────────────────────────────
def _build_styles():
base = getSampleStyleSheet()
def add(name, parent='Normal', **kw):
base.add(ParagraphStyle(name=name, parent=base[parent], **kw))
add('ReportTitle', parent='Normal',
fontSize=18, textColor=C_WHITE, fontName='Helvetica-Bold',
spaceAfter=2)
add('ReportSub', parent='Normal',
fontSize=9, textColor=colors.HexColor('#94a3b8'),
fontName='Helvetica', spaceAfter=0)
add('SectionHead', parent='Normal',
fontSize=10, textColor=C_DARK, fontName='Helvetica-Bold',
spaceBefore=8, spaceAfter=4)
add('FieldLabel', parent='Normal',
fontSize=7.5, textColor=C_SLATE, fontName='Helvetica',
spaceAfter=1)
add('FieldValue', parent='Normal',
fontSize=8.5, textColor=C_DARK, fontName='Helvetica',
spaceAfter=2)
add('MetaLabel', parent='Normal',
fontSize=7, textColor=C_SLATE, fontName='Helvetica',
spaceAfter=0)
add('MetaValue', parent='Normal',
fontSize=9, textColor=C_DARK, fontName='Helvetica-Bold',
spaceAfter=0)
add('IssueDesc', parent='Normal',
fontSize=8, textColor=C_DARK, fontName='Helvetica',
spaceAfter=2)
add('FooterStyle', parent='Normal',
fontSize=7, textColor=C_SLATE, fontName='Helvetica',
alignment=TA_CENTER)
return base
STYLES = _build_styles()
# ── Image compression helper ──────────────────────────────────────────────────
# Max pixel dimension for any side of an image embedded in the PDF.
# Phone cameras produce 1248 MP images; 800px is plenty for a printed report.
_IMG_MAX_PX = 800
# JPEG quality for re-encoded images (095). 55 is visually acceptable for
# a printed report and reduces a typical phone photo by ~90% vs the original.
_IMG_JPEG_QUALITY = 55
def _compress_image(src_path: str) -> io.BytesIO | None:
"""
Load an image from disk, resize it to fit within _IMG_MAX_PX on any side,
re-encode as JPEG at _IMG_JPEG_QUALITY, and return a BytesIO buffer.
Returns None if PIL is unavailable or the image cannot be processed,
signalling the caller to fall back to the original file path.
"""
if not _PIL_AVAILABLE:
return None
try:
from PIL import ImageOps
img = PILImage.open(src_path)
# Apply EXIF orientation tag so rotated phone photos appear upright.
img = ImageOps.exif_transpose(img)
# Strip transparency — JPEG does not support alpha channels.
# Convert palette / RGBA / LA → RGB before saving.
if img.mode in ('RGBA', 'LA', 'P'):
background = PILImage.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
# Resize proportionally so neither dimension exceeds _IMG_MAX_PX.
img.thumbnail((_IMG_MAX_PX, _IMG_MAX_PX), PILImage.LANCZOS)
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=_IMG_JPEG_QUALITY, optimize=True)
buf.seek(0)
return buf
except Exception:
return None
# ── Header / footer callbacks ─────────────────────────────────────────────────
def _on_page(canvas, doc, title, generated_at):
"""Draw page header band and footer on every page."""
w, h = letter
margin = 0.65 * inch
# ── Dark header band ──
canvas.saveState()
canvas.setFillColor(C_DARK)
canvas.rect(0, h - 1.1 * inch, w, 1.1 * inch, stroke=0, fill=1)
canvas.setFont('Helvetica-Bold', 13)
canvas.setFillColor(C_WHITE)
canvas.drawString(margin, h - 0.55 * inch, title)
canvas.setFont('Helvetica', 8)
canvas.setFillColor(colors.HexColor('#94a3b8'))
canvas.drawString(margin, h - 0.78 * inch, 'Janitorial Quality Control System')
# Page number — right-aligned
page_txt = f'Page {doc.page}'
canvas.setFont('Helvetica', 8)
canvas.setFillColor(colors.HexColor('#94a3b8'))
canvas.drawRightString(w - margin, h - 0.66 * inch, page_txt)
canvas.restoreState()
# ── Footer ──
canvas.saveState()
canvas.setStrokeColor(C_BORDER)
canvas.line(margin, 0.55 * inch, w - margin, 0.55 * inch)
canvas.setFont('Helvetica', 7)
canvas.setFillColor(C_SLATE)
canvas.drawString(margin, 0.35 * inch,
f'Generated: {generated_at} | Janitorial QC System')
canvas.drawRightString(w - margin, 0.35 * inch, 'CONFIDENTIAL')
canvas.restoreState()
# ── Score badge helper ────────────────────────────────────────────────────────
def _score_color(score):
if score is None:
return C_SLATE
s = float(score)
if s >= 90:
return C_GREEN
if s >= 70:
return C_YELLOW
return C_RED
# ── Meta info table ───────────────────────────────────────────────────────────
def _meta_table(inspection):
"""Render a 6-column label/value grid covering the key inspection metadata."""
date_str = inspection.inspection_date.strftime('%B %d, %Y')
time_str = inspection.inspection_date.strftime('%I:%M %p ET')
completed = (inspection.completed_at.strftime('%B %d, %Y %I:%M %p ET')
if inspection.completed_at else '—')
score_val = (f'{float(inspection.overall_score):.1f}%'
if inspection.overall_score is not None else '—')
status_txt = inspection.status.replace('_', ' ').title()
area_txt = inspection.area.name if inspection.area else '—'
def lbl(text):
return Paragraph(text, STYLES['MetaLabel'])
def val(text):
return Paragraph(str(text), STYLES['MetaValue'])
# Each row: alternating label / value columns (6 cols total)
rows = [
[lbl('INSPECTOR'), val(inspection.inspector.username),
lbl('DATE'), val(date_str),
lbl('TIME'), val(time_str)],
[lbl('FACILITY'), val(inspection.facility.name),
lbl('AREA'), val(area_txt),
lbl('COMPLETED'), val(completed)],
[lbl('TEMPLATE'), val(inspection.template.name),
lbl('FREQUENCY'), val(inspection.template.frequency.title()),
lbl('STATUS / SCORE'), val(f'{status_txt} {score_val}')],
]
col_w = (letter[0] - 1.3 * inch) / 6
tbl = Table(rows, colWidths=[col_w] * 6)
tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), C_LIGHT),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('INNERGRID', (0, 0), (-1, -1), 0.5, C_BORDER),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (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),
]))
return tbl
# ── Score banner ──────────────────────────────────────────────────────────────
def _score_banner(inspection):
score = inspection.overall_score
score_txt = f'{float(score):.1f}%' if score is not None else 'N/A'
sc = _score_color(score)
grade = 'PASS' if (score is not None and float(score) >= 70) else 'FAIL'
grade_color = C_GREEN if grade == 'PASS' else C_RED
data = [[
Paragraph(f'<font color="white"><b>Overall Score</b></font>',
ParagraphStyle('x', fontName='Helvetica-Bold', fontSize=9,
textColor=C_WHITE, alignment=TA_CENTER)),
Paragraph(f'<font color="white"><b>{score_txt}</b></font>',
ParagraphStyle('x2', fontName='Helvetica-Bold', fontSize=22,
textColor=C_WHITE, alignment=TA_CENTER)),
Paragraph(f'<font color="white"><b>{grade}</b></font>',
ParagraphStyle('x3', fontName='Helvetica-Bold', fontSize=14,
textColor=C_WHITE, alignment=TA_CENTER)),
]]
w = letter[0] - 1.3 * inch
tbl = Table(data, colWidths=[w * 0.35, w * 0.35, w * 0.30])
tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (1, 0), sc),
('BACKGROUND', (2, 0), (2, 0), grade_color),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TOPPADDING', (0, 0), (-1, -1), 10),
('BOTTOMPADDING',(0, 0), (-1, -1), 10),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
]))
return tbl
# ── 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)
_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):
"""
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.append(Paragraph('Inspection Results', STYLES['SectionHead']))
story.append(HRFlowable(width='100%', thickness=1, color=C_BORDER, spaceAfter=6))
FULL_W = letter[0] - 1.3 * inch # usable page width
UNIT = FULL_W / _GRID_COLS # width of one grid column unit
# ── Group fields by row number, preserving original order ────────────────
from collections import defaultdict, OrderedDict
rows_map = OrderedDict() # row_num -> [field, ...]
sections = {} # row_num -> section label that precedes this row
pending_section = None
for field in form_fields:
ftype = field.get('type', '')
if ftype in ('button_submit', 'button_print', 'button_email'):
continue
if ftype == 'section':
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)
# ── 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 = {'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 == 'pass_fail' and v: 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 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
# 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 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]
story.append(Paragraph(
sec_label,
ParagraphStyle('SecBanner', fontName='Helvetica-Bold',
fontSize=10, textColor=C_DARK,
spaceBefore=8, spaceAfter=3),
))
story.append(HRFlowable(width='100%', thickness=0.75,
color=C_BORDER, spaceAfter=4))
# 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
for field in fields_in_row:
ftype = field.get('type', '')
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
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'
cells_12[ci] = Paragraph(
field.get('text_content', ''),
ParagraphStyle('li', fontName=fw, fontSize=fs,
textColor=C_DARK, leading=fs + 3),
)
has_content = True
continue
fid = str(field.get('id', ''))
val = form_data.get(fid, '')
lbl = field.get('label', '')
# ── 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):
continue # cell stays empty; SPAN ensures correct column width
lbl_p = Paragraph(lbl, STYLES['FieldLabel'])
# ── Value content ────────────────────────────────────────────────
if ftype == 'rating':
score_int = int(val)
filled_stars = '<font color="#f59e0b">' + ('★' * score_int) + '</font>'
empty_stars = ('<font color="#d1d5db">' + ('★' * (5 - score_int)) + '</font>'
if score_int < 5 else '')
stars = filled_stars + empty_stars + f' <font color="#64748b">{score_int}/5</font>'
val_p = Paragraph(stars, ParagraphStyle(
'rv', fontName='Helvetica', fontSize=9, leading=12))
elif ftype == 'image':
img_path = os.path.join(static_folder, val)
try:
compressed = _compress_image(img_path)
img_src = compressed if compressed is not None else img_path
val_p = RLImage(img_src,
width=min(cell_w - 12, 1.4 * inch),
height=1.0 * inch,
kind='proportional')
except Exception:
continue # leave cell empty
elif ftype == 'signature':
val_p = Paragraph('[Signature captured]', STYLES['FieldValue'])
elif ftype == 'checkbox':
val_p = Paragraph('Yes', STYLES['FieldValue'])
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
val_p = Paragraph(
f'<font color="{colour.hexval()}">{str(val)}</font>',
STYLES['FieldValue'],
)
elif ftype == 'checkbox_group':
val_p = Paragraph(', '.join(val), STYLES['FieldValue'])
elif ftype == 'table':
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:
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_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_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
# ── Issues section ────────────────────────────────────────────────────────────
def _issues_section(issues):
if not issues:
return []
story = [
Spacer(1, 10),
Paragraph('Flagged Issues', STYLES['SectionHead']),
HRFlowable(width='100%', thickness=1, color=C_RED, spaceAfter=6),
]
headers = ['#', 'Severity', 'Area', 'Description', 'Status']
col_w = [0.3 * inch, 0.7 * inch, 1.1 * inch, 3.5 * inch, 0.9 * inch]
rows = [headers]
for i, issue in enumerate(issues, 1):
desc = issue.description[:120] + ('…' if len(issue.description) > 120 else '')
rows.append([
str(i),
issue.severity.title(),
issue.area.name if issue.area else (issue.resolved_facility.name if issue.resolved_facility else '—'),
desc,
issue.status.replace('_', ' ').title(),
])
tbl = Table(rows, colWidths=col_w, repeatRows=1)
sev_styles = []
for r, issue in enumerate(issues, 1):
sc = SEVERITY_COLORS.get(issue.severity, C_SLATE)
sev_styles.append(('TEXTCOLOR', (1, r), (1, r), sc))
sev_styles.append(('FONTNAME', (1, r), (1, r), 'Helvetica-Bold'))
tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_DARK),
('TEXTCOLOR', (0, 0), (-1, 0), C_WHITE),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('ROWBACKGROUNDS',(0, 1), (-1, -1), [C_WHITE, C_LIGHT]),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING',(0, 0), (-1, -1), 4),
('LEFTPADDING', (0, 0), (-1, -1), 5),
('RIGHTPADDING', (0, 0), (-1, -1), 5),
*sev_styles,
]))
story.append(tbl)
return story
# ── Notes section ─────────────────────────────────────────────────────────────
def _notes_section(inspection):
notes_text = None
if inspection.notes:
try:
parsed = json.loads(inspection.notes)
if isinstance(parsed, dict):
notes_text = parsed.get('_inspector_notes') or parsed.get('notes')
except (json.JSONDecodeError, TypeError):
notes_text = inspection.notes
if not notes_text:
return []
return [
Spacer(1, 10),
Paragraph('Inspector Notes', STYLES['SectionHead']),
HRFlowable(width='100%', thickness=1, color=C_BORDER, spaceAfter=6),
Paragraph(str(notes_text),
ParagraphStyle('Notes', fontName='Helvetica', fontSize=8.5,
textColor=C_DARK, leading=13,
backColor=colors.HexColor('#fffbeb'),
borderPad=6, spaceAfter=6)),
]
# ── Public entry point ────────────────────────────────────────────────────────
def generate_inspection_pdf(inspection, form_fields, form_data, issues,
static_folder) -> bytes:
"""
Build and return a PDF byte-string for the given inspection.
Parameters
----------
inspection : Inspection model instance
form_fields : list of field dicts from template.get_form_schema()
form_data : dict of {field_id: value}
issues : list of Issue model instances
static_folder: absolute path to app/static (for resolving photo paths)
"""
buf = io.BytesIO()
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
report_title = f'Inspection Report — {inspection.template.name}'
doc = SimpleDocTemplate(
buf,
pagesize=letter,
leftMargin=0.65 * inch,
rightMargin=0.65 * inch,
topMargin=1.25 * inch, # leave room for the header band
bottomMargin=0.75 * inch,
title=report_title,
author='Janitorial QC System',
)
def _page_cb(canvas, doc):
_on_page(canvas, doc, report_title, generated_at)
story = []
# ── Score banner ──
story.append(_score_banner(inspection))
story.append(Spacer(1, 8))
# ── Meta table ──
story.append(_meta_table(inspection))
story.append(Spacer(1, 12))
# ── Form fields ──
story.extend(_form_fields_section(form_fields, form_data, static_folder))
# ── Inspector notes ──
story.extend(_notes_section(inspection))
# ── Issues ──
story.extend(_issues_section(issues))
# ── Signature line ──
story.append(Spacer(1, 24))
w = letter[0] - 1.3 * inch
sig_data = [['Inspector Signature', '', 'Date']]
sig_tbl = Table(sig_data, colWidths=[w * 0.45, w * 0.1, w * 0.45])
sig_tbl.setStyle(TableStyle([
('LINEABOVE', (0, 0), (0, 0), 0.75, C_DARK),
('LINEABOVE', (2, 0), (2, 0), 0.75, C_DARK),
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('TEXTCOLOR', (0, 0), (-1, -1), C_SLATE),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING',(0, 0), (-1, -1), 0),
]))
story.append(sig_tbl)
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue()
# ══════════════════════════════════════════════════════════════════════════════
# ISSUE PDF
# ══════════════════════════════════════════════════════════════════════════════
def generate_issue_pdf(issue, static_folder: str) -> bytes:
"""Return a PDF byte-string for a single Issue.
Parameters
----------
issue : Issue model instance (relationships pre-loaded by caller)
static_folder : absolute path to app/static (for resolving photo paths)
"""
buf = io.BytesIO()
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
report_title = f'Issue Report — Issue #{issue.id}'
doc = SimpleDocTemplate(
buf,
pagesize=letter,
leftMargin=0.65 * inch,
rightMargin=0.65 * inch,
topMargin=1.25 * inch,
bottomMargin=0.75 * inch,
title=report_title,
author='Janitorial QC System',
)
def _page_cb(canvas, doc):
_on_page(canvas, doc, report_title, generated_at)
pw = letter[0] - 1.3 * inch # usable page width
# ── Severity / status banner ──────────────────────────────────────────────
sev = issue.severity or 'low'
sev_color = SEVERITY_COLORS.get(sev, C_SLATE)
stat_txt = (issue.status or '').replace('_', ' ').title()
banner_data = [[
Paragraph(f'<font color="white"><b>{sev.upper()}</b></font>',
ParagraphStyle('b1', fontName='Helvetica-Bold', fontSize=10,
textColor=C_WHITE, alignment=TA_CENTER)),
Paragraph(f'<font color="white"><b>Issue #{issue.id}</b></font>',
ParagraphStyle('b2', fontName='Helvetica-Bold', fontSize=16,
textColor=C_WHITE, alignment=TA_CENTER)),
Paragraph(f'<font color="white"><b>{stat_txt}</b></font>',
ParagraphStyle('b3', fontName='Helvetica-Bold', fontSize=10,
textColor=C_WHITE, alignment=TA_CENTER)),
]]
banner = Table(banner_data, colWidths=[pw * 0.20, pw * 0.55, pw * 0.25])
banner.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, 0), sev_color),
('BACKGROUND', (1, 0), (1, 0), C_DARK),
('BACKGROUND', (2, 0), (2, 0), C_SLATE),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TOPPADDING', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 10),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
]))
# ── Details grid ─────────────────────────────────────────────────────────
def lbl(t):
return Paragraph(t, STYLES['MetaLabel'])
def val(t):
return Paragraph(str(t) if t else '—', STYLES['MetaValue'])
facility_name = issue.resolved_facility.name if issue.resolved_facility else '—'
contract_name = (issue.resolved_facility.project.name
if issue.resolved_facility and issue.resolved_facility.project else '—')
area_name = issue.area.name if issue.area else '—'
reporter_name = issue.reporter.display_name if issue.reporter else '—'
assigned_name = issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —'
reported_str = issue.reported_at.strftime('%b %d, %Y %I:%M %p') if issue.reported_at else '—'
resolved_str = issue.resolved_at.strftime('%b %d, %Y %I:%M %p') if issue.resolved_at else '—'
cw = pw / 4
detail_rows = [
[lbl('CONTRACT'), val(contract_name), lbl('FACILITY'), val(facility_name)],
[lbl('AREA'), val(area_name), lbl('REPORTED BY'), val(reporter_name)],
[lbl('ASSIGNED TO'), val(assigned_name), lbl('REPORTED'), val(reported_str)],
[lbl('STATUS'), val(stat_txt), lbl('RESOLVED'), val(resolved_str)],
]
if issue.inspection_id:
detail_rows.append([lbl('INSPECTION'), val(f'#{issue.inspection_id}'),
lbl(''), val('')])
detail_tbl = Table(detail_rows, colWidths=[cw] * 4)
detail_tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), C_LIGHT),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('INNERGRID', (0, 0), (-1, -1), 0.5, C_BORDER),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
]))
# ── Section header helper ─────────────────────────────────────────────────
def _section(title, color=C_BORDER):
return [
Spacer(1, 10),
Paragraph(title, STYLES['SectionHead']),
HRFlowable(width='100%', thickness=1, color=color, spaceAfter=6),
]
# ── Photo grid helper ─────────────────────────────────────────────────────
def _photo_grid(paths, max_w=2.4 * inch, max_h=2.0 * inch, cols=3):
valid = []
for p in paths:
if not p:
continue
abs_p = os.path.join(static_folder, p)
if os.path.exists(abs_p):
valid.append(abs_p)
if not valid:
return [Paragraph('(photos not found on disk)', STYLES['FieldValue'])]
cells = []
for abs_p in valid:
try:
compressed = _compress_image(abs_p)
src = compressed if compressed is not None else abs_p
cells.append(RLImage(src, width=max_w, height=max_h, kind='proportional'))
except Exception:
cells.append(Paragraph('(unreadable)', STYLES['FieldValue']))
while len(cells) % cols != 0:
cells.append(Paragraph('', STYLES['FieldValue']))
rows = [cells[i:i + cols] for i in range(0, len(cells), cols)]
tbl = Table(rows, colWidths=[max_w + 6] * cols)
tbl.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
]))
return [tbl]
# ── Assemble story ────────────────────────────────────────────────────────
story = []
story.append(banner)
story.append(Spacer(1, 10))
story.append(detail_tbl)
# Description
story.extend(_section('Description'))
story.append(Paragraph(
issue.description or '—',
ParagraphStyle('Desc', fontName='Helvetica', fontSize=9,
textColor=C_DARK, leading=14,
backColor=colors.HexColor('#f8fafc'),
borderPad=8, spaceAfter=6),
))
# Photo Evidence
evidence_paths = []
if issue.photo_path:
evidence_paths.append(issue.photo_path)
evidence_paths.extend(issue.mobile_photo_paths or [])
if evidence_paths:
story.extend(_section('Photo Evidence'))
story.extend(_photo_grid(evidence_paths))
# Resolution Details
if issue.result_notes or issue.result_photos:
story.extend(_section('Resolution Details', C_GREEN))
if issue.result_notes:
story.append(Paragraph(
issue.result_notes,
ParagraphStyle('Res', fontName='Helvetica', fontSize=9,
textColor=C_DARK, leading=14,
backColor=colors.HexColor('#f0fdf4'),
borderPad=8, spaceAfter=6),
))
if issue.result_photos:
story.extend(_photo_grid(issue.result_photos))
# Verification
if issue.verified_at:
story.extend(_section('Verification', C_GREEN))
verifier_name = issue.verifier.display_name if issue.verifier else '—'
verified_str = issue.verified_at.strftime('%b %d, %Y %I:%M %p')
v_rows = [
[lbl('VERIFIED BY'), val(verifier_name), lbl('VERIFIED ON'), val(verified_str)],
]
if issue.verification_note:
v_rows.append([lbl('NOTE'),
Paragraph(issue.verification_note, STYLES['MetaValue']),
lbl(''), val('')])
v_tbl = Table(v_rows, colWidths=[cw] * 4)
v_tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f0fdf4')),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('INNERGRID', (0, 0), (-1, -1), 0.5, C_BORDER),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
]))
story.append(v_tbl)
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue()
# ══════════════════════════════════════════════════════════════════════════════
# SCHEDULED REPORT PDF
# ══════════════════════════════════════════════════════════════════════════════
def generate_scheduled_report_pdf(report_name, frequency, start, end,
facility_name=None, data=None):
"""Generate a PDF summary for a scheduled report email attachment.
Parameters
----------
report_name : str — the ScheduledReport.name
frequency : str — 'daily' / 'weekly' / 'monthly'
start, end : datetime — the reporting window
facility_name : str | None — scoped facility name, or None for all
data : dict — the assembled report data from _build_report_data()
Returns
-------
bytes — the PDF content
"""
if data is None:
data = {}
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf, pagesize=letter,
leftMargin=0.65 * inch, rightMargin=0.65 * inch,
topMargin=1.0 * inch, bottomMargin=0.65 * inch,
)
generated_at = datetime.now().strftime('%Y-%m-%d %H:%M')
title_text = f'{frequency.title()} Report — {report_name}'
def _page_cb(canvas, doc):
_on_page(canvas, doc, title_text, generated_at)
story = []
pw = letter[0] - 1.3 * inch # usable page width
# ── Sub-header ────────────────────────────────────────────────────────
period = f'{start.strftime("%b %d, %Y")}{end.strftime("%b %d, %Y")}'
scope = f'Facility: {facility_name}' if facility_name else 'All Facilities'
story.append(Paragraph(f'{period} · {scope}', STYLES['ReportSub']))
story.append(Spacer(1, 12))
# ── KPI cards (summary / facility report types) ───────────────────────
total_insp = data.get('total_inspections', 0)
completed = data.get('completed', 0)
open_iss = data.get('open_issues', 0)
avg_score = data.get('avg_score')
kpi_data = [[
Paragraph('<b>Inspections</b>', STYLES['FieldLabel']),
Paragraph('<b>Completed</b>', STYLES['FieldLabel']),
Paragraph('<b>Open Issues</b>', STYLES['FieldLabel']),
Paragraph('<b>Avg Score</b>', STYLES['FieldLabel']),
], [
Paragraph(f'<font size="14"><b>{total_insp}</b></font>', STYLES['FieldValue']),
Paragraph(f'<font size="14" color="{C_GREEN.hexval()}"><b>{completed}</b></font>', STYLES['FieldValue']),
Paragraph(f'<font size="14" color="{C_YELLOW.hexval()}"><b>{open_iss}</b></font>', STYLES['FieldValue']),
Paragraph(
f'<font size="14" color="{C_BLUE.hexval()}"><b>{f"{avg_score:.1f}%" if avg_score else "—"}</b></font>',
STYLES['FieldValue'],
),
]]
kpi_tbl = Table(kpi_data, colWidths=[pw * 0.25] * 4)
kpi_tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), C_LIGHT),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TOPPADDING', (0, 0), (-1, -1), 8),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('ROUNDEDCORNERS', [6, 6, 6, 6]),
]))
story.append(kpi_tbl)
story.append(Spacer(1, 16))
# ── Facility scores table ─────────────────────────────────────────────
fac_scores = data.get('facility_scores', [])
if fac_scores:
story.append(Paragraph('Facility Scores', STYLES['SectionHead']))
tbl_data = [['Facility', 'Inspections', 'Avg Score']]
for row in fac_scores:
sc = float(row.avg) if hasattr(row, 'avg') else float(row[1])
cnt = row.count if hasattr(row, 'count') else row[2]
nm = row.name if hasattr(row, 'name') else row[0]
sc_color = C_GREEN if sc >= 90 else C_YELLOW if sc >= 70 else C_RED
tbl_data.append([
Paragraph(str(nm), STYLES['FieldValue']),
Paragraph(str(cnt), STYLES['FieldValue']),
Paragraph(f'<font color="{sc_color.hexval()}">{sc:.1f}%</font>', STYLES['FieldValue']),
])
fac_tbl = Table(tbl_data, colWidths=[pw * 0.50, pw * 0.25, pw * 0.25])
fac_tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_LIGHT),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('ALIGN', (1, 0), (-1, -1), 'CENTER'),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]))
story.append(fac_tbl)
story.append(Spacer(1, 14))
# ── Critical / High issues ────────────────────────────────────────────
crit_issues = data.get('critical_issues', [])
if crit_issues:
story.append(Paragraph('Open Critical / High Issues', STYLES['SectionHead']))
tbl_data = [['#', 'Severity', 'Facility / Area', 'Description', 'Reported']]
for iss in crit_issues:
sev_c = SEVERITY_COLORS.get(iss.severity, C_SLATE)
tbl_data.append([
Paragraph(f'#{iss.id}', STYLES['FieldValue']),
Paragraph(f'<font color="{sev_c.hexval()}">{iss.severity.title()}</font>', STYLES['FieldValue']),
Paragraph(f'{iss.area.facility.name} / {iss.area.name}', STYLES['FieldValue']),
Paragraph(iss.description[:80] + ('…' if len(iss.description) > 80 else ''), STYLES['IssueDesc']),
Paragraph(iss.reported_at.strftime('%b %d'), STYLES['FieldValue']),
])
iss_tbl = Table(tbl_data, colWidths=[pw * 0.07, pw * 0.12, pw * 0.25, pw * 0.40, pw * 0.16])
iss_tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#fef2f2')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 7.5),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('TOPPADDING', (0, 0), (-1, -1), 3),
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
]))
story.append(iss_tbl)
story.append(Spacer(1, 14))
# ── Open issues list (issues report type) ─────────────────────────────
all_issues = data.get('issues', [])
if all_issues and not crit_issues:
story.append(Paragraph(f'Open Issues ({len(all_issues)})', STYLES['SectionHead']))
tbl_data = [['#', 'Severity', 'Facility / Area', 'Status', 'Description']]
for iss in all_issues:
sev_c = SEVERITY_COLORS.get(iss.severity, C_SLATE)
tbl_data.append([
Paragraph(f'#{iss.id}', STYLES['FieldValue']),
Paragraph(f'<font color="{sev_c.hexval()}">{iss.severity.title()}</font>', STYLES['FieldValue']),
Paragraph(f'{iss.area.facility.name} / {iss.area.name}', STYLES['FieldValue']),
Paragraph(iss.status.replace('_', ' ').title(), STYLES['FieldValue']),
Paragraph(iss.description[:70] + ('…' if len(iss.description) > 70 else ''), STYLES['IssueDesc']),
])
iss_tbl = Table(tbl_data, colWidths=[pw * 0.07, pw * 0.12, pw * 0.25, pw * 0.16, pw * 0.40])
iss_tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_LIGHT),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 7.5),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('TOPPADDING', (0, 0), (-1, -1), 3),
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
]))
story.append(iss_tbl)
# ── Footer note ───────────────────────────────────────────────────────
story.append(Spacer(1, 20))
story.append(HRFlowable(width='100%', thickness=0.5, color=C_BORDER))
story.append(Spacer(1, 6))
story.append(Paragraph(
'Janitorial QC System — automated scheduled report',
STYLES['FooterStyle'],
))
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue()
# ══════════════════════════════════════════════════════════════════════════════
# INSPECTIONS LIST PDF
# ══════════════════════════════════════════════════════════════════════════════
def generate_inspections_list_pdf(inspections, filter_summary: str = '') -> bytes:
"""Return a PDF byte-string for a filtered list of inspections.
Parameters
----------
inspections : list of Inspection model instances
filter_summary : human-readable string describing active filters (optional)
"""
buf = io.BytesIO()
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
report_title = 'Inspections List'
page_size = landscape(letter)
doc = SimpleDocTemplate(
buf,
pagesize=page_size,
leftMargin=0.65 * inch,
rightMargin=0.65 * inch,
topMargin=1.1 * inch,
bottomMargin=0.75 * inch,
title=report_title,
author='Janitorial QC System',
)
def _page_cb(canvas, doc):
_on_page(canvas, doc, report_title, generated_at)
pw = page_size[0] - 1.3 * inch # usable page width (~9.7 in)
story = []
# ── Filter summary line ───────────────────────────────────────────────────
if filter_summary:
story.append(Paragraph(f'Filters: {filter_summary}', STYLES['ReportSub']))
story.append(Paragraph(f'Total records: {len(inspections)}', STYLES['ReportSub']))
story.append(Spacer(1, 10))
if not inspections:
story.append(Paragraph('No inspections match the selected filters.', STYLES['FieldValue']))
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue()
# ── Column widths (total = pw) ────────────────────────────────────────────
# Date, Contract, Facility, Area, Template, Inspector, Score, Status
col_w = [
pw * 0.11, # Date
pw * 0.16, # Contract
pw * 0.17, # Facility
pw * 0.10, # Area
pw * 0.16, # Template
pw * 0.12, # Inspector
pw * 0.08, # Score
pw * 0.10, # Status
]
# ── Table header ──────────────────────────────────────────────────────────
hdr_style = ParagraphStyle('LH', fontName='Helvetica-Bold', fontSize=7.5,
textColor=C_WHITE, leading=9)
val_style = ParagraphStyle('LV', fontName='Helvetica', fontSize=7.5,
textColor=C_DARK, leading=9)
def _h(text):
return Paragraph(text, hdr_style)
def _v(text, color=None):
if color:
return Paragraph(f'<font color="{color.hexval()}">{text}</font>', val_style)
return Paragraph(text, val_style)
tbl_data = [[
_h('Date'), _h('Contract'), _h('Facility'), _h('Area'),
_h('Template'), _h('Inspector'), _h('Score'), _h('Status'),
]]
for ins in inspections:
date_str = ins.inspection_date.strftime('%Y-%m-%d %H:%M')
contract_str = (ins.facility.project.name
if ins.facility and ins.facility.project else '—')
facility_str = ins.facility.name if ins.facility else '—'
area_str = ins.area.name if ins.area else '—'
template_str = ins.template.name if ins.template else '—'
inspector_str = ins.inspector.display_name if ins.inspector else '—'
if ins.overall_score is not None:
sc = float(ins.overall_score)
score_str = f'{sc:.1f}%'
score_color = C_GREEN if sc >= 90 else C_YELLOW if sc >= 70 else C_RED
else:
score_str = '—'
score_color = C_SLATE
status_raw = ins.status or ''
if status_raw == 'completed':
status_str = 'Submitted'
status_color = C_GREEN
elif status_raw == 'flagged':
status_str = 'Flagged'
status_color = C_RED
else:
status_str = status_raw.replace('_', ' ').title()
status_color = C_SLATE
follow_up_suffix = ' (Follow-up)' if ins.follow_up_required else ''
tbl_data.append([
_v(date_str),
_v(contract_str[:30] + ('...' if len(contract_str) > 30 else '')),
_v(facility_str[:35] + ('...' if len(facility_str) > 35 else '')),
_v(area_str[:20] + ('...' if len(area_str) > 20 else '')),
_v(template_str[:30] + ('...' if len(template_str) > 30 else '')),
_v(inspector_str[:22] + ('...' if len(inspector_str) > 22 else '')),
_v(score_str, color=score_color),
_v(status_str + follow_up_suffix, color=status_color),
])
tbl = Table(tbl_data, colWidths=col_w, repeatRows=1)
tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_DARK),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [C_WHITE, C_LIGHT]),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
]))
story.append(tbl)
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue()