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 | bold | italic | strike | underline | highlight | source | confidence | color |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
71 | 73 | 74 | 87 | Times | EPFLBH | 14 | True | True | False | False | native | 1.00 | #007f00 | ||
ST12 | 493 | 103 | 514 | 111 | Arial | BCOMPD | 8 | True | False | False | False | native | 1.00 | #7f7f7f | |
71 | 104 | 75 | 118 | Arial | BCOMPD | 14 | True | False | False | False | native | 1.00 | #000000 | ||
11. | 71 | 129 | 94 | 143 | Arial | BCOMPD | 14 | True | False | False | False | native | 1.00 | #000000 | |
Jaké je hlavní zam | 119 | 129 | 241 | 143 | Arial | BCOMPD | 14 | True | False | False | False | native | 1.00 | #000000 | |
ě | 241 | 129 | 249 | 143 | Arial | BCOMPC | 14 | True | False | False | False | native | 1.00 | #000000 | |
stnání tvé matky? | 249 | 129 | 375 | 143 | Arial | BCOMPD | 14 | True | False | False | False | native | 1.00 | #000000 | |
(nap | 119 | 145 | 147 | 159 | Arial | EPFLAG | 14 | False | False | False | False | native | 1.00 | #000000 | |
ř | 147 | 145 | 152 | 159 | Arial | EPFKOG | 14 | False | False | False | False | native | 1.00 | #000000 | |
. u | 152 | 145 | 167 | 159 | Arial | EPFLAG | 14 | False | False | False | False | native | 1.00 | #000000 | |
č | 167 | 145 | 174 | 159 | Arial | EPFKOG | 14 | False | False | False | False | native | 1.00 | #000000 | |
itelka na st | 174 | 145 | 241 | 159 | Arial | EPFLAG | 14 | False | False | False | False | native | 1.00 | #000000 | |
ř | 241 | 145 | 246 | 159 | Arial | EPFKOG | 14 | False | False | False | False | native | 1.00 | #000000 | |
ední škole, kucha | 246 | 145 | 355 | 159 | Arial | EPFLAG | 14 | False | False | False | False | native | 1.00 | #000000 | |
ř | 355 | 145 | 359 | 159 | Arial | EPFKOG | 14 | False | False | False | False | native | 1.00 | #000000 | |
ka ve školní jídeln | 359 | 145 | 471 | 159 | Arial | EPFLAG | 14 | False | False | False | False | native | 1.00 | #000000 | |
ě | 471 | 145 | 478 | 159 | Arial | EPFKOG | 14 | False | False | False | False | native | 1.00 | #000000 | |
, vedoucí | 479 | 145 | 539 | 159 | Arial | EPFLAG | 14 | False | False | False | False | native | 1.00 | #000000 | |
prodeje) | 119 | 161 | 170 | 175 | Arial | EPFLAG | 14 | False | False | False | False | native | 1.00 | #000000 | |
170 | 161 | 174 | 175 | Arial | BCOMPD | 14 | True | False | False | False | native | 1.00 | #000000 | ||
71 | 180 | 74 | 191 | Arial | EPFLAG | 11 | False | False | False | False | native | 1.00 | #000000 | ||
(Jestliže v sou | 119 | 186 | 197 | 200 | Times | EPFLHA | 14 | False | True | False | False | native | 1.00 | #000000 | |
č | 197 | 186 | 204 | 200 | Times | EPFLGP | 14 | False | True | False | False | native | 1.00 | #000000 | |
asné dob | 204 | 186 | 254 | 200 | Times | EPFLHA | 14 | False | True | False | False | native | 1.00 | #000000 | |
ě | 254 | 186 | 260 | 200 | Times | EPFLGP | 14 | False | True | False | False | native | 1.00 | #000000 | |
nepracuje, uve | 260 | 186 | 346 | 200 | Times | EPFLHA | 14 | False | True | False | False | native | 1.00 | #000000 | |
ď | 346 | 186 | 354 | 200 | Times | EPFLGP | 14 | False | True | False | False | native | 1.00 | #000000 | |
její poslední zam | 354 | 186 | 453 | 200 | Times | EPFLHA | 14 | False | True | False | False | native | 1.00 | #000000 | |
ě | 453 | 186 | 459 | 200 | Times | EPFLGP | 14 | False | True | False | False | native | 1.00 | #000000 | |
stnání.) | 459 | 186 | 509 | 200 | Times | EPFLHA | 14 | False | True | False | False | 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))
JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT 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? | (V každém ř ádku zaškrtni pouze jeden č tvere ... | a) Matka (v č etn ě nevlastní matky nebo p ě s... |
1 | Jaké je hlavní zaměstnání tvé matky? | (Jestliže v sou č asné dob ě nepracuje, uve ď ... | (nap ř . u č itelka na st ř ední škole, kucha ... |
2 | Co tvá matka v zaměstnání dělá? | Popiš jednou v ě tou pracovní č innost, kterou... | (nap ř . u č í st ř edoškolské studenty, va ř ... |
3 | Jakého nejvyššího vzdělání z následujících mož... | Když si nebudeš jistý/jistá tím, co máš vybrat... | Ukon č ila st ř ední školu nebo u č ební obor ... |
4 | Ukončila tvá matka některý z těchto typů pomat... | Když si nebudeš jistý/jistá tím, co máš vybrat... | a) Získala na vysoké škole v ě decký titul (Ph... |
5 | Co v současné době dělá tvá matka? | (Zaškrtni pouze jeden č tvere č ek.) | Pracuje na plný úvazek. Pracuje na č áste č ný... |
6 | Jaké je hlavní zaměstnání tvého otce? | (Jestliže v sou č asné dob ě nepracuje, uve ď ... | (nap ř . u č itel na st ř ední škole, truhlá ř... |
7 | Co tvůj otec v zaměstnání dělá? | Popiš jednou v ě tou pracovní č innost, kterou... | (nap ř . u č í st ř edoškolské studenty, vyráb... |
8 | Jakého nejvyššího vzdělání z následujících mož... | Když si nebudeš jistý/jistá tím, co máš vybrat... | Ukon č il st ř ední školu nebo u č ební obor s... |
9 | Ukončil tvůj otec některý z těchto typů pomatu... | Když si nebudeš jistý/jistá tím, co máš vybrat... | a) Získal na vysoké škole v ě decký titul (Ph.... |
10 | Co v současné době dělá tvůj otec? | (Zaškrtni pouze jeden č tvere č ek.) | Pracuje na plný úvazek. Pracuje na č áste č ný... |
11 | Ve které zemi ses narodil/a ty a tvoji rodiče? | (Zaškrtni jeden č tvere č ek v každém sloupci ... | Č eská republika Slovenská republika Rusko Ukr... |
12 | Jestliže ses NENARODIL/A v České republice, ko... | V p ř ípad ě , že ti bylo mén ě než 12 m ě síc... | |
13 | Kterým jazykem doma většinou mluvíte? | (Zaškrtni pouze jeden č tvere č ek.) | Č esky Slovensky Romsky Rusky Ukrajinsky Vietn... |
14 | Které z uvedených věcí máte doma? | (V každém ř ádku zaškrtni pouze jeden č tvere ... | a) Psací st ů l, u kterého se m ů žeš u č it b... |
15 | Kolik těchto věcí máte doma? | (V každém ř ádku zaškrtni pouze jeden č tvere ... | a) Mobilní telefon b) Televize c) Po č íta č d... |
16 | Kolik máte doma knih? | Na polici dlouhou jeden metr se vejde asi 40 k... | 0 – 10 knih 11 – 25 knih 26 – 100 knih 101 – 2... |
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 | bold | italic | strike | underline | highlight | source | confidence | color |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
10. | 71 | 329 | 94 | 343 | Arial | BCOMPD | 14 | True | False | False | False | native | 1.00 | #000000 | |
Kte | 119 | 329 | 141 | 343 | Arial | BCOMPD | 14 | True | False | False | False | native | 1.00 | #000000 | |
ř | 141 | 329 | 147 | 343 | Arial | BCOMPC | 14 | True | False | False | False | native | 1.00 | #000000 | |
í lidé obvykle bydlí u vás doma? | 147 | 329 | 365 | 343 | Arial | BCOMPD | 14 | True | False | False | False | native | 1.00 | #000000 | |
71 | 354 | 74 | 368 | Times | EPFLHA | 14 | False | True | False | False | native | 1.00 | #000000 | ||
(V každém | 119 | 354 | 181 | 368 | Times | EPFLHA | 14 | False | True | False | False | native | 1.00 | #000000 | |
ř | 181 | 354 | 186 | 368 | Times | EPFLGP | 14 | False | True | False | False | native | 1.00 | #000000 | |
ádku zaškrtni pouze jeden | 186 | 354 | 335 | 368 | Times | EPFLHA | 14 | False | True | False | False | native | 1.00 | #000000 | |
č | 335 | 354 | 341 | 368 | Times | EPFLGP | 14 | False | True | False | False | native | 1.00 | #000000 | |
tvere | 341 | 354 | 369 | 368 | Times | EPFLHA | 14 | False | True | False | False | native | 1.00 | #000000 | |
č | 369 | 354 | 375 | 368 | Times | EPFLGP | 14 | False | True | False | False | native | 1.00 | #000000 | |
ek.) | 375 | 354 | 399 | 368 | Times | EPFLHA | 14 | False | True | False | False | native | 1.00 | #000000 | |
483 | 385 | 486 | 399 | Times | EPFLBJ | 14 | False | False | False | False | native | 1.00 | #000000 | ||
Ano | 385 | 386 | 406 | 397 | Times | EPFLHA | 11 | False | True | False | False | native | 1.00 | #000000 | |
Ne | 431 | 386 | 446 | 397 | Times | EPFLHA | 11 | False | True | False | False | native | 1.00 | #000000 | |
113 | 386 | 122 | 398 | Times | EPFLBJ | 12 | False | False | False | False | native | 1.00 | #000000 | ||
483 | 419 | 486 | 433 | Times | EPFLBJ | 14 | False | False | False | False | native | 1.00 | #000000 | ||
a) Matka (v | 104 | 420 | 163 | 432 | Times | EPFLBJ | 12 | False | False | False | False | native | 1.00 | #000000 | |
č | 163 | 420 | 168 | 432 | Times | EPFLBI | 12 | False | False | False | False | native | 1.00 | #000000 | |
etn | 168 | 420 | 183 | 432 | Times | EPFLBJ | 12 | False | False | False | False | native | 1.00 | #000000 | |
ě | 183 | 420 | 188 | 432 | Times | EPFLBI | 12 | False | False | False | False | native | 1.00 | #000000 | |
nevlastní matky nebo p | 188 | 420 | 303 | 432 | Times | EPFLBJ | 12 | False | False | False | False | native | 1.00 | #000000 | |
ě | 303 | 420 | 308 | 432 | Times | EPFLBI | 12 | False | False | False | False | native | 1.00 | #000000 | |
stounky) | 308 | 420 | 350 | 432 | Times | EPFLBJ | 12 | False | False | False | False | native | 1.00 | #000000 | |
350 | 420 | 353 | 432 | Times | EPFLHA | 12 | False | True | False | False | native | 1.00 | #000000 | ||
406 | 421 | 410 | 435 | Times | EPFLBJ | 14 | False | False | False | False | native | 1.00 | #000000 | ||
449 | 421 | 452 | 435 | Times | EPFLBJ | 14 | False | False | False | False | native | 1.00 | #000000 | ||
1 | 403 | 429 | 406 | 435 | Times | EPFLBJ | 6 | False | False | False | False | native | 1.00 | #000000 | |
2 | 445 | 429 | 449 | 435 | Times | EPFLBJ | 6 | False | False | False | False | native | 1.00 | #000000 | |
483 | 453 | 486 | 467 | Times | EPFLBJ | 14 | False | False | False | False | 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)
JOIN WITHOUT LAYOUT 10. Kte ř í lidé obvykle bydlí u vás doma?
notes = sections[0].find_all('text:italic[size~=14]').extract_text()
print(notes)
JOIN WITHOUT LAYOUT (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)
JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT JOIN WITHOUT LAYOUT
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!