import os import re import tempfile from pathlib import Path import gradio as gr import pandas as pd from pypdf import PdfReader from parsers.systemax import parse as parse_systemax from parsers.home_depot import parse as parse_home_depot from parsers.staples_com import parse as parse_staples_com from parsers.staples_advantage import parse as parse_staples_advantage from parsers.walmart import parse as parse_walmart from parsers.lowes import parse as parse_lowes from parsers.quill import parse as parse_quill from parsers.msc_industrial import parse as parse_msc_industrial from parsers.lowes_rona import parse as parse_lowes_rona from parsers.united_stationers import parse as parse_united_stationers from parsers.bh_photo_video import parse as parse_bh_photo_video HEADERS = ["Trading Partner", "PO Number", "Ship Via", "Order Date", "Source PDF"] def normalize(text): text = text.replace("\x00", " ") text = text.replace("\r", "\n") text = re.sub(r"[ \t]+", " ", text) text = re.sub(r"\n[ \t]+", "\n", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def read_pdf(path): reader = PdfReader(path) pages = [] for page in reader.pages: try: pages.append(page.extract_text() or "") except Exception: pages.append("") return "\n\n".join(pages) def identify_vendor(filename): n = re.sub(r"[^a-z0-9]", "", Path(filename).stem.lower()) # More specific names must be checked before generic substrings # (e.g. "lowesrona" before "lowes"). if "systemax" in n: return "SYSTEMAX" if "homedepot" in n: return "HOME DEPOT" if "staplesadvantage" in n: return "STAPLES ADVANTAGE" if "staplescom" in n: return "STAPLES.COM" if "walmartcomdirecttocustomer" in n or "walmart" in n: return "WALMART" if "lowesrona" in n or "ronaps" in n or ("rona" in n and "lowes" in n): return "LOWE'S / RONA" if "lowessos" in n or "lowes" in n: return "LOWE'S" if "quillcom" in n or "quill" in n: return "QUILL" if "mscindustrial" in n or n.startswith("msc"): return "MSC INDUSTRIAL SUPPLY" if "unitedstationers" in n or "essendant" in n: return "UNITED STATIONERS" if "bandhphotovideo" in n or "bhphotovideo" in n: return "B&H PHOTO-VIDEO" return "UNKNOWN" PARSERS = { "SYSTEMAX": parse_systemax, "HOME DEPOT": parse_home_depot, "STAPLES.COM": parse_staples_com, "STAPLES ADVANTAGE": parse_staples_advantage, "WALMART": parse_walmart, "LOWE'S": parse_lowes, "QUILL": parse_quill, "MSC INDUSTRIAL SUPPLY": parse_msc_industrial, "LOWE'S / RONA": parse_lowes_rona, "UNITED STATIONERS": parse_united_stationers, "B&H PHOTO-VIDEO": parse_bh_photo_video, } def process_one(path): filename = Path(path).name vendor = identify_vendor(filename) if vendor == "UNKNOWN": return [], f"⚠️ UNKNOWN VENDOR: {filename}" raw = read_pdf(path) text = normalize(raw) if not text: return [], f"⚠️ NO TEXT EXTRACTED: {filename}" rows = PARSERS[vendor](text) clean = [] for row in rows: row = { "Trading Partner": vendor, "PO Number": str(row.get("PO Number", "")).strip(), "Ship Via": str(row.get("Ship Via", "")).strip(), "Order Date": str(row.get("Order Date", "")).strip(), "Source PDF": filename, } # Never allow obvious non-PO text into the PO column. po = row["PO Number"] if po and ( len(po) > 40 or "direct inquiries" in po.lower() or "customer service" in po.lower() or po.lower() in { "services", "service", "customer", "sales", "date", "location", "salesperson", "release", "order", } ): row["PO Number"] = "" # Reject dates mistakenly captured as Ship Via. ship = row["Ship Via"] if ship and re.fullmatch(r"\d{1,2}/\d{1,2}/\d{2,4}", ship): if not row["Order Date"]: row["Order Date"] = ship row["Ship Via"] = "" if any(row[h] for h in HEADERS[:-1]): clean.append(row) return clean, f"✅ {vendor}: {len(clean)} record(s)" def process_files(files): if not files: return "Please upload one or more PDF files.", None all_rows = [] messages = [] for f in files: path = getattr(f, "name", f) try: rows, message = process_one(path) all_rows.extend(rows) messages.append(message) except Exception as e: messages.append(f"❌ {Path(path).name}: {type(e).__name__}: {e}") df = pd.DataFrame(all_rows, columns=HEADERS) # Keep leading zeros by storing PO values as strings. if not df.empty: df["PO Number"] = df["PO Number"].astype(str) tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") tmp.close() df.to_excel(tmp.name, index=False, engine="openpyxl") summary = "\n".join(messages) summary += f"\n\nTOTAL RECORDS: {len(df)}" return summary, tmp.name THEME = gr.themes.Soft( primary_hue="rose", secondary_hue="teal", neutral_hue="stone", font=[gr.themes.GoogleFont("Nunito"), "ui-sans-serif", "system-ui", "sans-serif"], font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace"], ).set( body_background_fill="#f7f3ef", body_background_fill_dark="#f7f3ef", block_background_fill="#ffffff", block_border_width="0px", block_radius="18px", block_shadow="0 8px 28px rgba(120, 90, 90, 0.08)", button_primary_background_fill="#e86b7a", button_primary_background_fill_hover="#d95a6a", button_primary_text_color="#ffffff", button_large_radius="14px", input_radius="14px", ) APP_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@500;600;700;800&display=swap'); .gradio-container { max-width: 1100px !important; margin: 0 auto !important; padding: 0.85rem 1rem 1rem !important; font-family: 'Nunito', ui-sans-serif, system-ui, sans-serif !important; } .main-wrap { display: flex; flex-direction: column; gap: 0.7rem !important; } .hero-card { background: linear-gradient(135deg, #fff7f5 0%, #ffffff 55%, #f3fbf8 100%); border: 1px solid rgba(232, 107, 122, 0.18); border-radius: 18px; padding: 0.75rem 1.1rem !important; box-shadow: 0 10px 30px rgba(180, 110, 110, 0.08); } .hero-card h1 { font-size: 1.4rem !important; font-weight: 800 !important; letter-spacing: -0.02em; color: #3d2c2e !important; margin: 0 !important; } .hero-card p { display: none !important; } .panel { background: #ffffff; border-radius: 16px; padding: 0.65rem 0.75rem 0.55rem !important; box-shadow: 0 8px 24px rgba(120, 90, 90, 0.07); border: 1px solid rgba(80, 60, 60, 0.05); } .panel .label-wrap span, .panel label span { font-weight: 700 !important; color: #5c4548 !important; } button.primary, .primary { min-height: 42px !important; font-weight: 800 !important; letter-spacing: 0.02em !important; box-shadow: 0 8px 18px rgba(232, 107, 122, 0.28) !important; } /* Force true 50/50 side-by-side layout */ #main-split { display: grid !important; grid-template-columns: 1fr 1fr !important; gap: 0.75rem !important; align-items: stretch !important; } #main-split > div { min-width: 0 !important; width: 100% !important; max-width: none !important; flex: unset !important; } #upload-col { height: 100% !important; } #upload-col .wrap, #upload-col [data-testid="file"] { height: 100% !important; } #upload-col .file-preview-holder, #upload-col .file-preview, #upload-col ul, #upload-col .scroll-hide { max-height: 260px !important; overflow-y: auto !important; } #side-col { display: flex !important; flex-direction: column !important; gap: 0.7rem !important; height: 100% !important; min-height: 340px !important; } #side-col > div { flex: 1 1 0 !important; min-height: 160px !important; } #status-box textarea { min-height: 140px !important; height: 140px !important; max-height: 180px !important; overflow-y: auto !important; resize: none !important; } #download-box, #download-box .wrap, #download-box [data-testid="file"] { min-height: 140px !important; height: 140px !important; } @media (max-width: 760px) { #main-split { grid-template-columns: 1fr !important; } } footer { display: none !important; } """ with gr.Blocks(title="Packing List PDF Extractor") as demo: with gr.Column(elem_classes=["main-wrap"]): gr.Markdown("# Packing List PDF Extractor", elem_classes=["hero-card"]) process = gr.Button("Process PDFs", variant="primary", size="lg") with gr.Row(elem_id="main-split"): with gr.Column(elem_id="upload-col", elem_classes=["panel"]): files = gr.File( label="Upload PDFs", file_count="multiple", file_types=[".pdf"], type="filepath", height=340, ) with gr.Column(elem_id="side-col"): with gr.Column(elem_classes=["panel"]): status = gr.Textbox( label="Status", lines=7, max_lines=9, elem_id="status-box", ) with gr.Column(elem_classes=["panel"]): download = gr.File( label="Excel download", height=140, elem_id="download-box", ) clear = gr.Button("Clear form", variant="secondary", size="lg") def clear_form(): return None, "", None process.click( fn=process_files, inputs=files, outputs=[status, download], ) clear.click( fn=clear_form, inputs=None, outputs=[files, status, download], ) if __name__ == "__main__": # HF Spaces needs 0.0.0.0; locally bind to localhost so the URL is usable. on_spaces = bool(os.getenv("SPACE_ID") or os.getenv("SYSTEM") == "spaces") demo.launch( server_name="0.0.0.0" if on_spaces else "127.0.0.1", server_port=7860, ssr_mode=False, theme=THEME, css=APP_CSS, )