mahrooshishaq commited on
Commit
327b929
·
1 Parent(s): 96a2a78

feat(resume): improve PDF and DOCX extraction

Browse files
requirements.txt CHANGED
@@ -1,6 +1,7 @@
1
  fastapi==0.115.0
2
  uvicorn==0.30.6
3
  pdfplumber==0.11.4
 
4
  python-multipart==0.0.9
5
  pydantic==2.8.2
6
 
 
1
  fastapi==0.115.0
2
  uvicorn==0.30.6
3
  pdfplumber==0.11.4
4
+ python-docx==1.1.2
5
  python-multipart==0.0.9
6
  pydantic==2.8.2
7
 
routers/extraction.py CHANGED
@@ -1,6 +1,6 @@
1
  import io
2
  from fastapi import APIRouter, HTTPException, File, UploadFile
3
- from services.pdf_extractor import extract_resume_from_stream
4
 
5
  router = APIRouter(prefix="/extract-resume", tags=["extraction"])
6
 
@@ -14,7 +14,15 @@ async def extract_resume(file: UploadFile = File(...)):
14
  try:
15
  content = await file.read()
16
  pdf_stream = io.BytesIO(content)
17
- result = extract_resume_from_stream(pdf_stream)
 
 
 
 
 
 
18
  return result
 
 
19
  except Exception as e:
20
  raise HTTPException(status_code=500, detail=f"Extraction failed: {str(e)}")
 
1
  import io
2
  from fastapi import APIRouter, HTTPException, File, UploadFile
3
+ from services.pdf_extractor import extract_docx_from_stream, extract_resume_from_stream
4
 
5
  router = APIRouter(prefix="/extract-resume", tags=["extraction"])
6
 
 
14
  try:
15
  content = await file.read()
16
  pdf_stream = io.BytesIO(content)
17
+ file_name = (file.filename or '').lower()
18
+ if file_name.endswith('.docx'):
19
+ result = extract_docx_from_stream(pdf_stream)
20
+ elif file_name.endswith('.pdf'):
21
+ result = extract_resume_from_stream(pdf_stream)
22
+ else:
23
+ raise HTTPException(status_code=400, detail='Only PDF and DOCX files are supported.')
24
  return result
25
+ except HTTPException:
26
+ raise
27
  except Exception as e:
28
  raise HTTPException(status_code=500, detail=f"Extraction failed: {str(e)}")
services/pdf_extractor.py CHANGED
@@ -23,6 +23,9 @@ def extract_resume_from_stream(stream) -> dict:
23
  has_tables = False
24
  has_images = False
25
  page_count = 0
 
 
 
26
 
27
  with pdfplumber.open(stream) as pdf:
28
  page_count = len(pdf.pages)
@@ -36,6 +39,16 @@ def extract_resume_from_stream(stream) -> dict:
36
  if page.images:
37
  has_images = True
38
 
 
 
 
 
 
 
 
 
 
 
39
  page_text = page.extract_text() or ""
40
  text_pages.append(page_text)
41
 
@@ -47,6 +60,12 @@ def extract_resume_from_stream(stream) -> dict:
47
  confidence = round(confidence, 2)
48
 
49
  sections = _detect_sections(full_text)
 
 
 
 
 
 
50
 
51
  return {
52
  "text": full_text,
@@ -56,10 +75,116 @@ def extract_resume_from_stream(stream) -> dict:
56
  "hasImages": has_images,
57
  "pageCount": page_count,
58
  "confidence": confidence,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  },
60
  }
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  def _detect_sections(text: str) -> dict:
64
  """
65
  Splits the resume text into named sections by detecting section headers.
 
23
  has_tables = False
24
  has_images = False
25
  page_count = 0
26
+ font_names = set()
27
+ font_sizes = []
28
+ has_multiple_columns = False
29
 
30
  with pdfplumber.open(stream) as pdf:
31
  page_count = len(pdf.pages)
 
39
  if page.images:
40
  has_images = True
41
 
42
+ has_multiple_columns = has_multiple_columns or _detect_multiple_columns(page)
43
+
44
+ for char in page.chars:
45
+ font_name = _normalize_font_name(str(char.get("fontname", "")))
46
+ if font_name:
47
+ font_names.add(font_name)
48
+ size = char.get("size")
49
+ if isinstance(size, (int, float)) and size > 0:
50
+ font_sizes.append(float(size))
51
+
52
  page_text = page.extract_text() or ""
53
  text_pages.append(page_text)
54
 
 
60
  confidence = round(confidence, 2)
61
 
62
  sections = _detect_sections(full_text)
63
+ non_empty_lines = [line.strip() for line in full_text.splitlines() if line.strip()]
64
+ long_line_count = sum(len(line) > 120 for line in non_empty_lines)
65
+ bullet_count = sum(
66
+ bool(re.match(r"^[\u2022\u25e6\u25aa\u25cf\-\u2013\u2014*]\s+", line))
67
+ for line in non_empty_lines
68
+ )
69
 
70
  return {
71
  "text": full_text,
 
75
  "hasImages": has_images,
76
  "pageCount": page_count,
77
  "confidence": confidence,
78
+ "hasMultipleColumns": has_multiple_columns,
79
+ "fontCount": len(font_names),
80
+ "minFontSize": round(min(font_sizes), 1) if font_sizes else None,
81
+ "averageFontSize": round(sum(font_sizes) / len(font_sizes), 1) if font_sizes else None,
82
+ "averageCharsPerPage": round(len(full_text) / max(page_count, 1)),
83
+ "longLineCount": long_line_count,
84
+ "bulletCount": bullet_count,
85
+ },
86
+ }
87
+
88
+
89
+ def extract_docx_from_stream(stream) -> dict:
90
+ """Extract ordered text and layout signals from a DOCX document."""
91
+ from docx import Document
92
+ from docx.oxml.ns import qn
93
+
94
+ document = Document(stream)
95
+ text_lines = []
96
+ font_names = set()
97
+ font_sizes = []
98
+
99
+ for paragraph in document.paragraphs:
100
+ text = paragraph.text.strip()
101
+ if text:
102
+ text_lines.append(text)
103
+ for run in paragraph.runs:
104
+ if run.font.name:
105
+ font_names.add(_normalize_font_name(run.font.name))
106
+ if run.font.size:
107
+ font_sizes.append(float(run.font.size.pt))
108
+
109
+ for table in document.tables:
110
+ for row in table.rows:
111
+ cells = [cell.text.strip() for cell in row.cells if cell.text.strip()]
112
+ if cells:
113
+ text_lines.append(" | ".join(cells))
114
+
115
+ full_text = "\n".join(text_lines).strip()
116
+ estimated_pages = max(1, round(len(full_text) / 3500))
117
+ has_multiple_columns = False
118
+ for section in document.sections:
119
+ columns = section._sectPr.xpath('./w:cols')
120
+ if columns:
121
+ count = columns[0].get(qn('w:num'))
122
+ if count and int(count) > 1:
123
+ has_multiple_columns = True
124
+
125
+ long_line_count = sum(len(line) > 120 for line in text_lines)
126
+ bullet_count = sum(
127
+ bool(re.match(r"^[\u2022\u25e6\u25aa\u25cf\-\u2013\u2014*]\s+", line))
128
+ for line in text_lines
129
+ )
130
+ confidence = 1.0 if len(full_text) >= 100 else round(len(full_text) / 100, 2)
131
+
132
+ return {
133
+ "text": full_text,
134
+ "sections": _detect_sections(full_text),
135
+ "metadata": {
136
+ "hasTables": bool(document.tables),
137
+ "hasImages": bool(document.inline_shapes),
138
+ "pageCount": estimated_pages,
139
+ "confidence": confidence,
140
+ "hasMultipleColumns": has_multiple_columns,
141
+ "fontCount": len(font_names),
142
+ "minFontSize": round(min(font_sizes), 1) if font_sizes else None,
143
+ "averageFontSize": round(sum(font_sizes) / len(font_sizes), 1) if font_sizes else None,
144
+ "averageCharsPerPage": round(len(full_text) / estimated_pages),
145
+ "longLineCount": long_line_count,
146
+ "bulletCount": bullet_count,
147
  },
148
  }
149
 
150
 
151
+ def _normalize_font_name(font_name: str) -> str:
152
+ """Collapse embedded font prefixes and style suffixes into a font family."""
153
+ family = font_name.split("+")[-1].lower()
154
+ family = re.sub(r"(bold|italic|oblique|regular|medium|light|semibold|mt)", "", family)
155
+ return re.sub(r"[^a-z0-9]", "", family)
156
+
157
+
158
+ def _detect_multiple_columns(page) -> bool:
159
+ """Detect repeated, independently populated left/right text regions."""
160
+ words = page.extract_words(use_text_flow=False, keep_blank_chars=False)
161
+ if len(words) < 20 or not page.width:
162
+ return False
163
+
164
+ rows: dict[int, list[dict]] = {}
165
+ for word in words:
166
+ row_key = round(float(word.get("top", 0)) / 5)
167
+ rows.setdefault(row_key, []).append(word)
168
+
169
+ midpoint = float(page.width) / 2
170
+ split_rows = 0
171
+ for row_words in rows.values():
172
+ left = [w for w in row_words if float(w.get("x1", 0)) < midpoint]
173
+ right = [w for w in row_words if float(w.get("x0", 0)) > midpoint]
174
+ if not left or not right:
175
+ continue
176
+
177
+ left_text = " ".join(str(w.get("text", "")) for w in left)
178
+ right_text = " ".join(str(w.get("text", "")) for w in right)
179
+ gap = min(float(w.get("x0", 0)) for w in right) - max(
180
+ float(w.get("x1", 0)) for w in left
181
+ )
182
+ if len(left_text) >= 8 and len(right_text) >= 8 and gap > float(page.width) * 0.08:
183
+ split_rows += 1
184
+
185
+ return split_rows >= max(4, round(len(rows) * 0.15))
186
+
187
+
188
  def _detect_sections(text: str) -> dict:
189
  """
190
  Splits the resume text into named sections by detecting section headers.
tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
tests/test_pdf_extractor.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ import sys
3
+ import io
4
+ from unittest.mock import Mock
5
+
6
+ # The layout helper tests do not open PDFs, so keep them runnable before the
7
+ # optional service dependencies are installed in a fresh clone.
8
+ sys.modules.setdefault('pdfplumber', Mock())
9
+
10
+ from services.pdf_extractor import (
11
+ _detect_multiple_columns,
12
+ _detect_sections,
13
+ _normalize_font_name,
14
+ extract_docx_from_stream,
15
+ )
16
+
17
+
18
+ class FakePage:
19
+ width = 600
20
+
21
+ def __init__(self, words):
22
+ self._words = words
23
+
24
+ def extract_words(self, **_kwargs):
25
+ return self._words
26
+
27
+
28
+ class PdfExtractorTests(unittest.TestCase):
29
+ def test_normalizes_embedded_font_variants(self):
30
+ self.assertEqual(_normalize_font_name('ABCDEF+Arial-BoldMT'), 'arial')
31
+
32
+ def test_detects_repeated_left_and_right_regions(self):
33
+ words = []
34
+ for row in range(8):
35
+ top = row * 20
36
+ words.extend([
37
+ {'top': top, 'x0': 30, 'x1': 140, 'text': 'Left column content'},
38
+ {'top': top, 'x0': 390, 'x1': 550, 'text': 'Right column content'},
39
+ ])
40
+ # Ensure the fixture passes the minimum word-count guard.
41
+ words.extend({'top': 200 + i, 'x0': 30, 'x1': 60, 'text': 'word'} for i in range(5))
42
+ self.assertTrue(_detect_multiple_columns(FakePage(words)))
43
+
44
+ def test_extracts_named_sections(self):
45
+ sections = _detect_sections('Jane Doe\nSUMMARY\nEngineer\nSKILLS\nPython\nEDUCATION\nBS CS')
46
+ self.assertEqual(sections['summary'], ['Engineer'])
47
+ self.assertEqual(sections['skills'], ['Python'])
48
+ self.assertEqual(sections['education'], ['BS CS'])
49
+
50
+ def test_extracts_docx_text_and_layout_metadata(self):
51
+ from docx import Document
52
+ from docx.shared import Pt
53
+
54
+ document = Document()
55
+ heading = document.add_paragraph('Jane Doe')
56
+ heading.runs[0].font.name = 'Arial'
57
+ heading.runs[0].font.size = Pt(16)
58
+ document.add_paragraph('SUMMARY')
59
+ document.add_paragraph('Software engineer building reliable products.')
60
+ document.add_paragraph('SKILLS')
61
+ document.add_paragraph('Python, TypeScript')
62
+ stream = io.BytesIO()
63
+ document.save(stream)
64
+ stream.seek(0)
65
+
66
+ result = extract_docx_from_stream(stream)
67
+ self.assertIn('Software engineer', result['text'])
68
+ self.assertEqual(result['sections']['skills'], ['Python, TypeScript'])
69
+ self.assertIn('fontCount', result['metadata'])
70
+
71
+
72
+ if __name__ == '__main__':
73
+ unittest.main()