Spaces:
Sleeping
Sleeping
Hussien Haider commited on
Commit ·
ddbf80f
1
Parent(s): fee6fb4
redesign: cyber/tech dark theme certificate - dark bg, hexagons, gold accents
Browse files- app/services/certificate_service.py +200 -167
app/services/certificate_service.py
CHANGED
|
@@ -220,25 +220,25 @@ def build_certificate_pdf(
|
|
| 220 |
from reportlab.pdfgen import canvas
|
| 221 |
import qrcode
|
| 222 |
from qrcode.image.pil import PilImage
|
|
|
|
| 223 |
|
| 224 |
W, H = landscape(A4)
|
| 225 |
buf = io.BytesIO()
|
| 226 |
c = canvas.Canvas(buf, pagesize=landscape(A4))
|
| 227 |
|
| 228 |
-
# ──
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
# Register Arabic font
|
| 242 |
ar_font, ar_font_bold = _ensure_arabic_font()
|
| 243 |
en_font = "Helvetica"
|
| 244 |
en_font_bold = "Helvetica-Bold"
|
|
@@ -259,197 +259,223 @@ def build_certificate_pdf(
|
|
| 259 |
def _tx(text):
|
| 260 |
return _ar(text) if _is_ar(text) else text
|
| 261 |
|
| 262 |
-
# ──
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
c.setFillColor(GOLD)
|
| 268 |
-
c.rect(0, H - 1.
|
| 269 |
|
| 270 |
-
# ──
|
| 271 |
-
|
| 272 |
-
c.setFillColor(
|
| 273 |
-
c.
|
| 274 |
|
| 275 |
-
# gold
|
| 276 |
-
c.
|
| 277 |
-
c.
|
|
|
|
| 278 |
|
| 279 |
-
#
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
|
|
|
| 284 |
|
| 285 |
-
# ──
|
| 286 |
if _LOGO_JPG_PATH and os.path.exists(_LOGO_JPG_PATH):
|
| 287 |
-
logo_size =
|
| 288 |
-
|
| 289 |
-
c.drawImage(_LOGO_JPG_PATH, (BAR - logo_size) / 2, logo_y,
|
| 290 |
width=logo_size, height=logo_size, preserveAspectRatio=True)
|
| 291 |
|
| 292 |
-
|
| 293 |
-
c.
|
| 294 |
-
c.
|
| 295 |
-
c.
|
| 296 |
-
c.
|
| 297 |
-
c.
|
| 298 |
-
c.drawCentredString(BAR/2, H - 48*mm, _tx("CYBERSECURITY"))
|
| 299 |
|
| 300 |
-
#
|
| 301 |
-
c.
|
| 302 |
-
c.
|
| 303 |
-
|
| 304 |
-
c.
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
c.
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
c.
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
sp2.lineTo(hdr_x - 3.5*mm, hdr_y + 3.5*mm)
|
| 332 |
-
sp2.close()
|
| 333 |
-
c.drawPath(sp2, fill=1, stroke=0)
|
| 334 |
-
c.setStrokeColor(GOLD)
|
| 335 |
-
c.setLineWidth(0.5)
|
| 336 |
-
c.rect(hdr_x - 1.2*mm, hdr_y + 0.5*mm, 2.4*mm, 2*mm, fill=0, stroke=1)
|
| 337 |
-
c.arc(hdr_x - 0.8*mm, hdr_y + 2*mm, hdr_x + 0.8*mm, hdr_y + 3.5*mm, 0, 180)
|
| 338 |
-
|
| 339 |
-
c.setFillColor(DEEP_NAVY)
|
| 340 |
-
c.setFont(_font("bold"), 13)
|
| 341 |
-
c.drawString(hdr_x + 6*mm, TOP, _tx("CYBERARENA"))
|
| 342 |
-
c.setFillColor(TEXT_LIGHT)
|
| 343 |
-
c.setFont(_font(), 7.5)
|
| 344 |
-
c.drawString(hdr_x + 6*mm, TOP - 4.5*mm, _tx("CERTIFICATE OF COMPLETION"))
|
| 345 |
-
|
| 346 |
-
# gold separator line under header
|
| 347 |
-
c.setStrokeColor(GOLD)
|
| 348 |
-
c.setLineWidth(0.6)
|
| 349 |
-
c.line(BAR + 12*mm, TOP - 8*mm, RIGHT, TOP - 8*mm)
|
| 350 |
|
| 351 |
-
#
|
| 352 |
-
cert_label = "شهادة إتمام" if lang == "ar" else "CERTIFICATE"
|
| 353 |
cert_label2 = "في الأمن السيبراني" if lang == "ar" else "OF COMPLETION"
|
| 354 |
-
|
| 355 |
-
c.
|
| 356 |
-
c.setFont(_font("bold"), 32)
|
| 357 |
-
c.drawCentredString(CX, TOP - 30*mm, _tx(cert_label))
|
| 358 |
-
c.setFillColor(TEXT_MED)
|
| 359 |
-
c.setFont(_font(), 15)
|
| 360 |
c.drawCentredString(CX, TOP - 38*mm, _tx(cert_label2))
|
| 361 |
|
| 362 |
-
#
|
| 363 |
-
|
| 364 |
-
c.setStrokeColor(
|
| 365 |
-
c.setLineWidth(0.
|
| 366 |
-
c.line(CX -
|
| 367 |
-
c.line(CX + 2.5*mm,
|
| 368 |
-
# diamond
|
| 369 |
c.setFillColor(GOLD)
|
| 370 |
d2 = c.beginPath()
|
| 371 |
-
d2.moveTo(CX,
|
| 372 |
-
d2.lineTo(CX +
|
| 373 |
-
d2.lineTo(CX,
|
| 374 |
-
d2.lineTo(CX -
|
| 375 |
d2.close()
|
| 376 |
c.drawPath(d2, fill=1, stroke=0)
|
| 377 |
|
| 378 |
# ── Body ──
|
| 379 |
-
# intro line
|
| 380 |
intro = "نشهد بأن" if lang == "ar" else "This is to certify that"
|
| 381 |
-
c.setFillColor(
|
| 382 |
-
c.setFont(_font(),
|
| 383 |
c.drawCentredString(CX, TOP - 56*mm, _tx(intro))
|
| 384 |
|
| 385 |
-
#
|
| 386 |
name = (user_name or "Trainee")[:60]
|
| 387 |
-
c.setFillColor(
|
| 388 |
-
c.setFont(_font_for(name, "bold"),
|
| 389 |
-
c.drawCentredString(CX, TOP -
|
| 390 |
|
| 391 |
-
# gold underline
|
| 392 |
-
nw = c.stringWidth(_tx(name), _font_for(name, "bold"),
|
| 393 |
c.setStrokeColor(GOLD)
|
| 394 |
-
c.setLineWidth(1.
|
| 395 |
-
c.line(CX - nw/2 - 2, TOP -
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
|
| 397 |
# body text
|
| 398 |
body = "قد أتمّ بنجاح جميع التحديات التفاعلية والمختبرات العملية في مجال:" if lang == "ar" else "has successfully completed all interactive challenges and practical labs in"
|
| 399 |
-
c.setFillColor(
|
| 400 |
-
c.setFont(_font(),
|
| 401 |
-
c.drawCentredString(CX, TOP -
|
| 402 |
|
| 403 |
-
# category
|
| 404 |
cat_label = category_label(category, lang=lang)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
c.setFillColor(GOLD)
|
| 406 |
-
c.setFont(_font("bold"),
|
| 407 |
-
c.drawCentredString(CX, TOP -
|
| 408 |
|
| 409 |
# subtitle
|
| 410 |
-
c.setFillColor(
|
| 411 |
-
c.setFont(_font_for(title),
|
| 412 |
-
c.drawCentredString(CX, TOP -
|
| 413 |
|
| 414 |
# ── Bottom section ──
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
# thin separator above bottom
|
| 418 |
c.setStrokeColor(BORDER)
|
| 419 |
c.setLineWidth(0.3)
|
| 420 |
-
c.line(
|
| 421 |
|
| 422 |
# 1) QR code
|
| 423 |
qr_payload = (CERT_VERIFY_BASE_URL or "https://cyberarena.app/verify") + "/verify?code=" + verify_code
|
| 424 |
-
qr = qrcode.QRCode(box_size=
|
| 425 |
qr.add_data(qr_payload)
|
| 426 |
qr.make(fit=True)
|
| 427 |
-
qr_img = qr.make_image(fill_color="#
|
| 428 |
qr_path = os.path.join(tempfile.gettempdir(), f"ca_qr_{verify_code.replace(' ', '_')}.png")
|
| 429 |
qr_img.save(qr_path, format="PNG")
|
| 430 |
-
qr_s =
|
| 431 |
-
qr_x =
|
| 432 |
-
c.drawImage(qr_path, qr_x, BOT +
|
| 433 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
qr_caption = "SCAN TO VERIFY" if lang == "en" else "امسح للتحقق"
|
| 435 |
-
c.setFont(_font_for(qr_caption, "bold"), 6
|
| 436 |
-
c.drawCentredString(qr_x + qr_s/2, BOT -
|
| 437 |
-
c.
|
| 438 |
-
c.
|
| 439 |
-
c.drawCentredString(qr_x + qr_s/2, BOT -
|
| 440 |
|
| 441 |
# 2) Info rows
|
| 442 |
-
infox =
|
| 443 |
date_str = (_format_date_en(issue_date) if lang == "en" else _format_date_ar(issue_date)) or "—"
|
| 444 |
info_items = [
|
| 445 |
-
("ISSUE DATE" if lang == "en" else "تاريخ الإصدار", date_str,
|
| 446 |
-
("CERTIFICATE ID" if lang == "en" else "رقم الشهادة", str(cert_id)[:28],
|
| 447 |
("VERIFICATION CODE" if lang == "en" else "رمز التحقق", verify_code, GOLD, 7),
|
| 448 |
]
|
| 449 |
for i, (label, val, vcolor, vsize) in enumerate(info_items):
|
| 450 |
-
ypos = BOT +
|
| 451 |
-
c.setFillColor(
|
| 452 |
-
c.setFont(_font_for(label, "bold"), 6
|
| 453 |
c.drawString(infox, ypos, _tx(label))
|
| 454 |
c.setFillColor(vcolor)
|
| 455 |
vfont = "Courier" if not _is_ar(val) and lang == "en" else _font_for(val)
|
|
@@ -457,24 +483,31 @@ def build_certificate_pdf(
|
|
| 457 |
c.drawString(infox, ypos - 3.5*mm, _tx(val))
|
| 458 |
|
| 459 |
# 3) Signature
|
| 460 |
-
sig_x =
|
| 461 |
c.setStrokeColor(BORDER)
|
| 462 |
-
c.setLineWidth(0.
|
| 463 |
-
c.line(sig_x -
|
| 464 |
sig_label1 = "Alpha Team Academic Board" if lang == "en" else "المجلس الأكاديمي"
|
| 465 |
sig_label2 = "Alpha Team" if lang == "en" else "فريق ألفا"
|
| 466 |
-
c.setFillColor(
|
| 467 |
-
c.setFont(_font_for(sig_label1, "bold"),
|
| 468 |
-
c.
|
| 469 |
-
c.setFillColor(
|
| 470 |
-
c.setFont(_font_for(sig_label2),
|
| 471 |
-
c.
|
| 472 |
-
|
| 473 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
c.setFillColor(GOLD)
|
| 475 |
-
c.rect(0, 0, W,
|
| 476 |
-
c.setFillColor(
|
| 477 |
-
c.rect(0,
|
| 478 |
|
| 479 |
c.showPage()
|
| 480 |
c.save()
|
|
|
|
| 220 |
from reportlab.pdfgen import canvas
|
| 221 |
import qrcode
|
| 222 |
from qrcode.image.pil import PilImage
|
| 223 |
+
import math
|
| 224 |
|
| 225 |
W, H = landscape(A4)
|
| 226 |
buf = io.BytesIO()
|
| 227 |
c = canvas.Canvas(buf, pagesize=landscape(A4))
|
| 228 |
|
| 229 |
+
# ── Cyber color palette ──
|
| 230 |
+
BG_DARK = HexColor("#080E1A")
|
| 231 |
+
BG_MID = HexColor("#0D1526")
|
| 232 |
+
BG_CARD = HexColor("#111C33")
|
| 233 |
+
GOLD = HexColor("#D4A853")
|
| 234 |
+
GOLD_DIM = HexColor("#A8873A")
|
| 235 |
+
CYBER = HexColor("#22D3EE") # cyan accent
|
| 236 |
+
CYBER_DIM = HexColor("#0E7490")
|
| 237 |
+
TEXT_PRIMARY = HexColor("#F1F5F9")
|
| 238 |
+
TEXT_SECOND = HexColor("#94A3B8")
|
| 239 |
+
TEXT_MUTED = HexColor("#475569")
|
| 240 |
+
BORDER = HexColor("#1E293B")
|
| 241 |
+
|
|
|
|
| 242 |
ar_font, ar_font_bold = _ensure_arabic_font()
|
| 243 |
en_font = "Helvetica"
|
| 244 |
en_font_bold = "Helvetica-Bold"
|
|
|
|
| 259 |
def _tx(text):
|
| 260 |
return _ar(text) if _is_ar(text) else text
|
| 261 |
|
| 262 |
+
# ── Background: dark gradient ──
|
| 263 |
+
for y in range(0, int(H), 3):
|
| 264 |
+
t = 1 - y / H
|
| 265 |
+
r = 0.03 + 0.05 * t
|
| 266 |
+
g = 0.05 + 0.07 * t
|
| 267 |
+
b = 0.10 + 0.12 * t
|
| 268 |
+
c.setFillColorRGB(min(r, 1), min(g, 1), min(b, 1))
|
| 269 |
+
c.rect(0, y, W, 3, fill=1, stroke=0)
|
| 270 |
+
|
| 271 |
+
# ── Decorative tech grid ──
|
| 272 |
+
c.setStrokeColor(HexColor("#1A2744"))
|
| 273 |
+
c.setLineWidth(0.15)
|
| 274 |
+
for x in range(0, int(W), 20):
|
| 275 |
+
c.line(x, 0, x, H)
|
| 276 |
+
for y in range(0, int(H), 20):
|
| 277 |
+
c.line(0, y, W, y)
|
| 278 |
+
|
| 279 |
+
# ── Decorative hexagons ──
|
| 280 |
+
def _hexagon(cx, cy, r, stroke_color=None, fill_color=None):
|
| 281 |
+
p = c.beginPath()
|
| 282 |
+
for i in range(6):
|
| 283 |
+
a = math.radians(60 * i - 90)
|
| 284 |
+
px = cx + r * math.cos(a)
|
| 285 |
+
py = cy + r * math.sin(a)
|
| 286 |
+
if i == 0:
|
| 287 |
+
p.moveTo(px, py)
|
| 288 |
+
else:
|
| 289 |
+
p.lineTo(px, py)
|
| 290 |
+
p.close()
|
| 291 |
+
if fill_color:
|
| 292 |
+
c.setFillColor(fill_color)
|
| 293 |
+
c.drawPath(p, fill=1, stroke=0)
|
| 294 |
+
if stroke_color:
|
| 295 |
+
c.setStrokeColor(stroke_color)
|
| 296 |
+
c.setLineWidth(0.3)
|
| 297 |
+
c.drawPath(p, fill=0, stroke=1)
|
| 298 |
+
|
| 299 |
+
_hexagon(W - 25*mm, H - 25*mm, 15*mm, stroke_color=HexColor("#1A2744"))
|
| 300 |
+
_hexagon(W - 35*mm, H - 15*mm, 8*mm, stroke_color=HexColor("#1A2744"))
|
| 301 |
+
_hexagon(35*mm, H - 20*mm, 10*mm, stroke_color=HexColor("#1A2744"))
|
| 302 |
+
_hexagon(W - 40*mm, 30*mm, 12*mm, stroke_color=HexColor("#1A2744"))
|
| 303 |
+
|
| 304 |
+
# ── Top gold accent bar ──
|
| 305 |
c.setFillColor(GOLD)
|
| 306 |
+
c.rect(0, H - 1.5*mm, W, 1.5*mm, fill=1, stroke=0)
|
| 307 |
|
| 308 |
+
# ── Main card (slightly lighter bg) ──
|
| 309 |
+
MARGIN = 10*mm
|
| 310 |
+
c.setFillColor(BG_CARD)
|
| 311 |
+
c.roundRect(MARGIN, MARGIN, W - 2*MARGIN, H - 2*MARGIN - 1.5*mm, 4*mm, fill=1, stroke=0)
|
| 312 |
|
| 313 |
+
# thin gold border on card
|
| 314 |
+
c.setStrokeColor(GOLD_DIM)
|
| 315 |
+
c.setLineWidth(0.3)
|
| 316 |
+
c.roundRect(MARGIN, MARGIN, W - 2*MARGIN, H - 2*MARGIN - 1.5*mm, 4*mm, fill=0, stroke=1)
|
| 317 |
|
| 318 |
+
# ── Inner content area ──
|
| 319 |
+
CX = W / 2
|
| 320 |
+
INNER_L = MARGIN + 12*mm
|
| 321 |
+
INNER_R = W - MARGIN - 12*mm
|
| 322 |
+
TOP = H - MARGIN - 16*mm
|
| 323 |
+
BOT = MARGIN + 14*mm
|
| 324 |
|
| 325 |
+
# ── Top left: ALPHA logo + brand ──
|
| 326 |
if _LOGO_JPG_PATH and os.path.exists(_LOGO_JPG_PATH):
|
| 327 |
+
logo_size = 16 * mm
|
| 328 |
+
c.drawImage(_LOGO_JPG_PATH, INNER_L, TOP - 2*mm,
|
|
|
|
| 329 |
width=logo_size, height=logo_size, preserveAspectRatio=True)
|
| 330 |
|
| 331 |
+
c.setFillColor(TEXT_PRIMARY)
|
| 332 |
+
c.setFont(_font("bold"), 11)
|
| 333 |
+
c.drawString(INNER_L + 20*mm, TOP + 1*mm, _tx("ALPHA TEAM"))
|
| 334 |
+
c.setFillColor(TEXT_MUTED)
|
| 335 |
+
c.setFont(_font(), 7)
|
| 336 |
+
c.drawString(INNER_L + 20*mm, TOP - 5*mm, _tx("CYBERSECURITY"))
|
|
|
|
| 337 |
|
| 338 |
+
# ── Top right: CYBERARENA brand ──
|
| 339 |
+
c.setFillColor(GOLD)
|
| 340 |
+
c.setFont(_font("bold"), 11)
|
| 341 |
+
c.drawRightString(INNER_R, TOP + 1*mm, _tx("CYBERARENA"))
|
| 342 |
+
c.setFillColor(TEXT_MUTED)
|
| 343 |
+
c.setFont(_font(), 7)
|
| 344 |
+
c.drawRightString(INNER_R, TOP - 5*mm, _tx("CERTIFICATE OF COMPLETION"))
|
| 345 |
+
|
| 346 |
+
# ── Gold divider ──
|
| 347 |
+
div_y = TOP - 12*mm
|
| 348 |
+
c.setStrokeColor(GOLD_DIM)
|
| 349 |
+
c.setLineWidth(0.4)
|
| 350 |
+
c.line(INNER_L, div_y, INNER_R, div_y)
|
| 351 |
+
|
| 352 |
+
# ── Cyber seal (large hexagon) ──
|
| 353 |
+
seal_cx = INNER_R - 5*mm
|
| 354 |
+
seal_cy = (TOP + BOT) / 2 + 15*mm
|
| 355 |
+
_hexagon(seal_cx, seal_cy, 18*mm, stroke_color=HexColor("#D4A853"), fill_color=HexColor("#0D1526"))
|
| 356 |
+
_hexagon(seal_cx, seal_cy, 14*mm, stroke_color=HexColor("#A8873A"))
|
| 357 |
+
_hexagon(seal_cx, seal_cy, 10*mm, stroke_color=HexColor("#D4A853"))
|
| 358 |
+
c.setFillColor(GOLD_DIM)
|
| 359 |
+
c.setFont(_font("bold"), 5)
|
| 360 |
+
c.drawCentredString(seal_cx, seal_cy - 2*mm, "✓")
|
| 361 |
+
c.setFont(_font(), 4.5)
|
| 362 |
+
c.drawCentredString(seal_cx, seal_cy - 6*mm, "VERIFIED")
|
| 363 |
+
|
| 364 |
+
# ── Title ──
|
| 365 |
+
cert_label = "شهادة إتمام" if lang == "ar" else "CERTIFICATE"
|
| 366 |
+
c.setFillColor(GOLD)
|
| 367 |
+
c.setFont(_font("bold"), 34)
|
| 368 |
+
c.drawCentredString(CX, TOP - 30*mm, _tx(cert_label))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
|
| 370 |
+
# subtitle
|
|
|
|
| 371 |
cert_label2 = "في الأمن السيبراني" if lang == "ar" else "OF COMPLETION"
|
| 372 |
+
c.setFillColor(TEXT_SECOND)
|
| 373 |
+
c.setFont(_font(), 13)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
c.drawCentredString(CX, TOP - 38*mm, _tx(cert_label2))
|
| 375 |
|
| 376 |
+
# ── Gold diamond divider ──
|
| 377 |
+
div2_y = TOP - 44*mm
|
| 378 |
+
c.setStrokeColor(GOLD_DIM)
|
| 379 |
+
c.setLineWidth(0.5)
|
| 380 |
+
c.line(CX - 25*mm, div2_y, CX - 2.5*mm, div2_y)
|
| 381 |
+
c.line(CX + 2.5*mm, div2_y, CX + 25*mm, div2_y)
|
|
|
|
| 382 |
c.setFillColor(GOLD)
|
| 383 |
d2 = c.beginPath()
|
| 384 |
+
d2.moveTo(CX, div2_y + 1.8*mm)
|
| 385 |
+
d2.lineTo(CX + 1.8*mm, div2_y)
|
| 386 |
+
d2.lineTo(CX, div2_y - 1.8*mm)
|
| 387 |
+
d2.lineTo(CX - 1.8*mm, div2_y)
|
| 388 |
d2.close()
|
| 389 |
c.drawPath(d2, fill=1, stroke=0)
|
| 390 |
|
| 391 |
# ── Body ──
|
|
|
|
| 392 |
intro = "نشهد بأن" if lang == "ar" else "This is to certify that"
|
| 393 |
+
c.setFillColor(TEXT_SECOND)
|
| 394 |
+
c.setFont(_font(), 9)
|
| 395 |
c.drawCentredString(CX, TOP - 56*mm, _tx(intro))
|
| 396 |
|
| 397 |
+
# Name (large, gold)
|
| 398 |
name = (user_name or "Trainee")[:60]
|
| 399 |
+
c.setFillColor(TEXT_PRIMARY)
|
| 400 |
+
c.setFont(_font_for(name, "bold"), 26)
|
| 401 |
+
c.drawCentredString(CX, TOP - 68*mm, _tx(name))
|
| 402 |
|
| 403 |
+
# gold underline
|
| 404 |
+
nw = c.stringWidth(_tx(name), _font_for(name, "bold"), 26)
|
| 405 |
c.setStrokeColor(GOLD)
|
| 406 |
+
c.setLineWidth(1.2)
|
| 407 |
+
c.line(CX - nw/2 - 2, TOP - 71*mm, CX + nw/2 + 2, TOP - 71*mm)
|
| 408 |
+
|
| 409 |
+
# small cyber dots on underline
|
| 410 |
+
c.setFillColor(CYBER)
|
| 411 |
+
for dot_x in [CX - nw/2 - 3, CX + nw/2 + 3]:
|
| 412 |
+
c.circle(dot_x, TOP - 71*mm, 0.6*mm, fill=1, stroke=0)
|
| 413 |
|
| 414 |
# body text
|
| 415 |
body = "قد أتمّ بنجاح جميع التحديات التفاعلية والمختبرات العملية في مجال:" if lang == "ar" else "has successfully completed all interactive challenges and practical labs in"
|
| 416 |
+
c.setFillColor(TEXT_SECOND)
|
| 417 |
+
c.setFont(_font(), 9)
|
| 418 |
+
c.drawCentredString(CX, TOP - 84*mm, _tx(body))
|
| 419 |
|
| 420 |
+
# category badge
|
| 421 |
cat_label = category_label(category, lang=lang)
|
| 422 |
+
# badge background
|
| 423 |
+
badge_w = c.stringWidth(_tx(cat_label), _font("bold"), 18) + 10*mm
|
| 424 |
+
c.setFillColor(BG_DARK)
|
| 425 |
+
c.roundRect(CX - badge_w/2, TOP - 99*mm - 3*mm, badge_w, 12*mm, 3*mm, fill=1, stroke=0)
|
| 426 |
+
c.setStrokeColor(GOLD_DIM)
|
| 427 |
+
c.setLineWidth(0.5)
|
| 428 |
+
c.roundRect(CX - badge_w/2, TOP - 99*mm - 3*mm, badge_w, 12*mm, 3*mm, fill=0, stroke=1)
|
| 429 |
c.setFillColor(GOLD)
|
| 430 |
+
c.setFont(_font("bold"), 18)
|
| 431 |
+
c.drawCentredString(CX, TOP - 99*mm, _tx(cat_label))
|
| 432 |
|
| 433 |
# subtitle
|
| 434 |
+
c.setFillColor(TEXT_MUTED)
|
| 435 |
+
c.setFont(_font_for(title), 7.5)
|
| 436 |
+
c.drawCentredString(CX, TOP - 110*mm, _tx(title))
|
| 437 |
|
| 438 |
# ── Bottom section ──
|
| 439 |
+
# divider
|
|
|
|
|
|
|
| 440 |
c.setStrokeColor(BORDER)
|
| 441 |
c.setLineWidth(0.3)
|
| 442 |
+
c.line(INNER_L, BOT + 34*mm, INNER_R, BOT + 34*mm)
|
| 443 |
|
| 444 |
# 1) QR code
|
| 445 |
qr_payload = (CERT_VERIFY_BASE_URL or "https://cyberarena.app/verify") + "/verify?code=" + verify_code
|
| 446 |
+
qr = qrcode.QRCode(box_size=3, border=0)
|
| 447 |
qr.add_data(qr_payload)
|
| 448 |
qr.make(fit=True)
|
| 449 |
+
qr_img = qr.make_image(fill_color="#D4A853", back_color="#111C33", image_factory=PilImage)
|
| 450 |
qr_path = os.path.join(tempfile.gettempdir(), f"ca_qr_{verify_code.replace(' ', '_')}.png")
|
| 451 |
qr_img.save(qr_path, format="PNG")
|
| 452 |
+
qr_s = 18*mm
|
| 453 |
+
qr_x = INNER_L
|
| 454 |
+
c.drawImage(qr_path, qr_x, BOT + 4*mm, width=qr_s, height=qr_s, preserveAspectRatio=True, mask="auto")
|
| 455 |
+
# qr border
|
| 456 |
+
c.setStrokeColor(BORDER)
|
| 457 |
+
c.setLineWidth(0.3)
|
| 458 |
+
c.roundRect(qr_x - 1*mm, BOT + 3*mm, qr_s + 2*mm, qr_s + 2*mm, 1.5*mm, fill=0, stroke=1)
|
| 459 |
+
c.setFillColor(TEXT_SECOND)
|
| 460 |
qr_caption = "SCAN TO VERIFY" if lang == "en" else "امسح للتحقق"
|
| 461 |
+
c.setFont(_font_for(qr_caption, "bold"), 6)
|
| 462 |
+
c.drawCentredString(qr_x + qr_s/2, BOT - 0.5*mm, _tx(qr_caption))
|
| 463 |
+
c.setFillColor(TEXT_MUTED)
|
| 464 |
+
c.setFont(_font_for(verify_code), 5)
|
| 465 |
+
c.drawCentredString(qr_x + qr_s/2, BOT - 4*mm, _tx(verify_code))
|
| 466 |
|
| 467 |
# 2) Info rows
|
| 468 |
+
infox = INNER_L + 30*mm
|
| 469 |
date_str = (_format_date_en(issue_date) if lang == "en" else _format_date_ar(issue_date)) or "—"
|
| 470 |
info_items = [
|
| 471 |
+
("ISSUE DATE" if lang == "en" else "تاريخ الإصدار", date_str, TEXT_PRIMARY, 8),
|
| 472 |
+
("CERTIFICATE ID" if lang == "en" else "رقم الشهادة", str(cert_id)[:28], TEXT_SECOND, 7),
|
| 473 |
("VERIFICATION CODE" if lang == "en" else "رمز التحقق", verify_code, GOLD, 7),
|
| 474 |
]
|
| 475 |
for i, (label, val, vcolor, vsize) in enumerate(info_items):
|
| 476 |
+
ypos = BOT + 27*mm - i * 8.5*mm
|
| 477 |
+
c.setFillColor(TEXT_MUTED)
|
| 478 |
+
c.setFont(_font_for(label, "bold"), 6)
|
| 479 |
c.drawString(infox, ypos, _tx(label))
|
| 480 |
c.setFillColor(vcolor)
|
| 481 |
vfont = "Courier" if not _is_ar(val) and lang == "en" else _font_for(val)
|
|
|
|
| 483 |
c.drawString(infox, ypos - 3.5*mm, _tx(val))
|
| 484 |
|
| 485 |
# 3) Signature
|
| 486 |
+
sig_x = INNER_R
|
| 487 |
c.setStrokeColor(BORDER)
|
| 488 |
+
c.setLineWidth(0.3)
|
| 489 |
+
c.line(sig_x - 18*mm, BOT + 20*mm, sig_x, BOT + 20*mm)
|
| 490 |
sig_label1 = "Alpha Team Academic Board" if lang == "en" else "المجلس الأكاديمي"
|
| 491 |
sig_label2 = "Alpha Team" if lang == "en" else "فريق ألفا"
|
| 492 |
+
c.setFillColor(TEXT_SECOND)
|
| 493 |
+
c.setFont(_font_for(sig_label1, "bold"), 6.5)
|
| 494 |
+
c.drawRightString(sig_x, BOT + 15*mm, _tx(sig_label1))
|
| 495 |
+
c.setFillColor(TEXT_MUTED)
|
| 496 |
+
c.setFont(_font_for(sig_label2), 5.5)
|
| 497 |
+
c.drawRightString(sig_x, BOT + 9*mm, _tx(sig_label2))
|
| 498 |
+
|
| 499 |
+
# small gold star near seal
|
| 500 |
+
c.setFillColor(GOLD)
|
| 501 |
+
for i in range(3):
|
| 502 |
+
sx = seal_cx + 8*mm * math.cos(math.radians(120 * i - 30))
|
| 503 |
+
sy = seal_cy + 8*mm * math.sin(math.radians(120 * i - 30))
|
| 504 |
+
c.circle(sx, sy, 0.4*mm, fill=1, stroke=0)
|
| 505 |
+
|
| 506 |
+
# ── Bottom accent bars ──
|
| 507 |
c.setFillColor(GOLD)
|
| 508 |
+
c.rect(0, 0, W, 0.8*mm, fill=1, stroke=0)
|
| 509 |
+
c.setFillColor(BG_DARK)
|
| 510 |
+
c.rect(0, 0.8*mm, W, 0.4*mm, fill=1, stroke=0)
|
| 511 |
|
| 512 |
c.showPage()
|
| 513 |
c.save()
|