From 546cea4346d031e076ffe42929f09f8eec008e61 Mon Sep 17 00:00:00 2001 From: PJ Beers Date: Wed, 29 May 2024 16:00:22 +0200 Subject: [PATCH 1/3] make_sheets: Use pypdf to merge pdfs when pdftk is absent Without pdftk, dungeon-sheets was not able to merge the various files produced by the fillable pdf forms. This patch adds a function that uses pdfrw to merge to pdfs. --- README.rst | 1 - dungeonsheets/make_sheets.py | 12 ++++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 6307e253..e194db1e 100644 --- a/README.rst +++ b/README.rst @@ -69,7 +69,6 @@ limitations: - Produces v1.3 PDF files - Not able to flatten PDF forms -- Will produce separate character-sheets, spell-lists and spell-books. Different linux distributions have different names for packages. While pdftk is available in Debian and derivatives as **pdftk**, the package diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py index b7e2f844..d4b4a98a 100644 --- a/dungeonsheets/make_sheets.py +++ b/dungeonsheets/make_sheets.py @@ -12,6 +12,7 @@ from itertools import product from multiprocessing import Pool, cpu_count from pathlib import Path +from pypdf import PdfReader, PdfWriter from typing import Union, Sequence, Optional, List from dungeonsheets import ( @@ -666,9 +667,16 @@ def merge_pdfs(src_filenames, dest_filename, clean_up=False): subprocess.call(popenargs) except FileNotFoundError: warnings.warn( - f"Could not run `{PDFTK_CMD}`; skipping file concatenation.", RuntimeWarning + f"Could not run `{PDFTK_CMD}`, using fallback.", RuntimeWarning ) - else: + + merger = PdfWriter() + for pdf in src_filenames: + merger.append(pdf) + merger.set_need_appearances_writer(True) + merger.write(dest_filename) + merger.close() + finally: # Remove temporary files if clean_up: for sheet in src_filenames: From 02ea4e65639f6b873104abe9127bd071d23f96eb Mon Sep 17 00:00:00 2001 From: PJ Beers Date: Thu, 2 May 2024 15:18:54 +0200 Subject: [PATCH 2/3] fill_pdf_template: switch fallback from pdfrw to pypdf Switch the fallback function to fill pdf forms to pypdf. Pypdf has a simpler interface for form filling and appears to be somewhat more robust that pdfrw. Also, this yields pdfs in version 1.7 instead of 1.3, and it eliminates a dependency on pdfrw. Finally, the pdfrw code appeared to be buggy: fill_pdf_template.py, line 454, in _make_pdf_pdfrw this_field = annot[FIELD][1:-1] TypeError: 'NoneType' object is not subscriptable Unfortunately, this patch is not able to flatten pdfs, since this functionality isn't implemented in pypdf (it isn't implemented in pdfrw either). This is a bugfix (no issue reported). --- README.rst | 7 +-- dungeonsheets/fill_pdf_template.py | 92 ++++++++++-------------------- pyproject.toml | 2 +- requirements.txt | 1 - 4 files changed, 33 insertions(+), 69 deletions(-) diff --git a/README.rst b/README.rst index e194db1e..d81deb07 100644 --- a/README.rst +++ b/README.rst @@ -64,11 +64,8 @@ Optional External dependencies generate the PDF spell pages (optional). If **pdftk** is available, it will be used for pdf generation. If not, -a fallback python library (pdfrw) will be used. This has some -limitations: - -- Produces v1.3 PDF files -- Not able to flatten PDF forms +a fallback python library (pypdf) will be used. This has the +limitation that it is not able to flatten PDF forms. Different linux distributions have different names for packages. While pdftk is available in Debian and derivatives as **pdftk**, the package diff --git a/dungeonsheets/fill_pdf_template.py b/dungeonsheets/fill_pdf_template.py index 23be8966..bcd88fe5 100644 --- a/dungeonsheets/fill_pdf_template.py +++ b/dungeonsheets/fill_pdf_template.py @@ -3,8 +3,8 @@ import subprocess import warnings -import pdfrw from fdfgen import forge_fdf +from pypdf import PdfWriter, PdfReader from dungeonsheets.forms import mod_str @@ -405,7 +405,7 @@ def make_pdf(fields: dict, src_pdf: str, basename: str, flatten: bool = False): src_pdf : Path to the PDF that will serve as the template. basename : - The path of the destination PDF without the file extensions. The + The basename of the destination PDF without the file extensions. The resulting pdf will be {basename}.pdf flatten : If truthy, the PDF will be collapsed so it is no longer @@ -415,71 +415,39 @@ def make_pdf(fields: dict, src_pdf: str, basename: str, flatten: bool = False): try: _make_pdf_pdftk(fields, src_pdf, basename, flatten) except FileNotFoundError: - # pdftk could not run, so alert the user and use pdfrw + # pdftk could not run, so alert the user and use pypdf warnings.warn( f"Could not run `{PDFTK_CMD}`, using fallback; forcing `--editable`.", RuntimeWarning, ) - _make_pdf_pdfrw(fields, src_pdf, basename, flatten) - - -def _make_pdf_pdfrw(fields: dict, src_pdf: str, basename: str, flatten: bool = False): - """Backup make_pdf function in case pdftk is not available.""" - template = pdfrw.PdfReader(src_pdf) - # Different types of PDF fields - BUTTON = "/Btn" - # Names for entries in PDF annotation list - # DEFAULT_VALUE = "/DV" - # APPEARANCE = "/MK" - FIELD = "/T" - # PROPS = "/P" - TYPE = "/FT" - # FLAGS = "/Ff" - # SUBTYPE = "/Subtype" - # ALL_KEYS = [ - # "/DV", - # "/F", - # "/FT", - # "/Ff", - # "/MK", - # "/P", - # "/Rect", - # "/Subtype", - # "/T", - # "/Type", - # ] - annots = template.pages[0]["/Annots"] - # Update each annotation if it's in the requested dictionary - for annot in annots: - this_field = annot[FIELD][1:-1] - # Check if the field has a new value passed - if this_field in fields.keys(): - val = fields[this_field] - # Convert integers to strings - if isinstance(val, int): - val = str(val) - log.debug( - f"Set field '{this_field}' " - f"({annot[TYPE]}) " - f"to `{val}` ({val.__class__}) " - f"in file '{basename}.pdf'" + _make_pdf_pypdf(fields, src_pdf, basename, flatten=flatten) + + +def _make_pdf_pypdf(fields: dict, src_pdf: str, basename: str, flatten: bool = False): + """ + Writes the dictionary values to the pdf. Supports text and checkboxes. + Does so by updating each individual annotation with the contents of the fiels. + + """ + + writer = PdfWriter() + reader = PdfReader(src_pdf) + form_fields = reader.get_fields() + writer.append(reader) + + for key in fields.keys(): + if key in form_fields: + if fields[key] == "Yes": + fields[key] = r"/Yes" + if fields[key] == "Off": + fields[key] = r"/Off" + writer.update_page_form_field_values( + writer.pages[0], {key: fields[key]}, + auto_regenerate=False, ) - # Prepare a PDF dictionary based on the fields properties - if annot[TYPE] == BUTTON: - # Radio buttons require special appearance streams - if val == CHECKBOX_ON: - val = bytes(val, "utf-8") - pdf_dict = pdfrw.PdfDict(V=val, AS=val) - else: - continue - else: - # All other widget types - pdf_dict = pdfrw.PdfDict(V=val) - annot.update(pdf_dict) - else: - log.debug(f"Skipping unused field '{this_field}' in file '{basename}.pdf'") - # Now write the PDF to the new pdf file - pdfrw.PdfWriter().write(f"{basename}.pdf", template) + + with open(f"{basename}.pdf", "wb") as output_stream: + writer.write(output_stream) def _make_pdf_pdftk(fields, src_pdf, basename, flatten=False): diff --git a/pyproject.toml b/pyproject.toml index 6e4710b4..fdad426c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ "Topic :: Games/Entertainment :: Role-Playing", ] keywords = ["D&D", "character", "sheets"] -dependencies = ["certifi", "fdfgen", "npyscreen", "jinja2", "sphinx", "pdfrw", "EbookLib", "reportlab", "docutils", "pypdf"] +dependencies = ["certifi", "fdfgen", "npyscreen", "jinja2", "sphinx", "EbookLib", "reportlab", "docutils", "pypdf"] [project.optional-dependencies] dev = ["pytest", "pytest-cov", "coverall", "flake8", "black"] diff --git a/requirements.txt b/requirements.txt index 1c9a6135..0fe13b05 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ fdfgen>=0.16 npyscreen~=4.10.5 jinja2~=3.1.2 sphinx~=7.2.6 -pdfrw~=0.4 EbookLib~=0.18 reportlab~=4.0.8 setuptools~=69.0.3 From 08fb360035980ad582cf5b86cd1e4925228b1883 Mon Sep 17 00:00:00 2001 From: PJ Beers Date: Wed, 5 Jun 2024 16:41:04 +0200 Subject: [PATCH 3/3] Forms: Update blank personality sheet When filling forms with pypdf, the old blank personality sheet gave the following error: Font dictionary for /Helvetica not found. This patch uses the blank personality sheet split out from the current WotC fillable character sheet, with an added pdf bookmark. --- .../forms/blank-personality-sheet-default.pdf | Bin 103592 -> 106608 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/dungeonsheets/forms/blank-personality-sheet-default.pdf b/dungeonsheets/forms/blank-personality-sheet-default.pdf index 57c948188fe02894ac90e302a6263acbda0d0d63..1e8e6c36ae4a160adada883ebc1df26a30ba1478 100644 GIT binary patch delta 11972 zcmb_iU2GiH72XXbg#kkOPjE@I=ggfO-z&XV=6H9O8v zA3Ca1D2O16hRQEh)mBwiT1tN`6!ob;4^^dBLZZ)=3YGfMqKbz~Rr>%v=giFR+?m}C ziJHXm?wNbfJ@=gN{M>uyhi`8C=-1os**8B?IUp4bVc!RTefLj-#6;F!d|uf2jh^4T zx%-WeM+8BT6U6&Br+@a3dx5BMMEeiFM54+Or~dX96161pGRx<8AXVq6&Pe_!E@ed2 z%dmcVClJiUu)UYX`TXB;n3be%p3}4~Hwl6ya@1#b{vdAwHQthN@SsqvtuJ~P)*M!R zdUi%A&N!_V?=&+MQxl4HqR%>>y=;5-#-kVV{^l=Eo!f<+lN#8&Id|fnJW^v71b@Ea zz>c{xlW_ah#3GfpyBfT|{hm8#ZO?6-XA3Bc$mstQ!3{SaH;6M#nRgp!Z4cx+b_^()XI_-6L$>}f&wFm>p$J@XscS@@sK4OzJn>cB68}8(ZUF2)#N||1MTXd0Kl6oPj#3o29KY-7oelY zpxpRIGk;g`{bjcCUpK!!_+a@C6`sa$3k_zkMFuiN5U}PstH+q33&p1+vLmIzu+Q2x+Dh=<*PVxwLu2Q}`=6 zDgjn_JZKaLxj~~e(FcDB0S9;+I4nlqP{A|~fdhGQC>i$$$gjm_8onfFhk;9SCg4XI zJulTb_4n|r$VmXZ>kdLRc=HA}dSJZWTo%$u)}jd6LvrxdZTs(_Oq0a0B?fPA+Z{Zy z^~Ywyyc%b?D@(EW{`C4f22akDXno|HTwceaEfTJnOK*B*E_;juCoH^zrrEn~DpcEe2?jN)E3_}ohm zg#a^6T|^JyeFBnSH>D2NYSt%YE_Wo=rILD+Y{&mU)_ORpDK55g%agT&k6yYn_{+;X z4`=;*qQpj_<{WP`sDA6G01GRiHr(O3*Aj|@O&I+9yU+ESR!B)DKN4(XPp|3ZJ6+-F#ti=mvL# z&|R^Yyhgiq#9rm12MCQm_t-0>K~w1uM&lYqrkcK2d+;KsB=$3oQ(ECf)gpHf^Ng(P z%#gK$A}UNVq=Kn3Llp~FSNZ*SGB^nObj}-?YvilQHJP1+6*(6Kq6{7-O~Wb#*S~Pz zuz-NakNA{&RRQ5DGR{~=@~7ww$VpB$LGb$*hK8?XL3LHqa>-_Rq?I$$PF~Wjc+2N|#M+L<){vyyw<#O{j&beCgtYL$qyNYY{~yT2ICS=L?-Y_Vajw zfLA>Hp`dprpdzNrEOUBG9`qpOB#u&*UF)fSxO)&vbr#1ofhG#RBFAylEP5 zCy+(Q`3<|7ADQoeaM#1~Ke2a%OS>`G=GlH+@A3!S2lQdf<-d?^y;02w3#PU^4qh+7 zd7-iDbPiP8t2R^+z0+Fa-ob}+3TQQ1HW*x}4;OmIo>)xFpIqn}d+pQ&++!@-UWZ#L zsTc0(6-qOC`CHBWj;(O_LmNi>UxvG%2a#gLk$dxzU3+#SAr6k4@0mv*L_!Qmx|a<; z*}nDq;t{57UU%!mf)9^l5RwB_8cq{HMaYDh7~!RP7PcQ&TMSWD4a;;=A5Ei*jDP}@ zPz`V|N*V*Cz+^HuJV8q^gK`7a^phJ)=kvP`_RI*1Na`YsywUKEvjd0Sw&%E=vEY@H zq90ie?#a06T$wrD^6(&OMmd!%HacD^AD*htJ$Yn;)ee;=rjJa)_MYt6D-JV5G5fPN zIV<^nc~`2X*~zP9%YkYz*BURSkE*50B&7kUcM`FvFTX>RrHRREis3+hF}BF)U4D3Z z1XDtl&a_wBym$k-#lYmaMfLNQ?L$Q=ZQO^5l-_w?+jG1J*r&%f%hlq7R=j_M+dr{Q z=yNDPE?RZ@CDoa+Pal4M_lDWxaM_n3Z=XKgdd&Kwk=Nl|b+$G(-SwGa>{L#9p?$By zo2`CWwkYYv&>P~-7Nx}jZenpEF!pAicYm}DXN%hCChN!;eYp0(LhJ^5v(*pNJijzr zhRP?2oJC>*eNLwwT&}>bADVgjDukC9F8$d}MhzXBiRxP@9&0$~SP{Aejg?lf7^3N2 z{g&a{3vL~!Tc_HXoi_e+DPK6zt$j%GM52$eCc0h*f14#zAFm?Gv@ZkvS^~B&2-xHe|VIy!y*|#eO)1fPNe;w|8l5nGr zz;LNy^-!!xY*cDeYF!I1jqDBnIkGz;nw}AD|A2O#X=1zXejmGm2|tiTF0oIZhf=8n z7eSq{&MQrfPllfmTgRa6f@iFVo&REY?2L2PX)-19uwt*f8J7N0fl`L{c(d)VKm&2f zZDWs{Q2`2joLl#rjTW}6#VA!B@_WBweN2y7P_L+w&8jTqh3>d>5ciweQIqqXl3*Sqix^JT4!eu%E zYDT6b76;5YLetprz^hBIKd=u@F4TDHx>Ma=>_Be;QvB)l)kQ`xE&!^^;NnpCLmNAi zBP^6rIK1+SUy&g8iPsy#?=^Vef`Ti_g;<}edHAY@a4`5)e(P=t9!DNJRg;-Qco2$H zG*1aGFT@;4npsIATo8hF*ht3jn$@2K%|*~ zyF2pQjb|?9N0L1tNknaRSwxwXp(B*QaoV{>a3q$S1~;03PaSZm75L1+?P}nREO&e~ zH%q>Up60=X4tU@3F*wZ3wwEW+?8s{(_b527m_x@E{hR?GWg_5jC1>Ui0L94dhyTUg zewYm1!?iUCT*XTG;s^-_c<;{b`kEi&(&4Y9m*vSm+ zZIOK3&0T=+5^yZgk2IEu`{%(K1pu2QZnR#_7vYve1~lUz)?xyEkW>kA%F$#1eR`a1 zSa?aujZ;(|!zM>lB^~2EPctQKcZohwy9+~`24BkH25Oq6L2Tt|x{9vO(RAog0GxQ5 zs^B#PM>9k#IS$tsMMKuHlZSu0!swcaewpaX@;+0 z_l76xG8PRfnwl|*q3d}2k{V~?nkHj8Mf&gV*wDaunSr{Toecf@^f*0> z4?|Tl_%Kurdp})aNdJav;M)gXB7QJb3$HbKng+&8(?k$ELo@NVnx89L*rrR;AbA@; zRt`y`Xh7AR8VEE=$t)r%cv|7-%378mG^8?fWi!H--vgW>TlgYWilK#oQ}zprj#5A&j%hE0E3vlBsOT!X3MKg30uCY?YOaOtZ5E|(GIHV=R zC^4}t3wsqwe&AwaI(!4Ir$IkTIzWUJlbI~zX`N>%Y3ZN>!f5!e5kC&RI0ax>BNO0! zAZlq<11%G)fd&Vt%p!2OO4B4Io$WwCwa?}(0CkvuVi3YW5$hj*Cz6y-sW47XqZ(;x zuma6U#{i(2J-qp0&Ih7}bt=EGtZS)^1+Hk~xse|SIjqmPtY#Do`vCD=58kk9ZtQp? zf6M8xjDz}idIC-{op?t9<6_~9R)ziay#>qDcOwvB})$b;wQTI0*`in2t9 X+>U3v9+f&!x5$Pj?AtdvR}uaP-1~zP delta 8763 zcmb_iU2I%O71mTq5ld6rCbpZzNybi`WE;GD@67#0!R_ukc1+^f*^k1l_tB#XXgCQcg~#YhrfCJt)D%9cx--hYFy4KnX$k9{SSZ2$W)^3g_mgNoj4Y&O|Ipp?VCr?k9A?K-^&U8h1N9;IVVN?beqIae%GL?N#O;;o;qsPmci3#`M z>tv|s*$vWAV6kc2^(}0u-deFKcz?-8cDq#C`3%jVV4;co(Qaa$q1=>TvMRJCwXa&Tmr_Su@kBh;%?zMxKd8 z7(D|@tUYlzxAxEfUVcnb)=u9&kPSUB)$T0A`3y5$@y|rj&!x3*+}*d zF%#s@m8jz9{H*148gS@k|Ip*Xk9@w`|MA_^9OJQ&p-Yj+^)%x^b$}bk=5upY0EPvO;##jMzW??K#TV-o7`4iL!&Kx^m}(+vG$B zjx1NA5Pwrn*;YUh*k{(cFtOc>Ya7Jnj=Sg=MmWEsi)G;Rd;$?F_Qred9RUw^49Rrc00<+aoTD9w4xVzoTx<45uhc^PX zv?ty7M?Fx}9sy^0q|Fk6j=0XYoD1^N)Wfk4qWg%%d4=@lq;#S;D#!4=W6+@C9^yuVj3y_r`t zd`#XKiA zQ)N0cTbMqTjk;0P4POm+E>G;E*qwgSx?xqs6e!9olH@^oqp*LUeMR+%?%Ug32Z8SQ zcUz>vg5238g#_AdwAVr`(*)jZ#1`Wchx)!;Vi1X3c*%a-_+&raF2z(5Y5f<5cL{jCUBY5R! zL1%#4^h>|~$Np)W(PJqWUP*Po!3Zy2Sc#$jiR%)~L5uC6IO7f5y)2Xcdm_hGEJyiw z-ecs*PMELjk$M40AI>bJ+g`RN7oyujEMfE`uJHNG*2>}}T$UFsryI-Cz9pOvVLOeQ6%!Lnv2`(i1>M-5YNPDAiGzGJBWh5!v21t8%k5>W6%!Q1 z*z?>xcrFOAuyC^Mb&$d8LI2=S3r(#>suTbl>aefQWES~mW?^lA6c(c6(tu9RayTMb-+^W-TwBWnp6_%))5hw~m zJhxi(_`RVCTBVQ;^+|jbpEMZDZ#cg#;0;gW=!WY@a+_g z?(?sZ+%&XUD=Rqmobo2l@Jv%*t=S!VyZ{d(HigG8dbY99u{yWl6(%R;Em7x~byaGR9|Uy|fUCb1d{RM$T0{9toOmCIxeDnvmmhJ_WKaMN>Ql!TwNd zu4qz9h+@d84OI!>*5X1`Ih8Y928svY{60Vu)Y}HI03nv7d>4eiI?6Tr~x|}{ux*8bL3*E|fP$#um z#}RE}Lmiv?1kHc|rD!RT;yS}NGqM)^&vhl0nI>oPIKdK=b9}vyEC!mQ;gd#!rsL=! zK{N19oS>PC)JqaImdXVapg-YO87dPt7|{)Z2H={aDalg;d;knSpZo7O$UsljOXvk= zCh3)vHLIp;mZ@7(jai0n*K3lYF31ap1XL#T33`%A8nYJ{)CEg5xvkoDCadsRq1zlB sV$|#j@qcTy9V&Vc*tpWTZ9~b!5)Usm9Y+*FP(