Spaces:
Sleeping
Sleeping
| 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 = """ | |
| <style> | |
| .lp-scan-btn { | |
| display: inline-flex; align-items: center; gap: 8px; | |
| padding: 10px 18px; font-size: 13px; font-weight: 600; | |
| border: 2px solid #1e293b; border-radius: 8px; | |
| background: #1e293b; color: #f1f5f9; cursor: pointer; | |
| transition: all .18s; letter-spacing: .03em; font-family: inherit; | |
| } | |
| .lp-scan-btn:hover { background: #334155; border-color: #334155; } | |
| .lp-scan-btn.active { background: #dc2626; border-color: #dc2626; } | |
| #lp-overlay { | |
| display: none; position: fixed; inset: 0; | |
| background: rgba(0,0,0,.8); z-index: 99999; | |
| align-items: center; justify-content: center; | |
| } | |
| #lp-overlay.open { display: flex; } | |
| #lp-modal { | |
| background: #0f172a; border-radius: 20px; padding: 28px 24px 20px; | |
| width: min(400px, 94vw); display: flex; flex-direction: column; | |
| align-items: center; gap: 14px; | |
| box-shadow: 0 32px 80px rgba(0,0,0,.7); | |
| } | |
| #lp-modal-title { | |
| color: #e2e8f0; font-size: 13px; font-weight: 600; | |
| letter-spacing: .12em; text-transform: uppercase; margin: 0; | |
| } | |
| #lp-viewfinder { | |
| position: relative; width: 100%; max-width: 340px; | |
| border-radius: 12px; overflow: hidden; | |
| border: 2px solid #1e3a5f; background: #000; | |
| } | |
| #lp-video { width: 100%; display: block; aspect-ratio: 4/3; object-fit: cover; } | |
| /* scanning line animation */ | |
| #lp-scanline { | |
| position: absolute; left: 10%; right: 10%; height: 2px; | |
| background: #ef4444; box-shadow: 0 0 10px #ef4444, 0 0 20px #ef4444; | |
| animation: lpscan 1.8s ease-in-out infinite; top: 0; | |
| } | |
| @keyframes lpscan { | |
| 0% { top: 10%; opacity: .8; } | |
| 50% { top: 88%; opacity: 1; } | |
| 100% { top: 10%; opacity: .8; } | |
| } | |
| /* corner brackets */ | |
| #lp-viewfinder::before, #lp-viewfinder::after, | |
| #lp-corner-bl, #lp-corner-br { | |
| content: ''; position: absolute; width: 24px; height: 24px; | |
| border-color: #38bdf8; border-style: solid; z-index: 2; | |
| } | |
| #lp-viewfinder::before { top:8px; left:8px; border-width: 3px 0 0 3px; } | |
| #lp-viewfinder::after { top:8px; right:8px; border-width: 3px 3px 0 0; } | |
| #lp-corner-bl { bottom:8px; left:8px; border-width: 0 0 3px 3px; } | |
| #lp-corner-br { bottom:8px; right:8px; border-width: 0 3px 3px 0; } | |
| #lp-status { | |
| color: #94a3b8; font-size: 12px; min-height: 16px; | |
| text-align: center; letter-spacing: .03em; | |
| } | |
| #lp-status.ok { color: #4ade80; font-weight: 600; } | |
| #lp-status.err { color: #f87171; } | |
| .lp-close { | |
| padding: 7px 28px; border-radius: 6px; cursor: pointer; | |
| background: transparent; border: 1px solid #334155; | |
| color: #94a3b8; font-size: 12px; font-family: inherit; | |
| transition: all .18s; | |
| } | |
| .lp-close:hover { border-color: #ef4444; color: #ef4444; } | |
| </style> | |
| <button class="lp-scan-btn" id="lp-btn" onclick="lpToggle()"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"> | |
| <rect x="2" y="2" width="6" height="6" rx="1"/> | |
| <rect x="16" y="2" width="6" height="6" rx="1"/> | |
| <rect x="2" y="16" width="6" height="6" rx="1"/> | |
| <line x1="16" y1="16" x2="22" y2="16"/> | |
| <line x1="16" y1="16" x2="16" y2="22"/> | |
| <line x1="22" y1="22" x2="20" y2="22"/> | |
| <line x1="22" y1="20" x2="22" y2="22"/> | |
| </svg> | |
| Scan Barcode | |
| </button> | |
| <div id="lp-overlay"> | |
| <div id="lp-modal"> | |
| <p id="lp-modal-title">Point camera at barcode</p> | |
| <div id="lp-viewfinder"> | |
| <video id="lp-video" autoplay muted playsinline></video> | |
| <div id="lp-scanline"></div> | |
| <div id="lp-corner-bl"></div> | |
| <div id="lp-corner-br"></div> | |
| </div> | |
| <div id="lp-status">Starting cameraβ¦</div> | |
| <button class="lp-close" onclick="lpStop()">β Cancel</button> | |
| </div> | |
| </div> | |
| <script src="https://unpkg.com/@zxing/library@0.21.3/umd/index.min.js"></script> | |
| <script> | |
| (function () { | |
| var reader = null; | |
| function status(msg, cls) { | |
| var el = document.getElementById('lp-status'); | |
| el.textContent = msg; | |
| el.className = cls || ''; | |
| } | |
| function injectValue(code) { | |
| // Gradio 4 wraps the textbox in a div with elem_id="barcode_field" | |
| var wrapper = document.getElementById('barcode_field'); | |
| if (!wrapper) return; | |
| var input = wrapper.querySelector('input, textarea'); | |
| if (!input) return; | |
| var proto = Object.getOwnPropertyDescriptor( | |
| input.tagName === 'TEXTAREA' | |
| ? HTMLTextAreaElement.prototype | |
| : HTMLInputElement.prototype, 'value'); | |
| if (proto && proto.set) proto.set.call(input, code); | |
| input.dispatchEvent(new Event('input', { bubbles: true })); | |
| input.dispatchEvent(new Event('change', { bubbles: true })); | |
| } | |
| window.lpToggle = function () { | |
| var overlay = document.getElementById('lp-overlay'); | |
| if (overlay.classList.contains('open')) { lpStop(); return; } | |
| overlay.classList.add('open'); | |
| document.getElementById('lp-btn').classList.add('active'); | |
| lpStart(); | |
| }; | |
| window.lpStop = function () { | |
| if (reader) { try { reader.reset(); } catch(_){} reader = null; } | |
| var vid = document.getElementById('lp-video'); | |
| if (vid.srcObject) { vid.srcObject.getTracks().forEach(function(t){ t.stop(); }); vid.srcObject = null; } | |
| document.getElementById('lp-overlay').classList.remove('open'); | |
| document.getElementById('lp-btn').classList.remove('active'); | |
| status(''); | |
| }; | |
| async function lpStart() { | |
| status('Starting cameraβ¦'); | |
| try { | |
| var hints = new Map(); | |
| hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, [ | |
| ZXing.BarcodeFormat.EAN_13, | |
| ZXing.BarcodeFormat.CODE_128, | |
| ]); | |
| reader = new ZXing.BrowserMultiFormatReader(hints); | |
| var devices = await ZXing.BrowserCodeReader.listVideoInputDevices(); | |
| if (!devices.length) throw new Error('No camera found'); | |
| // Prefer back camera on mobile | |
| var dev = devices.find(function(d){ return /back|rear|environment/i.test(d.label); }) | |
| || devices[devices.length - 1]; | |
| status('Scanningβ¦'); | |
| await reader.decodeFromVideoDevice(dev.deviceId, 'lp-video', function(result, err) { | |
| if (result) { | |
| var code = result.getText(); | |
| status('β ' + code, 'ok'); | |
| injectValue(code); | |
| setTimeout(lpStop, 700); | |
| } | |
| }); | |
| } catch(e) { | |
| status('Error: ' + e.message, 'err'); | |
| } | |
| } | |
| })(); | |
| </script> | |
| """ | |
| # ββ 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("<small>Tip: you can edit or delete rows directly in the table.</small>") | |
| 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() | |