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.

from natural_pdf import PDF

pdf = PDF("czech-republic-pisa2012_zakovsky_dotaznik_a.pdf")
pdf.pages[6:15].show()

If we want to look at one of the pages, it seems like the questions are in bold.

pdf.pages[7].find_all("text:bold:not-empty").show()

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!

Multiple approaches

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.

pdf.pages[7].find_all("text:bold:not-empty").dissolve().show()

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.

questions = (
    pdf
    .pages[6:15]
    .find_all('text:bold[size~=14][x0>100]:not-empty')
    .dissolve(padding=5)
)
questions.show()

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.

answer_areas = (
    questions.below(
        until='text:bold[size~=14]:regex(\d+) | rect[width>300] | text:regex(^ST\d)',
        include_endpoint=False
    )
)
answer_areas.show()

Now we can find the text of the question by asking for the text that is neither bold nor tiny:

answer_areas[3].find_all('text:not(:italic):not-empty[size>8]').show()

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))
Output
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.

sections = (
    pdf
    .pages[6:15]
    .get_sections(
        start_elements='text:bold[size~=14]:regex(\d+)'
    )
)
sections.show()

Let's a look at the first section.

sections[0].show()

If we wanted the rough text from the section, we just ask for it.

text = sections[0].extract_text(layout=True)
print(text)
Output
          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)
Output
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)
Output
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)
Output
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)
Output
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!