ChristopherJKoen commited on
Commit
8c60160
·
1 Parent(s): 303d067

PDF Export V1

Browse files
README.md CHANGED
@@ -72,3 +72,17 @@ Environment variables for the API:
72
 
73
  Frontend environment variables:
74
  - `VITE_API_BASE` (optional, default: `/api`)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  Frontend environment variables:
74
  - `VITE_API_BASE` (optional, default: `/api`)
75
+
76
+ ## PDF Export (ReportLab)
77
+
78
+ The server generates PDFs using ReportLab at:
79
+
80
+ ```
81
+ GET /api/sessions/{session_id}/export.pdf
82
+ ```
83
+
84
+ Install dependencies:
85
+
86
+ ```powershell
87
+ pip install -r server/requirements.txt
88
+ ```
frontend/src/pages/ExportPage.tsx CHANGED
@@ -84,6 +84,9 @@ export default function ExportPage() {
84
  const serverExportUrl = sessionId
85
  ? `${API_BASE}/sessions/${sessionId}/export`
86
  : "";
 
 
 
87
 
88
  function downloadJson() {
89
  const pack: Record<string, unknown> = {};
@@ -261,18 +264,31 @@ export default function ExportPage() {
261
  <div>
262
  <div className="text-sm font-semibold text-gray-900">PDF export</div>
263
  <div className="text-xs text-gray-500">
264
- Use the browser print dialog to save as PDF
265
  </div>
266
  </div>
267
  </div>
268
 
269
- <button
270
- type="button"
271
- onClick={handlePrint}
272
- className="mt-4 inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-white font-semibold hover:bg-blue-700 transition"
273
- >
274
- Print / Save PDF
275
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  </div>
277
  </div>
278
  </div>
 
84
  const serverExportUrl = sessionId
85
  ? `${API_BASE}/sessions/${sessionId}/export`
86
  : "";
87
+ const serverPdfUrl = sessionId
88
+ ? `${API_BASE}/sessions/${sessionId}/export.pdf`
89
+ : "";
90
 
91
  function downloadJson() {
92
  const pack: Record<string, unknown> = {};
 
264
  <div>
265
  <div className="text-sm font-semibold text-gray-900">PDF export</div>
266
  <div className="text-xs text-gray-500">
267
+ Generate a PDF on the server (ReportLab) or use the browser print dialog.
268
  </div>
269
  </div>
270
  </div>
271
 
272
+ <div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
273
+ <a
274
+ href={serverPdfUrl}
275
+ target="_blank"
276
+ rel="noreferrer"
277
+ className={[
278
+ "inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-white font-semibold hover:bg-blue-700 transition",
279
+ !sessionId ? "pointer-events-none opacity-50" : "",
280
+ ].join(" ")}
281
+ >
282
+ Download PDF (server)
283
+ </a>
284
+ <button
285
+ type="button"
286
+ onClick={handlePrint}
287
+ className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
288
+ >
289
+ Print / Save PDF (browser)
290
+ </button>
291
+ </div>
292
  </div>
293
  </div>
294
  </div>
package-lock.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "name": "RepEx",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {}
6
+ }
server/app/api/routes/sessions.py CHANGED
@@ -19,6 +19,7 @@ from ..schemas import (
19
  from ...services import SessionStore
20
  from ...services.session_store import DATA_EXTS, IMAGE_EXTS
21
  from ...services.data_import import populate_session_from_data_files
 
22
 
23
 
24
  router = APIRouter()
@@ -291,6 +292,24 @@ def export_package(
291
  )
292
 
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  @router.get("/{session_id}/export.xlsx")
295
  def export_excel(
296
  session_id: str, store: SessionStore = Depends(get_session_store)
 
19
  from ...services import SessionStore
20
  from ...services.session_store import DATA_EXTS, IMAGE_EXTS
21
  from ...services.data_import import populate_session_from_data_files
22
+ from ...services.pdf_reportlab import render_report_pdf
23
 
24
 
25
  router = APIRouter()
 
292
  )
293
 
294
 
295
+ @router.get("/{session_id}/export.pdf")
296
+ def export_pdf(
297
+ session_id: str, store: SessionStore = Depends(get_session_store)
298
+ ) -> FileResponse:
299
+ session_id = _normalize_session_id(session_id, store)
300
+ session = store.get_session(session_id)
301
+ if not session:
302
+ raise HTTPException(status_code=404, detail="Session not found.")
303
+ pages = store.ensure_pages(session)
304
+ export_path = Path(store.session_dir(session_id)) / "export.pdf"
305
+ render_report_pdf(store, session, pages, export_path)
306
+ return FileResponse(
307
+ export_path,
308
+ media_type="application/pdf",
309
+ filename=f"repex_report_{session_id}.pdf",
310
+ )
311
+
312
+
313
  @router.get("/{session_id}/export.xlsx")
314
  def export_excel(
315
  session_id: str, store: SessionStore = Depends(get_session_store)
server/app/services/pdf_reportlab.py ADDED
@@ -0,0 +1,608 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from pathlib import Path
5
+ from typing import Iterable, List, Optional
6
+
7
+ from reportlab.lib import colors
8
+ from reportlab.lib.pagesizes import A4
9
+ from reportlab.lib.units import mm
10
+ from reportlab.lib.utils import ImageReader
11
+ from PIL import Image
12
+ from reportlab.pdfgen import canvas
13
+
14
+ from .session_store import SessionStore
15
+
16
+
17
+ def _has_template_content(template: dict | None) -> bool:
18
+ if not template:
19
+ return False
20
+ for value in template.values():
21
+ if isinstance(value, str) and value.strip():
22
+ return True
23
+ if value not in (None, ""):
24
+ return True
25
+ return False
26
+
27
+
28
+ def _chunk(items: List[str], size: int) -> List[List[str]]:
29
+ if not items:
30
+ return []
31
+ return [items[i : i + size] for i in range(0, len(items), size)]
32
+
33
+
34
+ def _safe_text(value: Optional[str]) -> str:
35
+ return (value or "").strip()
36
+
37
+
38
+ def _wrap_lines(
39
+ pdf: canvas.Canvas,
40
+ text: str,
41
+ width: float,
42
+ max_lines: int,
43
+ font: str,
44
+ size: int,
45
+ ) -> List[str]:
46
+ if not text:
47
+ return []
48
+ pdf.setFont(font, size)
49
+ words = text.split()
50
+ lines: List[str] = []
51
+ current: List[str] = []
52
+ for word in words:
53
+ test = " ".join(current + [word])
54
+ if pdf.stringWidth(test, font, size) <= width or not current:
55
+ current.append(word)
56
+ else:
57
+ lines.append(" ".join(current))
58
+ current = [word]
59
+ if len(lines) >= max_lines:
60
+ current = []
61
+ break
62
+ if current and len(lines) < max_lines:
63
+ lines.append(" ".join(current))
64
+ return lines
65
+
66
+
67
+ def _draw_wrapped(
68
+ pdf: canvas.Canvas,
69
+ text: str,
70
+ x: float,
71
+ y: float,
72
+ width: float,
73
+ leading: float,
74
+ max_lines: int,
75
+ font: str,
76
+ size: int,
77
+ ) -> float:
78
+ lines = _wrap_lines(pdf, text, width, max_lines, font, size)
79
+ if not lines:
80
+ return y
81
+ pdf.setFont(font, size)
82
+ for line in lines:
83
+ pdf.drawString(x, y, line)
84
+ y -= leading
85
+ return y
86
+
87
+
88
+ def _draw_centered_block(
89
+ pdf: canvas.Canvas,
90
+ lines: List[str],
91
+ x_center: float,
92
+ box_bottom: float,
93
+ box_height: float,
94
+ leading: float,
95
+ font: str,
96
+ size: int,
97
+ ) -> None:
98
+ if not lines:
99
+ return
100
+ pdf.setFont(font, size)
101
+ block_h = len(lines) * leading
102
+ start_y = box_bottom + (box_height + block_h) / 2 - leading
103
+ y = start_y
104
+ for line in lines:
105
+ pdf.drawCentredString(x_center, y, line)
106
+ y -= leading
107
+
108
+
109
+ def _draw_label_value(
110
+ pdf: canvas.Canvas,
111
+ label: str,
112
+ value: str,
113
+ x: float,
114
+ y: float,
115
+ label_font: str,
116
+ value_font: str,
117
+ label_size: int,
118
+ value_size: int,
119
+ label_color: colors.Color,
120
+ value_color: colors.Color,
121
+ ) -> float:
122
+ pdf.setFillColor(label_color)
123
+ pdf.setFont(label_font, label_size)
124
+ pdf.drawString(x, y, label)
125
+ y -= label_size + 1
126
+ pdf.setFillColor(value_color)
127
+ pdf.setFont(value_font, value_size)
128
+ pdf.drawString(x, y, value or "-")
129
+ return y
130
+
131
+
132
+ def _badge_style(value: str, scale: dict) -> tuple[str, colors.Color, colors.Color]:
133
+ key = (value or "").strip().upper()
134
+ tone = scale.get(key)
135
+ if not tone:
136
+ return (value or "-"), colors.HexColor("#f9fafb"), colors.HexColor("#374151")
137
+ return f"{key} - {tone['label']}", tone["bg"], tone["text"]
138
+
139
+
140
+ def _resolve_logo_path(store: SessionStore, session: dict, raw: str) -> Optional[Path]:
141
+ value = _safe_text(raw)
142
+ if not value:
143
+ return None
144
+ uploads = (session.get("uploads") or {}).get("photos") or []
145
+ lower = value.lower()
146
+ for item in uploads:
147
+ name = (item.get("name") or "").lower()
148
+ if not name:
149
+ continue
150
+ stem = name.rsplit(".", 1)[0]
151
+ if lower == name or lower == stem:
152
+ path = store.resolve_upload_path(session, item.get("id"))
153
+ if path and path.exists():
154
+ return path
155
+ return None
156
+
157
+
158
+ def _draw_image_fit(
159
+ pdf: canvas.Canvas,
160
+ image_path: Path,
161
+ x: float,
162
+ y: float,
163
+ width: float,
164
+ height: float,
165
+ ) -> bool:
166
+ try:
167
+ reader = ImageReader(str(image_path))
168
+ iw, ih = reader.getSize()
169
+ except Exception:
170
+ try:
171
+ img = Image.open(image_path)
172
+ img.load()
173
+ if img.mode not in ("RGB", "L"):
174
+ img = img.convert("RGB")
175
+ reader = ImageReader(img)
176
+ iw, ih = reader.getSize()
177
+ except Exception:
178
+ try:
179
+ pdf.drawImage(
180
+ str(image_path),
181
+ x,
182
+ y,
183
+ width,
184
+ height,
185
+ preserveAspectRatio=True,
186
+ mask="auto",
187
+ )
188
+ return True
189
+ except Exception:
190
+ return False
191
+ if iw <= 0 or ih <= 0:
192
+ return False
193
+ scale = min(width / iw, height / ih)
194
+ draw_w = iw * scale
195
+ draw_h = ih * scale
196
+ draw_x = x + (width - draw_w) / 2
197
+ draw_y = y + (height - draw_h) / 2
198
+ pdf.drawImage(
199
+ reader,
200
+ draw_x,
201
+ draw_y,
202
+ draw_w,
203
+ draw_h,
204
+ preserveAspectRatio=True,
205
+ mask="auto",
206
+ )
207
+ return True
208
+
209
+
210
+ def render_report_pdf(
211
+ store: SessionStore,
212
+ session: dict,
213
+ pages: List[dict],
214
+ output_path: Path,
215
+ ) -> Path:
216
+ width, height = A4
217
+ margin = 10 * mm
218
+ header_h = 20 * mm
219
+ footer_h = 8 * mm
220
+ gap = 4 * mm
221
+
222
+ gray_50 = colors.HexColor("#f9fafb")
223
+ gray_200 = colors.HexColor("#e5e7eb")
224
+ gray_500 = colors.HexColor("#6b7280")
225
+ gray_700 = colors.HexColor("#374151")
226
+ gray_800 = colors.HexColor("#1f2937")
227
+ gray_900 = colors.HexColor("#111827")
228
+ gray_600 = colors.HexColor("#4b5563")
229
+ amber_50 = colors.HexColor("#fffbeb")
230
+ amber_300 = colors.HexColor("#fcd34d")
231
+ amber_800 = colors.HexColor("#92400e")
232
+ emerald_50 = colors.HexColor("#ecfdf5")
233
+ emerald_800 = colors.HexColor("#065f46")
234
+ blue_50 = colors.HexColor("#eff6ff")
235
+ blue_300 = colors.HexColor("#93c5fd")
236
+ blue_800 = colors.HexColor("#1e40af")
237
+ blue_200 = colors.HexColor("#bfdbfe")
238
+ blue_900 = colors.HexColor("#1e3a8a")
239
+ purple_200 = colors.HexColor("#e9d5ff")
240
+ purple_800 = colors.HexColor("#6b21a8")
241
+ green_100 = colors.HexColor("#dcfce7")
242
+ green_200 = colors.HexColor("#bbf7d0")
243
+ yellow_100 = colors.HexColor("#fef9c3")
244
+ yellow_200 = colors.HexColor("#fef08a")
245
+ orange_200 = colors.HexColor("#fed7aa")
246
+ red_200 = colors.HexColor("#fecaca")
247
+
248
+ output_path.parent.mkdir(parents=True, exist_ok=True)
249
+ pdf = canvas.Canvas(str(output_path), pagesize=A4)
250
+
251
+ uploads = (session.get("uploads") or {}).get("photos") or []
252
+ by_id = {item.get("id"): item for item in uploads if item.get("id")}
253
+
254
+ print_pages: List[dict] = []
255
+ for page_index, page in enumerate(pages):
256
+ template = page.get("template") or {}
257
+ photo_ids = page.get("photo_ids") or []
258
+ photo_entries = []
259
+ for pid in photo_ids:
260
+ item = by_id.get(pid)
261
+ if not item:
262
+ continue
263
+ path = store.resolve_upload_path(session, pid)
264
+ if path and path.exists():
265
+ label = _safe_text(item.get("name") or path.name)
266
+ photo_entries.append({"path": path, "label": label})
267
+ chunks = _chunk(photo_entries, 6) or [[]]
268
+ for chunk_index, chunk in enumerate(chunks):
269
+ print_pages.append(
270
+ {
271
+ "page_index": page_index,
272
+ "template": template,
273
+ "photos": chunk,
274
+ "variant": "full" if chunk_index == 0 else "photos",
275
+ }
276
+ )
277
+
278
+ if not print_pages:
279
+ print_pages = [
280
+ {
281
+ "page_index": 0,
282
+ "template": {},
283
+ "photos": [],
284
+ "variant": "full",
285
+ }
286
+ ]
287
+
288
+ total_pages = len(print_pages)
289
+ repo_root = Path(__file__).resolve().parents[3]
290
+ logo_candidates = [
291
+ repo_root / "frontend" / "public" / "assets" / "prosento-logo.png",
292
+ repo_root / "frontend" / "dist" / "assets" / "prosento-logo.png",
293
+ repo_root / "server" / "assets" / "prosento-logo.png",
294
+ ]
295
+ default_logo = next((path for path in logo_candidates if path.exists()), None)
296
+
297
+ for output_index, payload in enumerate(print_pages):
298
+ template = payload["template"]
299
+ photos = payload["photos"]
300
+ variant = payload["variant"]
301
+
302
+ header_y = height - margin
303
+ content_top = header_y - header_h - gap
304
+ content_bottom = margin + footer_h + gap
305
+ content_height = content_top - content_bottom
306
+
307
+ # Header
308
+ logo_x = margin
309
+ logo_y = header_y - 15 * mm
310
+ logo_w = 28 * mm
311
+ logo_h = 15 * mm
312
+ logo_drawn = False
313
+ if default_logo:
314
+ logo_drawn = _draw_image_fit(pdf, default_logo, logo_x, logo_y, logo_w, logo_h)
315
+ if not logo_drawn:
316
+ pdf.setStrokeColor(colors.red)
317
+ pdf.setLineWidth(1)
318
+ pdf.rect(logo_x, logo_y, logo_w, logo_h, stroke=1, fill=0)
319
+ pdf.setFillColor(colors.red)
320
+ pdf.setFont("Helvetica-Bold", 9)
321
+ pdf.drawString(logo_x + 2, logo_y + logo_h / 2 - 3, "LOGO MISSING")
322
+ else:
323
+ pdf.setStrokeColor(gray_200)
324
+ pdf.setLineWidth(0.5)
325
+ pdf.rect(logo_x, logo_y, logo_w, logo_h, stroke=1, fill=0)
326
+ client_logo = _resolve_logo_path(store, session, template.get("company_logo", ""))
327
+ if client_logo:
328
+ _draw_image_fit(
329
+ pdf,
330
+ client_logo,
331
+ width - margin - 32 * mm,
332
+ header_y - 15 * mm,
333
+ 32 * mm,
334
+ 15 * mm,
335
+ )
336
+ pdf.setFillColor(gray_900)
337
+ pdf.setFont("Helvetica-Bold", 13)
338
+ pdf.drawCentredString(width / 2, header_y - 7 * mm, "RepEx Inspection Job Sheet")
339
+ pdf.setFillColor(gray_600)
340
+ pdf.setFont("Helvetica", 11)
341
+ pdf.drawCentredString(width / 2, header_y - 13 * mm, f"Page {output_index + 1} of {total_pages}")
342
+ pdf.setStrokeColor(gray_200)
343
+ pdf.line(margin, header_y - 17 * mm, width - margin, header_y - 17 * mm)
344
+
345
+ y = content_top
346
+
347
+ if variant == "full":
348
+ # Observations and Findings
349
+ pdf.setFillColor(gray_800)
350
+ pdf.setFont("Helvetica-Bold", 14)
351
+ pdf.drawString(margin, y, "Observations and Findings")
352
+ pdf.setStrokeColor(gray_200)
353
+ pdf.line(margin, y - 2, width - margin, y - 2)
354
+ y -= 8 * mm
355
+
356
+ ref = _safe_text(template.get("reference"))
357
+ area = _safe_text(template.get("area"))
358
+ location = _safe_text(template.get("functional_location"))
359
+ item_desc = _safe_text(template.get("item_description"))
360
+ condition_desc = _safe_text(template.get("condition_description"))
361
+ action_type = _safe_text(template.get("action_type"))
362
+ required_action = _safe_text(template.get("required_action"))
363
+
364
+ left_w = (width - 2 * margin) * 0.6
365
+ right_w = (width - 2 * margin) * 0.4
366
+ left_x = margin
367
+ right_x = margin + left_w + 4 * mm
368
+
369
+ label_size = 11
370
+ value_size = 12
371
+ value_gap = 2 * mm
372
+ row_gap = 4 * mm
373
+ leading = 14
374
+
375
+ row_y = y
376
+ _draw_label_value(
377
+ pdf,
378
+ "Ref",
379
+ ref,
380
+ left_x,
381
+ row_y,
382
+ "Helvetica",
383
+ "Helvetica-Bold",
384
+ label_size,
385
+ value_size,
386
+ gray_500,
387
+ gray_900,
388
+ )
389
+ _draw_label_value(
390
+ pdf,
391
+ "Area",
392
+ area,
393
+ left_x + left_w / 2,
394
+ row_y,
395
+ "Helvetica",
396
+ "Helvetica-Bold",
397
+ label_size,
398
+ value_size,
399
+ gray_500,
400
+ gray_900,
401
+ )
402
+
403
+ pdf.setFillColor(gray_500)
404
+ pdf.setFont("Helvetica", label_size)
405
+ pdf.drawString(right_x, row_y, "Location")
406
+ pdf.setFillColor(gray_900)
407
+ loc_lines = _wrap_lines(
408
+ pdf,
409
+ location or "-",
410
+ right_w - 2 * mm,
411
+ 2,
412
+ "Helvetica-Bold",
413
+ value_size,
414
+ )
415
+ _draw_wrapped(
416
+ pdf,
417
+ location or "-",
418
+ right_x,
419
+ row_y - label_size - value_gap,
420
+ right_w - 2 * mm,
421
+ leading,
422
+ 2,
423
+ "Helvetica-Bold",
424
+ value_size,
425
+ )
426
+
427
+ y = row_y - (label_size + value_gap + leading * max(1, len(loc_lines))) - row_gap
428
+
429
+ category = _safe_text(template.get("category"))
430
+ priority = _safe_text(template.get("priority"))
431
+ cat_scale = {
432
+ "0": {"label": "Excellent", "bg": green_100, "text": colors.HexColor("#166534")},
433
+ "1": {"label": "Good", "bg": green_200, "text": colors.HexColor("#166534")},
434
+ "2": {"label": "Fair", "bg": yellow_100, "text": colors.HexColor("#854d0e")},
435
+ "3": {"label": "Poor", "bg": yellow_200, "text": colors.HexColor("#854d0e")},
436
+ "4": {"label": "Worse", "bg": orange_200, "text": colors.HexColor("#9a3412")},
437
+ "5": {"label": "Severe", "bg": red_200, "text": colors.HexColor("#991b1b")},
438
+ }
439
+ pr_scale = {
440
+ "1": {"label": "Immediate", "bg": red_200, "text": colors.HexColor("#991b1b")},
441
+ "2": {"label": "1 Year", "bg": orange_200, "text": colors.HexColor("#9a3412")},
442
+ "3": {"label": "3 Years", "bg": green_200, "text": colors.HexColor("#166534")},
443
+ "X": {"label": "At Use", "bg": purple_200, "text": purple_800},
444
+ "M": {"label": "Monitor", "bg": blue_200, "text": blue_900},
445
+ }
446
+ cat_text, cat_bg, cat_text_color = _badge_style(category, cat_scale)
447
+ pr_text, pr_bg, pr_text_color = _badge_style(priority, pr_scale)
448
+
449
+ badge_w = 40 * mm
450
+ badge_h = 10 * mm
451
+ y -= 2 * mm
452
+ pdf.setFillColor(gray_500)
453
+ pdf.setFont("Helvetica", 11)
454
+ cat_label_x = margin + 20 * mm + badge_w / 2
455
+ pr_label_x = margin + 100 * mm + badge_w / 2
456
+ label_y = y
457
+ pdf.drawCentredString(cat_label_x, label_y, "Category")
458
+ pdf.drawCentredString(pr_label_x, label_y, "Priority")
459
+ y -= 16 * mm
460
+ pdf.setFillColor(cat_bg)
461
+ pdf.setStrokeColor(gray_200)
462
+ pdf.roundRect(margin + 20 * mm, y - 2, badge_w, badge_h, 2 * mm, stroke=1, fill=1)
463
+ pdf.setFillColor(cat_text_color)
464
+ pdf.setFont("Helvetica-Bold", 11)
465
+ pdf.drawCentredString(cat_label_x, y - 2 + badge_h / 2 - 4, cat_text)
466
+ pdf.setFillColor(pr_bg)
467
+ pdf.roundRect(margin + 100 * mm, y - 2, badge_w, badge_h, 2 * mm, stroke=1, fill=1)
468
+ pdf.setFillColor(pr_text_color)
469
+ pdf.drawCentredString(pr_label_x, y - 2 + badge_h / 2 - 4, pr_text)
470
+ y -= 10 * mm
471
+
472
+ condition = " - ".join([v for v in [item_desc, condition_desc] if v])
473
+ action = " - ".join([v for v in [action_type, required_action] if v])
474
+
475
+ pdf.setFillColor(gray_500)
476
+ pdf.setFont("Helvetica", 11)
477
+ pdf.drawCentredString(
478
+ margin + (width - 2 * margin) / 2, y, "Condition Description"
479
+ )
480
+ y -= 4 * mm
481
+ pdf.setFillColor(amber_50)
482
+ pdf.setStrokeColor(amber_300)
483
+ cond_lines = _wrap_lines(
484
+ pdf,
485
+ condition or "-",
486
+ width - 2 * margin - 4 * mm,
487
+ 4,
488
+ "Helvetica-Bold",
489
+ 11,
490
+ )
491
+ cond_h = max(18 * mm, (len(cond_lines) or 1) * leading + 6 * mm)
492
+ cond_bottom = y - cond_h
493
+ pdf.rect(margin, cond_bottom, width - 2 * margin, cond_h, stroke=1, fill=1)
494
+ pdf.setLineWidth(3)
495
+ pdf.line(margin, cond_bottom, margin, y)
496
+ pdf.setLineWidth(1)
497
+ pdf.setFillColor(amber_800)
498
+ pdf.setFont("Helvetica-Bold", 11)
499
+ text_center_x = margin + (width - 2 * margin) / 2
500
+ _draw_centered_block(
501
+ pdf,
502
+ cond_lines,
503
+ text_center_x,
504
+ cond_bottom,
505
+ cond_h,
506
+ leading,
507
+ "Helvetica-Bold",
508
+ 11,
509
+ )
510
+ y = cond_bottom - 6 * mm
511
+
512
+ pdf.setFillColor(gray_500)
513
+ pdf.setFont("Helvetica", 11)
514
+ pdf.drawCentredString(
515
+ margin + (width - 2 * margin) / 2, y, "Required Action"
516
+ )
517
+ y -= 4 * mm
518
+ pdf.setFillColor(blue_50)
519
+ pdf.setStrokeColor(blue_300)
520
+ action_lines = _wrap_lines(
521
+ pdf,
522
+ action or "-",
523
+ width - 2 * margin - 4 * mm,
524
+ 4,
525
+ "Helvetica-Bold",
526
+ 11,
527
+ )
528
+ action_h = max(18 * mm, (len(action_lines) or 1) * leading + 6 * mm)
529
+ action_bottom = y - action_h
530
+ pdf.rect(margin, action_bottom, width - 2 * margin, action_h, stroke=1, fill=1)
531
+ pdf.setLineWidth(3)
532
+ pdf.line(margin, action_bottom, margin, y)
533
+ pdf.setLineWidth(1)
534
+ pdf.setFillColor(blue_800)
535
+ pdf.setFont("Helvetica-Bold", 11)
536
+ text_center_x = margin + (width - 2 * margin) / 2
537
+ _draw_centered_block(
538
+ pdf,
539
+ action_lines,
540
+ text_center_x,
541
+ action_bottom,
542
+ action_h,
543
+ leading,
544
+ "Helvetica-Bold",
545
+ 11,
546
+ )
547
+ y = action_bottom - 6 * mm
548
+ else:
549
+ pdf.setFillColor(gray_800)
550
+ pdf.setFont("Helvetica-Bold", 11)
551
+ pdf.drawString(margin, y, "Photo Documentation (continued)")
552
+ pdf.setStrokeColor(gray_200)
553
+ pdf.line(margin, y - 2, width - margin, y - 2)
554
+ y -= 8 * mm
555
+
556
+ if variant == "full":
557
+ y -= 2 * mm
558
+ pdf.setFillColor(gray_800)
559
+ pdf.setFont("Helvetica-Bold", 14)
560
+ pdf.drawString(margin, y, "Photo Documentation")
561
+ pdf.setStrokeColor(gray_200)
562
+ pdf.line(margin, y - 2, width - margin, y - 2)
563
+ y -= 8 * mm
564
+
565
+ photo_area_top = y
566
+ photo_area_height = max(40 * mm, photo_area_top - content_bottom)
567
+
568
+ if photos:
569
+ columns = 1 if len(photos) == 1 else 2
570
+ rows = math.ceil(len(photos) / columns)
571
+ cell_w = (width - 2 * margin - (columns - 1) * 6 * mm) / columns
572
+ cell_h = (photo_area_height - (rows - 1) * 6 * mm) / rows
573
+
574
+ for idx, photo in enumerate(photos):
575
+ photo_path = photo["path"]
576
+ label = photo.get("label") or photo_path.name
577
+ row = idx // columns
578
+ col = idx % columns
579
+ x = margin + col * (cell_w + 6 * mm)
580
+ y = photo_area_top - (row + 1) * cell_h - row * 6 * mm
581
+ pdf.setStrokeColor(gray_200)
582
+ pdf.setFillColor(gray_50)
583
+ pdf.roundRect(x, y, cell_w, cell_h, 3 * mm, stroke=1, fill=1)
584
+ _draw_image_fit(
585
+ pdf, photo_path, x + 2 * mm, y + 8 * mm, cell_w - 4 * mm, cell_h - 14 * mm
586
+ )
587
+ pdf.setFillColor(gray_500)
588
+ pdf.setFont("Helvetica", 11)
589
+ if label:
590
+ pdf.drawCentredString(
591
+ x + cell_w / 2, y + 3 * mm, f"Fig {idx + 1}: {label}"
592
+ )
593
+ else:
594
+ pdf.setFont("Helvetica", 11)
595
+ pdf.drawString(margin, photo_area_top - 10 * mm, "No photos selected.")
596
+
597
+ # Footer
598
+ footer_y = margin
599
+ pdf.setFillColor(gray_500)
600
+ pdf.setFont("Helvetica", 11)
601
+ pdf.drawCentredString(width / 2, footer_y + 5 * mm, "Prosento - (c) 2026 All Rights Reserved")
602
+ pdf.drawCentredString(width / 2, footer_y + 1 * mm, "Automatically generated job sheet")
603
+
604
+ pdf.showPage()
605
+
606
+ pdf.save()
607
+ return output_path
608
+
server/assets/prosento-logo.png ADDED
server/requirements.txt CHANGED
@@ -3,3 +3,5 @@ uvicorn[standard]>=0.30.0,<0.32.0
3
  python-multipart>=0.0.9,<0.1.0
4
  openpyxl>=3.1.2,<4.0.0
5
  xlrd>=2.0.1,<3.0.0
 
 
 
3
  python-multipart>=0.0.9,<0.1.0
4
  openpyxl>=3.1.2,<4.0.0
5
  xlrd>=2.0.1,<3.0.0
6
+ reportlab>=4.2.5,<5.0.0
7
+ pillow>=10.4.0,<11.0.0