ibrahimgulbutt commited on
Commit
be87d41
·
1 Parent(s): 7c66219

deploy: extract only the text a person can see (hidden/white/1pt detection)

Browse files
services/pdf_extractor.py CHANGED
@@ -20,6 +20,7 @@ def extract_resume_from_stream(stream) -> dict:
20
  detects basic metadata, and identifies resume sections.
21
  """
22
  text_pages = []
 
23
  has_tables = False
24
  has_images = False
25
  page_count = 0
@@ -49,12 +50,15 @@ def extract_resume_from_stream(stream) -> dict:
49
  if isinstance(size, (int, float)) and size > 0:
50
  font_sizes.append(float(size))
51
 
52
- page_text = page.extract_text() or ""
 
 
53
  if _is_sparse_page_text(page_text, page):
54
  page_text = _extract_words_as_lines(page) or page_text
55
  text_pages.append(page_text)
56
 
57
  full_text = "\n".join(text_pages).strip()
 
58
 
59
  # Confidence: penalise if text is very short relative to page count
60
  expected_min_chars = page_count * 200
@@ -84,6 +88,12 @@ def extract_resume_from_stream(stream) -> dict:
84
  "averageCharsPerPage": round(len(full_text) / max(page_count, 1)),
85
  "longLineCount": long_line_count,
86
  "bulletCount": bullet_count,
 
 
 
 
 
 
87
  },
88
  }
89
 
@@ -95,19 +105,33 @@ def extract_docx_from_stream(stream) -> dict:
95
 
96
  document = Document(stream)
97
  text_lines = []
 
98
  font_names = set()
99
  font_sizes = []
100
 
101
  for paragraph in document.paragraphs:
102
- text = paragraph.text.strip()
103
- if text:
104
- text_lines.append(text)
 
 
105
  for run in paragraph.runs:
 
 
 
 
 
106
  if run.font.name:
107
  font_names.add(_normalize_font_name(run.font.name))
108
  if run.font.size:
109
  font_sizes.append(float(run.font.size.pt))
110
 
 
 
 
 
 
 
111
  for table in document.tables:
112
  for row in table.rows:
113
  cells = [cell.text.strip() for cell in row.cells if cell.text.strip()]
@@ -115,6 +139,7 @@ def extract_docx_from_stream(stream) -> dict:
115
  text_lines.append(" | ".join(cells))
116
 
117
  full_text = "\n".join(text_lines).strip()
 
118
  estimated_pages = max(1, round(len(full_text) / 3500))
119
  has_multiple_columns = False
120
  for section in document.sections:
@@ -146,10 +171,109 @@ def extract_docx_from_stream(stream) -> dict:
146
  "averageCharsPerPage": round(len(full_text) / estimated_pages),
147
  "longLineCount": long_line_count,
148
  "bulletCount": bullet_count,
 
 
149
  },
150
  }
151
 
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  def _normalize_font_name(font_name: str) -> str:
154
  """Collapse embedded font prefixes and style suffixes into a font family."""
155
  family = font_name.split("+")[-1].lower()
 
20
  detects basic metadata, and identifies resume sections.
21
  """
22
  text_pages = []
23
+ hidden_pages = []
24
  has_tables = False
25
  has_images = False
26
  page_count = 0
 
50
  if isinstance(size, (int, float)) and size > 0:
51
  font_sizes.append(float(size))
52
 
53
+ page_text, page_hidden = _split_hidden(page)
54
+ if page_hidden:
55
+ hidden_pages.append(page_hidden)
56
  if _is_sparse_page_text(page_text, page):
57
  page_text = _extract_words_as_lines(page) or page_text
58
  text_pages.append(page_text)
59
 
60
  full_text = "\n".join(text_pages).strip()
61
+ hidden_text = "\n".join(hidden_pages).strip()
62
 
63
  # Confidence: penalise if text is very short relative to page count
64
  expected_min_chars = page_count * 200
 
88
  "averageCharsPerPage": round(len(full_text) / max(page_count, 1)),
89
  "longLineCount": long_line_count,
90
  "bulletCount": bullet_count,
91
+ # Text the page draws but nobody can read. The backend treats this
92
+ # as a high-severity integrity finding rather than as CV content:
93
+ # skills only present here were written for the scorer, not for the
94
+ # person doing the hiring.
95
+ "hiddenTextFound": bool(hidden_text),
96
+ "hiddenTextSample": hidden_text[:300] or None,
97
  },
98
  }
99
 
 
105
 
106
  document = Document(stream)
107
  text_lines = []
108
+ hidden_runs = []
109
  font_names = set()
110
  font_sizes = []
111
 
112
  for paragraph in document.paragraphs:
113
+ # Word has a literal hidden-text attribute (w:vanish) as well as the
114
+ # white-on-white trick, and python-docx exposes both. A run hidden by
115
+ # either is dropped from the text and kept as evidence: it is not
116
+ # content the candidate is showing the employer.
117
+ visible_runs = []
118
  for run in paragraph.runs:
119
+ if _is_hidden_run(run):
120
+ if run.text.strip():
121
+ hidden_runs.append(run.text.strip())
122
+ continue
123
+ visible_runs.append(run)
124
  if run.font.name:
125
  font_names.add(_normalize_font_name(run.font.name))
126
  if run.font.size:
127
  font_sizes.append(float(run.font.size.pt))
128
 
129
+ text = ("".join(run.text for run in visible_runs) or paragraph.text).strip()
130
+ if hidden_runs and not visible_runs:
131
+ text = ""
132
+ if text:
133
+ text_lines.append(text)
134
+
135
  for table in document.tables:
136
  for row in table.rows:
137
  cells = [cell.text.strip() for cell in row.cells if cell.text.strip()]
 
139
  text_lines.append(" | ".join(cells))
140
 
141
  full_text = "\n".join(text_lines).strip()
142
+ hidden_text = "\n".join(hidden_runs).strip()
143
  estimated_pages = max(1, round(len(full_text) / 3500))
144
  has_multiple_columns = False
145
  for section in document.sections:
 
171
  "averageCharsPerPage": round(len(full_text) / estimated_pages),
172
  "longLineCount": long_line_count,
173
  "bulletCount": bullet_count,
174
+ "hiddenTextFound": bool(hidden_text),
175
+ "hiddenTextSample": hidden_text[:300] or None,
176
  },
177
  }
178
 
179
 
180
+ # ----------------------------------------------------------------- hiding --
181
+
182
+ # Below this, nothing is readable on a screen or on paper. A CV does not contain
183
+ # text nobody is meant to read unless somebody meant it.
184
+ _UNREADABLE_PT = 3.5
185
+
186
+ # How close to the page colour counts as invisible. Not exact equality: a CV
187
+ # built to hide text often uses 0.98 grey rather than pure white, precisely
188
+ # because an exact-match check is the obvious one to write.
189
+ _INK_EPSILON = 0.04
190
+
191
+
192
+ def _is_background_colour(color) -> bool:
193
+ """True when a character is drawn in (near) the page's own colour.
194
+
195
+ pdfplumber reports `non_stroking_color` in whatever colourspace the PDF
196
+ used, so this has to handle all of them:
197
+
198
+ None unknown - treated as visible, never as evidence
199
+ 0.97 greyscale, 1 is white
200
+ (0.99,1,1) RGB
201
+ (0,0,0,0) CMYK, where 0 ink is white
202
+ """
203
+ if color is None:
204
+ return False
205
+ if isinstance(color, (int, float)):
206
+ return float(color) >= 1 - _INK_EPSILON
207
+ try:
208
+ channels = [float(c) for c in color]
209
+ except (TypeError, ValueError):
210
+ return False
211
+ if not channels:
212
+ return False
213
+ if len(channels) == 4: # CMYK: no ink at all
214
+ return all(c <= _INK_EPSILON for c in channels)
215
+ return all(c >= 1 - _INK_EPSILON for c in channels)
216
+
217
+
218
+ def _is_hidden_char(char) -> bool:
219
+ """A character the page renders but a person cannot read."""
220
+ size = char.get("size")
221
+ if isinstance(size, (int, float)) and 0 < float(size) < _UNREADABLE_PT:
222
+ return True
223
+ return _is_background_colour(char.get("non_stroking_color"))
224
+
225
+
226
+ def _is_hidden_run(run) -> bool:
227
+ """A DOCX run Word will render invisibly, or not at all."""
228
+ font = run.font
229
+ # w:vanish - Word's own "hidden text" checkbox.
230
+ if getattr(font, "hidden", None):
231
+ return True
232
+ size = getattr(font, "size", None)
233
+ if size is not None and getattr(size, "pt", None) and float(size.pt) < _UNREADABLE_PT:
234
+ return True
235
+ color = getattr(font, "color", None)
236
+ rgb = getattr(color, "rgb", None) if color is not None else None
237
+ if rgb is None:
238
+ return False
239
+ try:
240
+ r, g, b = int(str(rgb)[0:2], 16), int(str(rgb)[2:4], 16), int(str(rgb)[4:6], 16)
241
+ except (ValueError, IndexError):
242
+ return False
243
+ return r >= 245 and g >= 245 and b >= 245
244
+
245
+
246
+ def _split_hidden(page):
247
+ """Return (visible_text, hidden_text) for one page.
248
+
249
+ The important subtlety is the RATIO. A CV designed with a dark background
250
+ and white type is a legitimate design choice, and on such a page nearly
251
+ every character is 'white' - flagging it would accuse a graphic designer of
252
+ fraud for owning a portfolio template. Hiding, by contrast, is a small block
253
+ of invisible text on an otherwise ordinary page.
254
+
255
+ So white text is only treated as hidden when it is the MINORITY of the page.
256
+ Above half, the page is read as a design and nothing is stripped.
257
+ """
258
+ chars = page.chars or []
259
+ if not chars:
260
+ return page.extract_text() or "", ""
261
+
262
+ hidden = [c for c in chars if _is_hidden_char(c)]
263
+ if not hidden:
264
+ return page.extract_text() or "", ""
265
+
266
+ if len(hidden) > len(chars) * 0.5:
267
+ # A dark-background or light-on-colour design, not a hiding place.
268
+ return page.extract_text() or "", ""
269
+
270
+ hidden_text = "".join(str(c.get("text", "")) for c in hidden).strip()
271
+ visible = page.filter(
272
+ lambda obj: obj.get("object_type") != "char" or not _is_hidden_char(obj)
273
+ )
274
+ return (visible.extract_text() or ""), hidden_text
275
+
276
+
277
  def _normalize_font_name(font_name: str) -> str:
278
  """Collapse embedded font prefixes and style suffixes into a font family."""
279
  family = font_name.split("+")[-1].lower()
tests/test_pdf_extractor.py CHANGED
@@ -71,3 +71,102 @@ class PdfExtractorTests(unittest.TestCase):
71
 
72
  if __name__ == '__main__':
73
  unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  if __name__ == '__main__':
73
  unittest.main()
74
+
75
+
76
+ class FakeCharPage:
77
+ """A page whose characters we control, for the hidden-text splitter."""
78
+
79
+ def __init__(self, chars):
80
+ self.chars = chars
81
+
82
+ def extract_text(self):
83
+ return "".join(str(c.get("text", "")) for c in self.chars)
84
+
85
+ def filter(self, predicate):
86
+ kept = [c for c in self.chars if predicate({**c, "object_type": "char"})]
87
+ return FakeCharPage(kept)
88
+
89
+
90
+ def _char(text, size=11.0, color=(0, 0, 0)):
91
+ return {"text": text, "size": size, "non_stroking_color": color, "object_type": "char"}
92
+
93
+
94
+ class HiddenTextTests(unittest.TestCase):
95
+ """White-on-white and 1pt text is written for the scorer, not the reader."""
96
+
97
+ def test_background_colour_detection_across_colourspaces(self):
98
+ from services.pdf_extractor import _is_background_colour
99
+
100
+ self.assertTrue(_is_background_colour(1)) # greyscale white
101
+ self.assertTrue(_is_background_colour((1, 1, 1))) # RGB white
102
+ self.assertTrue(_is_background_colour((0.99, 1, 0.98))) # near-white, the sneaky one
103
+ self.assertTrue(_is_background_colour((0, 0, 0, 0))) # CMYK, no ink
104
+ self.assertFalse(_is_background_colour((0, 0, 0))) # black
105
+ self.assertFalse(_is_background_colour(0.2)) # dark grey
106
+ self.assertFalse(_is_background_colour(None)) # unknown is never evidence
107
+
108
+ def test_unreadably_small_text_counts_as_hidden(self):
109
+ from services.pdf_extractor import _is_hidden_char
110
+
111
+ self.assertTrue(_is_hidden_char(_char("x", size=1.0)))
112
+ self.assertFalse(_is_hidden_char(_char("x", size=9.0)))
113
+
114
+ def test_white_block_is_split_out_of_the_visible_text(self):
115
+ from services.pdf_extractor import _split_hidden
116
+
117
+ # The hidden block has to be the MINORITY of the page, which is what a
118
+ # real attack looks like: an ordinary CV with a small invisible payload.
119
+ body = "Frontend Developer at Orchard Labs building the customer dashboard"
120
+ visible = [_char(c) for c in body]
121
+ hidden = [_char(c, color=(1, 1, 1)) for c in "KubernetesTerraform"]
122
+ page = FakeCharPage(visible + hidden)
123
+
124
+ text, hidden_text = _split_hidden(page)
125
+ self.assertEqual(text, body)
126
+ self.assertEqual(hidden_text, "KubernetesTerraform")
127
+ self.assertNotIn("Kubernetes", text)
128
+
129
+ def test_a_dark_background_design_is_not_treated_as_hiding(self):
130
+ """Mostly-white type means a dark template, not a hiding place.
131
+
132
+ Flagging it would accuse a designer of fraud for owning a portfolio CV.
133
+ """
134
+ from services.pdf_extractor import _split_hidden
135
+
136
+ page = FakeCharPage([_char(c, color=(1, 1, 1)) for c in "Creative Director"])
137
+ text, hidden_text = _split_hidden(page)
138
+ self.assertEqual(text, "Creative Director")
139
+ self.assertEqual(hidden_text, "")
140
+
141
+ def test_an_ordinary_page_reports_nothing_hidden(self):
142
+ from services.pdf_extractor import _split_hidden
143
+
144
+ page = FakeCharPage([_char(c) for c in "Registered Nurse"])
145
+ text, hidden_text = _split_hidden(page)
146
+ self.assertEqual(text, "Registered Nurse")
147
+ self.assertEqual(hidden_text, "")
148
+
149
+
150
+ class HiddenDocxRunTests(unittest.TestCase):
151
+ def _run(self, hidden=None, size_pt=None, rgb=None):
152
+ run = Mock()
153
+ run.font.hidden = hidden
154
+ run.font.size = Mock(pt=size_pt) if size_pt is not None else None
155
+ run.font.color = Mock(rgb=rgb) if rgb is not None else None
156
+ return run
157
+
158
+ def test_words_own_hidden_attribute_is_respected(self):
159
+ from services.pdf_extractor import _is_hidden_run
160
+
161
+ self.assertTrue(_is_hidden_run(self._run(hidden=True)))
162
+
163
+ def test_white_font_colour_is_hidden(self):
164
+ from services.pdf_extractor import _is_hidden_run
165
+
166
+ self.assertTrue(_is_hidden_run(self._run(rgb="FFFFFF")))
167
+ self.assertFalse(_is_hidden_run(self._run(rgb="222222")))
168
+
169
+ def test_normal_run_is_visible(self):
170
+ from services.pdf_extractor import _is_hidden_run
171
+
172
+ self.assertFalse(_is_hidden_run(self._run(size_pt=11.0, rgb="000000")))