import io
import os
import pandas as pd
import gradio as gr
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
# ── Font setup ────────────────────────────────────────────────────────────────
FONT_BOLD = "Helvetica-Bold"
_bold_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Montserrat-Bold.ttf")
if os.path.exists(_bold_path):
try:
pdfmetrics.registerFont(TTFont("Montserrat-Bold", _bold_path))
FONT_BOLD = "Montserrat-Bold"
except Exception:
pass
# ── PDF ───────────────────────────────────────────────────────────────────────
def build_cfg(pw, ph, lw, lh, ml, gh, vo, fb, fp, fbr, fr):
return dict(
PAGE_WIDTH=pw*mm, PAGE_HEIGHT=ph*mm,
LABEL_WIDTH=lw*mm, LABEL_HEIGHT=lh*mm,
MARGIN_LEFT=ml*mm, GAP_HORIZ=gh*mm, VERTICAL_OFFSET=vo*mm,
FONT_SIZE_BARCODE=fb, FONT_SIZE_PRICE=fp,
FONT_SIZE_BRAND=fbr, FONT_SIZE_REF=fr,
)
def generate_pdf_bytes(products: list, cfg: dict) -> io.BytesIO:
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=(cfg["PAGE_WIDTH"], cfg["PAGE_HEIGHT"]))
for i, item in enumerate(products):
if i > 0 and i % 3 == 0:
c.showPage()
slot = i % 3
x = cfg["MARGIN_LEFT"] + slot * (cfg["LABEL_WIDTH"] + cfg["GAP_HORIZ"])
cx = x + cfg["LABEL_WIDTH"] / 2
y = cfg["VERTICAL_OFFSET"]
lh = cfg["LABEL_HEIGHT"]
c.setFont(FONT_BOLD, cfg["FONT_SIZE_BARCODE"])
c.drawCentredString(cx, y + lh - 4.5*mm, str(item.get("barcode", "")))
c.setFont(FONT_BOLD, cfg["FONT_SIZE_PRICE"])
c.drawCentredString(cx, y + lh - 13*mm, str(item.get("price", "")))
c.setFont(FONT_BOLD, cfg["FONT_SIZE_BRAND"])
c.drawCentredString(cx, y + 10*mm, str(item.get("brand", "")).upper())
c.setFont(FONT_BOLD, cfg["FONT_SIZE_REF"])
c.drawCentredString(cx, y + 6*mm, str(item.get("ref", "")))
c.save()
buf.seek(0)
return buf
# ── Camera barcode scanner ────────────────────────────────────────────────────
SCANNER_HTML = """
Point camera at barcode
Starting camera…
"""
# ── State helpers ─────────────────────────────────────────────────────────────
COLS = ["barcode", "price", "brand", "ref"]
EMPTY_DF = pd.DataFrame(columns=COLS)
def df_from(products):
return pd.DataFrame(products, columns=COLS) if products else EMPTY_DF.copy()
def add_product(products, barcode, price, brand, ref):
if not (barcode or "").strip():
raise gr.Error("Barcode is required.")
products = list(products) + [{
"barcode": barcode.strip(), "price": (price or "").strip(),
"brand": (brand or "").strip(), "ref": (ref or "").strip(),
}]
return products, df_from(products), "", "", "", ""
def remove_last(products):
products = list(products)[:-1]
return products, df_from(products)
def clear_all(_):
return [], EMPTY_DF.copy()
def sync_df(df):
if df is None or df.empty:
return []
return df.fillna("").to_dict("records")
def generate_pdf(products, *cfg_vals):
if not products:
raise gr.Error("Add at least one product first.")
cfg = build_cfg(*cfg_vals)
buf = generate_pdf_bytes(products, cfg)
path = "/tmp/labels.pdf"
with open(path, "wb") as f:
f.write(buf.read())
return path
# ── UI ────────────────────────────────────────────────────────────────────────
with gr.Blocks(title="Label Printer", theme=gr.themes.Soft()) as demo:
state = gr.State([])
gr.Markdown("# 🏷️ Label Printer")
gr.Markdown("Add products using the form or camera scanner, then export a print-ready PDF (3-up labels, 110 × 30 mm).")
with gr.Row(equal_height=False):
with gr.Column(scale=1, min_width=280):
gr.Markdown("### ➕ Add product")
gr.HTML(SCANNER_HTML)
barcode_in = gr.Textbox(label="Barcode", placeholder="Scan or type…", elem_id="barcode_field")
price_in = gr.Textbox(label="Price", placeholder="29.99")
brand_in = gr.Textbox(label="Brand", placeholder="ACME")
ref_in = gr.Textbox(label="Reference", placeholder="REF-001")
with gr.Row():
add_btn = gr.Button("➕ Add", variant="primary")
undo_btn = gr.Button("↩ Undo last", variant="secondary")
clr_btn = gr.Button("🗑 Clear", variant="stop")
with gr.Column(scale=2):
gr.Markdown("### 📋 Product list")
tbl = gr.Dataframe(
value=EMPTY_DF.copy(),
headers=COLS,
datatype=["str"] * 4,
col_count=(4, "fixed"),
interactive=True,
wrap=True,
label=None,
)
gr.Markdown("Tip: you can edit or delete rows directly in the table.")
with gr.Accordion("⚙️ Label layout", open=False):
with gr.Row():
pw = gr.Number(value=110.6, label="Page width (mm)", precision=1)
ph = gr.Number(value=30, label="Page height (mm)", precision=1)
lw = gr.Number(value=27, label="Label width (mm)", precision=1)
lh = gr.Number(value=28, label="Label height (mm)", precision=1)
with gr.Row():
ml = gr.Number(value=6.0, label="Left margin (mm)", precision=1)
gh = gr.Number(value=8.0, label="Horiz gap (mm)", precision=1)
vo = gr.Number(value=1.5, label="Vert offset (mm)", precision=1)
with gr.Row():
fb = gr.Number(value=8, label="Font: barcode", precision=0)
fp = gr.Number(value=16, label="Font: price", precision=0)
fbr = gr.Number(value=8, label="Font: brand", precision=0)
fr = gr.Number(value=11, label="Font: ref", precision=0)
cfg_inputs = [pw, ph, lw, lh, ml, gh, vo, fb, fp, fbr, fr]
with gr.Row():
gen_btn = gr.Button("🖨️ Generate PDF", variant="primary", scale=1)
pdf_out = gr.File(label="Download PDF", scale=2)
# ── Wiring ────────────────────────────────────────────────────────────────
shared = dict(
inputs=[state, barcode_in, price_in, brand_in, ref_in],
outputs=[state, tbl, barcode_in, price_in, brand_in, ref_in],
)
add_btn.click(fn=add_product, **shared)
ref_in.submit(fn=add_product, **shared) # Enter on last field adds row
undo_btn.click(fn=remove_last, inputs=[state], outputs=[state, tbl])
clr_btn.click( fn=clear_all, inputs=[state], outputs=[state, tbl])
tbl.change( fn=sync_df, inputs=[tbl], outputs=[state])
gen_btn.click(fn=generate_pdf, inputs=[state] + cfg_inputs, outputs=[pdf_out])
if __name__ == "__main__":
demo.launch()