pyOpenVBA 3.1.0__tar.gz → 3.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/PKG-INFO +8 -3
  2. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/README.md +7 -2
  3. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/pyproject.toml +1 -1
  4. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/__init__.py +1 -1
  5. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/_templates/__init__.py +168 -0
  6. pyopenvba-3.3.0/src/pyopenvba/_templates/blank_files/blank_excel_addin.xlam +0 -0
  7. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/excel.py +6 -2
  8. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/vba.py +237 -62
  9. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/.gitignore +0 -0
  10. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/LICENSE.md +0 -0
  11. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/docs/architecture.md +0 -0
  12. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/docs/ms-ovba-implementation-guide_v2.md +0 -0
  13. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/docs/roadmap.md +0 -0
  14. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/__main__.py +0 -0
  15. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/_host.py +0 -0
  16. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/_templates/blank_files/blank_database.accdb +0 -0
  17. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/_templates/blank_files/blank_document.docm +0 -0
  18. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/_templates/blank_files/blank_presentation.pptm +0 -0
  19. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsb +0 -0
  20. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsm +0 -0
  21. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/access_read.py +0 -0
  22. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/cfb.py +0 -0
  23. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/exceptions.py +0 -0
  24. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/powerpoint.py +0 -0
  25. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/vba_pcode.py +0 -0
  26. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/src/pyopenvba/word.py +0 -0
  27. {pyopenvba-3.1.0 → pyopenvba-3.3.0}/tests/fuzz_corpus/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyOpenVBA
3
- Version: 3.1.0
3
+ Version: 3.3.0
4
4
  Summary: Read and write VBA macros inside Excel, Word, and PowerPoint files in pure Python, no dependencies.
5
5
  Project-URL: Homepage, https://github.com/WilliamSmithEdward/pyOpenVBA
6
6
  Project-URL: Repository, https://github.com/WilliamSmithEdward/pyOpenVBA
@@ -200,7 +200,8 @@ path controls the format:
200
200
  ```python
201
201
  from pyopenvba import ExcelFile, WordFile, PowerPointFile
202
202
 
203
- # Excel - macro-enabled workbook (.xlsm) or binary workbook (.xlsb)
203
+ # Excel - macro-enabled workbook (.xlsm), binary workbook (.xlsb),
204
+ # or add-in (.xlam)
204
205
  with ExcelFile.create_new("new_book.xlsm") as wb:
205
206
  wb.set_module("Module1", 'Sub Hello()\r\n MsgBox "xlsm"\r\nEnd Sub\r\n')
206
207
  wb.save()
@@ -209,6 +210,10 @@ with ExcelFile.create_new("new_book.xlsb") as wb:
209
210
  wb.set_module("Module1", 'Sub Hello()\r\n MsgBox "xlsb"\r\nEnd Sub\r\n')
210
211
  wb.save()
211
212
 
213
+ with ExcelFile.create_new("new_addin.xlam") as wb:
214
+ wb.set_module("Module1", 'Sub Hello()\r\n MsgBox "xlam"\r\nEnd Sub\r\n')
215
+ wb.save()
216
+
212
217
  # Word - macro-enabled document (.docm)
213
218
  with WordFile.create_new("new_doc.docm") as doc:
214
219
  doc.set_module("Module1", 'Sub Hello()\r\n MsgBox "docm"\r\nEnd Sub\r\n')
@@ -329,7 +334,7 @@ modules, `.cls` for class modules and code-behind.
329
334
  |-----------|------------------------------|:----:|:-----:|:----------:|
330
335
  | `.xlsm` | Macro-enabled workbook | yes | yes | yes |
331
336
  | `.xlsb` | Binary workbook | yes | yes | yes |
332
- | `.xlam` | Macro-enabled add-in | yes | yes | no |
337
+ | `.xlam` | Macro-enabled add-in | yes | yes | yes |
333
338
  | `.xls` | Legacy (Excel 97-2003) | yes | yes | no |
334
339
 
335
340
  ### Word
@@ -164,7 +164,8 @@ path controls the format:
164
164
  ```python
165
165
  from pyopenvba import ExcelFile, WordFile, PowerPointFile
166
166
 
167
- # Excel - macro-enabled workbook (.xlsm) or binary workbook (.xlsb)
167
+ # Excel - macro-enabled workbook (.xlsm), binary workbook (.xlsb),
168
+ # or add-in (.xlam)
168
169
  with ExcelFile.create_new("new_book.xlsm") as wb:
169
170
  wb.set_module("Module1", 'Sub Hello()\r\n MsgBox "xlsm"\r\nEnd Sub\r\n')
170
171
  wb.save()
@@ -173,6 +174,10 @@ with ExcelFile.create_new("new_book.xlsb") as wb:
173
174
  wb.set_module("Module1", 'Sub Hello()\r\n MsgBox "xlsb"\r\nEnd Sub\r\n')
174
175
  wb.save()
175
176
 
177
+ with ExcelFile.create_new("new_addin.xlam") as wb:
178
+ wb.set_module("Module1", 'Sub Hello()\r\n MsgBox "xlam"\r\nEnd Sub\r\n')
179
+ wb.save()
180
+
176
181
  # Word - macro-enabled document (.docm)
177
182
  with WordFile.create_new("new_doc.docm") as doc:
178
183
  doc.set_module("Module1", 'Sub Hello()\r\n MsgBox "docm"\r\nEnd Sub\r\n')
@@ -293,7 +298,7 @@ modules, `.cls` for class modules and code-behind.
293
298
  |-----------|------------------------------|:----:|:-----:|:----------:|
294
299
  | `.xlsm` | Macro-enabled workbook | yes | yes | yes |
295
300
  | `.xlsb` | Binary workbook | yes | yes | yes |
296
- | `.xlam` | Macro-enabled add-in | yes | yes | no |
301
+ | `.xlam` | Macro-enabled add-in | yes | yes | yes |
297
302
  | `.xls` | Legacy (Excel 97-2003) | yes | yes | no |
298
303
 
299
304
  ### Word
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyOpenVBA"
7
- version = "3.1.0"
7
+ version = "3.3.0"
8
8
  description = "Read and write VBA macros inside Excel, Word, and PowerPoint files in pure Python, no dependencies."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -204,4 +204,4 @@ __all__ = [
204
204
  "push_word",
205
205
  ]
206
206
 
207
- __version__ = "3.1.0"
207
+ __version__ = "3.3.0"
@@ -1161,3 +1161,171 @@ _EMPTY_XLSB_B85 = (
1161
1161
 
1162
1162
  EMPTY_XLSB_BYTES: bytes = zlib.decompress(base64.b85decode(_EMPTY_XLSB_B85))
1163
1163
  # --- end XLSB template ---
1164
+
1165
+ # --- XLAM template (generated by bake_xlam_template.py) ---
1166
+ # Source bytes: 8400 | zlib: 7791 | base85: 9739
1167
+ _EMPTY_XLAM_B85 = (
1168
+ "c-nn=1yEhf(k>9(-Q8V7aJS$R+%-5GC%A9i-"
1169
+ "Gh4w9&AH!LU7j&!9755*gVcX_3HeG|Gu|Yt(mD=>+4n3Gu=J?wVL7^SX?M5D8!eC3hLp#PMi"
1170
+ "fa6qFb&6co<OjDZxu$=%Ay-9*#d*~-m`{gb0ZZOV5Q5-"
1171
+ "v>O*?a$pv^txU$v0JM?@0B19Na!*aMm;E@m<I65KWJ*6tBV3Vq#);Eh6}W&M54KyUAVcJay@"
1172
+ "~J$8G?l|r`#q?g@yCr&1=b8Mx9VQA@(f07Rva)5Hj05@=@M6T2aN_F~Qa&f>G@b!j?z}$I5o"
1173
+ "(kPBBsx6+v(VEc2hRiH6l&XQnm*QDF=sR{`J5byBy@T0B&EwIXMm;$yn_5xs4RVJv1wydr?F"
1174
+ "guXKRSnPWS%YHv%)fl-"
1175
+ "$oEajWE8%h9&&H2$ovqT7F>&4j&;oNFGs`t&V&J|bxaN(_#FqiPE@Y-"
1176
+ "QQ4^(S#XCPGLUkwi>#fH%1Vnx=6sq9%>2=9I{3y3BZ<qwh3E2nc(d24*D9DpVqxh3r^T+%dS"
1177
+ "?BoJX7?a?S@;z&;UOWsYa{ebl!C0*oTA#llO30Q@2hFN$RFcZ`egKfh!MAz8kFUdGNH#}!<O"
1178
+ "jh5h{cBB{wK!>xUq(tWP*5l@y@{)pgBu6?Uv2I5cZD}xnD84N8$w}iUe*Q@(%C06Ve8WK2!t"
1179
+ "sIPK&fS$rUTD1Q<mq&3`tY_6h*NrPJMwIibB@w063DY>e@;H8G*U42X*dLX~E8dZ+Zf^EOP!"
1180
+ ">Al^<&-|(sUD=H2?9c?6K3)5EIo6Nc;-yLbF6a<S0X4R`ZD^dVDEL9;m34E-"
1181
+ "8uVJxhm%@hXDi?5dy8L+?K{rSK#KlOxcIAn#O*fMCI=lR#b1VG1ZpBX!PW3AyG)AA@0obreg"
1182
+ "~Dy<c!K3OgM5m(<!D+zTdsfuj?0>ycbh#QhK!cm_U(Su9I}I5)wnPFADu{rO5p{edF^63W^X"
1183
+ "33JT+8=92@57r@ot900KY&n8Vb0Ia;j9Red93t{?MODm*0ASIy~EkG&jlo&Ta!^7wou~E-"
1184
+ "j(c-Zn-mf7rfoM<2W1BdVKl$AXwTdB`1Q6OGk+)43IZD1~=Y;KKTGn{Gf(dE<IYkQNsQZC?>"
1185
+ "78(xs$m~S3^=f3wfWF&tp&rh!m3zGmuLnqy)-"
1186
+ "6zLCK_v3;@)3(%0fC=Fo3$b}#2=@fc{UEVZ&Y*5k!N^P_@2B$e%0iVP`toLcDQ;=mlB<0*1<"
1187
+ "rxW!5JU1ZlExR%TBqd|u^)s?Lu~%4G4e^lr+w!|wbNdk~eoO*XWYiyTLa7N+e~3yGM?OeZ=;"
1188
+ "Kz+oeUb{I(DhvN8BT|AbQV)NOYx0vkg!T@b}^T@MRbkYUsp?-6$x-"
1189
+ "^6OnXD`}I<MkB}|p}ISXafd!?pQs^cprcAu%BjHxW>DoyP_B>#TB!LG+(@QVxqo$>pkiE3?s"
1190
+ "f=l=^7r&SDy4sSKFq`{49J$ooZ$LP7!fH)Dt9O?;;FkGFyEzs<7Yjgn^mr*XLuKj#q~f1)Lm"
1191
+ "g2|a&b5kr{_tOgS0H^ptn-N_v0Ey~YDhlG+q035grDUqAMTq)UEU|Qx)GAqUFED<kFuOc`p9"
1192
+ "<Hi<?;E$q97$`Nn6=kt2(Mhmv~Pe5d6vMVIsJy+&4A-B<1WEBHMdzGdZO#8=DChC&dxd-"
1193
+ "zmY!pu-vK?+>5b)cV@_LSPP%@lFbt#OAq5G3|VVx$0@CpwCQm3Yx3VVH^{=oK2*8a^qh?04x"
1194
+ "32*L>u#jJN6-"
1195
+ ")I=Vit=x0I_4i<QHt$!lVGCBB)xwNvXkK}C%H7Fho8#3al1xytG9Kr=`XC9H}cVNk6mF4BqQ"
1196
+ ";HY;-"
1197
+ "XiAn`@7%tN1ssRC`?hD=whO4rrxYAYQY=Fx8?D1*xAcjLZj2Hp50GBNbIJSf9qf3efU_Zdrp"
1198
+ "~sf(Gtax~%_JPbu0d|ITxd%hQi!Dt9{)o1&Qin$dM;)y0}tnVV0HPAI+G+ZyC%zdV+8fVW^V"
1199
+ "JtIh1aJTOB_H^yq%?F3_oBWXExbq`(cI~#c;9je>Abmky#)9>Iz>oABue-MKDUV#)y$3lyzA"
1200
+ "3TM<1;D5qd=1Tr=Aaz{>PSBuDM~<f}_?p`4$8M?y@);gGApOXL@$NeDVW#I@*!pW_5yKZ@f<"
1201
+ "UpRve~pTSULV+X{dOdftJdq?jL9ufX+ron%ZS?*p&<uAUA_-"
1202
+ "`})+kO2{WBuEeB}=IEabn8cykGsEE#jhqDOsVyhgc~T)(dTDot(LxKWw4k>MI@do+Uh5MyL3"
1203
+ "h;JIsnRVUQTJR*q=4o)ULw8ylfrBLg)wg^}_Xrn}`mJCjlle({}6QmC+ni<j97{6wPKZ9Y}*"
1204
+ "EpmowVWwj$W>1;`~9VG-"
1205
+ "{V)TIg77jOS??l@TD(N0^euhCEO!@3B!d}%BuRIXNM0nx}mC;qQbzpp^3S&5vWDe^#dgviH+"
1206
+ "Zd5-)ED=*}yp5^($}Fsxk03%`~0F$}PIMcQ86*6N;4=w)??S$6U!WoD-mU5JspJ+8@iOZ&*V"
1207
+ "(Q<k$4FUlT!rpZkDF0SifRiGq|0MwT{^i^L@oR22R#xtA9RKOK{?b|Aj3Qu#6BTOXA?OJq*h"
1208
+ "}ovE|yUyhgBDYB-"
1209
+ "&%eD9P3!l8!ienxF6*jKZQQNSQ&+7mY18mEQF2%wyDeN>BzUg<sw?`JOqgQsD8vE$TE#fJ2E"
1210
+ "%pL=*S68$uzK#UFD((8-FpPGeQ7gFN04r=*a2Q?Ie4-sVxjxyRPXXXtazXW57P-"
1211
+ "WVcFW%<S>odM#DEvynRZ*4N&pXm3yQLaAp#4c^khjXsT6r|VS$}a26hnKU@~K&?_F^aqW~Sj"
1212
+ "&6`EeXW0F)sT)1VIRo9pvKyJ@b5@N?tTS6z7mOyt9C}I)_5zb<eG^X_>Nr}UWsk7tn@nY_t$"
1213
+ "~LTdBqLdiROVRx-QiHLLl+@-"
1214
+ "ckIGrypu3>s0fN5Tyn=A<2zt&&#ApQHhV`=Tb|SX)ev@niCdm7fPh$_6EzDpd8bH|cnpds1}"
1215
+ "`Ea5f!c*k9;+d>(cxogiRrVE&_GY8PF=-"
1216
+ "VRliuorqpKqIE)Vm~1_&P}2{(65Iccr?vJ8eThFG3I)L=b%<9mYS8Wd*3|a&p#G;MR)V~W^P"
1217
+ "I2E-0{u2;aWVV*RbJ>9|($&zOs3%-"
1218
+ "V58QrP_p|)KFjSeV;trxncuD{d?uFO`9Zs!$Uz~QU87PdRcjQ8!JbvzuW&T`icHT!d43&h+z"
1219
+ "Mja{siM)&Rn#tGrRLbUc2H{;|_w`<8VI+lx_y@_>lsJIuiDtYR>X?UZ8hx+`KdyiE{n+n)qI"
1220
+ "%9wXgG|1`=tjq2n8L}KdxDQ5P9h~_--jd(lvL0+ARHy3dM56q(G3~%0U6FiiiB;xeDpsIOBC"
1221
+ ">*wZ37#oj=N}2dyEkuco!4lOpB9BZJ&nC98dWbhpDmn<A*RL1qoqOqN3_lRzg*YXoT3IvB*B"
1222
+ "&G8cvH{4MuvCQ~~DTn*gC349b%YWwt@rUbr02H4U_g(R=AFq7@Fq(QaC{pqpisAfVeCnIR2V"
1223
+ "|yhUPZ22=TLaiIQY`Yt`1{73A&eZnsNpxO#DFS@g0XxgyU4)Sc_+0XPThhh%LB6bwy4wes92"
1224
+ "f^To9*p)2zl&d%)0vH(d)nGdo(#gHdbgmi@KC!}UX6;lliHWKF}Cn?3#$)j<$Y=ku>=a32OK"
1225
+ "U=d*Z367av$oKZza`K9v*f<zF<X}g%edgp!kgh$1mC)NV>_&*JT!?+sK(n!>9a%IjVqPDagQ"
1226
+ "$R;S<6|%+-}4Jh>uy+LvERjWFIRdYwBn~q(-l1DwKaD7(e6Lc~~rrE{AA$T*QLwYt%-"
1227
+ "r7)EN%{guL6{DgSB0|4^Aysze~>kha)C<hon&k25ap+@VJ6W<a%dzc8ksS|rzzTX2w9-"
1228
+ "pkc`L<-"
1229
+ "pp07E&y&q1%fy14%mQS0bttS3=YZQSG2^^w4JqV<`9=E9F?{!tDnmxufK9@mH=7Qt76rt=<-"
1230
+ "|Hg7Z%ES<|2`m&MB<ZfM=^0$qQUvyx1*?hB-"
1231
+ "5LLO%kM<tobINTNW5gQhrHGQ7xKB7dIAqX+`FR?rcTu+k~?*!mX$ltxPY6G;NuW^%>h-"
1232
+ "I;rX6Y>`e9etC${K0suE$~uIt<cg~W7!=2zwb}MIg?tkiu|^rj8p#GE;BSUT#Fa%+%tD6r7T"
1233
+ "%mYDsp!a!;FcFml>Eko5VnglQJB`i@_U#G7>x($!U*GMWvK$?N>YE4+ERi7oad-"
1234
+ "8NZEflAXxVqe~|6F{2oE`ey~vOiBFHdqZy79zly~#*hOcS^!T-"
1235
+ "FgfZCO6>MY%at1`!!BHMVB>ZfTW}_bV!;x6*wU}(16v0=|GO{oo80s1*GQ7EU#KL9eIQ9l6W"
1236
+ "ky1pZF!-"
1237
+ "281bryixD<$5wEWEGydwF?Cs+=zN3CJ;f&ud>GDag3Ed^^a6Y;IkHA_Ei9P*WqTFb(#-"
1238
+ "P#`i+s+)ZtzYKIuU%H5V<Mbql=Zt!owp_K*uX=gC@u*gm0$kMOWr1V2%~dYCJ;@=Wr~rzZm#"
1239
+ "WDJPCQT%6|`X*N^y)={n%mMi@d*0`d;0phBgE_aGtMS6l27?{a-Y1OtcNV(SJklu-"
1240
+ ")4QOiyKYuT^pl;3kX_K{HVzwz1>9H*&~_eh#mI8-"
1241
+ "{XrB(X*k>v$DLa9iiYs}6fRB?akS>z#~;a9>K!d@p)mp#_AxwnEUYOpu-"
1242
+ "v$dUx=yjOAmB)iEx5X77-"
1243
+ "k)@9nel8!9?u_MA=~dXu2qPP`tgOw5^M)>XWGNt(Fp^y@27hTB@P)Jc>Grnn>bTh^7svX}BI"
1244
+ "_DMAVtWs!9)yRaK+DQa_SOpM7vtq$WsFRn6bVr@$$=xUNT-"
1245
+ "o{p53W|c@NJc)9or75?M69+5l=k9E;SD;iFOyl#j#Fj{{8K31tf@b2J;?YDsxtls8EjKpluD"
1246
+ "zvSotVE;Qk2%Yw;%OwWkXH(MGGb48W}Rn{RRl;~1BVdcVmia!5%6E@#}MfPmXGCmsm+YM)7H"
1247
+ "I7@KRgDqjk3$ZFB))7c7$WVwZmJk{D}!xh;MTCchY8mL_PE@a)g5M@#oBG2>$>`-"
1248
+ "0%E=?x`wjpkPEg%N|jR($sus|FdC^GlVV!SOIv4W;7KU=ayedCS{}(pP)U4Te9Mq{<Mm@fv-"
1249
+ "xM0pGCvbjT1k8z<rcj$~=#veO4)%pz!He>IyU+wlS%Qej^Hmj_MXY7FI3$v!!=A-O`1m#c-"
1250
+ "_hQ&RhcY^mGFu`DNU__d>o)X_#7KW$Us5k=%sP~@t6u9e1_D9Cl>k2?t|#X6ab+<5By0XV8M"
1251
+ "o&b8CSmiO}BrQEBZc3fGVK@+?ZW^zGDzCD!G|PxtlXsIr$%nZP`6(7#HUacwRnovoa&Jh*=A"
1252
+ "TFT;x#*=xO_1iUU(4Ll>u}V8?nyva9gOPvA_YtAC8;iaR&)K1p6XTuRWT2V>UF6%+^JfQCE9"
1253
+ "W8lL?Y!o-h<dB6euYUv8*vx^;cDxbO@%#*EKA$}`sY<-MI3O-"
1254
+ "OmsB<_{2wvO#B%F6|T+Bo4MeJz3!3W<rUomx{3|ybhYZ@Bgn4`z6=0?Z>E!O<l3Vf9Zt6nos"
1255
+ "r@x#dbzXub+TQ`v&E4C<%I&YvSegd5U*W`jN%$Tp!q&<3zMRlWXxnBJBO4Gkg?2Hw<VGGwB&"
1256
+ "E(SEk27{GPp<%y<5)MclWwqz43cbwUC!=I#i1zOMt0rj`;1165F5lAaMQ<8|2_ir3EG8F_dq"
1257
+ "6=*BY%6p@#{BNTkOzLQLqM$mlgt!`n4&709}QMPTTsSdGPKRcr8?YH$u^-g-"
1258
+ "J=+v=?1T>Uv_%U~@?kOV8uf11f)7056&FM;hKjgy&SMA=|JF*(c8~31>*T9gCJHiP1vQGP&%"
1259
+ "&8YO3}y|gG8ZS>604stxLVr)b$OFrb2{U~cyJjAy1cIAN^tc+IA(r{$%8g$;#Muo1ej8jd_s"
1260
+ "ZqyD}M6xVcjJbf*Z5TzcZMR7EA+)4CgchUbZ4j8jVIR9j_NgTV+NYrT<@fJ{_3Qmjbv#^+Re"
1261
+ "NRAH9MTJV;{0hWWJM#M2nuttHwpll;hZ^sZ(647eJ8-"
1262
+ "kIm(VkNPc*)cOXLhoOThsysWq{+Z6Xhhas+hAeB|op13oy~0Dat$b&_z#N;m_OF-viBRZ>(r"
1263
+ "`GHbDRGkf9x9_LKpIfUmAs}{BREd3v4KfqPZV=JC;6N3U#k0YXFKycelm3M$_ai91lPI^CUv"
1264
+ "fyXOq=nhQJxUK$0xUwG+(PzoPw>=2t%^@99W)am~?xlAiehWS|dzH)r&-"
1265
+ "d!^9^OW*Yn$yr1^Ogur#C_A=0c>ujQ9>0VaY%3A|(73x0Wk2MT%|CnAGaMc_CMGJcJxJK^WL"
1266
+ "fhwHrz2C@Q;CjVk!nOZPbjVnvd)nRcz*iiRp_6-eXb1Ls_2Y%N!@iIR=%ueW#-"
1267
+ "9cy>xfUNL$LfK8^#(#B!1kSi5oHW=K+4d*n{GwFa3zWYG5l#E{-XXePn`-"
1268
+ "oS;k@^1MEFGKp5Q{LYj*wfrh%@ts0W#P_lZtHZCeFng}AsaIB^2D~_3`^ujEaXK$(T+a2lM6"
1269
+ "9&&RE4@m<+R2mR-jFk$bBvd~9fG!Wb*dZ8m-"
1270
+ "JIYl=@fx=<ty2hS2N@Y3!os{r$8Je>2Qb}dbJ6qqyK+{k6`4iuR;Sn{(8M;pzyCJJLr<V}Or"
1271
+ "@-->yY(wEN65p3*y$}o>!y|PYE4aX+c-UR@N$Co-pQhqHZz{tGO8x=t%k(%Vemwd*r>BqY>^"
1272
+ ">ZfA=uv^(yi0sLw(6;tbt)>fa>Hc=E^2b_<~D(q<_oy3HCSo5~#7<ZZXWJZR~=FBR<++=nBx"
1273
+ "qScmS9)SSgW1cH}6_W&XZEuCx7WNGGxo@Kg)uqI~Q8bLXQZB~oxY*L~u|3&-"
1274
+ "L??Sj)I#veBkpzraL9eahZ@w}O0nH*qrb|x0-"
1275
+ "FboQ+c?LVe>sse%;yy3R&vR){$L$i@5N0@PBAVIMr^PS8NtE&!vFj3)J!OeRy<3*#Cid@6eH"
1276
+ "%liSD@DmRBC#|`(>l$nq?XI!1QHM*Rht@=unapR`D_@hGdW4x=1dTIrCawKhE6Ek_iyky5J{"
1277
+ "fTz!r-{eAo|PwWd+wBsFRk(8AInO-X0ke4EuDt#WOytdxKr-"
1278
+ "R^NE<UmtuA`MWrqaSt%X{qurqA_<8KT6+im!W$I;)rPM30vF@t)Znt>NwIw^^xsHLa4)gb`l"
1279
+ "#N0mLty*;XQbKgTZjE;^~2{-tt&A>$LnlQX~bu-"
1280
+ "D!vcNj&iq(>U3CIy|7)NIN}pmYpt6LKjoW_5_{gYhYQ}d)@9x`ght*qz;1E&slT@J&9>1RUq"
1281
+ "QN8M>{yNqvP~!GH`hAxk%G-"
1282
+ "?~?oH=+%9KKlSxDs?GX+aOtJYe)q+q`otmr^Gc54mWj!hnXqV-"
1283
+ "4{~i)ITg#O;W}=J{?2UR@Z-"
1284
+ "H%Chw&98??9oX@1j%;y!8V^&YK?GE$JIbG+lB9DQ@7i`>kI@hNcxGJ16RtNgNJnNNdVAIw#L"
1285
+ "OfT3XQFol7B6XN?F^}KI*!+;aGC$(DqbPP~28u$v*lY=H2xQS{u(UR)?YwK~><UBEW4*|vfz"
1286
+ "8}q;yXV!E9%Y$w_ztRnfV#l0l_qY)aBeiDg;yh3K`R-"
1287
+ "UH$!^oapVlw9L)_IE=hQ;R3k#TFBlk-O0BD5#mh2xkfF>L_*eMpAE&^O;HPkvvTh9>-"
1288
+ "m3<a0`^-$Ho_=^O-O!?!{H)(-"
1289
+ "*S1u&vf6pQ6a6MiPT64rRTT_K#TX$<AFrxKA;5KcAQbB{}m(b4sI($Cz<zhya(EZIwk@4Jym"
1290
+ "9a^*ZSJVM`{1^wUiP>U#`#-"
1291
+ "X3hARy#)fWSUxjD!bE<+mYyDSLq=q(7C7nLb8ad&Akz^v20x7~l(MT%Dj$aK6iPYcSc79~GR"
1292
+ "NXt_uaufLEJP>AK|f;3W>XBsBg(AlaEQ7p(kw}ad&(L_nrN+W&fwINe8RSxf!ed|t{jZi8<1"
1293
+ "lgLl9rxG!O}5JxNswwGS>KMI>kG$6())xqQRKFFd-"
1294
+ "2!jHv!$nR<?VIX?gjhIqpuW!aHx4D0o_bL+(@xIDXKBF@Rpi8_R0k*CSsp<~=8qYjuuJY3x("
1295
+ "sECw?N&0@m)@KItu1WD2KGR|aXE=u?Nm!;wHF`$|yS*a)?4}tJWr#}?BRpqO&99VKYlr*0@8"
1296
+ "|tJvQ<v>;eoI%I0VXYi^OVLTU;@^%vsp5EJgf4rVkXcS*$?eBk?Iz8N^@NG5FTHQlLrsGsv?"
1297
+ ">i_cuo7o!(e>{TNRRWmy;ur1)cfYU9VZ)tou?ZYkb8_{!ntBf4*iwQ$uXNDfuQ-HlWkwhm53"
1298
+ "mCw}E?v*FW)jv=*t16dRw3o@bceRJZKmyqC6i!t=kAT=m2T=#-"
1299
+ "Ha{_Qsv=+@LC=dpS`DFtL@T)?*#jGeSt;}9L4j?`NwaOb)cAA0zZ7&88Pc3-"
1300
+ "i%R>DXvk4m6bg#yXs~1sPIJVZHF?ozZ=`zymHrspzT>eX$GAQ6<taTzWAAPb>RwK0HCPi>8o"
1301
+ ">4qHIMm2>vIjCQgl05)mo)WYwS_>O&K$k$RRq{Xy)9&R)3swl|@9T;ou@3G6~L&mGkzU^TbU"
1302
+ "9A$;V}=$&o|JQI1G2iYH`!Bi_kAd{Pxax8dB-`dmI+V~(RyHnb-sm&HN4Ycz%M{dG5DGm>_S"
1303
+ "I?aF)jsTc*yY$%R{>8ks}~%rgnqqw&<uo|T0nu=;K4=zhcj^y&5$2QCzM~y7ya?%;^EM~8z_"
1304
+ "ieFnQ|}i@_6qIPX0m5b7Bx{#110IW(?YGoz`iz+xTNsb<($lyrU>(!BrYd99leo(nG=DD#Nx"
1305
+ "QnN&+iZv0g)DPz<^7Xqv#DeDQi2tdS@Z}V|gUc?p@QxwQ2Fo}xNhBv2`WQ;E@(Y0on(!8Rw<"
1306
+ "4<ulu?h97u$irMs<)bl`r|?*8CbuSg?OjOptC4HkV?JYkv^`BwG{QF#@(Y_9rBMC{kFB@kX}"
1307
+ "a9^y4RQT7SQHbLlw;>7z1@cqv`8=Sw9IrsY&`C(9nmV*KO3zN)Zg$022Wnu*5H^vC-"
1308
+ "Q1NXZ@f~1>AV3uFTT|>zS97e)Q;1x6bA0!Pwiv|x;f!W`MSKTk;Q19@V=tV1#`i+h*~9!D%q"
1309
+ ";;HFOl5Yjl%-qYW3HNG%nf5zLyghc!uzRL3iUgEhsHzU`ArBOt|lS%uXwZHcBAhG9M_W-"
1310
+ "+Z_`@CCdl%y34vQ!~eW2TxJLi}!;k1e<de*|r7G$6#y*NkP(5meUGN(*^^2?C_x@S?EV?gbs"
1311
+ "l0mXV6KR-"
1312
+ "0b!l({KpN<H=sIQSz~Bf3)c+@Q~x`EU)`eCU>81%@4w$O0`137_aRtdb;lzXI1!t(~LJCwPM"
1313
+ "p*M^g8LwNK&D{jN%o4_OkK}ovH*#^B2GD16=D^yO|NVa+ce%SV{|9Qi4b=kCg>#v|g4d)*sU"
1314
+ "v2!m(iP;(QcBZZbbiH@bQGCWvDYM$ApLTACV`-&4n-"
1315
+ "p%MV6o?lSk(!U+o`w^U8ThM7t=^GcjpfDqS-94~w-w>0DYDin8Ce)(<mf85638v<Kc?-"
1316
+ "Bbj=(z2O65@z6wma$&wP|W|SWiw~z|Inbx-"
1317
+ "<@7)P~h2{dvxGwT<#Y}ptv>{EsM|MS19?FM*5k0H9cJE`L8#BLLK`(I`VyJ0wI)QiJGlOTaE"
1318
+ "bG0;u7-b*@PgUv=A^nfv?BPcL$YBb6EBhM>atgZpbiw?)@TG_;=LdJDJW;T2h1vI-5nZ-"
1319
+ "Lr`RPJ$goV-BW@B`ZVs@!n0<qUY3(Q4uDq)DzY>>s22e7GNmlt&93^^3@E_s<vQC=2Iq2)i1"
1320
+ "AWbjX;OJQMn)?$Jv#v@khCzIqoAljj>@ifD-L({kj$KDE-"
1321
+ "Eu>UGyjhV<Gh5#+Y}>huGPu!9Zr8v<OdaY63q<%_$tHRM99^XrCL#-psFZ--KT5n%DanoeK*"
1322
+ "cj|&jlY5;?*mm;#`TQWB^t|yvXF49RNE}yweyv0Tt&g%#HB2EK3n|S>n~zx+sm~Q@c(2<i43"
1323
+ "j{tL?sxY;@0S|m72FVQ9?ROgF7>rX*-"
1324
+ "_r1GTnP0;D`q6|i{+ZwZn=!cNqRv}%{jrAW?tb*1LQN4G1{do8T|WN5R}eJRe=jD#7Vx^(`H"
1325
+ "z59=zmo{Ujtv)1OEY5!u+r5;A`;fD#brwHTeHu&EmD3*W~>_a)J^5-"
1326
+ "&Fo<DX)p(f28;#{a0G}wSd=&@;?G{F<+(4uc5C~lz*T`IR7PKc@2N<6#s!Ull&JK`5OM(+W&"
1327
+ "zklKyYoS5t(8|LZOC%kBU2LlN@7KK?(+6cj@"
1328
+ )
1329
+
1330
+ EMPTY_XLAM_BYTES: bytes = zlib.decompress(base64.b85decode(_EMPTY_XLAM_B85))
1331
+ # --- end XLAM template ---
@@ -55,10 +55,11 @@ class ExcelFile(VBAHostFile):
55
55
  VBA project (``ThisWorkbook``, ``Sheet1``, and a bare ``Module1``)
56
56
  and return an open :class:`ExcelFile` for it.
57
57
 
58
- Supported extensions: ``.xlsm`` (default) and ``.xlsb``.
58
+ Supported extensions: ``.xlsm`` (default), ``.xlsb``, and
59
+ ``.xlam`` (Excel add-in).
59
60
 
60
61
  The bytes are decoded from a baked-in template captured from a
61
- freshly Excel-authored workbook, so the resulting file opens
62
+ freshly Excel-authored file, so the resulting file opens
62
63
  cleanly in Excel without any "found a problem" repair prompt.
63
64
 
64
65
  ``path`` is overwritten if it already exists.
@@ -68,6 +69,9 @@ class ExcelFile(VBAHostFile):
68
69
  if suffix == ".xlsb":
69
70
  from pyopenvba._templates import EMPTY_XLSB_BYTES
70
71
  template = EMPTY_XLSB_BYTES
72
+ elif suffix == ".xlam":
73
+ from pyopenvba._templates import EMPTY_XLAM_BYTES
74
+ template = EMPTY_XLAM_BYTES
71
75
  else:
72
76
  from pyopenvba._templates import EMPTY_XLSM_BYTES
73
77
  template = EMPTY_XLSM_BYTES
@@ -25,7 +25,7 @@ Critical implementation traps (guide section 31)
25
25
  from __future__ import annotations
26
26
 
27
27
  import struct
28
- from collections.abc import Iterable
28
+ from collections.abc import Callable, Iterable
29
29
  from dataclasses import dataclass, field
30
30
  from enum import Enum
31
31
  from typing import TYPE_CHECKING
@@ -58,7 +58,9 @@ def copy_token_help(decompressed_current: int, decompressed_chunk_start: int) ->
58
58
  return length_mask, offset_mask, bit_count
59
59
 
60
60
 
61
- def decompress(data: bytes, *, stream_name: str = "<unknown>") -> bytes:
61
+ def decompress(
62
+ data: bytes, *, stream_name: str = "<unknown>", max_bytes: int | None = None
63
+ ) -> bytes:
62
64
  """
63
65
  Decompress a VBA stream using the MS-OVBA compression algorithm.
64
66
 
@@ -81,6 +83,36 @@ def decompress(data: bytes, *, stream_name: str = "<unknown>") -> bytes:
81
83
  ``stream_name`` is purely contextual: it is embedded in any
82
84
  :class:`VBAProjectError` raised here so callers can identify which
83
85
  CFB stream a malformed chunk came from.
86
+
87
+ ``max_bytes``, when given, stops decompression at the first chunk
88
+ boundary at or beyond that many output bytes and returns the
89
+ chunk-aligned prefix. Copy tokens never reference across chunk
90
+ boundaries (this decoder enforces it), so the prefix is
91
+ byte-identical to the same range of a full decompression. Useful
92
+ for reading a module's ``Attribute VB_*`` header without paying for
93
+ the whole stream.
94
+ """
95
+ out, _ = _decompress(data, stream_name=stream_name, max_bytes=max_bytes)
96
+ return out
97
+
98
+
99
+ def _decompress(
100
+ data: bytes, *, stream_name: str, max_bytes: int | None
101
+ ) -> tuple[bytes, bool]:
102
+ """Core decoder behind :func:`decompress`.
103
+
104
+ Returns ``(decompressed, consumed_all)`` where ``consumed_all`` is
105
+ True when the entire compressed input was processed — i.e. the
106
+ returned bytes are the complete decompression, not a prefix.
107
+
108
+ Performance shape (measured; see issue #5): output is produced with
109
+ slice operations wherever the spec allows — non-overlapping copy
110
+ tokens move as one slice, runs of literal tokens within a flag byte
111
+ extend once — and the copy-token masks are recomputed only when the
112
+ chunk-local output size crosses a power of two, since
113
+ :func:`copy_token_help` depends on nothing else. Overlapping
114
+ copies (offset < length) keep the spec's byte-at-a-time semantics,
115
+ which are load-bearing there.
84
116
  """
85
117
 
86
118
  def _err(msg: str, offset: int) -> VBAProjectError:
@@ -92,13 +124,17 @@ def decompress(data: bytes, *, stream_name: str = "<unknown>") -> bytes:
92
124
  raise _err("Invalid compressed stream: missing 0x01 signature byte.", 0)
93
125
 
94
126
  pos = 1
127
+ n = len(data)
95
128
  out = bytearray()
96
129
 
97
- while pos < len(data):
98
- if pos + 2 > len(data):
130
+ while pos < n:
131
+ if max_bytes is not None and len(out) >= max_bytes:
132
+ return bytes(out), False
133
+
134
+ if pos + 2 > n:
99
135
  raise _err("Truncated compressed stream: missing chunk header.", pos)
100
136
 
101
- header = int(struct.unpack_from("<H", data, pos)[0])
137
+ header = data[pos] | data[pos + 1] << 8
102
138
  chunk_data_size = (header & 0x0FFF) + 1 # data bytes after the header
103
139
  chunk_signature = (header >> 12) & 0x7
104
140
  chunk_flag = (header >> 15) & 0x1
@@ -113,10 +149,10 @@ def decompress(data: bytes, *, stream_name: str = "<unknown>") -> bytes:
113
149
  )
114
150
 
115
151
  chunk_end = pos + chunk_data_size
116
- if chunk_end > len(data):
152
+ if chunk_end > n:
117
153
  raise _err(
118
154
  f"Truncated chunk: header announces {chunk_data_size} bytes "
119
- f"but only {len(data) - pos} remain.",
155
+ f"but only {n - pos} remain.",
120
156
  header_offset,
121
157
  )
122
158
  decompressed_chunk_start = len(out)
@@ -129,32 +165,42 @@ def decompress(data: bytes, *, stream_name: str = "<unknown>") -> bytes:
129
165
  f"got {chunk_data_size}.",
130
166
  header_offset,
131
167
  )
132
- if pos + 4096 > len(data):
168
+ if pos + 4096 > n:
133
169
  raise _err("Truncated raw chunk.", pos)
134
170
  out.extend(data[pos: pos + 4096])
135
171
  pos += 4096
136
172
  else:
137
- # Token-compressed chunk.
173
+ # Token-compressed chunk. Masks depend only on the
174
+ # chunk-local output offset; recompute at power-of-two
175
+ # crossings instead of per token.
176
+ length_mask, offset_mask, bit_count = copy_token_help(
177
+ len(out), decompressed_chunk_start
178
+ )
179
+ next_threshold = (1 << bit_count) + 1
180
+
138
181
  while pos < chunk_end:
139
- if pos >= len(data):
182
+ if pos >= n:
140
183
  break
141
- flag_byte = int(data[pos])
184
+ flag_byte = data[pos]
142
185
  pos += 1
143
186
 
144
- for bit in range(8):
145
- if pos >= chunk_end or pos >= len(data):
187
+ bit = 0
188
+ while bit < 8:
189
+ if pos >= chunk_end or pos >= n:
146
190
  break
147
191
 
148
192
  if (flag_byte >> bit) & 1:
149
193
  # Copy token — back-reference into already-decompressed output.
150
- if pos + 2 > len(data):
194
+ if pos + 2 > n:
151
195
  raise _err("Truncated copy token.", pos)
152
- token = int(struct.unpack_from("<H", data, pos)[0])
196
+ token = data[pos] | data[pos + 1] << 8
153
197
  pos += 2
154
198
 
155
- length_mask, offset_mask, bit_count = copy_token_help(
156
- len(out), decompressed_chunk_start
157
- )
199
+ if len(out) - decompressed_chunk_start >= next_threshold:
200
+ length_mask, offset_mask, bit_count = copy_token_help(
201
+ len(out), decompressed_chunk_start
202
+ )
203
+ next_threshold = (1 << bit_count) + 1
158
204
  length = (token & length_mask) + 3
159
205
  offset = ((token & offset_mask) >> (16 - bit_count)) + 1
160
206
 
@@ -171,16 +217,29 @@ def decompress(data: bytes, *, stream_name: str = "<unknown>") -> bytes:
171
217
  pos - 2,
172
218
  )
173
219
 
174
- # Byte-by-byte copy; overlap is intentional and required by spec.
175
- for _ in range(length):
176
- out.append(out[copy_src])
177
- copy_src += 1
220
+ if offset >= length:
221
+ # Source range is fully materialized: one slice.
222
+ out += out[copy_src: copy_src + length]
223
+ else:
224
+ # Overlapping copy; byte-at-a-time is required
225
+ # by spec semantics (the pattern repeats).
226
+ for _ in range(length):
227
+ out.append(out[copy_src])
228
+ copy_src += 1
229
+ bit += 1
178
230
  else:
179
- # Literal token.
180
- out.append(int(data[pos]))
181
- pos += 1
231
+ # Run of literal tokens: consecutive clear bits in
232
+ # this flag byte copy verbatim as one slice.
233
+ run_start = pos
234
+ while (bit < 8
235
+ and not ((flag_byte >> bit) & 1)
236
+ and pos < chunk_end
237
+ and pos < n):
238
+ pos += 1
239
+ bit += 1
240
+ out += data[run_start:pos]
182
241
 
183
- return bytes(out)
242
+ return bytes(out), True
184
243
 
185
244
 
186
245
  def compress(data: bytes) -> bytes:
@@ -678,35 +737,101 @@ class _DirInfo:
678
737
  # Public data model
679
738
  # ---------------------------------------------------------------------------
680
739
 
681
- @dataclass
682
740
  class VBAModule:
683
- """A parsed VBA module with its source code."""
684
- name: str
685
- stream_name: str
686
- source: str
687
- kind: VBAModuleKind = VBAModuleKind.standard
688
- text_offset: int = 0
689
- is_read_only: bool = False
690
- is_private: bool = False
691
- # Dir-stream sub-records preserved for round-trip serialization.
692
- name_unicode: str = ""
693
- stream_name_unicode: str = ""
694
- doc_string: str = ""
695
- doc_string_unicode: str = ""
696
- help_context: int = 0
697
- cookie: int = 0
698
- # Original bytes 0..text_offset of the module stream (performance cache
699
- # / version-dependent prefix). Preserved across write-back so that
700
- # Office's cache invalidation logic operates the same way as it would
701
- # for an untouched stream.
702
- prefix_bytes: bytes = field(default=b"", repr=False)
703
- # Whether the source has been edited and needs to be recompressed on save.
704
- dirty: bool = field(default=False, repr=False)
705
- # Cached attribute header captured at parse time so a body-only source
706
- # replacement (the VBE-style edit surface) can re-prepend the required
707
- # ``Attribute VB_*`` / ``VERSION ... CLASS`` block. Re-derived on demand
708
- # when missing via ``split_attribute_header(self.source)``.
709
- attribute_header: str = field(default="", repr=False)
741
+ """A parsed VBA module with its source code.
742
+
743
+ ``source`` may be materialized lazily: modules produced by
744
+ :func:`parse_vba_project` defer the MS-OVBA decompression of their
745
+ stream until the first ``.source`` access (issue #5 — decompression
746
+ is 88-96% of the cost of opening a project, and callers that only
747
+ want names, kinds, or one module out of many should not pay for all
748
+ of it). Constructing a module with an explicit ``source`` string is
749
+ fully eager and behaves exactly as before. One consequence of
750
+ laziness: a corrupt chunk past the first one raises
751
+ :class:`VBAProjectError` at first access rather than at parse time.
752
+ """
753
+
754
+ def __init__(
755
+ self,
756
+ name: str,
757
+ stream_name: str,
758
+ source: str = "",
759
+ kind: VBAModuleKind = VBAModuleKind.standard,
760
+ text_offset: int = 0,
761
+ is_read_only: bool = False,
762
+ is_private: bool = False,
763
+ # Dir-stream sub-records preserved for round-trip serialization.
764
+ name_unicode: str = "",
765
+ stream_name_unicode: str = "",
766
+ doc_string: str = "",
767
+ doc_string_unicode: str = "",
768
+ help_context: int = 0,
769
+ cookie: int = 0,
770
+ # Original bytes 0..text_offset of the module stream (performance
771
+ # cache / version-dependent prefix). Preserved across write-back
772
+ # so that Office's cache invalidation logic operates the same way
773
+ # as it would for an untouched stream.
774
+ prefix_bytes: bytes = b"",
775
+ # Whether the source has been edited and needs recompression on save.
776
+ dirty: bool = False,
777
+ # Cached attribute header captured at parse time so a body-only
778
+ # source replacement (the VBE-style edit surface) can re-prepend
779
+ # the required ``Attribute VB_*`` block. Re-derived on demand when
780
+ # missing via ``split_attribute_header(self.source)``.
781
+ attribute_header: str = "",
782
+ source_loader: Callable[[], str] | None = None,
783
+ ) -> None:
784
+ self.name = name
785
+ self.stream_name = stream_name
786
+ self.kind = kind
787
+ self.text_offset = text_offset
788
+ self.is_read_only = is_read_only
789
+ self.is_private = is_private
790
+ self.name_unicode = name_unicode
791
+ self.stream_name_unicode = stream_name_unicode
792
+ self.doc_string = doc_string
793
+ self.doc_string_unicode = doc_string_unicode
794
+ self.help_context = help_context
795
+ self.cookie = cookie
796
+ self.prefix_bytes = prefix_bytes
797
+ self.dirty = dirty
798
+ self.attribute_header = attribute_header
799
+ if source_loader is not None:
800
+ self._source: str | None = None
801
+ self._source_loader: Callable[[], str] | None = source_loader
802
+ else:
803
+ self._source = source
804
+ self._source_loader = None
805
+
806
+ def __repr__(self) -> str:
807
+ return (
808
+ f"VBAModule(name={self.name!r}, stream_name={self.stream_name!r}, "
809
+ f"kind={self.kind!r}, text_offset={self.text_offset}, "
810
+ f"source_loaded={self._source is not None})"
811
+ )
812
+
813
+ @property
814
+ def source_loaded(self) -> bool:
815
+ """True once the source text is materialized in memory."""
816
+ return self._source is not None
817
+
818
+ @property
819
+ def source(self) -> str:
820
+ """The module's full source text (header plus body).
821
+
822
+ Materializes lazily on first access for parsed modules.
823
+ """
824
+ if self._source is None:
825
+ loader = self._source_loader
826
+ assert loader is not None, "lazy module lost its loader"
827
+ self._source = loader()
828
+ self._source_loader = None
829
+ return self._source
830
+
831
+ @source.setter
832
+ def source(self, value: str) -> None:
833
+ self._source = value
834
+ self._source_loader = None
710
835
 
711
836
  @property
712
837
  def body(self) -> str:
@@ -1470,12 +1595,31 @@ def _project_section_end(lines: list[str]) -> int:
1470
1595
  # Public factory
1471
1596
  # ---------------------------------------------------------------------------
1472
1597
 
1598
+ def _make_source_loader(
1599
+ compressed: bytes, encoding: str, stream_name: str
1600
+ ) -> Callable[[], str]:
1601
+ """Build the deferred decompress-and-decode thunk for a lazy module."""
1602
+ def _load() -> str:
1603
+ return decompress(compressed, stream_name=stream_name).decode(
1604
+ encoding, errors="replace"
1605
+ )
1606
+ return _load
1607
+
1608
+
1473
1609
  def parse_vba_project(cfb: CFB) -> VBAProject:
1474
1610
  """
1475
- Extract and decompress all VBA module sources from a parsed CFB.
1611
+ Extract all VBA module metadata from a parsed CFB.
1476
1612
 
1477
1613
  The CFB must be the vbaProject.bin from an xlsm/xlsb, or the whole
1478
1614
  file for an xls workbook.
1615
+
1616
+ Module source text is loaded lazily: only the first compressed
1617
+ chunk of each module stream is decompressed here (enough for the
1618
+ ``Attribute VB_*`` header, and for single-chunk modules it is
1619
+ already the whole source). The remaining chunks decompress on the
1620
+ first ``VBAModule.source`` access. Stream lookup and MODULEOFFSET
1621
+ bounds checks stay eager so structural corruption still surfaces at
1622
+ parse time.
1479
1623
  """
1480
1624
  try:
1481
1625
  dir_compressed = cfb.get_stream_in_storage("VBA", "dir")
@@ -1510,17 +1654,47 @@ def parse_vba_project(cfb: CFB) -> VBAProject:
1510
1654
  f"{len(stream_compressed)} for module {info.name!r}."
1511
1655
  )
1512
1656
 
1513
- compressed_source = stream_compressed[info.text_offset:]
1514
- source_bytes = decompress(
1515
- compressed_source, stream_name=f"VBA/{stream_name}"
1657
+ compressed_source = bytes(stream_compressed[info.text_offset:])
1658
+ ovba_name = f"VBA/{stream_name}"
1659
+
1660
+ # Decompress just the first chunk. For single-chunk modules
1661
+ # (decompressed size <= 4096) this is the complete source and
1662
+ # the module is materialized eagerly with zero extra work.
1663
+ prefix_raw, consumed_all = _decompress(
1664
+ compressed_source, stream_name=ovba_name, max_bytes=1
1516
1665
  )
1517
- source = source_bytes.decode(encoding, errors="replace")
1518
- header, _ = split_attribute_header(source)
1666
+ prefix_text = prefix_raw.decode(encoding, errors="replace")
1667
+
1668
+ source: str | None
1669
+ loader: Callable[[], str] | None
1670
+ if consumed_all:
1671
+ source = prefix_text
1672
+ header, _ = split_attribute_header(prefix_text)
1673
+ loader = None
1674
+ else:
1675
+ header, body_prefix = split_attribute_header(prefix_text)
1676
+ if header and body_prefix:
1677
+ # Header fits inside the first chunk (the normal case);
1678
+ # defer the rest of the stream.
1679
+ source = None
1680
+ loader = _make_source_loader(
1681
+ compressed_source, encoding, ovba_name
1682
+ )
1683
+ else:
1684
+ # Either no attribute header at all, or the header runs
1685
+ # to the chunk boundary and may be truncated. Both are
1686
+ # abnormal; fall back to eager full decompression so
1687
+ # attribute_header is always derived from complete text.
1688
+ source = decompress(
1689
+ compressed_source, stream_name=ovba_name
1690
+ ).decode(encoding, errors="replace")
1691
+ header, _ = split_attribute_header(source)
1692
+ loader = None
1519
1693
 
1520
1694
  modules.append(VBAModule(
1521
1695
  name=info.name,
1522
1696
  stream_name=stream_name,
1523
- source=source,
1697
+ source=source if source is not None else "",
1524
1698
  kind=info.module_kind,
1525
1699
  text_offset=info.text_offset,
1526
1700
  is_read_only=info.is_read_only,
@@ -1531,8 +1705,9 @@ def parse_vba_project(cfb: CFB) -> VBAProject:
1531
1705
  doc_string_unicode=info.doc_string_unicode,
1532
1706
  help_context=info.help_context,
1533
1707
  cookie=info.cookie,
1534
- prefix_bytes=stream_compressed[: info.text_offset],
1708
+ prefix_bytes=bytes(stream_compressed[: info.text_offset]),
1535
1709
  attribute_header=header,
1710
+ source_loader=loader,
1536
1711
  ))
1537
1712
 
1538
1713
  project = VBAProject(modules=modules, code_page=code_page)
File without changes
File without changes
File without changes