Spaces:
Sleeping
Sleeping
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 +128 -4
- tests/test_pdf_extractor.py +99 -0
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 =
|
|
|
|
|
|
|
| 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 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
| 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")))
|