Complex Table Extraction from OECD Czech PISA Assessment
This PDF is a document from the OECD regarding the PISA assessment, provided in Czech. The main extraction goal is to get the survey question table found on page 9. Challenges include the weird table format, making it hard to extract automatically.
I'm assuming by "survey question" the submitter wants as much as possible. You can extend the work we do here to get all of the surveys in the PDF, but for now we're just going to do a single section of the survey, from pages 7-15.
If we want to look at one of the pages, it seems like the questions are in bold.
Zoom in! You can see that some of the words, like vzdělání, are broken up into multiple words. We can see why if we inspect the text on the page.
pdf.pages[7].inspect()
Collection Inspection (144 elements)
Word Elements
text | x0 | top | x1 | bottom | font_family | font_variant | size | styles | source | confidence | color |
---|---|---|---|---|---|---|---|---|---|---|---|
71 | 73 | 74 | 87 | Times | EPFLBH | 14 | bold, italic | native | 1.00 | #007f00 | |
ST12 | 493 | 103 | 514 | 111 | Arial | BCOMPD | 8 | bold | native | 1.00 | #7f7f7f |
71 | 104 | 75 | 118 | Arial | BCOMPD | 14 | bold | native | 1.00 | #000000 | |
11. | 71 | 129 | 94 | 143 | Arial | BCOMPD | 14 | bold | native | 1.00 | #000000 |
Jaké je hlavní zam | 119 | 129 | 241 | 143 | Arial | BCOMPD | 14 | bold | native | 1.00 | #000000 |
ě | 241 | 129 | 249 | 143 | Arial | BCOMPC | 14 | bold | native | 1.00 | #000000 |
stnání tvé matky? | 249 | 129 | 375 | 143 | Arial | BCOMPD | 14 | bold | native | 1.00 | #000000 |
(nap | 119 | 145 | 147 | 159 | Arial | EPFLAG | 14 | native | 1.00 | #000000 | |
ř | 147 | 145 | 152 | 159 | Arial | EPFKOG | 14 | native | 1.00 | #000000 | |
. u | 152 | 145 | 167 | 159 | Arial | EPFLAG | 14 | native | 1.00 | #000000 | |
č | 167 | 145 | 174 | 159 | Arial | EPFKOG | 14 | native | 1.00 | #000000 | |
itelka na st | 174 | 145 | 241 | 159 | Arial | EPFLAG | 14 | native | 1.00 | #000000 | |
ř | 241 | 145 | 246 | 159 | Arial | EPFKOG | 14 | native | 1.00 | #000000 | |
ední škole, kucha | 246 | 145 | 355 | 159 | Arial | EPFLAG | 14 | native | 1.00 | #000000 | |
ř | 355 | 145 | 359 | 159 | Arial | EPFKOG | 14 | native | 1.00 | #000000 | |
ka ve školní jídeln | 359 | 145 | 471 | 159 | Arial | EPFLAG | 14 | native | 1.00 | #000000 | |
ě | 471 | 145 | 478 | 159 | Arial | EPFKOG | 14 | native | 1.00 | #000000 | |
, vedoucí | 479 | 145 | 539 | 159 | Arial | EPFLAG | 14 | native | 1.00 | #000000 | |
prodeje) | 119 | 161 | 170 | 175 | Arial | EPFLAG | 14 | native | 1.00 | #000000 | |
170 | 161 | 174 | 175 | Arial | BCOMPD | 14 | bold | native | 1.00 | #000000 | |
71 | 180 | 74 | 191 | Arial | EPFLAG | 11 | native | 1.00 | #000000 | ||
(Jestliže v sou | 119 | 186 | 197 | 200 | Times | EPFLHA | 14 | italic | native | 1.00 | #000000 |
č | 197 | 186 | 204 | 200 | Times | EPFLGP | 14 | italic | native | 1.00 | #000000 |
asné dob | 204 | 186 | 254 | 200 | Times | EPFLHA | 14 | italic | native | 1.00 | #000000 |
ě | 254 | 186 | 260 | 200 | Times | EPFLGP | 14 | italic | native | 1.00 | #000000 |
nepracuje, uve | 260 | 186 | 346 | 200 | Times | EPFLHA | 14 | italic | native | 1.00 | #000000 |
ď | 346 | 186 | 354 | 200 | Times | EPFLGP | 14 | italic | native | 1.00 | #000000 |
její poslední zam | 354 | 186 | 453 | 200 | Times | EPFLHA | 14 | italic | native | 1.00 | #000000 |
ě | 453 | 186 | 459 | 200 | Times | EPFLGP | 14 | italic | native | 1.00 | #000000 |
stnání.) | 459 | 186 | 509 | 200 | Times | EPFLHA | 14 | italic | native | 1.00 | #000000 |
Showing 30 of 138 elements (pass limit= to see more) |
Rect Elements
x0 | top | x1 | bottom | width | height | stroke | fill | stroke_width |
---|---|---|---|---|---|---|---|---|
428 | 550 | 440 | 562 | 12 | 12 | #000000 | #000000 | 0 |
428 | 584 | 440 | 596 | 12 | 12 | #000000 | #000000 | 0 |
428 | 624 | 440 | 637 | 12 | 12 | #000000 | #000000 | 0 |
428 | 665 | 440 | 678 | 12 | 12 | #000000 | #000000 | 0 |
428 | 699 | 440 | 711 | 12 | 12 | #000000 | #000000 | 0 |
69 | 779 | 526 | 779 | 457 | 0 | #000000 | #000000 | 0 |
Turns out the accented letters are a font variant! Each change in boldness, font size, or font type trigger the idea that something is a new word, even if we know it's not.
Do we deal with it? Do we ignore it? At least two paths open up ahead!
By default we'll assume you don't know why this is happening, and lean heavily in dissolve()
. Dissolve can be used to combine texts or regions that are close to one another.
When we use dissolve()
on the selection you'll see them combine into blocks. Along with weird font issues, dissolving is also useful for combining parts of the same question that are broken into separate rows. By using padding=5
we have the dissolve reach out five pixels to find nearby overlapping regions, including the ones on the row above/below.
If we were just interested in the questions, we could pull them each out now.
questions.extract_each_text()
['Kteří lidé obvykle bydlí u vás doma?', 'Jaké je hlavní zaměstnání tvé matky?', 'Co tvá matka v zaměstnání dělá?', 'Jakého nejvyššího vzdělání z následujících možností dosáhla\ntvá matka?', 'Ukončila tvá matka některý z těchto typů pomaturitního studia?', 'Co v současné době dělá tvá matka?', 'Jaké je hlavní zaměstnání tvého otce?', 'Co tvůj otec v zaměstnání dělá?', 'Jakého nejvyššího vzdělání z následujících možností dosáhl\ntvůj otec?', 'Ukončil tvůj otec některý z těchto typů pomaturitního studia?', 'Co v současné době dělá tvůj otec?', 'Ve které zemi ses narodil/a ty a tvoji rodiče?', 'Jestliže ses NENARODIL/A v České republice, kolik ti bylo let,\nkdyž ses do České republiky přistěhoval/a?', 'Kterým jazykem doma většinou mluvíte?', 'Které z uvedených věcí máte doma?', 'Kolik těchto věcí máte doma?', 'Kolik máte doma knih?']
Instead, we're going to use the question to break the page into sections. Starting from each question, we'll look .below()
until it hits the either:
- The next question
- A wide line (Why is it a
rect
? Who knows!) - The STXX text used to denote questions
This didn't come easy: It took a lot of trial and error to see the right selectors.
Now we can find the text of the question by asking for the text that is neither bold nor tiny:
And if we want it for each of the questions, we'll just search through each of them.
There are about two hundred ways to do this part.
results = []
for question, answer_area in zip(questions, answer_areas):
result = {}
result['question'] = question.extract_text()
result['notes'] = (
answer_area
.find_all('text:italic:not-empty[size>8]')
.extract_text()
)
result['answers'] = (
answer_area
.find_all('text:not(:italic):not-empty[size>8]')
.extract_text()
)
results.append(result)
print("Found", len(results))
Found 17
Now we can pack it up into pandas and be good to go.
import pandas as pd
df = pd.DataFrame(results)
df
question | notes | answers | |
---|---|---|---|
0 | Kteří lidé obvykle bydlí u vás doma? | 10. Kte ř í lidé obvykle bydlí u vás doma? | |
1 | Jaké je hlavní zaměstnání tvé matky? | 11. Jaké je hlavní zam ě stnání tvé matky? | |
2 | Co tvá matka v zaměstnání dělá? | 12. Co tvá matka v zam ě stnání d ě lá? | |
3 | Jakého nejvyššího vzdělání z následujících mož... | 13. Jakého nejvyššího vzd ě lání z následující... | |
4 | Ukončila tvá matka některý z těchto typů pomat... | 14. Ukon č ila tvá matka n ě který z t ě chto ... | |
5 | Co v současné době dělá tvá matka? | 15. Co v sou č asné dob ě d ě lá tvá matka? | |
6 | Jaké je hlavní zaměstnání tvého otce? | 16. Jaké je hlavní zam ě stnání tvého otce? | |
7 | Co tvůj otec v zaměstnání dělá? | 17. Co tv ů j otec v zam ě stnání d ě lá? | |
8 | Jakého nejvyššího vzdělání z následujících mož... | 18. Jakého nejvyššího vzd ě lání z následující... | |
9 | Ukončil tvůj otec některý z těchto typů pomatu... | 19. Ukon č il tv ů j otec n ě který z t ě chto... | |
10 | Co v současné době dělá tvůj otec? | 20. Co v sou č asné dob ě d ě lá tv ů j otec? | |
11 | Ve které zemi ses narodil/a ty a tvoji rodiče? | 21. Ve které zemi ses narodil/a ty a tvoji rod... | |
12 | Jestliže ses NENARODIL/A v České republice, ko... | 22. Jestliže ses NENARODIL/A v Č eské republic... | |
13 | Kterým jazykem doma většinou mluvíte? | 23. Kterým jazykem doma v ě tšinou mluvíte? | |
14 | Které z uvedených věcí máte doma? | 24. Které z uvedených v ě cí máte doma? | |
15 | Kolik těchto věcí máte doma? | 25. Kolik t ě chto v ě cí máte doma? | |
16 | Kolik máte doma knih? | 26. Kolik máte doma knih? |
Instead of focusing on the questions, we can also think about patterns on the page: each question begins with a number. Let's break the page up based on bold, size 14 text that includes numbers.
Let's a look at the first section.
If we wanted the rough text from the section, we just ask for it.
text = sections[0].extract_text(layout=True)
print(text)
10. Kteří lidé obvykle bydlí u vás doma? (V každém řádku zaškrtni pouze jeden čtvereček.) Ano Ne a) Matka (včetně nevlastní matky nebo pěstounky) 1 2 b) Otec (včetně nevlastního otce nebo pěstouna) 1 2 c) Bratr/bratři (včetně nevlastních) 1 2 d) Sestra/sestry (včetně nevlastních) 1 2 e) Prarodič/e 1 2 f) Jiní (např. sestřenice, bratranec) 1 2 7
Most likely we want to pull out the pieces separately: the italic, the bold, the normal. We can inspect the text on the page to see what selectors might work for each.
sections[0].find_all('text').inspect()
Collection Inspection (88 elements)
Word Elements
text | x0 | top | x1 | bottom | font_family | font_variant | size | styles | source | confidence | color |
---|---|---|---|---|---|---|---|---|---|---|---|
10. | 71 | 329 | 94 | 343 | Arial | BCOMPD | 14 | bold | native | 1.00 | #000000 |
Kte | 119 | 329 | 141 | 343 | Arial | BCOMPD | 14 | bold | native | 1.00 | #000000 |
ř | 141 | 329 | 147 | 343 | Arial | BCOMPC | 14 | bold | native | 1.00 | #000000 |
í lidé obvykle bydlí u vás doma? | 147 | 329 | 365 | 343 | Arial | BCOMPD | 14 | bold | native | 1.00 | #000000 |
71 | 354 | 74 | 368 | Times | EPFLHA | 14 | italic | native | 1.00 | #000000 | |
(V každém | 119 | 354 | 181 | 368 | Times | EPFLHA | 14 | italic | native | 1.00 | #000000 |
ř | 181 | 354 | 186 | 368 | Times | EPFLGP | 14 | italic | native | 1.00 | #000000 |
ádku zaškrtni pouze jeden | 186 | 354 | 335 | 368 | Times | EPFLHA | 14 | italic | native | 1.00 | #000000 |
č | 335 | 354 | 341 | 368 | Times | EPFLGP | 14 | italic | native | 1.00 | #000000 |
tvere | 341 | 354 | 369 | 368 | Times | EPFLHA | 14 | italic | native | 1.00 | #000000 |
č | 369 | 354 | 375 | 368 | Times | EPFLGP | 14 | italic | native | 1.00 | #000000 |
ek.) | 375 | 354 | 399 | 368 | Times | EPFLHA | 14 | italic | native | 1.00 | #000000 |
483 | 385 | 486 | 399 | Times | EPFLBJ | 14 | native | 1.00 | #000000 | ||
Ano | 385 | 386 | 406 | 397 | Times | EPFLHA | 11 | italic | native | 1.00 | #000000 |
Ne | 431 | 386 | 446 | 397 | Times | EPFLHA | 11 | italic | native | 1.00 | #000000 |
113 | 386 | 122 | 398 | Times | EPFLBJ | 12 | native | 1.00 | #000000 | ||
483 | 419 | 486 | 433 | Times | EPFLBJ | 14 | native | 1.00 | #000000 | ||
a) Matka (v | 104 | 420 | 163 | 432 | Times | EPFLBJ | 12 | native | 1.00 | #000000 | |
č | 163 | 420 | 168 | 432 | Times | EPFLBI | 12 | native | 1.00 | #000000 | |
etn | 168 | 420 | 183 | 432 | Times | EPFLBJ | 12 | native | 1.00 | #000000 | |
ě | 183 | 420 | 188 | 432 | Times | EPFLBI | 12 | native | 1.00 | #000000 | |
nevlastní matky nebo p | 188 | 420 | 303 | 432 | Times | EPFLBJ | 12 | native | 1.00 | #000000 | |
ě | 303 | 420 | 308 | 432 | Times | EPFLBI | 12 | native | 1.00 | #000000 | |
stounky) | 308 | 420 | 350 | 432 | Times | EPFLBJ | 12 | native | 1.00 | #000000 | |
350 | 420 | 353 | 432 | Times | EPFLHA | 12 | italic | native | 1.00 | #000000 | |
406 | 421 | 410 | 435 | Times | EPFLBJ | 14 | native | 1.00 | #000000 | ||
449 | 421 | 452 | 435 | Times | EPFLBJ | 14 | native | 1.00 | #000000 | ||
1 | 403 | 429 | 406 | 435 | Times | EPFLBJ | 6 | native | 1.00 | #000000 | |
2 | 445 | 429 | 449 | 435 | Times | EPFLBJ | 6 | native | 1.00 | #000000 | |
483 | 453 | 486 | 467 | Times | EPFLBJ | 14 | native | 1.00 | #000000 | ||
Showing 30 of 88 elements (pass limit= to see more) |
question = sections[0].find_all('text:bold').extract_text()
print(question)
10. Kte ř í lidé obvykle bydlí u vás doma?
notes = sections[0].find_all('text:italic[size~=14]').extract_text()
print(notes)
(V každém ř ádku zaškrtni pouze jeden č tvere č ek.)
answers = (
sections[0]
.find_all('text:not(:bold):not(:italic)[size=12]')
.extract_text(
layout=True,
strip=True,
)
)
print(answers)
a) Matka (včetně nevlastní matky nebo pěstounky) b) Otec (včetně nevlastního otce nebo pěstouna) c) Bratr/bratři (včetně nevlastních) d) Sestra/sestry (včetně nevlastních) e) Prarodič/e f) Jiní (např. sestřenice, bratranec)
Now that we know it works for one of them, we can do it for all of the sections.
results = []
for section in sections:
question = (
section
.find_all('text:bold')
.extract_text()
)
notes = (
section
.find_all('text:italic[size~=14]')
.extract_text()
)
answers = (
section
.find_all('text:not(:bold):not(:italic)[size=12]')
.extract_text(layout=True, strip=True)
)
results.append({
'question': question,
'notes': notes,
'answers': answers
})
len(results)
17
Pop it into a pandas dataframe and you're ready to go!
import pandas as pd
df = pd.DataFrame(results)
df.head()
question | notes | answers | |
---|---|---|---|
0 | 10. Kte ř í lidé obvykle bydlí u vás doma? | (V každém ř ádku zaškrtni pouze jeden č tvere ... | a) Matka (včetně nevlastní matky nebo pěstounk... |
1 | 11. Jaké je hlavní zam ě stnání tvé matky? | (Jestliže v sou č asné dob ě nepracuje, uve ď ... | |
2 | 12. Co tvá matka v zam ě stnání d ě lá? ST13 | Popiš jednou v ě tou pracovní č innost, kterou... | |
3 | 13. Jakého nejvyššího vzd ě lání z následující... | Když si nebudeš jistý/jistá tím, co máš vybrat... | Ukončila střední školu nebo učební obor s matu... |
4 | 14. Ukon č ila tvá matka n ě který z t ě chto ... | Když si nebudeš jistý/jistá tím, co máš vybrat... | a) Získala na vysoké škole vědecký titul (Ph.D... |
Done!