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

Modificarea unor pagini PDF, folosind python-pyPdf

PDF | Python
2013 aug

Fişierele PDF iniţiale MT1_09_i.pdf, MT1_09_ii.pdf şi MT1_09_iii.pdf - vezi [1] şi [2] - încarcă repetat (pe fiecare pagină) o serie de elemente inutile (faţă de enunţurile subiectelor).

Funcţia extract_page() din [1] ne elimina din definiţia PDF a paginii obiectele responsabile de crearea adnotărilor şi "filigranelor" (scurtând codul PDF al paginii cu 5 KB); extindem acum această funcţie, pentru a elimina direct şi celelalte elemente "inutile" ale paginii (ceea ce în [2] realizam în mod expeditiv sau "mecanic", apelând la pdfcrop).

În principiu, ar fi simplu: comasăm într-unul sigur, stream-urile de text; căutăm în acest stream termenul "SUBIECT" şi ştergem textul care îl precede ("scăpăm" astfel de antet şi de nota de subsol); restul îl înscriem drept nou conţinut al primului obiect din definiţia PDF a paginii, ştergând în acelaşi timp pe celelalte (implicate în concatenarea iniţială de stream-uri).

Dar "ar fi simplu"! La vizualizare, fireşte că antetul apare la începutul paginii (deasupra termenului "SUBIECT") - dar în cazul fişierelor PDF sunt posibile nenumărate fragmentări diferite ale textului; iar un text admite infinite reprezentări, fiind posibilă intercalarea unor parametri numerici de poziţionare a subsecvenţelor - încât identificarea unui termen în pagini diferite este dificilă.

Modelarea în Python a formatului PDF

În [1] am investigat fişiere PDF practicând comenzi elementare (precum tail, head, xxd); dar există diverse biblioteci de programe care modelează formatul PDF într-unul sau altul dintre limbajele uzuale, permiţând atât o investigare comodă cât şi anumite operaţii tipice pe documente PDF.

Modulul pyPdf defineşte în Python obiectele specifice formatului PDF şi pe baza acestora, instituie două obiecte principale - unul care permite parcurgerea unui fişier PDF, analizând şi interpretând conţinutul acestuia şi constituind obiectele Python corespunzătoare şi unul "invers": datele curente din obiectele Python se "traduc" şi se scriu rând pe rând ca obiecte PDF într-un fişier, menţinând pe parcurs o listă a offset-urilor la care au ajuns să fie aşezate obiectele respective - această listă servind pentru a adăuga la sfârşit structurile tipice PDF xref şi trailer (vezi eventual [1]).

Interpretorul Python facilitează documentarea directă asupra unui modul, prin "comanda" dir:

vb@vb:~/bacmath/DOC/C$ python
>>> import pyPdf
>>> dir(pyPdf)
['PdfFileReader', 'PdfFileWriter', 'filters', 'generic', 'pdf', 'utils' ...]
>>> dir(pyPdf.PdfFileReader)
['documentInfo', 'getDocumentInfo', 'getNumPages', 'getObject', 'getPage' ...]

Am obţinut (aici, redată selectiv) lista obiectelor subiacente modulului pyPdf şi respectiv, obiectului pyPdf.PdfFileReader; cu dir(pyPdf.pdf) vom obţine lista numelor de obiecte prin care sunt modelate obiectele PDF; ş.a.m.d. Cu help în loc de dir() (sau consultând direct codul sursă), vedem că pentru construirea unui obiect de tip PdfFileReader trebuie să indicăm un fişier PDF - dar nu un nume:

>>> pdf_obj = pyPdf.PdfFileReader('MT1_09_i.pdf')
  ...
AttributeError: 'str' object has no attribute 'seek'

Cu dir(str) ne putem convinge că obiectele "str" nu cunosc o metodă "seek"; în schimb, dir(file) ne arată că obiectele "file" au metodele (standard pentru citire şi poziţionare într-un fişier) "read" şi "seek". Trebuie să transmitem nu nume, ci obiectul creat de file() pe baza numelui de fişier:

>>> pdf_obj = pyPdf.PdfFileReader(file('MT1_09_i.pdf', 'rb'))

Pentru obiectul rezultat putem folosi diversele metode ale clasei din care a fost instanţiat:

>>> print pdf_obj.getNumPages()    # pdf_obj.getDocumentInfo() ş.a.m.d.
100

Cu dir(pdf_obj) putem vedea ce proprietăţi şi metode "cunoaşte" obiectul creat. Dar dacă ne-ar interesa de exemplu conţinutul tabelului pdf_obj.xref - atunci trebuie să renunţăm la maniera interactivă, fiindcă tabelul respectiv este foarte voluminos (şi "nu încape" pe ecran). În schimb, putem alcătui un script conţinând "comenzile" lansate anterior una câte una:

    # fişierul "helper.py"
import pyPdf
import pprint as ppr  # "pretty-print" structuri de date (print help(pprint))

pdf_obj = pyPdf.PdfFileReader(file('MT1_09_i.pdf', 'rb'))
# pprint.pprint(help(pdf_obj))  # dacă vrem informaţii ajutătoare
ppr.pprint(pdf_obj.trailer)
ppr.pprint(pdf_obj.xref)

Lansând interpretorul de Python pentru acest script şi redirecţionând ieşirea într-un fişier text:

vb@vb:~/bacmath/DOC/C$ python helper.py > helper.txt

obţinem în acest fişier conţinutul obiectului trailer şi cele peste 9000 de linii ale tabelelor xref - prima constatare fiind aceea că sunt două tabele 'xref', semn că fişierul PDF respectiv a fost "updatat" anterior momentului în care am deschis noi fişierul (puteam adăuga în "helper.py": print len(pdf_obj.xref[0]) şi respectiv .xref[1], pentru a lista şi lungimile tabelelor 'xref').

Numărul de linii din ultimul 'xref' (circa 2100) ne arată câte obiecte au fost modificate - "modificat" însemnând în specificaţia PDF că obiectul respectiv rămâne intact, dar este marcat ca "modificat" (astfel că va fi ignorat de "Document Viewer"), conţinutul modificat al acestuia este înscris într-un nou obiect undeva la sfârşitul fişierului, iar offset-ul acestuia este înregistrat în noul tabel 'xref'.

Conţinutul unei pagini

Operând consecvent astfel: se pleacă de la sfârşitul fişierului, consultând dicţionarul 'trailer' şi tabelul 'xref' şi urmând apoi referinţele de la un obiect la altul - rezultă un acces mult mai rapid la obiectele conţinute, decât în cazul obişnuit al citirii/scrierii secvenţiale; în plus, obiectele aferente diverselor "părţi" ale paginii se pot afla în orice loc din fişier şi în orice ordine.

În cazul fişierelor iniţiale vizate aici, avem "noroc": ele au fost generate automat (plecând de la documente Microsoft Word) şi au o structură comună; pagina redată de vizualizator rezultă (aşa cum ne-am dorit mai sus) concatenând secvenţial stream-urile de text ale obiectelor aferente paginii.

N.B. Pe de altă parte, tocmai fiindcă au fost generate automat, plecând de la documente Microsoft Word - lungimea lor este de (cel puţin) 10 ori mai mare decât în cazul când ar fi fost scrise manual.

Să vedem întâi cum este modelat în Python conţinutul unei pagini (pentru a şti să accesăm din Python, obiectele conţinute); ştergem ultimele două linii din scriptul "helper.py" şi adăugăm liniile:

page = pdf_obj.getPage(0)  # prima pagină
ppr.pprint(page)

Lansând acum "helper.py" obţinem:

{'/Annots': IndirectObject(3787, 0),  # în PDF: /Annots 3787 0 R (vezi [1])
 '/Contents': [IndirectObject(6892, 0),  
               # ... #
               IndirectObject(8699, 0)],
 '/CropBox': [0, 0, 595, 842],
 '/MediaBox': [0, 0, 595, 842],
 '/Parent': IndirectObject(34, 0),
 '/Resources': {'/ExtGState': {'/GS1': IndirectObject(12, 0),
                               # ... #
                               '/NxGS9': IndirectObject(7896, 0)},
                '/Font': {'/F1': IndirectObject(13, 0),
                          # ... #
                          '/TT3': IndirectObject(30, 0)},
                '/ProcSet': ['/PDF', '/Text']},
 '/Rotate': 0,
 '/Type': '/Page'}

Prin urmare, structurilor de date PDF (evidenţiate deja în [1]) le corespund structuri similare în Python; dicţionarul PDF "<< ... >>" este reprezentat de dicţionarul Python "{ ... }", păstrând aceleaşi chei ca în PDF; tablourile PDF (ca "[0 0 595 842]") sunt reprezentate prin liste Python (ca "[0,0,595,842]"); ş.a.m.d.

Cu ppr.pprint(dir(page['/Contents'][0])) vedem că obiectele IndirectObject() au un atribut .idnum (astfel, referinţa PDF "6892 0 R" are idnum=6892).

Adăugând în "helper.py" linia:

ppr.pprint([[ob.idnum, ob.getObject().getData()] for ob in page['/Contents']])

obţinem într-o reprezentare uzuală, conţinuturile obiectelor implicate direct la redarea paginii, în ordinea în care apar acestea în lista de cheie "/Contents" a dicţionarului redat mai sus:

Recunoaştem succesiv (vezi [1]): obiectele "PUSH" (conţinând operatorul q), apoi reprezentarea antetului oficial în cuprinsul obiectelor 4, 5 şi 6, apoi reprezentarea enunţurilor problemelor pe obiectele 6 şi 7..11, încheind cu obiectele "POP" (conţinând operatorul Q) şi cele cinci "/Annot".

Am văzut în [1], cum eliminăm obiectele "PUSH", "POP" şi "/Annot". Pentru a "scăpa" de antet, nu prea putem proceda la fel, eliminând obiectele 4, 5 şi 6 - pentru că enunţul subiectului începe în interiorul obiectului 6, iar pe de altă parte - poate că pe alte pagini (poate din celelalte două fişiere iniţiale, "_ii_" şi "_iii_") începe în interiorul obiectului 5; în schimb, constatăm valabilitatea ideii enunţate la început: putem concatena conţinuturile obiectelor 4..11 (chiar în această ordine), urmând să identificăm termenul "SUBIECTUL" şi să îndepărtăm ceea ce îl precede.

Obiecte text

Ca să identificăm un fragment de text obişnuit, în cadrul conţinutului concatenat al obiectelor 4..11 - trebuie să ţinem seama de faptul că un "obiect text" PDF (sau, un stream text) este mai mult decât un text obişnuit; teoria aferentă (poziţionare, sisteme şi transformări de coordonate, de unităţi şi de spaţii grafice (inclusiv, de culoare), codificare şi decodificare, implicarea fonturilor, etc. - împreună cu operatorii specifici) nu este deloc simplă - dar aici vom fixa numai câteva aspecte pe care le putem simplifica şi de care avem de ţinut seama în contextul nostru.

Un obiect text este (obligatoriu) delimitat de operatorii BT ("BeginText") şi ET ("EndText"), dar fără imbricare. Astfel că, dacă am elimina stream-ul 4, atunci am greşi: pe la sfârşitul acestuia (înainte de "BACAL") apare un operator "BT" a cărui pereche "ET" o găsim abia pe la sfârşitul stream-ului 6 (deci eliminând 4, am avea un operator ET "orfan").

Ceea ce vrem să identificăm este "SUBIECTUL", termen care apare în fiecare pagină din cele trei fişiere PDF iniţiale, precedând enunţul subiectului; intenţia este de a şterge "textul" care precede acest termen (inclusiv), precum şi textul care urmează după el până la primul operator "BT". Iată câteva reprezentări ale acestui termen, extrase din diverse pagini:

[(SUB)-9.8(I)-5.2(ECTUL I)-5.2( ()-6.9(30p)9.6())-6.9( )]TJ # SUBIECTUL I
[(SUB)-10.2(I)-5.6(ECTUL I)-5.6(I)-5.6( (30p))-7.3( )]TJ    # SUBIECTUL II
[(SU)6.5(BIE)5.9(C)6.5(T)5.9(U)6.5(L)5.9( III )10.9((30p)14.4() )]TJ  # SUBIECTUL III

TJ şi Tj sunt operatorii "show text" ("arată" textul). Pentru Tj putem avea ca operand un string PDF: fie o secvenţă de caractere ambalată cu paranteze rotunde, fie o secvenţă de cifre hexazecimale, ambalată de paranteze unghiulare; de exemplu, "SUBIECTUL I" putea fi reprezentat simplu, prin (SUBIECTUL I) Tj (şi atunci ar fi fost uşor de găsit).

Operandul lui TJ este un array PDF ("tablou" unidimensional), conţinând string-uri precedate eventual de valori numerice care precizează interpretorului PDF cum să poziţioneze (în miimi de unitate de spaţiere) un string faţă de cel precedent acestuia în tablou. În cazul nostru - mai ales aceste valori de ajustare variază (pentru un acelaşi termen) de la o pagină la alta.

Dar acest lucru era de aşteptat: paginile diferă de multe ori prin "marginea stângă" şi "marginea de sus"; în cadrul antetului (acelaşi, pe toate paginile) diferă adesea mărimea de caracter folosită (pentru un acelaşi termen) şi spaţierea între rânduri. De fapt, numai ca text propriu-zis, antetul poate fi considerat "acelaşi" - dar chiar şi aşa, numai cu oarecare indulgenţă (pe unele pagini termenii sunt distorsionaţi: "informa- tică", sau "matematică-.informatică"). O explicaţie plauzibilă: paginile au fost "strânse" de la diverşi autori, fiecare cu propriile formatări şi obiceiuri "Microsoft Word" (iar în rest, "Save As PDF").

Prin urmare, dacă vrem să identificăm pe oricare pagină termenul "SUBIECTUL", va trebui să folosim o expresie regulată; ar fi suficient să căutăm prima apariţie (în stream-ul concatenat din obiectele 4..11) a termenului care are şablonul S.*U.*B.*I.*E.*C.*T - în care .* ("orice caracter") acoperă în cazul de faţă oricare valori de ajustare din TJ.

Adăugăm un exemplu de folosire a cifrelor hexazecimale - extras din stream-urile redate mai sus, dar acum redat în formă expandată (interpretând fireşte "\n" ca new Line):

/F1 1 Tf   % setează fontul şi mărimea (factor de scalare)
0.39 0 TD  % poziţionează textul pe rândul curent
0.0008 Tc  % spaţiere între caractere consecutive
-0.0008 Tw % spaţiere între cuvinte consecutive
(i Evaluare \xeen \xcenv)Tj  % "show text" (cu setările stabilite deasupra)

Putem vedea definiţia obiectului numit "/F1" (un "obiect font") adăugând în "helper.py" linia
ppr.pprint(page['/Resources']['/Font']['/F1'].getObject()).

String-ul indicat ca operand lui Tj conţine cifre hexazecimale (indicate de prefixul "\x"); o pereche de cifre consecutive indică o anumită intrare în tabelul de forme grafice de caracter specific fontului curent setat. În cazul de faţă, \xee depistează forma grafică pentru "î", iar \xce pe cea a lui "Î" - astfel că fragmentul de text arătat de Tj este: "i Evaluare în Înv" (din textul "... şi Evaluare în Învăţământul Preuniversitar", existent în cadrul antetului).

Exemplul de mai sus evidenţiază şi faptul că operatorii Tj şi TJ trebuie neapărat corelaţi cu anumiţi operatori care îi preced în cadrul obiectului text (între BT şi ET) care îi conţin.

Înscrierea noului stream, drept conţinut al unui obiect

Să presupunem că am concatenat stream-urile 4..11, am identificat termenul "SUBIECT", am şters partea care îl precede împreună cu ceea ce urmează acesteia până la primul operator BT şi în plus, am eliminat obiectele 5..11. Ne rămâne atunci să înscriem stream-ul rezultat astfel, în obiectul rămas 4 - folosind desigur, metodele oferite de pyPdf.

Cu ppr.pprint(dir(page['/Contents'][0].getObject())) vedem că obiectele Python asociate obiectelor PDF dispun şi de o metodă setData() - şi probabil, de ea ar trebui să ne folosim acum; numai că încercările de a folosi această metodă eşuează: "Creating EncodedStreamObject is not currently supported" - însemnând că metoda respectivă încă nu este implementată.

În schimb constatăm prezenţa unui câmp intern _data; este drept că este vorba de un câmp "privat" şi nu "public" (în convenţia generală, prefixul _ indică atribute sau metode "private" ale obiectului). Recomandarea uzuală spune "nu te atinge" de elementele private - acestea trebuie accesate sau modificate numai prin intermediul interfeţei publice a obiectului. Dar mai ales că nu găsim în partea "publică" ceea ce avem acum nevoie, devine firesc să încercăm şi elementele "private".

Putem evidenţia câmpul _data - de exemplu pentru obiectul 4 - adăugând în "helper.py" liniile:

print "### Câmpul _data al obiectului 4 ###"
_data = page['/Contents'][3].getObject()._data
ppr.pprint(_data)

Este absolut firesc să asumăm că şi ob.getObject()._data şi ob.getObject().getData() se referă la acelaşi lucru: conţinutul obiectului respectiv, care este un "stream text"; _data păstrează acest stream în forma binară comprimată citită direct din fişier (vezi [1]), iar metoda publică getData() decodifică această reprezentare a streamului (prin algoritmul /FlateDecode, specificat în definiţia PDF a obiectului). Ne putem convinge adăugând în "helper.py" liniile:

import zlib
ppr.pprint(zlib.decompress(_data))

Rezultă exact textul redat anterior - folosind atunci getData() - pentru streamul 4.

Streamul nostru (rezultat prin concatenarea streamurilor 4..11 şi "purificarea" prezentată anterior) este în formă textuală ("decompresată"); pentru a-l înscrie în obiectul 4 trebuie să-l comprimăm (folosind metoda zlib.compress()) şi să atribuim rezultatul câmpului _data al obiectului 4.

leachPdf() - extragerea şi modificarea paginilor

În final, urmând considerentele redate mai sus, rescriem "extract_page()" din [1] astfel:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from pyPdf import PdfFileWriter, PdfFileReader
import zlib
import re

PATT_SUB = re.compile('(S.*U.*B.*I.*E.*C.*T)') 
PUNCTAJ = re.compile("5p")

def leachPdf(from_pdf, subj, list_pag):
    pdf = PdfFileReader(file(from_pdf, 'rb'))
    qQ = 1 if 'ii' in subj else 3
    for pag in list_pag:
        page = pdf.getPage(pag - 1)
        del page['/Annots']
        del page['/Contents'][:qQ] 
        del page['/Contents'][-5-qQ:]
        del page['/Resources']['/ExtGState']
        stm = ''.join([ob.getObject().getData() for ob in page['/Contents']])
        # print(stm)
        del page['/Contents'][1:]    
        p_sub = PATT_SUB.search(stm).group(1)
        stm = stm[stm.find(p_sub):]
        idx = stm.find('BT\n')        
        gcl = '''
q\n0.5 g\n1 i\nBT\n/NxF3 1 Tf\n12.000000 0.000000 0.000000 12.000000 60 690 Tm\n
0 Tw\n0 Tc\n100 Tz\n0 TL\n0 Ts\n0.000000 0.250000 Td\n(Varianta %d) Tj\nET\nQ\n
''' % (pag) if qQ==3 else ''
        sd = '\nBT\n/F1 1 Tf\n' + stm[idx+3:]  + gcl
        # print(sd)
        sd = PUNCTAJ.sub(' ', sd)
        page['/Contents'][0].getObject()._data = zlib.compress(sd)
        pdf_out = "D/%s_%03d.pdf" % (subj, pag)
        out = PdfFileWriter()
        out.addPage(page)
        out.write(file(pdf_out, 'wb'))

leachPdf() primeşte numele unuia dintre cele trei fişiere PDF iniţiale, un şir care specifică subiectul vizat în acel fişier ("i", "ii", sau "iii") şi o listă conţinând numerele de pagină ale acelor pagini care trebuie extrase din fişier; de exemplu, leachPdf('MT1_09_i.pdf', 'i', [5,8]).

În liniile 13-16 se şterg din dicţionarul Python al paginii curente, obiectele care reprezintă adnotări, operaţii PUSH şi POP (sunt trei perechi pentru "i", dar numai una pentru "ii" sau "iii" - ceea ce se decelează în linia 10) şi obiectele de "stare grafică" (fiind utilizate numai de adnotări, deja şterse).

În linia 17 se concatenează ("join") streamurile din obiectele rămase în lista /Contents a paginii. În liniile 18 (şi la fel, 28) am plasat nişte comentarii "strategice", pentru a afişa eventual rezultatele parţiale respective, în scopul verificării. Apoi - în linia 19 - am şters toate obiectele din /Contents, cu excepţia primului (în care urmează să plasăm streamul concatenat, după "purificare").

În liniile 20-22 se identifică în cadrul streamului concatenat, porţiunea corespunzătoare şablonului definit în linia 5 şi se determină indexul primei apariţii "BT", în continuarea acestei porţiuni; în linia 27 se reduce streamul începând de la acest index (înlăturând porţiunea dinaintea acestuia).

Pentru cazul subiectului "i", în liniile 24-26 am prevăzut un mic obiect text pentru a adăuga pe pagina respectivă Varianta %d, unde şablonul "%d" va fi înlocuit de numărul paginii; acest obiect text este adăugat la sfârşitul streamului (vezi linia 27), dar "matricea de text" Tm din linia 24 asigură poziţionarea acestui text ceva mai deasupra enunţului subiectului (acesta începe la o înălţime mai mică decât 690, cea specificată de Tm).

În linia 29 se şterg toate punctajele "5p", utilizând şablonul definit în linia 6.

Am avut "noroc": se pare că punctajul apare totdeauna sub forma 5p (fără valori de ajustare între '5' şi 'p', cum a fost cazul la "SUBIECT"); altfel, era dificil de prevăzut un şablon "corect" - de exemplu, '5.*p' n-ar fi corect fiindcă ar acoperi şi "512 păsări", text posibil în vreun enunţ de problemă.

În linia 30 se comprimă streamul astfel "purificat" (folosind modulul zlib, inclus prin linia 2) şi se înscrie rezultatul în câmpul _data al obiectului rămas. Apoi se instituie un obiect PdfFileWriter() şi se înscrie în dicţionarul acestuia, pagina curentă (cu obiectele Python rezultate după modificările precizate mai sus), scriind rezultatul într-un fişier cu numele constituit în linia 31.

Următorul program apelează funcţia redată mai sus pentru fiecare dintre fişierele PDF iniţiale, rezultând cele câte 100 de fişiere PDF corespunzătoare subiectelor I, II şi respectiv III, existente în cele 100 de variante vizate:

for sub in ['i', 'ii', 'iii']:
    from_pdf = "MT1_09_%s.pdf" % (sub)
    leachPdf(from_pdf, sub, xrange(1, 101))

Redăm unul dintre cele 300 de fişiere PDF obţinute astfel:

i_099.pdf (subiectul I, varianta 99)

Se poate compara eventual, cu paginile iniţiale redate în [1]; acum nu mai avem antet, notă de subsol, adnotări, punctaje şi nici "SUBIECTUL" (şi pe paginile pentru subiectul I, am adăugat menţiunea de "Varianta" - stilată asemănător adnotărilor iniţiale).
Timpul de lucru a fost neaşteptat de bun: sub 20 secunde (pe un laptop obişnuit acum 5-6 ani).

Vizualizează 2 secunde, fiecare dintre 300 de fişiere PDF!

Să exagerăm puţin şi să zicem că vrem să verificăm aceste… 300 de fişiere. Ar fi suficient să vedem preţ de 1-2 secunde fişierul respectiv, ca să ne dăm seama dacă este ceea ce ne propusesem să obţinem; "soluţia" de netăgăduit pe Windows este de natură "point-and-click": click pe numele fişierului, închizi, click pe numele următorului fişier, închizi ş.a.m.d.

Pe Ubuntu Linux, "Document Viewer"-ul obişnuit este Evince şi desigur - poate fi lansat şi de pe linia de comandă; putem specula aceasta într-un script Bash foarte simplu, astfel:

for pdf in `ls D/*.pdf` 
do
    evince $pdf
done

Îl lansăm la fel cum am lansat "helper.py" mai sus (precizând pe linia de comandă, interpretorul şi numele scriptului): bash viewer.sh. Se parcurge lista numelor fişierelor PDF din directorul "D" (unde avem cele 300 de fişiere - vezi linia 31, mai sus), returnată de comanda ls şi pentru fişierul curent, se lansează evince; ne uităm 2 secunde la fişier şi închidem cu click pe butonul marcat cu "X"; lucrurile se vor repeta pentru următorul fişier din listă, până la epuizarea acesteia.

vezi Cărţile mele (de programare)

docerpro | Prev | Next