lithish2602 commited on
Commit
350f8c3
·
verified ·
1 Parent(s): a5de5ac

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +333 -0
  2. requirements.txt +9 -0
app.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ import streamlit as st
3
+ import fitz # PyMuPDF
4
+ import pdfplumber
5
+ import camelot
6
+ import json
7
+ import tempfile
8
+ import os
9
+ import re
10
+ import base64
11
+ from io import BytesIO
12
+ from statistics import mean, pstdev
13
+
14
+ # Optional OCR
15
+ try:
16
+ import pytesseract
17
+ from PIL import Image
18
+ OCR_AVAILABLE = True
19
+ except Exception:
20
+ OCR_AVAILABLE = False
21
+
22
+ EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
23
+ PHONE_RE = re.compile(r"(\+?\d{1,3})?[\s\-.(]*(\d{2,4})[\s\-.)]*(\d{3,4})[\s\-]*(\d{3,4})")
24
+ URL_RE = re.compile(r"(https?://\S+|www\.\S+)")
25
+ CIN_RE = re.compile(r"\bCIN\b.*", flags=re.IGNORECASE)
26
+
27
+ def image_bytes_to_base64(img_bytes, mime="image/png"):
28
+ b64 = base64.b64encode(img_bytes).decode("utf-8")
29
+ return f"data:{mime};base64,{b64}"
30
+
31
+ def detect_headings(spans):
32
+ """
33
+ Heuristic detection of sections/subsections using font sizes in spans.
34
+ spans: list of (text, size, flags, font)
35
+ Returns thresholds (section_threshold, subsection_threshold)
36
+ """
37
+ sizes = [s for (_, s, _, _) in spans if s > 0]
38
+ if not sizes:
39
+ return (16, 12)
40
+ avg = mean(sizes)
41
+ sd = pstdev(sizes) if len(sizes) > 1 else 0
42
+ # Section threshold: avg + 1*sd or at least 14
43
+ section_t = max(14, avg + sd)
44
+ subsection_t = max(11, avg)
45
+ return (section_t, subsection_t)
46
+
47
+ def classify_footer_and_signature(lines):
48
+ """
49
+ Given list of lines (strings) attempt to classify footer, signature, or normal.
50
+ Returns (type, combined_text) where type in {"footer","signature","paragraph"}.
51
+ """
52
+ combined = "\n".join(lines).strip()
53
+ # Look for signature clues
54
+ if any(x in combined.lower() for x in ["yours sincerely", "yours faithfully", "for "]) or re.search(r"\b(dean|director|manager|ceo|coo)\b", combined.lower()):
55
+ return "signature", combined
56
+ if EMAIL_RE.search(combined) or URL_RE.search(combined) or PHONE_RE.search(combined) or CIN_RE.search(combined):
57
+ return "footer", combined
58
+ return "paragraph", combined
59
+
60
+ def extract_images_from_page(page, embed_images):
61
+ """
62
+ Extract images from a PyMuPDF page.
63
+ Returns list of dicts: {"type":"chart","description":...,"image_b64":...}
64
+ """
65
+ imgs = []
66
+ image_list = page.get_images(full=True)
67
+ for img_index, img in enumerate(image_list, start=1):
68
+ xref = img[0]
69
+ try:
70
+ pix = fitz.Pixmap(page.parent, xref)
71
+ if pix.n - pix.alpha >= 4: # e.g., CMYK
72
+ pix = fitz.Pixmap(fitz.csRGB, pix)
73
+ img_bytes = pix.tobytes("png")
74
+
75
+ img_entry = {
76
+ "type": "chart",
77
+ "description": f"Image {img_index} on page {page.number + 1}",
78
+ }
79
+ if embed_images:
80
+ img_entry["image_b64"] = image_bytes_to_base64(img_bytes, mime="image/png")
81
+ imgs.append(img_entry)
82
+
83
+ pix = None # free memory
84
+ except Exception as e:
85
+ print(f"⚠️ Could not extract image {img_index} on page {page.number+1}: {e}")
86
+ continue
87
+ return imgs
88
+
89
+
90
+ def ocr_image_bytes(img_b64):
91
+ """
92
+ If OCR available, decode base64 and run OCR to extract text.
93
+ Returns OCR text or None.
94
+ """
95
+ if not OCR_AVAILABLE:
96
+ return None
97
+ header, data = img_b64.split(",", 1)
98
+ img_bytes = base64.b64decode(data)
99
+ im = Image.open(BytesIO(img_bytes)).convert("RGB")
100
+ text = pytesseract.image_to_string(im)
101
+ return text.strip()
102
+
103
+ def extract_pdf_content(pdf_path, embed_images=False, do_ocr_images=False):
104
+ """
105
+ Main extraction pipeline:
106
+ - Uses PyMuPDF for text with spans/size metadata (section/subsection detection)
107
+ - Uses Camelot for tables
108
+ - Detects images and optionally embeds them
109
+ - Classifies signature/footer blocks
110
+ """
111
+ result = {"pages": []}
112
+ doc = fitz.open(pdf_path)
113
+ # Pre-open pdfplumber for alternate text extraction if needed
114
+ plumber_doc = pdfplumber.open(pdf_path)
115
+
116
+ for page_index in range(len(doc)):
117
+ page = doc[page_index]
118
+ page_number = page_index + 1
119
+ page_entry = {"page_number": page_number, "content": []}
120
+
121
+ # --- Collect spans for heuristics ---
122
+ # each span: (text, size, flags, font)
123
+ spans = []
124
+ blocks = page.get_text("dict").get("blocks", [])
125
+ for block in blocks:
126
+ if "lines" not in block:
127
+ continue
128
+ for line in block["lines"]:
129
+ for span in line["spans"]:
130
+ text = span.get("text", "").strip()
131
+ size = span.get("size", 0)
132
+ flags = span.get("flags", 0)
133
+ font = span.get("font", "")
134
+ if text:
135
+ spans.append((text, size, flags, font))
136
+
137
+ section_t, subsection_t = detect_headings(spans)
138
+
139
+ # --- Walk blocks and create paragraphs or headings ---
140
+ current_section = None
141
+ current_subsection = None
142
+ # We'll group by block for better paragraph sense
143
+ for block in blocks:
144
+ if "lines" not in block:
145
+ continue
146
+ block_lines = []
147
+ # For each line, decide if it's heading/subheading/paragraph
148
+ for line in block["lines"]:
149
+ # join spans of the line preserving style info
150
+ line_spans = line.get("spans", [])
151
+ if not line_spans:
152
+ continue
153
+ # Determine the largest font size in the line
154
+ sizes = [s.get("size", 0) for s in line_spans if s.get("text", "").strip()]
155
+ if not sizes:
156
+ continue
157
+ max_size = max(sizes)
158
+ text_line = " ".join(s.get("text", "").strip() for s in line_spans).strip()
159
+ if not text_line:
160
+ continue
161
+
162
+ # Heading heuristics
163
+ if max_size >= section_t and (text_line.isupper() or len(text_line.split()) <= 6):
164
+ # Section heading
165
+ current_section = text_line
166
+ current_subsection = None
167
+ page_entry["content"].append({
168
+ "type": "section",
169
+ "section": current_section,
170
+ "sub_section": None,
171
+ "text": None
172
+ })
173
+ elif max_size >= subsection_t and (len(text_line.split()) <= 8):
174
+ current_subsection = text_line
175
+ page_entry["content"].append({
176
+ "type": "sub_section",
177
+ "section": current_section,
178
+ "sub_section": current_subsection,
179
+ "text": None
180
+ })
181
+ else:
182
+ block_lines.append(text_line)
183
+
184
+ if block_lines:
185
+ # Try to classify block (footer/signature) heuristics
186
+ btype, combined = classify_footer_and_signature(block_lines)
187
+ if btype == "signature":
188
+ page_entry["content"].append({
189
+ "type": "signature",
190
+ "section": current_section,
191
+ "sub_section": current_subsection,
192
+ "text": combined
193
+ })
194
+ elif btype == "footer":
195
+ page_entry["content"].append({
196
+ "type": "footer",
197
+ "section": current_section,
198
+ "sub_section": current_subsection,
199
+ "text": combined
200
+ })
201
+ else:
202
+ # regular paragraph
203
+ page_entry["content"].append({
204
+ "type": "paragraph",
205
+ "section": current_section,
206
+ "sub_section": current_subsection,
207
+ "text": combined
208
+ })
209
+
210
+ # --- Camelot tables for this page ---
211
+ try:
212
+ tables = camelot.read_pdf(pdf_path, pages=str(page_number))
213
+ for idx, table in enumerate(tables, start=1):
214
+ table_data = table.df.values.tolist()
215
+ page_entry["content"].append({
216
+ "type": "table",
217
+ "section": current_section,
218
+ "sub_section": current_subsection,
219
+ "description": f"Table {idx} on page {page_number}",
220
+ "table_data": table_data
221
+ })
222
+ except Exception:
223
+ # camelot may raise when no tables or not supported; ignore
224
+ pass
225
+
226
+ # --- Images / Charts detection ---
227
+ images = extract_images_from_page(page, embed_images)
228
+ # If OCR on images requested, attempt to extract text
229
+ if do_ocr_images and OCR_AVAILABLE:
230
+ for img in images:
231
+ if "image_b64" in img:
232
+ ocr_text = ocr_image_bytes(img["image_b64"])
233
+ if ocr_text:
234
+ img["ocr_text"] = ocr_text
235
+ # Append images as chart entries
236
+ for img in images:
237
+ page_entry["content"].append(img)
238
+
239
+ # If pdfplumber can find elements (fallback), add any missing text blocks (optional)
240
+ # (Skipping to avoid duplication — pdfplumber often duplicates fitz results.)
241
+
242
+ result["pages"].append(page_entry)
243
+
244
+ plumber_doc.close()
245
+ doc.close()
246
+ return result
247
+
248
+ # ---------------- Streamlit App UI ----------------
249
+ st.set_page_config(page_title="PDF → Structured JSON (Robust)", layout="wide")
250
+ st.title("PDF Parsing and Structured JSON Extraction")
251
+
252
+ st.markdown(
253
+ """
254
+ Upload a PDF and the app will:
255
+ - detect sections/subsections by font-size heuristics,
256
+ - extract paragraphs and group them,
257
+ - extract tables (Camelot),
258
+ - detect images/charts and optionally embed them (base64),
259
+ - identify signature/footer/contact blocks,
260
+ - optionally OCR text inside images (Tesseract required).
261
+ """
262
+ )
263
+
264
+ uploaded_file = st.file_uploader("Upload PDF", type=["pdf"])
265
+ col1, col2, col3 = st.columns([1, 1, 1])
266
+ with col1:
267
+ embed_images = st.checkbox("Embed images (base64) into JSON", value=False)
268
+ with col2:
269
+ do_ocr_images = st.checkbox("Run OCR on images (pytesseract)", value=False)
270
+ with col3:
271
+ pretty = st.checkbox("Pretty-print JSON preview", value=True)
272
+
273
+ if do_ocr_images and not OCR_AVAILABLE:
274
+ st.warning("pytesseract or PIL not available in environment — OCR disabled. Install pytesseract and Tesseract engine.")
275
+
276
+ if uploaded_file is not None:
277
+ # Save to temp file
278
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
279
+ tmp.write(uploaded_file.read())
280
+ tmp_path = tmp.name
281
+
282
+ st.info(f"Saved uploaded PDF to `{tmp_path}`")
283
+
284
+ if st.button("Extract → JSON"):
285
+ try:
286
+ with st.spinner("Extracting..."):
287
+ json_data = extract_pdf_content(tmp_path, embed_images=embed_images, do_ocr_images=do_ocr_images)
288
+
289
+ st.success("Extraction complete ✅")
290
+
291
+ # JSON preview
292
+ if pretty:
293
+ st.json(json_data)
294
+ else:
295
+ st.code(json.dumps(json_data, ensure_ascii=False))
296
+
297
+ # Offer download of JSON
298
+ json_bytes = json.dumps(json_data, indent=2, ensure_ascii=False).encode("utf-8")
299
+ st.download_button("⬇️ Download JSON", data=json_bytes, file_name="extracted.json", mime="application/json")
300
+
301
+ # If images embedded, show thumbnails (first page few)
302
+ if embed_images:
303
+ shown = 0
304
+ st.write("Extracted Images (embedded):")
305
+ for p in json_data["pages"]:
306
+ for content in p["content"]:
307
+ if content.get("type") == "chart" and content.get("image_b64"):
308
+ st.image(content["image_b64"], width=300)
309
+ shown += 1
310
+ if shown >= 6:
311
+ break
312
+ if shown >= 6:
313
+ break
314
+
315
+ except Exception as e:
316
+ st.error(f"Extraction failed: {e}")
317
+ st.exception(e)
318
+
319
+ # Cleanup temp file if desired (keep for debugging)
320
+ # os.remove(tmp_path)
321
+ else:
322
+ st.info("Upload a PDF to begin.")
323
+
324
+ st.markdown("---")
325
+ st.markdown("**Notes / Requirements**:")
326
+ st.markdown(
327
+ """
328
+ - **Camelot** requires Ghostscript and a compatible environment (works best with Linux).
329
+ - **pytesseract** requires the Tesseract engine installed on your system.
330
+ - Embedding images as base64 increases JSON size considerably; disable embedding if you only need metadata.
331
+ - The heuristics (font-size thresholds, regexes) are conservative — you may need to tweak thresholds for certain document families.
332
+ """
333
+ )
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ streamlit
2
+ PyMuPDF
3
+ pdfplumber
4
+ camelot-py[cv]
5
+ pandas
6
+ Pillow
7
+ pytesseract
8
+ numpy
9
+ ghostscript