diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..4f645db Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..071afc8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +output/ +*.pyc diff --git a/Employee ID Badge_1.pdf b/Employee ID Badge_1.pdf new file mode 100644 index 0000000..83d6a38 Binary files /dev/null and b/Employee ID Badge_1.pdf differ diff --git a/README.md b/README.md index 4ea6b21..0951c11 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,82 @@ # badge-generator +Bulk-generates print-ready employee badge PDFs laid out for Avery `8395` adhesive name badges. + +The generator is built around the sample badge you provided: +- employee photo cropped to fill the center frame without stretching +- auto-sized employee name in the black band +- auto-sized job title in the blue band, wrapped to two lines if needed +- two identical labels per employee, kept on the same 8-up sheet + +## Avery 8395 layout + +This project uses the standard 8-up Avery 8395 / 5395-compatible layout on US Letter: +- label size: `3.375" x 2.333"` +- sheet layout: `2 across x 4 down` +- default margins: about `0.688"` left/right and `0.594"` top/bottom +- default gaps: about `0.375"` horizontal and `0.187"` vertical + +Those dimensions match common Avery-compatible template specs. Printers still vary, so the CLI includes `--left-adjust` and `--top-adjust` for calibration. + +## Inputs + +Photos go in a directory and should be named as: +- `firstnamelastname.png` +- `firstnamelastname.jpg` + +Examples: +- `jaceyplace.jpg` +- `xylaensign.png` + +The CSV must include: +- `title` +- either `name` or both `first_name` and `last_name` + +Optional CSV columns: +- `prefix` for text before the name, like `Dr.` +- `suffix` for credentials after the name, like `MD` or `RN, BSN` + +The easiest setup is: +- keep the photo filenames based on the actual person name only, like `puneetbraich.jpg` +- put `Dr.` / `MD` / `RN, BSN` in separate CSV columns so badge text changes do not affect photo matching + +Example: + +```csv +prefix,name,suffix,title +,Jacey Place,,Paraoptometric Technician +Dr.,Puneet Braich,MD,Ophthalmologist +,Brandi Lourdeau,"RN, BSN",Clinical Manager +``` + +## Install + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +## Usage + +```bash +python3 badge_generator.py sample-data/employees.csv photos -o output/badges.pdf +``` + +Optional branding and print calibration: + +```bash +python3 badge_generator.py employees.csv photos \ + -o output/badges.pdf \ + --brand-text "CHITTICK\nEYE CARE" \ + --logo-path assets/logo.png \ + --left-adjust 0.02 \ + --top-adjust -0.01 +``` + +## Output behavior + +- Each employee gets exactly `2` labels. +- The two labels are placed consecutively, so both copies stay on the same sheet. +- Each sheet holds `8` labels total, which means `4 employees per sheet`. +- The output is a print-ready multi-page PDF. diff --git a/badge_generator.py b/badge_generator.py new file mode 100644 index 0000000..aab104b --- /dev/null +++ b/badge_generator.py @@ -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()) diff --git a/logo/cec logo horiz rgb white only 400x103.png b/logo/cec logo horiz rgb white only 400x103.png new file mode 100644 index 0000000..d9edffe Binary files /dev/null and b/logo/cec logo horiz rgb white only 400x103.png differ diff --git a/photos/BrandiLourdeau.png b/photos/BrandiLourdeau.png new file mode 100644 index 0000000..3b6e322 Binary files /dev/null and b/photos/BrandiLourdeau.png differ diff --git a/photos/DrPuneetBraich.png b/photos/DrPuneetBraich.png new file mode 100644 index 0000000..773d95d Binary files /dev/null and b/photos/DrPuneetBraich.png differ diff --git a/photos/DrShawnMallady.png b/photos/DrShawnMallady.png new file mode 100644 index 0000000..844ef98 Binary files /dev/null and b/photos/DrShawnMallady.png differ diff --git a/photos/JaceyPlace.jpeg b/photos/JaceyPlace.jpeg new file mode 100644 index 0000000..312ac1d Binary files /dev/null and b/photos/JaceyPlace.jpeg differ diff --git a/photos/JimStockton.jpeg b/photos/JimStockton.jpeg new file mode 100644 index 0000000..f799357 Binary files /dev/null and b/photos/JimStockton.jpeg differ diff --git a/photos/MorganNolan.jpeg b/photos/MorganNolan.jpeg new file mode 100644 index 0000000..85b9062 Binary files /dev/null and b/photos/MorganNolan.jpeg differ diff --git a/photos/SkylerBrewer.jpeg b/photos/SkylerBrewer.jpeg new file mode 100644 index 0000000..f69f2b1 Binary files /dev/null and b/photos/SkylerBrewer.jpeg differ diff --git a/photos/XylaEnsign.jpeg b/photos/XylaEnsign.jpeg new file mode 100644 index 0000000..7049423 Binary files /dev/null and b/photos/XylaEnsign.jpeg differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2a280d0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Pillow>=11.0.0 +reportlab>=4.0.0 diff --git a/sample-data/employees.csv b/sample-data/employees.csv new file mode 100644 index 0000000..e358400 --- /dev/null +++ b/sample-data/employees.csv @@ -0,0 +1,9 @@ +prefix,name,suffix,title +,Jacey Place,,Paraoptometric Technician +,Xyla Ensign,,Paraoptometric Technician +,Morgan Nolan,,Paraoptometric Technician +,Skyler Brewer,,Paraoptometric Technician +Dr.,Puneet Braich,MD,Ophthamologist +Dr.,Shawn Mallady,OD,Owner/CEO +,Jim Stockton,,Maintenance +,Brandi Lourdeau,"RN, BSN",Clinical Manager