1
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
7 python pdf_to_html.py input.pdf output.html
8
9Requirements:
10 pip install pdfplumber pypdf
11"""
12
13import sys
14import os
15import re
16import pdfplumber
17from pypdf import PdfReader
18
19
20
21
22def esc(text: str) -> str:
23 return (
24 str(text)
25 .replace("&", "&")
26 .replace("<", "<")
27 .replace(">", ">")
28 .replace('"', """)
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+"
74 r"(.+?)\s+"
75 r"(\d[\d\s]*,\d{2})\s+"
76 r"(Stk|m|kg|h|St\.?)\s+"
77 r"€\s*([\d\s]+,\d{2})\s+"
78 r"€\s*([\d\s]+,\d{2})\s*"
79 r"(.+)?$",
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
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:
115 color:
116 padding: 3rem 2rem;
117 font-size: 13px;
118}
119
120.page {
121 max-width: 960px;
122 margin: 0 auto;
123 background:
124 border: 1px solid
125 padding: 2.5rem 3rem;
126 box-shadow: 4px 4px 0
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:
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
156 border-bottom: 2px solid
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
169tbody tr:hover { background:
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:
185tfoot tr { border-top: 2px solid
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
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 = " | ".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
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