xgit simple git

pdf2html

pdf to html converter

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

pdf2html.py


1#!/usr/bin/env python3
2"""
3pdf_to_html.py  —  Reads an invoice-style PDF and outputs a faithful HTML table.
4 
5Usage:
6    python pdf_to_html.py input.pdf              # saves input_table.html
7    python pdf_to_html.py input.pdf output.html  # saves to custom path
8 
9Requirements:
10    pip install pdfplumber pypdf
11"""
12 
13import sys
14import os
15import re
16import pdfplumber
17from pypdf import PdfReader
18 
19 
20# ── helpers ───────────────────────────────────────────────────────────────────
21 
22def esc(text: str) -> str:
23    return (
24        str(text)
25        .replace("&", "&")
26        .replace("<", "&lt;")
27        .replace(">", "&gt;")
28        .replace('"', "&quot;")
29    )
30 
31 
32def extract_metadata(pdf_path: str) -> dict:
33    with pdfplumber.open(pdf_path) as pdf:
34        first_text = pdf.pages[0].extract_text() or ""
35 
36    lines = [l.strip() for l in first_text.splitlines() if l.strip()]
37    meta = {"auftraggeber": "", "date_range": "", "baustellenname": "", "teilrechnung": "", "kst_nr": ""}
38 
39    for line in lines:
40        if "Auftraggeber" in line:
41            parts = re.split(r"Auftraggeber:\s*", line, maxsplit=1)
42            if len(parts) == 2:
43                date_m = re.search(r"(\d{2}\.\d{2}\.\s*[-–]\s*\d{2}\.\d{2}\.\d{4})", parts[1])
44                if date_m:
45                    meta["date_range"]   = date_m.group(1).strip()
46                    meta["auftraggeber"] = parts[1][:date_m.start()].strip()
47                else:
48                    meta["auftraggeber"] = parts[1].strip()
49        elif "Baustellenname" in line:
50            parts = re.split(r"Baustellenname:\s*", line, maxsplit=1)
51            if len(parts) == 2:
52                meta["baustellenname"] = parts[1].strip()
53        elif re.match(r"Teilrechnung\s+\d+", line):
54            meta["teilrechnung"] = line.strip()
55        elif "Kst" in line and "Nr" in line:
56            kst_m = re.search(r"Kst\.?Nr\.?:?\s*(\S+)", line)
57            if kst_m:
58                meta["kst_nr"] = kst_m.group(1).strip()
59 
60    return meta
61 
62 
63def extract_rows(pdf_path: str) -> list:
64    """
65    Extract data rows from the PDF text.
66    This PDF has no grid lines — pdfplumber returns flat text per row.
67    The € symbol appears BEFORE each number: € 500,00
68    """
69    with pdfplumber.open(pdf_path) as pdf:
70        text = "\n".join(p.extract_text() or "" for p in pdf.pages)
71 
72    pattern = re.compile(
73        r"^(\d{2})\s+"                     # Pos. Nr.
74        r"(.+?)\s+"                         # Positionstext
75        r"(\d[\d\s]*,\d{2})\s+"            # Menge
76        r"(Stk|m|kg|h|St\.?)\s+"           # Einheit
77        r"€\s*([\d\s]+,\d{2})\s+"          # Einheitspreis (€ before number)
78        r"€\s*([\d\s]+,\d{2})\s*"          # Betrag        (€ before number)
79        r"(.+)?$",                          # Anmerkungen
80        re.MULTILINE,
81    )
82 
83    rows = []
84    for m in pattern.finditer(text):
85        pos, desc, menge, einheit, ep, betrag, anm = m.groups()
86        rows.append([
87            pos.strip(),
88            desc.strip(),
89            menge.strip(),
90            einheit.strip(),
91            f"€ {ep.strip()}",
92            f"€ {betrag.strip()}",
93            (anm or "").strip(),
94        ])
95    return rows
96 
97 
98def extract_total(pdf_path: str) -> str:
99    with pdfplumber.open(pdf_path) as pdf:
100        text = "\n".join(p.extract_text() or "" for p in pdf.pages)
101    m = re.search(r"Summe\s+€\s*([\d\s]+,\d{2})", text)
102    return f"€ {m.group(1).strip()}" if m else ""
103 
104 
105# ── CSS ───────────────────────────────────────────────────────────────────────
106 
107CSS = """
108@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;600;700&display=swap');
109 
110* { box-sizing: border-box; margin: 0; padding: 0; }
111 
112body {
113  font-family: 'IBM Plex Sans', sans-serif;
114  background: #f4f1ec;
115  color: #1a1a1a;
116  padding: 3rem 2rem;
117  font-size: 13px;
118}
119 
120.page {
121  max-width: 960px;
122  margin: 0 auto;
123  background: #fff;
124  border: 1px solid #ccc;
125  padding: 2.5rem 3rem;
126  box-shadow: 4px 4px 0 #c8c0b0;
127}
128 
129.header-meta {
130  display: flex;
131  justify-content: space-between;
132  align-items: flex-start;
133  margin-bottom: 0.3rem;
134  font-size: 12px;
135}
136 
137.header-meta .right {
138  text-align: right;
139  font-family: 'IBM Plex Mono', monospace;
140  font-size: 11px;
141}
142 
143h1 { font-size: 15px; font-weight: 700; margin-bottom: 0.15rem; }
144 
145.sub-meta {
146  font-size: 11.5px;
147  color: #555;
148  margin-bottom: 1.6rem;
149  font-family: 'IBM Plex Mono', monospace;
150}
151 
152table { width: 100%; border-collapse: collapse; font-size: 12px; }
153 
154thead tr {
155  border-top: 2px solid #1a1a1a;
156  border-bottom: 2px solid #1a1a1a;
157}
158 
159thead th {
160  padding: 5px 8px;
161  text-align: left;
162  font-weight: 700;
163  font-size: 11.5px;
164  white-space: nowrap;
165}
166 
167thead th.num { text-align: right; }
168tbody tr { border-bottom: 1px solid #e0ddd8; }
169tbody tr:hover { background: #faf8f4; }
170td { padding: 5px 8px; vertical-align: top; line-height: 1.4; }
171 
172td.num {
173  text-align: right;
174  font-family: 'IBM Plex Mono', monospace;
175  white-space: nowrap;
176}
177 
178td.pos {
179  font-family: 'IBM Plex Mono', monospace;
180  font-weight: 600;
181  white-space: nowrap;
182}
183 
184td.anmerkung { color: #444; font-size: 11.5px; }
185tfoot tr { border-top: 2px solid #1a1a1a; }
186tfoot td { padding: 7px 8px; font-weight: 700; font-size: 13px; }
187tfoot td.num { font-family: 'IBM Plex Mono', monospace; text-align: right; }
188"""
189 
190HEADERS  = ["Pos. Nr.", "Positionstext", "Menge", "Einheit", "Einheitspreis", "Betrag", "Anmerkungen"]
191NUM_COLS = {2, 4, 5}
192 
193 
194# ── HTML builder ──────────────────────────────────────────────────────────────
195 
196def build_html(meta, rows, total, pdf_name):
197    auftraggeber   = esc(meta.get("auftraggeber", ""))
198    date_range     = esc(meta.get("date_range", ""))
199    baustellenname = esc(meta.get("baustellenname", ""))
200    teilrechnung   = esc(meta.get("teilrechnung", ""))
201    kst_nr         = esc(meta.get("kst_nr", ""))
202 
203    th_cells = "".join(
204        f'<th{" class=\"num\"" if i in NUM_COLS else ""}>{esc(h)}</th>'
205        for i, h in enumerate(HEADERS)
206    )
207 
208    tbody = ""
209    for row in rows:
210        row = list(row) + [""] * (7 - len(row))
211        tds = ""
212        for i, cell in enumerate(row[:7]):
213            if i == 0:
214                tds += f'<td class="pos">{esc(cell)}</td>'
215            elif i in NUM_COLS:
216                tds += f'<td class="num">{esc(cell)}</td>'
217            elif i == 6:
218                tds += f'<td class="anmerkung">{esc(cell)}</td>'
219            else:
220                tds += f"<td>{esc(cell)}</td>"
221        tbody += f"    <tr>{tds}</tr>\n"
222 
223    tfoot = ""
224    if total:
225        tfoot = (
226            f'\n    <tfoot><tr><td colspan="5"><strong>Summe</strong></td>'
227            f'<td class="num">{esc(total)}</td><td></td></tr></tfoot>'
228        )
229 
230    sub_parts = [p for p in [teilrechnung, f"Kst.Nr.: {kst_nr}" if kst_nr else ""] if p]
231    sub_line  = " &nbsp;|&nbsp; ".join(sub_parts)
232 
233    return f"""<!DOCTYPE html>
234<html lang="de">
235<head>
236<meta charset="UTF-8">
237<title>{esc(pdf_name)}</title>
238<style>{CSS}</style>
239</head>
240<body>
241<div class="page">
242 
243  <div class="header-meta">
244    <div><strong>Auftraggeber:</strong> {auftraggeber}</div>
245    <div class="right">{date_range}</div>
246  </div>
247  {"<h1>Baustellenname: " + baustellenname + "</h1>" if baustellenname else ""}
248  {"<div class='sub-meta'>" + sub_line + "</div>" if sub_line else ""}
249 
250  <table>
251    <thead><tr>{th_cells}</tr></thead>
252    <tbody>
253{tbody}    </tbody>{tfoot}
254  </table>
255 
256</div>
257</body>
258</html>
259"""
260 
261 
262# ── main ──────────────────────────────────────────────────────────────────────
263 
264def main():
265    if len(sys.argv) < 2:
266        print("Usage: python pdf_to_html.py input.pdf [output.html]")
267        sys.exit(1)
268 
269    pdf_path = sys.argv[1]
270    if not os.path.exists(pdf_path):
271        print(f"❌  File not found: {pdf_path}")
272        sys.exit(1)
273 
274    output_path = (
275        sys.argv[2] if len(sys.argv) > 2
276        else os.path.splitext(pdf_path)[0] + "_table.html"
277    )
278 
279    print(f"📄  Reading: {pdf_path}")
280    meta  = extract_metadata(pdf_path)
281    rows  = extract_rows(pdf_path)
282    total = extract_total(pdf_path)
283 
284    print(f"   Rows found : {len(rows)}")
285    print(f"   Total      : {total or '(not found)'}")
286 
287    if not rows:
288        print("⚠️  No data rows extracted.")
289        sys.exit(1)
290 
291    html = build_html(meta, rows, total, os.path.basename(pdf_path))
292 
293    with open(output_path, "w", encoding="utf-8") as f:
294        f.write(html)
295 
296    print(f"✅  Saved: {output_path}")
297 
298 
299if __name__ == "__main__":
300    main()
301