momente şi schiţe de informatică şi matematică
To attain knowledge, write. To attain wisdom, rewrite.

Trei pagini PDF în una, folosind Python

PDF | Python
2013 jul

Am obţinut de pe undeva, arhiva subiectelor la proba "D" (matematică) pentru MT2, elaborate în 2009 de către specialişti în evaluare şi examinare (vezi [1], [2], [3]). Fiecare dintre cele 100 de variante este reprezentată în această arhivă pe trei fişiere PDF - câte unul pentru fiecare subiect - denumite după modelul "d_mt2_i_001.pdf" (_i pentru "Subiectul I", _ii pentru "Subiectul II" şi _iii pentru "Subiectul III"; variantele sunt indicate prin _001, ..., _100).

Următorul script Python extrage unica pagină din cele trei fişiere PDF aferente subiectelor care compun varianta "001" şi produce un fişier PDF nou, conţinând cele trei pagini extrase:

from pyPdf import PdfFileWriter, PdfFileReader
path = "/home/vb/bacmath/DOC/D_matematica_MT2/" + "d_mt2_"
subiecte = [ PdfFileReader(file(path + sub + "_001.pdf", "rb")).getPage(0) 
             for sub in ['i', 'ii', 'iii'] ]
out = PdfFileWriter()
for sub in subiecte:
    out.addPage(sub)
out.write(file("Varianta_1.pdf", "wb"))

"Varianta_1.pdf" conţine 3 pagini (subiecte) A4 Portrait (8.26 x 11.69 inch)

Pentru interfaţa de lucru preconizată pentru //bacmath - descrisă în [2] şi [3] - este de dorit ca fiecare variantă să fie redată într-un fişier PDF cu o singură pagină (de "format" ceva mai mic decât "formatul A4"), conţinând subiectele propriu-zise, fără antet şi fără vreo notă de subsol.

Prin urmare, am avea de făcut aceste operaţii: de "şters" antetele şi notele de subsol; de "eliminat" sau de redus la minimul necesar, spaţiul liber existent pe paginile "A4" iniţiale (după rândurile pe care este scris subiectul); în final - să înglobăm într-o singură pagină (de salvat ca "Varianta_N.pdf") cele trei pagini "trunchiate" astfel.

Aceasta ar fi cum nu se poate mai banal, pentru fişiere text obişnuite; însă în cazul unui fişier PDF, modificarea manuală devine extrem de pretenţioasă: nu ajunge să identifici "antetul" (nici aceasta nu-i simplu) şi să-l elimini sau să-l modifici - trebuie recalculate offset-urile în cadrul fişierului ale tuturor obiectelor înregistrate şi trebuie reconstruite tabelele de referinţe între aceste obiecte, specifice formatului PDF (care nici nu prevede "ştergeri", ci doar "updatări").

Am realizat operaţiile menţionate folosind micul modul Python pyPdf şi programul Perl pdfcrop.pl (mic şi acesta, integrat deja în LaTeX). Desigur - am precizat "mic", pentru că există şi programe comerciale "uriaşe" ca dimensiune (bineînţeles, aplicaţii de tip "point-and-click"), care fac tot ce-şi poate dori un utilizator obişnuit, asupra unui fişier PDF.

Elemente de "format PDF"

pdfinfo d_mt2_i_001.pdf ne arată Title: Microsoft Word - D_MT2_I_001.doc şi Optimized: yes - deci fişierele PDF iniţiale provin prin salvarea "ca PDF" a unor documente Microsoft Word, optând pentru "optimizare" (vezi şi [2]). "Optimizarea" afectează structura tipică a unui fişier PDF şi trebuie să ţinem seama de acest aspect, dacă avem de identificat şi de interpretat diverse obiecte din fişier.

Un fişier PDF este fişier binar (nu "text"; de exemplu, nu-i putem aplica dos2unix, pentru a înlocui caracterul "end-of-line" specific Windows-ului cu cel specific pentru Linux); deschizându-l totuşi într-un editor de text, sau şi mai bine - folosind viewer-ul intern din Gnome Commander, găsim chiar la început acest dicţionar (în care am adăugat aici, unele comentarii):

<<  % marchează un obiect de tip "dicţionar"
/Linearized 1  % "optimizare"...
/O 8   % indică primul obiect din prima pagină a documentului
/H [ 1313 234 ] 
/L 37073   % lungimea fişierului
/E 35286   % offset-ul ultimului octet de date aferente primei pagini
/N 1   % Numărul de pagini ale documentului
/T 36836   % offset-ul primei intrări în tabelul 'xref' principal 
>> 

În mod standard, informaţiile necesare unui "Document Viewer" pentru a vizualiza documentul PDF sunt înscrise la sfârşitul fişierului PDF - anume, într-un tabel sintetic denumit xref, de referinţe la obiectele conţinute (offset-urile lor faţă de începutul fişierului, informaţii privind "updatarea"), urmat de un dicţionar denumit trailer care indică între altele, obiectul "rădăcină" al documentului (catalogul) şi apoi, de o valoare denumită startxref, indicând offset-ul tabelului "xref".

Procedând astfel, se favorizează "updatarea incrementală" a documentului: informaţiile privitoare la modificările efectuate asupra documentului sunt adăugate (când se acţionează "Save") la sfârşitul fişierului, evitând rescrierea întregului fişier (şi implicit, lăsând intact conţinutul original).

Dar pe de altă parte, vizualizarea ulterioară a documentului este dezavantajată, prin faptul că "Document Viewer"-ul trebuie să încarce în memorie întregul fişier, pentru a putea accesa startxref şi trailer-ul (de la sfârşitul fişierului) necesare pentru a identifica obiectul "catalog" şi apoi obiectele conţinute de paginile care trebuie vizualizate.

De aceea, formatul PDF standard a fost extins cu posibilitatea de liniarizare (numită şi “Fast Web View”), care "optimizează" documentul în sensul că permite redarea primei pagini (sau a paginii prestabilite) fără să mai fie necesară încărcarea în memorie a întregului fişier.

Prin "liniarizare", fişierul este reorganizat astfel încât informaţiile necesare pentru redarea primei pagini (sau a paginii dorite) să fie reprezentate chiar la începutul fişierului PDF respectiv; citind această primă parte din fişier, "Document Viewer"-ul poate deja să redea prima pagină, continuând să încarce restul fişierului "în background". Pe de altă parte, "liniarizarea" necesită adăugarea la sfârşitul fişierului a câtorva tabele de referinţe, suplimentare.

Desigur - liniarizarea este de dorit, dacă avem un fişier de 500 de pagini; însă în cazul fişierelor PDF vizate aici, fiecare conţine câte o singură pagină şi este clar că "optimizarea" de liniarizare care li s-a aplicat este neavenită şi nu face decât să mărească dimensiunea fişierului.

Eliminarea lucrurilor inutile (antet, subsol, punctaj lateral)

Obiectul indicat sub cheia /O în dicţionarul redat mai sus (primul dintre cele aferente primeia şi unicei pagini din fişierul nostru) conţine un dicţionar în care apare şi specificarea

/MediaBox [ 0 0 595 842 ] 

care reprezintă coordonatele colţurilor stânga-jos (0, 0) şi dreapta-sus (595pt, 842pt), ale paginii. Adică iniţial, pagina are lăţimea de 595/72 ≈ 8.26 inch şi înălţimea 842/72 ≈ 11.69 inch (în mod standard, 1 inch = 72 points) - valori caracteristice formatului "pagină A4".

Deocamdată, m-am mulţumit să estimez "din ochi" cât ar trebui scăzut din înălţime, pentru a "elimina" antetul şi a încadra convenabil conţinutul propriu-zis al subiectului - experimentând cu ajutorul următoarei funcţii:

from pyPdf import PdfFileWriter, PdfFileReader
def chopPdf(file_name1, from_top_left, from_top_right, file_name2):
    pdf = PdfFileReader(file(file_name1, 'rb'))
    page = pdf.getPage(0)
    height = page.mediaBox.upperRight[1]
    page.mediaBox.lowerLeft = (
        70, #page.mediaBox.getLowerLeft_x(),
        height - from_top_left
    )
    page.mediaBox.upperRight = (
        page.mediaBox.getUpperRight_x(),
        height - from_top_right
    )
    out = PdfFileWriter()
    out.addPage(page)
    out.write(file(file_name2, 'wb'))

După câteva încercări, am găsit că este suficient să "ridic" colţul stânga-jos la cota 842 - 692 = 150 şi să "cobor" colţul dreapta-sus după caz: la cota (842 - 150) pentru pagina din fişierul "_i_" (la "Subiectul I" antetul fiind mai voluminos) şi la cota (842 - 62) pentru fişierele "_ii_" şi "_iii_".

Astfel, apelul chopPdf('d_mt2_i_001.pdf', 692, 150, '_i1.pdf') ne-a dat acest fişier:

'_i1.pdf', având /MediaBox [ 70 150 595 692 ]

pe care nu mai apare nici antetul, nici nota de subsol (şi nici menţiunile "5p", fiindcă am deplasat colţul stânga-jos şi spre dreapta, cu 70 pt.); dar "nu mai apare" se referă la partea vizibilă a paginii, neînseamnând şi "eliminat": cu pdftotext ne putem convinge imediat că antetul există încă şi în fişierul rezultat "_i1.pdf".

Rămâne să trunchiem partea vizibilă a paginii, "eliminând" spaţiul liber aflat dedesubtul subiectului:

vb@vb:~/bacmath/DOC/A$ pdfcrop _i1.pdf _i1_trunc.pdf

Eventual cu pdfinfo, putem vedea că fişierul rezultat are Page size: 458 x 142 pts, faţă de 525 x 542 pts pentru "_i1.pdf" - însemnând că spaţiul liber exterior enunţului subiectului a fost redus atât pe verticală, cât şi pe orizontală; mai putem vedea că pentru noul fişier avem Producer: pdfTeX.

Este foarte instructiv de văzut cum procedează pdfcrop.pl. Mai întâi, apelează Ghostscript (vezi şi [2]) cu -sDEVICE=bbox ("Bounding Box accumulator device"), pentru a obţine coordonatele regiunii care conţine elementele vizibile ale paginii; apoi, creează un fişier temporar .tex - în formatul specific documentelor create folosind limbajul de marcare LaTeX - în care înscrie numai elementele vizibile ale paginii originale; în final, apelează pdftex pentru a converti la format PDF fişierul ".tex" creat, redenumindu-l cu numele specificat la apel.

Cu pdftotext ne putem convinge că fişierul rezultat nu mai conţine acele elemente (antet, etc.) pe care anterior reuşisem doar să le "ascundem" (datorită faptului că el provine dintr-un PDF nou creat, în care s-au "copiat" numai elementele vizibile din pagina originală); dar aceasta depinde totuşi, de cât de bine am reuşit să delimităm iniţial regiunea vizibilă a paginii (aici am procedat experimental, "din ochi") şi mai depinde probabil, de gradul de updatare a fişierului iniţial (informaţia de "updatare" este greu de evitat, încât este posibil ca pdfcrop să o fi înscris totuşi, în PDF-ul nou creat).

Alipirea verticală, într-o aceeaşi pagină

În principiu, n-avem decât să imităm modul de lucru evidenţiat mai sus pentru pdfcrop: creem cumva un fişier PDF nou, cu o singură pagină (iniţial "vidă"), având ca "înălţime" suma "înălţimilor" paginilor pe care vrem să le alipim pe verticală; apoi, "înscriem" în pagina creată conţinuturile acestor pagini (n-ar trebui să conteze câte pagini sunt de alipit).

Dar nu ne grăbim să realizăm noi, un modul pentru această alipire… pyPdf ne arată despre ce este vorba: paginile au înregistrate în fişierele PDF respective informaţii asupra resurselor necesare redării - de exemplu, cele privitoare la fonturile folosite; prin urmare, alipirea într-o aceeaşi pagină trebuie să vizeze şi o anumită comasare a conţinuturilor paginilor de alipit, mai ţinând cont şi de faptul că anumite "resurse" sunt comune (şi n-ar fi cazul de a le înregistra de două ori).

pyPdf tratează chestiunea doar pentru cazul a două pagini, de "alipit" într-o aceeaşi pagină; n-am reuşit să adaptăm lucrurile pentru trei pagini (cum ne-ar fi necesar aici), încât ne-am mulţumit cu funcţia următoare:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def merge_vertical(fn1, fn2, fno):
    with open(fn1) as f1, open(fn2) as f2, open(fno, "wb") as f3:
        pg1 = PdfFileReader(f1).pages[0]
        pg2 = PdfFileReader(f2).pages[0]
        pdo = PdfFileWriter()
        page = pdo.addBlankPage(
            width = max(pg1.mediaBox.getWidth()+10, pg2.mediaBox.getWidth()+10),
            height = pg1.mediaBox.getHeight() + pg2.mediaBox.getHeight() + 20
        )
        page.mergeTranslatedPage(pg1, 5, pg2.mediaBox.getHeight() + 10)
        page.mergeTranslatedPage(pg2, 5, 5)
        pdo.write(f3)

În liniile 5 şi 6 se constituie un obiect PDF "nou" şi i se înscrie o pagină "albă", având ca lăţime (linia 7) cea mai mare dintre lăţimile celor două pagini de alipit şi ca înălţime, suma înălţimilor acestora.

În linia 10 se înscrie conţinutul primei pagini în pagina "albă", translatând în aşa fel încât să rămână loc dedesubt pentru cealaltă pagină; apoi, în linia 11 se "comasează" conţinutul tocmai înscris în pagina iniţial "albă" cu conţinutul celei de-a doua pagini, translatând la "baza" paginii. În final, obiectul PDF astfel constituit este "salvat" în fişierul indicat la apelul funcţiei.

În cazul nostru, va trebui să apelăm această funcţie de două ori consecutiv: odată pentru a obţine fişierul "2-3", conţinând subiectele 2 şi 3 în această ordine; a doua oară - pentru a obţine "1-2-3", alipind subiectul 1 cu rezultatul precedentei alipiri.

Constituirea fişierelor finale (câte o pagină, cu subiectele I, II şi III)

O să avem nevoie de patru fişiere temporare, încât includem modulul tempfile; vom avea de apelat programul extern pdfcrop, încât includem modulul subprocess:

import tempfile
import subprocess
tfi = tempfile.NamedTemporaryFile()
tfii = tempfile.NamedTemporaryFile()
tfiii = tempfile.NamedTemporaryFile()
ttf = tempfile.NamedTemporaryFile()

Metoda NamedTemporaryFile() asociază un anumit nume, fişierului creat (necesar, fiindcă metodele implicate - din pyPdf şi din pdfcrop - au parametri "fileName"). Definind "global" cele patru fişiere temporare, asigurăm posibilitatea folosirii lor din oricare funcţie (subînţelegând că toate funcţiile şi definiţiile redate mai sus sunt conţinute într-un acelaşi script Python), până în momentul încheierii programului "principal".

Următoarea funcţie primeşte un număr de variantă şi construieşte fişierul PDF conţinând o pagină cu cele trei subiecte aferente variantei respective ("extrase" din cele trei fişiere _i, _ii, _iii):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def i_ii_iii(nr_var):
    var = str(nr_var).zfill(3) + ".pdf"
    din = {
        "d_mt2_i_"+ var: tfi,
        "d_mt2_ii_"+ var: tfii,
        "d_mt2_iii_"+ var: tfiii,
    }
    for fn, tf in din.items():
        bottom, top = (692, 62) if 'ii' in fn else (692, 150)
        chopPdf(fn, bottom, top, tf.name)
        subprocess.call("pdfcrop" + " " + tf.name + " " + tf.name, shell=True)
    merge_vertical(tfii.name, tfiii.name, ttf.name)
    merge_vertical(tfi.name, ttf.name, "var_"+str(nr_var)+".pdf")

Bineînţeles că parametrul "nr_var" trebuie să fie număr întreg, între 1 şi 100 (m-am scutit de a mai prevedea verificări); în linia 2, valoarea respectivă este transformată în şir de forma "001" (sau "075", etc.), folosind metoda zfill() a obiectelor String().

Dicţionarul creat în linia 3 asociază fiecăruia din cele trei fişiere iniţiale, câte un fişier temporar. În liniile 8-11 este parcurs acest dicţionar (într-o ordine oarecare a cheilor, fiindcă parcurgerea unui dicţionar nu garantează nici o ordine), apelând la fiecare iteraţie funcţia chopPdf() - în linia 10 - şi apoi (în linia 11) programul extern pdfcrop. Apelul din linia 11 refoloseşte fişierul primit pentru a "salva" rezultatul.

În liniile 12 şi 13 se invocă funcţia merge_vertical(), rezultând fişierul final "var_<nr_var>.pdf":

'var_1.pdf' o pagină, cu subiectele I, II şi III

Cu pregătirile de mai sus, programul "principal" se scrie banal:

for n in xrange(1, 101):
    i_ii_iii(n)

Acum, în loc de cele 300 de fişiere pentru fiecare variantă şi pentru fiecare subiect (cu antet, subsol, etc. pe fiecare pagină), am obţinut 100 de fişiere conţinând pe câte o singură pagină subiectele respective, pentru fiecare variantă. Rezultatul este acceptabil, dar desigur nu este "perfect" (după cum nici fişierele de la care am plecat, nu erau "perfecte"): în unele dintre fişierele obţinute s-au păstrat (dar nu în partea vizibilă a paginii) fragmente de antet sau de notă-subsol; mărimea medie a fişierelor rezultate este cam de 100 KB - bineînţeles că mai mare decât a fişierelor iniţiale (în medie, de 40 kB), dar totuşi ea putea fi sensibil mai mică (dacă reuşeam fie o "comasare" mai bună la alipirea paginilor, fie o procedură de alipire mai bună).

vezi Cărţile mele (de programare)

docerpro | Prev | Next