1 #!/usr/bin/env python3 2 """ 3 pdf_to_html.py — Reads an invoice-style PDF and outputs a faithful HTML table. 4 5 Usage: 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 9 Requirements: 10 pip install pdfplumber pypdf 11 """ 12 13 import sys 14 import os 15 import re 16 import pdfplumber 17 from pypdf import PdfReader 18 19 20 # ── helpers ─────────────────────────────────────────────────────────────────── 21 22 def esc(text: str) -> str: 23 return ( 24 str(text) 25 .replace("&", "&") 26 .replace("<", "<") 27 .replace(">", ">") 28 .replace('"', """) 29 ) 30 31 32 def 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 63 def 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 98 def 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 107 CSS = """ 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 112 body { 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 143 h1 { 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 152 table { width: 100%; border-collapse: collapse; font-size: 12px; } 153 154 thead tr { 155 border-top: 2px solid #1a1a1a; 156 border-bottom: 2px solid #1a1a1a; 157 } 158 159 thead th { 160 padding: 5px 8px; 161 text-align: left; 162 font-weight: 700; 163 font-size: 11.5px; 164 white-space: nowrap; 165 } 166 167 thead th.num { text-align: right; } 168 tbody tr { border-bottom: 1px solid #e0ddd8; } 169 tbody tr:hover { background: #faf8f4; } 170 td { padding: 5px 8px; vertical-align: top; line-height: 1.4; } 171 172 td.num { 173 text-align: right; 174 font-family: 'IBM Plex Mono', monospace; 175 white-space: nowrap; 176 } 177 178 td.pos { 179 font-family: 'IBM Plex Mono', monospace; 180 font-weight: 600; 181 white-space: nowrap; 182 } 183 184 td.anmerkung { color: #444; font-size: 11.5px; } 185 tfoot tr { border-top: 2px solid #1a1a1a; } 186 tfoot td { padding: 7px 8px; font-weight: 700; font-size: 13px; } 187 tfoot td.num { font-family: 'IBM Plex Mono', monospace; text-align: right; } 188 """ 189 190 HEADERS = ["Pos. Nr.", "Positionstext", "Menge", "Einheit", "Einheitspreis", "Betrag", "Anmerkungen"] 191 NUM_COLS = {2, 4, 5} 192 193 194 # ── HTML builder ────────────────────────────────────────────────────────────── 195 196 def 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 # ── main ────────────────────────────────────────────────────────────────────── 263 264 def 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 299 if __name__ == "__main__": 300 main() 301