xgit simple git

pdf2html

pdf to html converter

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

pdf2html.py

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