xgit simple git

pdf2html

pdf to html converter

clone git clone https://kb.hax.al/pdf2html

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("&", "&amp;")
+        .replace("<", "&lt;")
+        .replace(">", "&gt;")
+        .replace('"', "&quot;")
+    )
+
+
+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  = " &nbsp;|&nbsp; ".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()
+