rafaaa2105 commited on
Commit
8af2c67
Β·
verified Β·
1 Parent(s): 1ee4d1a

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +342 -0
  2. requirements.txt +3 -0
app.py ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import os
3
+ import pandas as pd
4
+ import gradio as gr
5
+ from reportlab.pdfgen import canvas
6
+ from reportlab.lib.units import mm
7
+ from reportlab.pdfbase import pdfmetrics
8
+ from reportlab.pdfbase.ttfonts import TTFont
9
+
10
+ # ── Font setup ────────────────────────────────────────────────────────────────
11
+ FONT_BOLD = "Helvetica-Bold"
12
+ _bold_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Montserrat-Bold.ttf")
13
+ if os.path.exists(_bold_path):
14
+ try:
15
+ pdfmetrics.registerFont(TTFont("Montserrat-Bold", _bold_path))
16
+ FONT_BOLD = "Montserrat-Bold"
17
+ except Exception:
18
+ pass
19
+
20
+ # ── PDF ───────────────────────────────────────────────────────────────────────
21
+ def build_cfg(pw, ph, lw, lh, ml, gh, vo, fb, fp, fbr, fr):
22
+ return dict(
23
+ PAGE_WIDTH=pw*mm, PAGE_HEIGHT=ph*mm,
24
+ LABEL_WIDTH=lw*mm, LABEL_HEIGHT=lh*mm,
25
+ MARGIN_LEFT=ml*mm, GAP_HORIZ=gh*mm, VERTICAL_OFFSET=vo*mm,
26
+ FONT_SIZE_BARCODE=fb, FONT_SIZE_PRICE=fp,
27
+ FONT_SIZE_BRAND=fbr, FONT_SIZE_REF=fr,
28
+ )
29
+
30
+ def generate_pdf_bytes(products: list, cfg: dict) -> io.BytesIO:
31
+ buf = io.BytesIO()
32
+ c = canvas.Canvas(buf, pagesize=(cfg["PAGE_WIDTH"], cfg["PAGE_HEIGHT"]))
33
+ for i, item in enumerate(products):
34
+ if i > 0 and i % 3 == 0:
35
+ c.showPage()
36
+ slot = i % 3
37
+ x = cfg["MARGIN_LEFT"] + slot * (cfg["LABEL_WIDTH"] + cfg["GAP_HORIZ"])
38
+ cx = x + cfg["LABEL_WIDTH"] / 2
39
+ y = cfg["VERTICAL_OFFSET"]
40
+ lh = cfg["LABEL_HEIGHT"]
41
+
42
+ c.setFont(FONT_BOLD, cfg["FONT_SIZE_BARCODE"])
43
+ c.drawCentredString(cx, y + lh - 4.5*mm, str(item.get("barcode", "")))
44
+ c.setFont(FONT_BOLD, cfg["FONT_SIZE_PRICE"])
45
+ c.drawCentredString(cx, y + lh - 13*mm, str(item.get("price", "")))
46
+ c.setFont(FONT_BOLD, cfg["FONT_SIZE_BRAND"])
47
+ c.drawCentredString(cx, y + 10*mm, str(item.get("brand", "")).upper())
48
+ c.setFont(FONT_BOLD, cfg["FONT_SIZE_REF"])
49
+ c.drawCentredString(cx, y + 6*mm, str(item.get("ref", "")))
50
+
51
+ c.save()
52
+ buf.seek(0)
53
+ return buf
54
+
55
+ # ── Camera barcode scanner ────────────────────────────────────────────────────
56
+ SCANNER_HTML = """
57
+ <style>
58
+ .lp-scan-btn {
59
+ display: inline-flex; align-items: center; gap: 8px;
60
+ padding: 10px 18px; font-size: 13px; font-weight: 600;
61
+ border: 2px solid #1e293b; border-radius: 8px;
62
+ background: #1e293b; color: #f1f5f9; cursor: pointer;
63
+ transition: all .18s; letter-spacing: .03em; font-family: inherit;
64
+ }
65
+ .lp-scan-btn:hover { background: #334155; border-color: #334155; }
66
+ .lp-scan-btn.active { background: #dc2626; border-color: #dc2626; }
67
+
68
+ #lp-overlay {
69
+ display: none; position: fixed; inset: 0;
70
+ background: rgba(0,0,0,.8); z-index: 99999;
71
+ align-items: center; justify-content: center;
72
+ }
73
+ #lp-overlay.open { display: flex; }
74
+
75
+ #lp-modal {
76
+ background: #0f172a; border-radius: 20px; padding: 28px 24px 20px;
77
+ width: min(400px, 94vw); display: flex; flex-direction: column;
78
+ align-items: center; gap: 14px;
79
+ box-shadow: 0 32px 80px rgba(0,0,0,.7);
80
+ }
81
+ #lp-modal-title {
82
+ color: #e2e8f0; font-size: 13px; font-weight: 600;
83
+ letter-spacing: .12em; text-transform: uppercase; margin: 0;
84
+ }
85
+
86
+ #lp-viewfinder {
87
+ position: relative; width: 100%; max-width: 340px;
88
+ border-radius: 12px; overflow: hidden;
89
+ border: 2px solid #1e3a5f; background: #000;
90
+ }
91
+ #lp-video { width: 100%; display: block; aspect-ratio: 4/3; object-fit: cover; }
92
+
93
+ /* scanning line animation */
94
+ #lp-scanline {
95
+ position: absolute; left: 10%; right: 10%; height: 2px;
96
+ background: #ef4444; box-shadow: 0 0 10px #ef4444, 0 0 20px #ef4444;
97
+ animation: lpscan 1.8s ease-in-out infinite; top: 0;
98
+ }
99
+ @keyframes lpscan {
100
+ 0% { top: 10%; opacity: .8; }
101
+ 50% { top: 88%; opacity: 1; }
102
+ 100% { top: 10%; opacity: .8; }
103
+ }
104
+
105
+ /* corner brackets */
106
+ #lp-viewfinder::before, #lp-viewfinder::after,
107
+ #lp-corner-bl, #lp-corner-br {
108
+ content: ''; position: absolute; width: 24px; height: 24px;
109
+ border-color: #38bdf8; border-style: solid; z-index: 2;
110
+ }
111
+ #lp-viewfinder::before { top:8px; left:8px; border-width: 3px 0 0 3px; }
112
+ #lp-viewfinder::after { top:8px; right:8px; border-width: 3px 3px 0 0; }
113
+ #lp-corner-bl { bottom:8px; left:8px; border-width: 0 0 3px 3px; }
114
+ #lp-corner-br { bottom:8px; right:8px; border-width: 0 3px 3px 0; }
115
+
116
+ #lp-status {
117
+ color: #94a3b8; font-size: 12px; min-height: 16px;
118
+ text-align: center; letter-spacing: .03em;
119
+ }
120
+ #lp-status.ok { color: #4ade80; font-weight: 600; }
121
+ #lp-status.err { color: #f87171; }
122
+
123
+ .lp-close {
124
+ padding: 7px 28px; border-radius: 6px; cursor: pointer;
125
+ background: transparent; border: 1px solid #334155;
126
+ color: #94a3b8; font-size: 12px; font-family: inherit;
127
+ transition: all .18s;
128
+ }
129
+ .lp-close:hover { border-color: #ef4444; color: #ef4444; }
130
+ </style>
131
+
132
+ <button class="lp-scan-btn" id="lp-btn" onclick="lpToggle()">
133
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
134
+ <rect x="2" y="2" width="6" height="6" rx="1"/>
135
+ <rect x="16" y="2" width="6" height="6" rx="1"/>
136
+ <rect x="2" y="16" width="6" height="6" rx="1"/>
137
+ <line x1="16" y1="16" x2="22" y2="16"/>
138
+ <line x1="16" y1="16" x2="16" y2="22"/>
139
+ <line x1="22" y1="22" x2="20" y2="22"/>
140
+ <line x1="22" y1="20" x2="22" y2="22"/>
141
+ </svg>
142
+ Scan Barcode
143
+ </button>
144
+
145
+ <div id="lp-overlay">
146
+ <div id="lp-modal">
147
+ <p id="lp-modal-title">Point camera at barcode</p>
148
+ <div id="lp-viewfinder">
149
+ <video id="lp-video" autoplay muted playsinline></video>
150
+ <div id="lp-scanline"></div>
151
+ <div id="lp-corner-bl"></div>
152
+ <div id="lp-corner-br"></div>
153
+ </div>
154
+ <div id="lp-status">Starting camera…</div>
155
+ <button class="lp-close" onclick="lpStop()">βœ• Cancel</button>
156
+ </div>
157
+ </div>
158
+
159
+ <script src="https://unpkg.com/@zxing/library@0.21.3/umd/index.min.js"></script>
160
+ <script>
161
+ (function () {
162
+ var reader = null;
163
+
164
+ function status(msg, cls) {
165
+ var el = document.getElementById('lp-status');
166
+ el.textContent = msg;
167
+ el.className = cls || '';
168
+ }
169
+
170
+ function injectValue(code) {
171
+ // Gradio 4 wraps the textbox in a div with elem_id="barcode_field"
172
+ var wrapper = document.getElementById('barcode_field');
173
+ if (!wrapper) return;
174
+ var input = wrapper.querySelector('input, textarea');
175
+ if (!input) return;
176
+ var proto = Object.getOwnPropertyDescriptor(
177
+ input.tagName === 'TEXTAREA'
178
+ ? HTMLTextAreaElement.prototype
179
+ : HTMLInputElement.prototype, 'value');
180
+ if (proto && proto.set) proto.set.call(input, code);
181
+ input.dispatchEvent(new Event('input', { bubbles: true }));
182
+ input.dispatchEvent(new Event('change', { bubbles: true }));
183
+ }
184
+
185
+ window.lpToggle = function () {
186
+ var overlay = document.getElementById('lp-overlay');
187
+ if (overlay.classList.contains('open')) { lpStop(); return; }
188
+ overlay.classList.add('open');
189
+ document.getElementById('lp-btn').classList.add('active');
190
+ lpStart();
191
+ };
192
+
193
+ window.lpStop = function () {
194
+ if (reader) { try { reader.reset(); } catch(_){} reader = null; }
195
+ var vid = document.getElementById('lp-video');
196
+ if (vid.srcObject) { vid.srcObject.getTracks().forEach(function(t){ t.stop(); }); vid.srcObject = null; }
197
+ document.getElementById('lp-overlay').classList.remove('open');
198
+ document.getElementById('lp-btn').classList.remove('active');
199
+ status('');
200
+ };
201
+
202
+ async function lpStart() {
203
+ status('Starting camera…');
204
+ try {
205
+ var hints = new Map();
206
+ hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, [
207
+ ZXing.BarcodeFormat.EAN_13,
208
+ ZXing.BarcodeFormat.CODE_128,
209
+ ]);
210
+ reader = new ZXing.BrowserMultiFormatReader(hints);
211
+ var devices = await ZXing.BrowserCodeReader.listVideoInputDevices();
212
+ if (!devices.length) throw new Error('No camera found');
213
+ // Prefer back camera on mobile
214
+ var dev = devices.find(function(d){ return /back|rear|environment/i.test(d.label); })
215
+ || devices[devices.length - 1];
216
+ status('Scanning…');
217
+ await reader.decodeFromVideoDevice(dev.deviceId, 'lp-video', function(result, err) {
218
+ if (result) {
219
+ var code = result.getText();
220
+ status('βœ“ ' + code, 'ok');
221
+ injectValue(code);
222
+ setTimeout(lpStop, 700);
223
+ }
224
+ });
225
+ } catch(e) {
226
+ status('Error: ' + e.message, 'err');
227
+ }
228
+ }
229
+ })();
230
+ </script>
231
+ """
232
+
233
+ # ── State helpers ─────────────────────────────────────────────────────────────
234
+ COLS = ["barcode", "price", "brand", "ref"]
235
+ EMPTY_DF = pd.DataFrame(columns=COLS)
236
+
237
+ def df_from(products):
238
+ return pd.DataFrame(products, columns=COLS) if products else EMPTY_DF.copy()
239
+
240
+ def add_product(products, barcode, price, brand, ref):
241
+ if not (barcode or "").strip():
242
+ raise gr.Error("Barcode is required.")
243
+ products = list(products) + [{
244
+ "barcode": barcode.strip(), "price": (price or "").strip(),
245
+ "brand": (brand or "").strip(), "ref": (ref or "").strip(),
246
+ }]
247
+ return products, df_from(products), "", "", "", ""
248
+
249
+ def remove_last(products):
250
+ products = list(products)[:-1]
251
+ return products, df_from(products)
252
+
253
+ def clear_all(_):
254
+ return [], EMPTY_DF.copy()
255
+
256
+ def sync_df(df):
257
+ if df is None or df.empty:
258
+ return []
259
+ return df.fillna("").to_dict("records")
260
+
261
+ def generate_pdf(products, *cfg_vals):
262
+ if not products:
263
+ raise gr.Error("Add at least one product first.")
264
+ cfg = build_cfg(*cfg_vals)
265
+ buf = generate_pdf_bytes(products, cfg)
266
+ path = "/tmp/labels.pdf"
267
+ with open(path, "wb") as f:
268
+ f.write(buf.read())
269
+ return path
270
+
271
+ # ── UI ────────────────────────────────────────────────────────────────────────
272
+ with gr.Blocks(title="Label Printer", theme=gr.themes.Soft()) as demo:
273
+ state = gr.State([])
274
+
275
+ gr.Markdown("# 🏷️ Label Printer")
276
+ gr.Markdown("Add products using the form or camera scanner, then export a print-ready PDF (3-up labels, 110 Γ— 30 mm).")
277
+
278
+ with gr.Row(equal_height=False):
279
+
280
+ with gr.Column(scale=1, min_width=280):
281
+ gr.Markdown("### βž• Add product")
282
+ gr.HTML(SCANNER_HTML)
283
+ barcode_in = gr.Textbox(label="Barcode", placeholder="Scan or type…", elem_id="barcode_field")
284
+ price_in = gr.Textbox(label="Price", placeholder="29.99")
285
+ brand_in = gr.Textbox(label="Brand", placeholder="ACME")
286
+ ref_in = gr.Textbox(label="Reference", placeholder="REF-001")
287
+ with gr.Row():
288
+ add_btn = gr.Button("βž• Add", variant="primary")
289
+ undo_btn = gr.Button("↩ Undo last", variant="secondary")
290
+ clr_btn = gr.Button("πŸ—‘ Clear", variant="stop")
291
+
292
+ with gr.Column(scale=2):
293
+ gr.Markdown("### πŸ“‹ Product list")
294
+ tbl = gr.Dataframe(
295
+ value=EMPTY_DF.copy(),
296
+ headers=COLS,
297
+ datatype=["str"] * 4,
298
+ col_count=(4, "fixed"),
299
+ interactive=True,
300
+ wrap=True,
301
+ label=None,
302
+ )
303
+ gr.Markdown("<small>Tip: you can edit or delete rows directly in the table.</small>")
304
+
305
+ with gr.Accordion("βš™οΈ Label layout", open=False):
306
+ with gr.Row():
307
+ pw = gr.Number(value=110.6, label="Page width (mm)", precision=1)
308
+ ph = gr.Number(value=30, label="Page height (mm)", precision=1)
309
+ lw = gr.Number(value=27, label="Label width (mm)", precision=1)
310
+ lh = gr.Number(value=28, label="Label height (mm)", precision=1)
311
+ with gr.Row():
312
+ ml = gr.Number(value=6.0, label="Left margin (mm)", precision=1)
313
+ gh = gr.Number(value=8.0, label="Horiz gap (mm)", precision=1)
314
+ vo = gr.Number(value=1.5, label="Vert offset (mm)", precision=1)
315
+ with gr.Row():
316
+ fb = gr.Number(value=8, label="Font: barcode", precision=0)
317
+ fp = gr.Number(value=16, label="Font: price", precision=0)
318
+ fbr = gr.Number(value=8, label="Font: brand", precision=0)
319
+ fr = gr.Number(value=11, label="Font: ref", precision=0)
320
+
321
+ cfg_inputs = [pw, ph, lw, lh, ml, gh, vo, fb, fp, fbr, fr]
322
+
323
+ with gr.Row():
324
+ gen_btn = gr.Button("πŸ–¨οΈ Generate PDF", variant="primary", scale=1)
325
+ pdf_out = gr.File(label="Download PDF", scale=2)
326
+
327
+ # ── Wiring ────────────────────────────────────────────────────────────────
328
+ shared = dict(
329
+ inputs=[state, barcode_in, price_in, brand_in, ref_in],
330
+ outputs=[state, tbl, barcode_in, price_in, brand_in, ref_in],
331
+ )
332
+ add_btn.click(fn=add_product, **shared)
333
+ ref_in.submit(fn=add_product, **shared) # Enter on last field adds row
334
+
335
+ undo_btn.click(fn=remove_last, inputs=[state], outputs=[state, tbl])
336
+ clr_btn.click( fn=clear_all, inputs=[state], outputs=[state, tbl])
337
+ tbl.change( fn=sync_df, inputs=[tbl], outputs=[state])
338
+
339
+ gen_btn.click(fn=generate_pdf, inputs=[state] + cfg_inputs, outputs=[pdf_out])
340
+
341
+ if __name__ == "__main__":
342
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio>=4.0.0
2
+ reportlab>=4.0.0
3
+ pandas>=2.0.0