badge generator commit

This commit is contained in:
Jake Kasper
2026-04-28 14:44:08 -05:00
parent 92187765c2
commit a7a0f74af7
16 changed files with 743 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.venv/
__pycache__/
output/
*.pyc

BIN
Employee ID Badge_1.pdf Normal file

Binary file not shown.

View File

@@ -1,2 +1,82 @@
# badge-generator # 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.

648
badge_generator.py Normal file
View 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())

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
photos/BrandiLourdeau.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

BIN
photos/DrPuneetBraich.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
photos/DrShawnMallady.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

BIN
photos/JaceyPlace.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
photos/JimStockton.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

BIN
photos/MorganNolan.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
photos/SkylerBrewer.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
photos/XylaEnsign.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
Pillow>=11.0.0
reportlab>=4.0.0

View File

@@ -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
1 prefix name suffix title
2 Jacey Place Paraoptometric Technician
3 Xyla Ensign Paraoptometric Technician
4 Morgan Nolan Paraoptometric Technician
5 Skyler Brewer Paraoptometric Technician
6 Dr. Puneet Braich MD Ophthamologist
7 Dr. Shawn Mallady OD Owner/CEO
8 Jim Stockton Maintenance
9 Brandi Lourdeau RN, BSN Clinical Manager