Files
badge-generator/badge_generator.py
2026-04-28 14:44:08 -05:00

649 lines
21 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import io
import math
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from PIL import Image, ImageDraw, ImageFont, ImageOps
from reportlab.lib.pagesizes import letter
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
PAGE_WIDTH_PT, PAGE_HEIGHT_PT = letter
INCH = 72.0
LABEL_WIDTH_IN = 3.375
LABEL_HEIGHT_IN = 2.333
LEFT_MARGIN_IN = 0.688
TOP_MARGIN_IN = 0.594
HORIZONTAL_GAP_IN = 0.375
VERTICAL_GAP_IN = 0.187
LABELS_ACROSS = 2
LABELS_DOWN = 4
LABELS_PER_SHEET = LABELS_ACROSS * LABELS_DOWN
COPIES_PER_EMPLOYEE = 2
PAIRS_PER_SHEET = LABELS_PER_SHEET // COPIES_PER_EMPLOYEE
PHOTO_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp")
BLUE = "#1b95cf"
BLACK = "#111111"
WHITE = "#ffffff"
LIGHT_GRAY = "#d8dce2"
LOGO_BLUE = "#2c8ecb"
FONT_REGULAR_PATH = "/System/Library/Fonts/Supplemental/Arial Narrow.ttf"
FONT_BOLD_PATH = "/System/Library/Fonts/Supplemental/Arial Narrow Bold.ttf"
@dataclass
class Employee:
name: str
title: str
photo_path: Path
@dataclass
class SheetPlacement:
page_index: int
slot_index: int
def normalize_key(value: str) -> str:
return re.sub(r"[^a-z0-9]", "", value.lower())
def title_case_name(value: str) -> str:
words = re.split(r"\s+", value.strip())
return " ".join(word[:1].upper() + word[1:] for word in words if word)
def clean_optional_value(value: object) -> str:
return str(value or "").strip()
def build_display_name(base_name: str, prefix: str, suffix: str) -> str:
parts = [part for part in [prefix.strip(), base_name.strip(), suffix.strip()] if part]
return " ".join(parts)
def build_photo_keys(base_name: str, prefix: str) -> list[str]:
keys: list[str] = []
for candidate in [base_name, f"{prefix} {base_name}".strip(), f"{prefix}{base_name}".strip()]:
normalized = normalize_key(candidate)
if normalized and normalized not in keys:
keys.append(normalized)
return keys
def register_reportlab_fonts() -> None:
if "ArialNarrow" not in pdfmetrics.getRegisteredFontNames():
pdfmetrics.registerFont(TTFont("ArialNarrow", FONT_REGULAR_PATH))
if "ArialNarrow-Bold" not in pdfmetrics.getRegisteredFontNames():
pdfmetrics.registerFont(TTFont("ArialNarrow-Bold", FONT_BOLD_PATH))
def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
font_path = FONT_BOLD_PATH if bold else FONT_REGULAR_PATH
return ImageFont.truetype(font_path, size=size)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate print-ready Avery 8395 employee badge PDFs."
)
parser.add_argument("employees_csv", type=Path, help="CSV with employee names and titles")
parser.add_argument("photos_dir", type=Path, help="Directory containing employee photos")
parser.add_argument(
"-o",
"--output",
type=Path,
default=Path("output/badges.pdf"),
help="Output PDF path",
)
parser.add_argument(
"--brand-text",
default="CHITTICK\nEYE CARE",
help="Brand text rendered on the badge header",
)
parser.add_argument(
"--logo-path",
type=Path,
help="Optional logo image to place above the brand text",
)
parser.add_argument(
"--left-adjust",
type=float,
default=0.0,
help="Shift all labels horizontally in inches for printer calibration",
)
parser.add_argument(
"--top-adjust",
type=float,
default=0.0,
help="Shift all labels vertically in inches for printer calibration",
)
return parser.parse_args()
def resolve_logo_path(explicit_logo_path: Path | None) -> Path | None:
if explicit_logo_path is not None:
return explicit_logo_path
logo_dir = Path("logo")
if not logo_dir.exists():
return None
candidates = sorted(
path for path in logo_dir.iterdir() if path.is_file() and path.suffix.lower() in PHOTO_EXTENSIONS
)
return candidates[0] if candidates else None
def read_employees(csv_path: Path, photos_dir: Path) -> list[Employee]:
if not csv_path.exists():
raise FileNotFoundError(f"Missing CSV file: {csv_path}")
if not photos_dir.exists():
raise FileNotFoundError(f"Missing photos directory: {photos_dir}")
photos = build_photo_index(photos_dir)
employees: list[Employee] = []
with csv_path.open(newline="", encoding="utf-8-sig") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise ValueError("CSV must include a header row.")
fields = {normalize_key(name): name for name in reader.fieldnames}
name_field = first_present(fields, ["name", "employee", "employeename", "fullname"])
title_field = first_present(fields, ["title", "jobtitle", "position", "role"])
first_name_field = first_present(fields, ["firstname", "first"])
last_name_field = first_present(fields, ["lastname", "last", "surname"])
prefix_field = first_present(fields, ["prefix", "nameprefix", "credentialprefix"])
suffix_field = first_present(fields, ["suffix", "namesuffix", "credentials"])
if title_field is None:
raise ValueError("CSV must include a title column.")
if name_field is None and (first_name_field is None or last_name_field is None):
raise ValueError(
"CSV must include either a name column or both first_name and last_name columns."
)
for row_number, row in enumerate(reader, start=2):
if name_field is not None:
base_name = clean_optional_value(row.get(name_field, ""))
else:
first_name = clean_optional_value(row.get(first_name_field, ""))
last_name = clean_optional_value(row.get(last_name_field, ""))
base_name = f"{first_name} {last_name}".strip()
prefix = clean_optional_value(row.get(prefix_field, "")) if prefix_field else ""
suffix = clean_optional_value(row.get(suffix_field, "")) if suffix_field else ""
title = clean_optional_value(row.get(title_field, ""))
if not base_name or not title:
raise ValueError(f"Row {row_number} is missing a name or title.")
photo_keys = build_photo_keys(base_name, prefix)
photo_path = next((photos.get(key) for key in photo_keys if photos.get(key) is not None), None)
if photo_path is None:
raise FileNotFoundError(
f"Could not find a photo for '{base_name}'. Expected a file like "
f"'{photo_keys[0]}.png' or '.jpg' in {photos_dir}."
)
display_name = build_display_name(
title_case_name(base_name),
title_case_name(prefix),
suffix.upper(),
)
employees.append(Employee(name=display_name, title=title, photo_path=photo_path))
if not employees:
raise ValueError("No employees found in the CSV.")
return employees
def build_photo_index(photos_dir: Path) -> dict[str, Path]:
photos: dict[str, Path] = {}
for path in photos_dir.iterdir():
if not path.is_file() or path.suffix.lower() not in PHOTO_EXTENSIONS:
continue
photos[normalize_key(path.stem)] = path
return photos
def first_present(field_map: dict[str, str], candidates: Iterable[str]) -> str | None:
for candidate in candidates:
field = field_map.get(candidate)
if field is not None:
return field
return None
def fit_text(
draw: ImageDraw.ImageDraw,
text: str,
box: tuple[int, int, int, int],
*,
max_size: int,
min_size: int,
bold: bool,
max_lines: int = 1,
) -> tuple[ImageFont.FreeTypeFont, list[str]]:
for size in range(max_size, min_size - 1, -1):
font = load_font(size=size, bold=bold)
lines = wrap_text(draw, text, font, box[2] - box[0], max_lines)
if lines is None:
continue
widths = [draw.textbbox((0, 0), line, font=font)[2] for line in lines]
line_height = draw.textbbox((0, 0), "Ag", font=font)[3]
total_height = len(lines) * line_height + max(0, len(lines) - 1) * int(size * 0.12)
if max(widths, default=0) <= box[2] - box[0] and total_height <= box[3] - box[1]:
return font, lines
font = load_font(size=min_size, bold=bold)
lines = wrap_text(draw, text, font, box[2] - box[0], max_lines) or [text]
return font, lines[:max_lines]
def split_name_for_badge(name: str) -> str:
words = name.split()
if len(words) <= 1:
return name
if len(words) == 2:
return "\n".join(words)
midpoint = math.ceil(len(words) / 2)
first_line = " ".join(words[:midpoint])
second_line = " ".join(words[midpoint:])
return f"{first_line}\n{second_line}"
def fit_name_text(
draw: ImageDraw.ImageDraw,
text: str,
box: tuple[int, int, int, int],
) -> tuple[ImageFont.FreeTypeFont, list[str]]:
single_line = text.upper()
for size in range(74, 27, -1):
font = load_font(size=size, bold=True)
if wrap_text(draw, single_line, font, box[2] - box[0], 1) is not None:
return font, [single_line]
split_font, split_lines = fit_text(
draw,
split_name_for_badge(single_line),
box,
max_size=78,
min_size=26,
bold=True,
max_lines=2,
)
return split_font, split_lines
def split_title_for_badge(title: str) -> str:
words = title.upper().split()
if len(words) <= 1:
return title.upper()
if len(words) == 2:
return f"{words[0]}\n{words[1]}"
if len(words) == 3:
return f"{' '.join(words[:2])}\n{words[2]}"
midpoint = math.ceil(len(words) / 2)
best_lines = (" ".join(words[:midpoint]), " ".join(words[midpoint:]))
best_delta = abs(len(best_lines[0]) - len(best_lines[1]))
for idx in range(1, len(words)):
candidate = (" ".join(words[:idx]), " ".join(words[idx:]))
if not candidate[0] or not candidate[1]:
continue
delta = abs(len(candidate[0]) - len(candidate[1]))
if delta < best_delta:
best_lines = candidate
best_delta = delta
return f"{best_lines[0]}\n{best_lines[1]}"
def fit_title_text(
draw: ImageDraw.ImageDraw,
title: str,
box: tuple[int, int, int, int],
) -> tuple[ImageFont.FreeTypeFont, list[str]]:
upper_title = title.upper()
variants: list[str] = []
forced_two_line = split_title_for_badge(title)
if forced_two_line not in variants:
variants.append(forced_two_line)
if upper_title not in variants:
variants.append(upper_title)
best_font: ImageFont.FreeTypeFont | None = None
best_lines: list[str] | None = None
best_size = -1
for variant in variants:
font, lines = fit_text(
draw,
variant,
box,
max_size=52,
min_size=20,
bold=True,
max_lines=2,
)
if font.size > best_size:
best_font = font
best_lines = lines
best_size = font.size
if best_font is None or best_lines is None:
return load_font(20, bold=True), [upper_title]
return best_font, best_lines
def wrap_text(
draw: ImageDraw.ImageDraw,
text: str,
font: ImageFont.FreeTypeFont,
max_width: int,
max_lines: int,
) -> list[str] | None:
words = text.split()
if not words:
return [""]
if "\n" in text:
lines = [line.strip() for line in text.splitlines() if line.strip()]
if not lines or len(lines) > max_lines:
return None
for line in lines:
width = draw.textbbox((0, 0), line, font=font)[2]
if width > max_width:
return None
return lines
if max_lines == 1:
width = draw.textbbox((0, 0), text, font=font)[2]
return [text] if width <= max_width else None
lines: list[str] = []
current = words[0]
for word in words[1:]:
proposal = f"{current} {word}"
width = draw.textbbox((0, 0), proposal, font=font)[2]
if width <= max_width:
current = proposal
else:
lines.append(current)
current = word
if len(lines) >= max_lines:
return None
lines.append(current)
return lines if len(lines) <= max_lines else None
def draw_placeholder_logo(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int]) -> None:
x0, y0, x1, y1 = box
cx = (x0 + x1) / 2
cy = (y0 + y1) / 2
width = x1 - x0
height = y1 - y0
outer = [cx - width * 0.42, cy - height * 0.22, cx + width * 0.42, cy + height * 0.22]
inner = [cx - width * 0.18, cy - height * 0.12, cx + width * 0.18, cy + height * 0.12]
pupil = [cx - width * 0.06, cy - height * 0.06, cx + width * 0.06, cy + height * 0.06]
draw.arc(outer, start=8, end=352, fill=LOGO_BLUE, width=max(2, int(height * 0.1)))
draw.arc(inner, start=200, end=20, fill=LOGO_BLUE, width=max(2, int(height * 0.08)))
draw.ellipse(pupil, fill=LOGO_BLUE)
def paste_logo(image: Image.Image, logo_path: Path, box: tuple[int, int, int, int]) -> None:
with Image.open(logo_path) as logo_src:
logo = logo_src.convert("RGBA")
logo = ImageOps.contain(logo, (box[2] - box[0], box[3] - box[1]))
offset_x = box[0] + ((box[2] - box[0]) - logo.width) // 2
offset_y = box[1] + ((box[3] - box[1]) - logo.height) // 2
image.alpha_composite(logo, (offset_x, offset_y))
def draw_photo_frame(
image: Image.Image,
photo_path: Path,
photo_box: tuple[int, int, int, int],
) -> None:
frame_pad = 10
matte_color = "#efe8da"
inner_border = "#dadde3"
draw = ImageDraw.Draw(image)
outer_box = (
photo_box[0] - frame_pad,
photo_box[1] - frame_pad,
photo_box[2] + frame_pad,
photo_box[3] + frame_pad,
)
draw.rectangle(outer_box, fill=matte_color)
with Image.open(photo_path) as src:
photo = ImageOps.fit(
ImageOps.exif_transpose(src).convert("RGB"),
(photo_box[2] - photo_box[0], photo_box[3] - photo_box[1]),
method=Image.Resampling.LANCZOS,
centering=(0.5, 0.2),
)
image.paste(photo, photo_box[:2])
draw.rectangle(photo_box, outline=inner_border, width=3)
def render_badge(employee: Employee, brand_text: str, logo_path: Path | None) -> Image.Image:
portrait_width = 700
portrait_height = 1012
image = Image.new("RGBA", (portrait_width, portrait_height), WHITE)
draw = ImageDraw.Draw(image)
radius = 42
black_top_height = int(portrait_height * 0.19)
blue_footer_height = int(portrait_height * 0.18)
name_band_height = int(portrait_height * 0.24)
name_overlap = int(name_band_height * 0.18)
photo_box = (
int(portrait_width * 0.19),
black_top_height + int(portrait_height * 0.08),
int(portrait_width * 0.81),
int(portrait_height * 0.64),
)
draw.rounded_rectangle((0, 0, portrait_width, portrait_height), radius=radius, fill=BLUE)
draw.rectangle((0, 0, portrait_width, black_top_height), fill=BLACK)
draw.polygon(
[
(0, portrait_height),
(0, int(portrait_height * 0.56)),
(int(portrait_width * 0.56), int(portrait_height * 0.56)),
(portrait_width, portrait_height),
],
fill="#229ad2",
)
draw.rectangle(
(
0,
photo_box[3] - name_overlap,
portrait_width,
photo_box[3] - name_overlap + name_band_height,
),
fill=BLACK,
)
draw_photo_frame(image, employee.photo_path, photo_box)
header_logo_box = (
int(portrait_width * 0.10),
int(black_top_height * 0.20),
int(portrait_width * 0.90),
int(black_top_height * 0.74),
)
if logo_path:
paste_logo(image, logo_path, header_logo_box)
else:
draw_placeholder_logo(draw, header_logo_box)
brand_box = (
int(portrait_width * 0.12),
int(black_top_height * 0.18),
int(portrait_width * 0.88),
int(black_top_height * 0.88),
)
if not logo_path:
brand_font, brand_lines = fit_text(
draw,
brand_text.upper(),
brand_box,
max_size=42,
min_size=16,
bold=False,
max_lines=2,
)
draw_multiline_centered(draw, brand_lines, brand_font, brand_box, WHITE, line_spacing=0.16)
name_box = (
int(portrait_width * 0.12),
photo_box[3] - name_overlap + int(name_band_height * 0.30),
int(portrait_width * 0.88),
photo_box[3] - name_overlap + name_band_height - int(name_band_height * 0.07),
)
name_font, name_lines = fit_name_text(draw, employee.name, name_box)
draw_multiline_centered(draw, name_lines, name_font, name_box, WHITE, line_spacing=0.04)
title_box = (
int(portrait_width * 0.07),
portrait_height - blue_footer_height + int(blue_footer_height * 0.03),
int(portrait_width * 0.93),
portrait_height - int(blue_footer_height * 0.10),
)
title_font, title_lines = fit_title_text(draw, employee.title, title_box)
draw_multiline_centered(draw, title_lines, title_font, title_box, BLACK, line_spacing=0.06)
return image.rotate(270, expand=True)
def draw_multiline_centered(
draw: ImageDraw.ImageDraw,
lines: list[str],
font: ImageFont.FreeTypeFont,
box: tuple[int, int, int, int],
fill: str,
*,
line_spacing: float,
) -> None:
x0, y0, x1, y1 = box
line_height = draw.textbbox((0, 0), "Ag", font=font)[3]
spacing_px = int(font.size * line_spacing)
total_height = len(lines) * line_height + max(0, len(lines) - 1) * spacing_px
current_y = y0 + ((y1 - y0) - total_height) / 2
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
width = bbox[2] - bbox[0]
current_x = x0 + ((x1 - x0) - width) / 2
draw.text((current_x, current_y), line, font=font, fill=fill)
current_y += line_height + spacing_px
def layout_positions(left_adjust_in: float, top_adjust_in: float) -> list[tuple[float, float]]:
label_width = LABEL_WIDTH_IN * INCH
label_height = LABEL_HEIGHT_IN * INCH
left_margin = (LEFT_MARGIN_IN + left_adjust_in) * INCH
top_margin = (TOP_MARGIN_IN + top_adjust_in) * INCH
horizontal_gap = HORIZONTAL_GAP_IN * INCH
vertical_gap = VERTICAL_GAP_IN * INCH
positions: list[tuple[float, float]] = []
for row in range(LABELS_DOWN):
for col in range(LABELS_ACROSS):
x = left_margin + col * (label_width + horizontal_gap)
y_top = top_margin + row * (label_height + vertical_gap)
y = PAGE_HEIGHT_PT - y_top - label_height
positions.append((x, y))
return positions
def write_pdf(
employees: list[Employee],
output_path: Path,
brand_text: str,
logo_path: Path | None,
left_adjust_in: float,
top_adjust_in: float,
) -> None:
register_reportlab_fonts()
output_path.parent.mkdir(parents=True, exist_ok=True)
pdf = canvas.Canvas(str(output_path), pagesize=letter)
positions = layout_positions(left_adjust_in, top_adjust_in)
label_width = LABEL_WIDTH_IN * INCH
label_height = LABEL_HEIGHT_IN * INCH
slot = 0
for employee in employees:
rendered = render_badge(employee, brand_text, logo_path).convert("RGB")
buffer = io.BytesIO()
rendered.save(buffer, format="PNG")
buffer.seek(0)
image_reader = ImageReader(buffer)
for _ in range(COPIES_PER_EMPLOYEE):
if slot and slot % LABELS_PER_SHEET == 0:
pdf.showPage()
x, y = positions[slot % LABELS_PER_SHEET]
pdf.drawImage(image_reader, x, y, width=label_width, height=label_height, preserveAspectRatio=False)
slot += 1
pdf.save()
def main() -> int:
args = parse_args()
try:
employees = read_employees(args.employees_csv, args.photos_dir)
logo_path = resolve_logo_path(args.logo_path)
if args.logo_path and not args.logo_path.exists():
raise FileNotFoundError(f"Missing logo file: {args.logo_path}")
write_pdf(
employees=employees,
output_path=args.output,
brand_text=args.brand_text,
logo_path=logo_path,
left_adjust_in=args.left_adjust,
top_adjust_in=args.top_adjust,
)
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
sheets = math.ceil(len(employees) / PAIRS_PER_SHEET)
print(f"Wrote {args.output} with {len(employees)} employees across {sheets} sheet(s).")
return 0
if __name__ == "__main__":
raise SystemExit(main())