#!/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())