badge generator commit
This commit is contained in:
648
badge_generator.py
Normal file
648
badge_generator.py
Normal file
@@ -0,0 +1,648 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user