commit 84e3b20f96ec96d6dd24290c606964e509b283d3
author: arianit <arianitkukaj@gmail.com>
date: 2026-04-16 22:56
parents: (none)
PDF text extraction to HTML.
diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fa9e51 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# pdf_to_html.py +### PDF Invoice → HTML Table Converter + +--- + +## What Is It? + +`pdf_to_html.py` is a command-line Python script that reads an invoice-style PDF file and produces a clean, styled HTML file containing the same table — faithfully reproducing every row, column, header block, and grand total exactly as they appear in the original document. + +It is designed for PDFs that have no embedded table grid lines (i.e. the table is plain text laid out with spaces). The script uses a regex parser tuned to the column order of the invoice rather than relying on pdfplumber's automatic table detector. + +--- + +## Requirements + +### Python Version + +Python **3.10 or newer** is required (uses the built-in `list[...]` type hint syntax). + +### Python Libraries + +Install both libraries with pip: + +```bash +pip install pdfplumber pypdf +``` + +| Package | Purpose | +|---|---| +| `pdfplumber` | Extracts raw text from each PDF page with layout awareness. Also attempts automatic table detection as a primary strategy. | +| `pypdf` | Reads PDF metadata (title, author, creation date) from the file header. | + +### No Other Dependencies + +The script uses only Python's built-in modules (`sys`, `os`, `re`) alongside the two packages above. No OCR engine, no browser, no external tools required. + +--- + +## Installation + +No installation step is needed. Simply download the script and run it directly: + +```bash +# 1. Download / save the script + +# 2. Install dependencies +pip install pdfplumber pypdf + +# 3. Run +python pdf_to_html.py your_invoice.pdf +``` + +--- + +## Usage + +### Basic — auto-named output + +```bash +python pdf_to_html.py input.pdf +``` + +Saves the result as `input_table.html` in the same directory. + +### Custom output path + +```bash +python pdf_to_html.py input.pdf output.html +``` + +Saves the result to the path you specify. + +### Examples + +```bash +python pdf_to_html.py examples.pdf +python pdf_to_html.py invoices/march.pdf reports/march_table.html +``` + +--- + +## Output + +The generated HTML file contains: + +- A header block with Auftraggeber, date range, Baustellenname, Teilrechnung number, and Kst.Nr. +- A full data table with all rows extracted from the PDF. +- Right-aligned, monospace numeric columns (Menge, Einheitspreis, Betrag). +- A bold **Summe** footer row with the grand total. +- Hover highlighting on rows for easy reading. + +Open the `.html` file in any web browser to view or print it. No internet connection is needed — all styling is self-contained. + +--- + +## How It Works + +### Step 1 — `extract_metadata()` + +Reads the first page of the PDF as plain text and uses regular expressions to locate the Auftraggeber line (splitting off the date range), Baustellenname, Teilrechnung number, and Kst.Nr. + +### Step 2 — `extract_rows()` + +Reads every page's text and applies a single multi-group regex that matches the invoice row format: + +``` +01 Positionstext description 1,00 Stk € 500,00 € 500,00 Address note +``` + +> **Important:** the `€` symbol appears *before* each number in this PDF (`€ 500,00`), not after. The regex is written to match that exact format. + +### Step 3 — `extract_total()` + +Scans the full text for the `Summe` line and extracts the euro total. + +### Step 4 — `build_html()` + +Assembles the metadata, rows, and total into a complete, self-contained HTML document with embedded CSS. Numeric columns are detected by index and rendered right-aligned in a monospace font. + +--- + +## Terminal Output + +When you run the script you will see: + +``` +📄 Reading: examples.pdf + Rows found : 27 + Total : € 12 325,00 +✅ Saved: examples.html +``` + +If no rows are found the script prints a warning and exits with code `1`. This typically means the PDF uses scanned/image-based text and would require OCR to process. + +--- + +## Limitations + +- **Scanned PDFs:** Image-based PDFs cannot be parsed. An OCR library such as `pytesseract` would be needed. +- **Different formats:** The regex is tuned to the column order and `€` placement of this specific invoice format. Different invoice layouts may need regex adjustments. +- **Multi-page tables:** The script reads all pages and combines the text, so multi-page tables are handled correctly as long as the row format is consistent. + +--- + +## Quick Reference + +| Task | Command | +|---|---| +| Install dependencies | `pip install pdfplumber pypdf` | +| Run (auto output name) | `python pdf_to_html.py file.pdf` | +| Run (custom output) | `python pdf_to_html.py file.pdf out.html` | +| Open result | Open the `.html` file in any browser | +| Python version | 3.10 or newer | diff --git a/pdf2html.py b/pdf2html.py new file mode 100644 index 0000000..1470212 --- /dev/null +++ b/pdf2html.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +""" +pdf_to_html.py — Reads an invoice-style PDF and outputs a faithful HTML table. + +Usage: + python pdf_to_html.py input.pdf # saves input_table.html + python pdf_to_html.py input.pdf output.html # saves to custom path + +Requirements: + pip install pdfplumber pypdf +""" + +import sys +import os +import re +import pdfplumber +from pypdf import PdfReader + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def esc(text: str) -> str: + return ( + str(text) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def extract_metadata(pdf_path: str) -> dict: + with pdfplumber.open(pdf_path) as pdf: + first_text = pdf.pages[0].extract_text() or "" + + lines = [l.strip() for l in first_text.splitlines() if l.strip()] + meta = {"auftraggeber": "", "date_range": "", "baustellenname": "", "teilrechnung": "", "kst_nr": ""} + + for line in lines: + if "Auftraggeber" in line: + parts = re.split(r"Auftraggeber:\s*", line, maxsplit=1) + if len(parts) == 2: + date_m = re.search(r"(\d{2}\.\d{2}\.\s*[-–]\s*\d{2}\.\d{2}\.\d{4})", parts[1]) + if date_m: + meta["date_range"] = date_m.group(1).strip() + meta["auftraggeber"] = parts[1][:date_m.start()].strip() + else: + meta["auftraggeber"] = parts[1].strip() + elif "Baustellenname" in line: + parts = re.split(r"Baustellenname:\s*", line, maxsplit=1) + if len(parts) == 2: + meta["baustellenname"] = parts[1].strip() + elif re.match(r"Teilrechnung\s+\d+", line): + meta["teilrechnung"] = line.strip() + elif "Kst" in line and "Nr" in line: + kst_m = re.search(r"Kst\.?Nr\.?:?\s*(\S+)", line) + if kst_m: + meta["kst_nr"] = kst_m.group(1).strip() + + return meta + + +def extract_rows(pdf_path: str) -> list: + """ + Extract data rows from the PDF text. + This PDF has no grid lines — pdfplumber returns flat text per row. + The € symbol appears BEFORE each number: € 500,00 + """ + with pdfplumber.open(pdf_path) as pdf: + text = "\n".join(p.extract_text() or "" for p in pdf.pages) + + pattern = re.compile( + r"^(\d{2})\s+" # Pos. Nr. + r"(.+?)\s+" # Positionstext + r"(\d[\d\s]*,\d{2})\s+" # Menge + r"(Stk|m|kg|h|St\.?)\s+" # Einheit + r"€\s*([\d\s]+,\d{2})\s+" # Einheitspreis (€ before number) + r"€\s*([\d\s]+,\d{2})\s*" # Betrag (€ before number) + r"(.+)?$", # Anmerkungen + re.MULTILINE, + ) + + rows = [] + for m in pattern.finditer(text): + pos, desc, menge, einheit, ep, betrag, anm = m.groups() + rows.append([ + pos.strip(), + desc.strip(), + menge.strip(), + einheit.strip(), + f"€ {ep.strip()}", + f"€ {betrag.strip()}", + (anm or "").strip(), + ]) + return rows + + +def extract_total(pdf_path: str) -> str: + with pdfplumber.open(pdf_path) as pdf: + text = "\n".join(p.extract_text() or "" for p in pdf.pages) + m = re.search(r"Summe\s+€\s*([\d\s]+,\d{2})", text) + return f"€ {m.group(1).strip()}" if m else "" + + +# ── CSS ─────────────────────────────────────────────────────────────────────── + +CSS = """ +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;600;700&display=swap'); + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'IBM Plex Sans', sans-serif; + background: #f4f1ec; + color: #1a1a1a; + padding: 3rem 2rem; + font-size: 13px; +} + +.page { + max-width: 960px; + margin: 0 auto; + background: #fff; + border: 1px solid #ccc; + padding: 2.5rem 3rem; + box-shadow: 4px 4px 0 #c8c0b0; +} + +.header-meta { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.3rem; + font-size: 12px; +} + +.header-meta .right { + text-align: right; + font-family: 'IBM Plex Mono', monospace; + font-size: 11px; +} + +h1 { font-size: 15px; font-weight: 700; margin-bottom: 0.15rem; } + +.sub-meta { + font-size: 11.5px; + color: #555; + margin-bottom: 1.6rem; + font-family: 'IBM Plex Mono', monospace; +} + +table { width: 100%; border-collapse: collapse; font-size: 12px; } + +thead tr { + border-top: 2px solid #1a1a1a; + border-bottom: 2px solid #1a1a1a; +} + +thead th { + padding: 5px 8px; + text-align: left; + font-weight: 700; + font-size: 11.5px; + white-space: nowrap; +} + +thead th.num { text-align: right; } +tbody tr { border-bottom: 1px solid #e0ddd8; } +tbody tr:hover { background: #faf8f4; } +td { padding: 5px 8px; vertical-align: top; line-height: 1.4; } + +td.num { + text-align: right; + font-family: 'IBM Plex Mono', monospace; + white-space: nowrap; +} + +td.pos { + font-family: 'IBM Plex Mono', monospace; + font-weight: 600; + white-space: nowrap; +} + +td.anmerkung { color: #444; font-size: 11.5px; } +tfoot tr { border-top: 2px solid #1a1a1a; } +tfoot td { padding: 7px 8px; font-weight: 700; font-size: 13px; } +tfoot td.num { font-family: 'IBM Plex Mono', monospace; text-align: right; } +""" + +HEADERS = ["Pos. Nr.", "Positionstext", "Menge", "Einheit", "Einheitspreis", "Betrag", "Anmerkungen"] +NUM_COLS = {2, 4, 5} + + +# ── HTML builder ────────────────────────────────────────────────────────────── + +def build_html(meta, rows, total, pdf_name): + auftraggeber = esc(meta.get("auftraggeber", "")) + date_range = esc(meta.get("date_range", "")) + baustellenname = esc(meta.get("baustellenname", "")) + teilrechnung = esc(meta.get("teilrechnung", "")) + kst_nr = esc(meta.get("kst_nr", "")) + + th_cells = "".join( + f'<th{" class=\"num\"" if i in NUM_COLS else ""}>{esc(h)}</th>' + for i, h in enumerate(HEADERS) + ) + + tbody = "" + for row in rows: + row = list(row) + [""] * (7 - len(row)) + tds = "" + for i, cell in enumerate(row[:7]): + if i == 0: + tds += f'<td class="pos">{esc(cell)}</td>' + elif i in NUM_COLS: + tds += f'<td class="num">{esc(cell)}</td>' + elif i == 6: + tds += f'<td class="anmerkung">{esc(cell)}</td>' + else: + tds += f"<td>{esc(cell)}</td>" + tbody += f" <tr>{tds}</tr>\n" + + tfoot = "" + if total: + tfoot = ( + f'\n <tfoot><tr><td colspan="5"><strong>Summe</strong></td>' + f'<td class="num">{esc(total)}</td><td></td></tr></tfoot>' + ) + + sub_parts = [p for p in [teilrechnung, f"Kst.Nr.: {kst_nr}" if kst_nr else ""] if p] + sub_line = " | ".join(sub_parts) + + return f"""<!DOCTYPE html> +<html lang="de"> +<head> +<meta charset="UTF-8"> +<title>{esc(pdf_name)}</title> +<style>{CSS}</style> +</head> +<body> +<div class="page"> + + <div class="header-meta"> + <div><strong>Auftraggeber:</strong> {auftraggeber}</div> + <div class="right">{date_range}</div> + </div> + {"<h1>Baustellenname: " + baustellenname + "</h1>" if baustellenname else ""} + {"<div class='sub-meta'>" + sub_line + "</div>" if sub_line else ""} + + <table> + <thead><tr>{th_cells}</tr></thead> + <tbody> +{tbody} </tbody>{tfoot} + </table> + +</div> +</body> +</html> +""" + + +# ── main ────────────────────────────────────────────────────────────────────── + +def main(): + if len(sys.argv) < 2: + print("Usage: python pdf_to_html.py input.pdf [output.html]") + sys.exit(1) + + pdf_path = sys.argv[1] + if not os.path.exists(pdf_path): + print(f"❌ File not found: {pdf_path}") + sys.exit(1) + + output_path = ( + sys.argv[2] if len(sys.argv) > 2 + else os.path.splitext(pdf_path)[0] + "_table.html" + ) + + print(f"📄 Reading: {pdf_path}") + meta = extract_metadata(pdf_path) + rows = extract_rows(pdf_path) + total = extract_total(pdf_path) + + print(f" Rows found : {len(rows)}") + print(f" Total : {total or '(not found)'}") + + if not rows: + print("⚠️ No data rows extracted.") + sys.exit(1) + + html = build_html(meta, rows, total, os.path.basename(pdf_path)) + + with open(output_path, "w", encoding="utf-8") as f: + f.write(html) + + print(f"✅ Saved: {output_path}") + + +if __name__ == "__main__": + main() +