pyOpenVBA 3.4.0__tar.gz → 3.5.1__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 (34) hide show
  1. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/PKG-INFO +149 -24
  2. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/README.md +147 -22
  3. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/docs/architecture.md +124 -14
  4. pyopenvba-3.5.1/docs/research/access_write/README.md +1118 -0
  5. pyopenvba-3.5.1/docs/research/pcode/README.md +40 -0
  6. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/docs/roadmap.md +10 -6
  7. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/pyproject.toml +1 -1
  8. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/__init__.py +7 -1
  9. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/__main__.py +55 -0
  10. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/_host.py +122 -14
  11. pyopenvba-3.5.1/src/pyopenvba/_oforms_pages.py +187 -0
  12. pyopenvba-3.5.1/src/pyopenvba/_oforms_records.py +733 -0
  13. pyopenvba-3.5.1/src/pyopenvba/_ppt_container.py +398 -0
  14. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/_templates/__init__.py +239 -0
  15. pyopenvba-3.5.1/src/pyopenvba/_templates/blank_files/blank_database_module.accdb +0 -0
  16. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/access_read.py +191 -70
  17. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/cfb.py +190 -1
  18. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/exceptions.py +9 -0
  19. pyopenvba-3.5.1/src/pyopenvba/forms.py +1943 -0
  20. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/powerpoint.py +22 -1
  21. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/vba.py +7 -1
  22. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/.gitignore +0 -0
  23. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/LICENSE.md +0 -0
  24. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/docs/ms-ovba-implementation-guide_v2.md +0 -0
  25. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/_templates/blank_files/blank_database.accdb +0 -0
  26. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/_templates/blank_files/blank_document.docm +0 -0
  27. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/_templates/blank_files/blank_excel_addin.xlam +0 -0
  28. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/_templates/blank_files/blank_presentation.pptm +0 -0
  29. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsb +0 -0
  30. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsm +0 -0
  31. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/excel.py +0 -0
  32. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/vba_pcode.py +0 -0
  33. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/src/pyopenvba/word.py +0 -0
  34. {pyopenvba-3.4.0 → pyopenvba-3.5.1}/tests/fuzz_corpus/README.md +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pyOpenVBA
3
- Version: 3.4.0
3
+ Version: 3.5.1
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
@@ -73,16 +73,22 @@ The write path is the whole point of the library:
73
73
  declaration, `PROJECTwm` name map, and `Attribute VB_Name` are all
74
74
  updated in lockstep).
75
75
  - **Delete** a module cleanly.
76
+ - **Design** UserForms, not just their code-behind: read a form's control
77
+ tree and its properties, edit them, add and remove controls, Frames,
78
+ MultiPages and pages, or compose a whole form from nothing.
76
79
  - **Save** the file and have it reopen in the host application with no
77
80
  repair dialog. Every supported format is verified against live Office.
78
- - **Create** new `.xlsm`, `.xlsb`, `.docm`, or `.pptm` files on the
79
- fly, and inject VBA code into them.
81
+ - **Create** new `.xlsm`, `.xlsb`, `.xlam`, `.docm`, or `.pptm` files on
82
+ the fly, and inject VBA code into them.
80
83
 
81
84
  That makes it a good fit for:
82
85
 
83
86
  - **Version-controlling your VBA** in git like normal source code, then
84
87
  pushing edits back without ever opening Office.
85
- - **Diffing** two workbooks or documents to see what changed in a module.
88
+ - **Diffing** two workbooks or documents to see what changed in a module --
89
+ or in a form's design, down to which properties an author actually set.
90
+ - **Building UserForms programmatically**, on a machine with no Office at
91
+ all.
86
92
  - **Generating or updating macros from a script** without scripting
87
93
  Office through COM automation.
88
94
  - **Reading and writing macros on a server** (Linux / CI) where Office
@@ -326,6 +332,120 @@ modules, `.cls` for class modules and code-behind.
326
332
 
327
333
  ---
328
334
 
335
+ ## Creating and editing a UserForm's design
336
+
337
+ A form's *code* is a module like any other. Its *design* -- which controls
338
+ exist, how they nest, and what their properties are -- lives in separate
339
+ streams that no module source carries. `forms()` reads them, with no
340
+ Office installed:
341
+
342
+ ```python
343
+ import pyopenvba
344
+
345
+ with pyopenvba.ExcelFile("book.xlsm") as wb:
346
+ for form in wb.forms():
347
+ print(form.name, len(form.walk()), "controls")
348
+ for control in form.walk():
349
+ print(f" {control.name:<16} {control.kind:<22} "
350
+ f"{control.properties()}")
351
+ ```
352
+
353
+ And edits them:
354
+
355
+ ```python
356
+ with pyopenvba.ExcelFile("book.xlsm") as wb:
357
+ form = wb.forms()[0]
358
+ form.control("OkButton").set_property("Caption", "Save")
359
+ form.control("NameBox").set_property("MaxLength", 40)
360
+ form.add_control("Label", "Hint", left=12, top=120, width=200)
361
+ form.remove_control("OldCheckbox")
362
+ wb.save()
363
+ ```
364
+
365
+ Containers work too. Each gets a storage of its own, and removing one
366
+ takes its children with it:
367
+
368
+ ```python
369
+ form.add_control("Frame", "Shipping", left=12, top=160, width=200, height=80)
370
+ form.add_control("OptionButton", "Ground", container="Shipping")
371
+ form.remove_control("OldFrame") # and everything inside it
372
+ ```
373
+
374
+ A `MultiPage` arrives with the two pages Excel gives it, and pages are
375
+ added and removed through it, because a page is also a *tab*:
376
+
377
+ ```python
378
+ form.add_control("MultiPage", "Wizard", left=12, top=40, width=300, height=200)
379
+ form.add_page("Wizard", name="Review", caption="Review && confirm")
380
+ form.add_control("Label", "Summary", container="Review")
381
+ form.remove_page("Page2", multipage="Wizard")
382
+ ```
383
+
384
+ Page names are scoped to their MultiPage rather than to the form, which is
385
+ why `remove_page` takes an optional `multipage` to disambiguate.
386
+
387
+ And a form can be built from nothing -- `add_form` creates the designer
388
+ storage and the code-behind module together, because a storage without a
389
+ module is not a component the host will show:
390
+
391
+ ```python
392
+ with pyopenvba.ExcelFile("book.xlsm") as wb:
393
+ form = wb.add_form("Wizard", caption="Setup", width=300, height=200)
394
+ form.add_control("Label", "Prompt", left=12, top=12, width=200)
395
+ form.add_control("TextBox", "Answer", left=12, top=40, width=200)
396
+ form.add_control("CommandButton", "Ok", left=12, top=80)
397
+ wb.set_module("Wizard", "Private Sub Ok_Click()\r\n Me.Hide\r\nEnd Sub\r\n")
398
+ wb.save()
399
+ ```
400
+
401
+ Geometry is in points, the unit the designer shows. `set_property(name,
402
+ None)` clears a property, which is how a control goes back to inheriting
403
+ the default.
404
+
405
+ Or from the command line:
406
+
407
+ ```bash
408
+ python -m pyopenvba forms book.xlsm
409
+ ```
410
+
411
+ ```
412
+ FrmNested (13 controls)
413
+ TopLabel MSForms.Label id=1 set=0x00000028
414
+ GroupBox MSForms.Frame id=2 set=0x0c0a0c48
415
+ OptOne MSForms.OptionButton id=3 set=0x0000000180c00146
416
+ Pages MSForms.MultiPage id=6 set=0x0c000c48
417
+ (unnamed) MSForms.TabStrip id=7 set=0x00fa8031
418
+ Page1 MSForms.Form id=8 set=0x0c000c48
419
+ PageOneCheck MSForms.CheckBox id=10 set=0x0000000080c00146
420
+ ```
421
+
422
+ Containers nest: a `Frame`'s children and a `MultiPage`'s `Page`s live in
423
+ storages of their own, and MSForms sites an unnamed `TabStrip` beside the
424
+ pages. `walk()` flattens the tree depth-first; `form.controls` gives just
425
+ the top level.
426
+
427
+ ### Only what the developer set
428
+
429
+ MSForms writes a property into a control's record **only when it differs
430
+ from that control's default**, so `properties()` returns the set the
431
+ developer chose -- not every property the control has. That is not
432
+ something a live host can tell you: a sited control reports inherited,
433
+ default, and chosen values indistinguishably.
434
+
435
+ `properties_set` is the same information as a raw bit mask, if you want to
436
+ diff two files without comparing names. Read a bit index together with
437
+ `property_mask_width`: MorphData controls (`TextBox`, `ListBox`,
438
+ `ComboBox`, `CheckBox`, `OptionButton`, `ToggleButton`) carry an 8-byte
439
+ mask and everything else carries 4.
440
+
441
+ Writing is lossless. An unedited form saves back byte for byte, because
442
+ alignment padding, string bytes, pictures, and anything the property
443
+ tables do not model are all replayed as they were read. If a form's
444
+ streams do not reconcile, this raises `FormParseError` rather than
445
+ returning a partly-guessed control list.
446
+
447
+ ---
448
+
329
449
  ## Supported formats
330
450
 
331
451
  ### Excel
@@ -432,12 +552,9 @@ wb.save(allow_invalidate_signature=True)
432
552
 
433
553
  ## What's out of scope
434
554
 
435
- This library is intentionally focused on **module source code**. The
555
+ A project's code and its UserForm designs are read and written. The
436
556
  following are preserved byte-for-byte but not interpreted:
437
557
 
438
- - UserForm **layout** (controls, properties, positions). Editing the
439
- **code-behind** of a UserForm works fine; editing the design surface
440
- does not.
441
558
  - VBA project password decryption / re-encryption.
442
559
  - Re-signing digitally signed projects.
443
560
  - ActiveX license editing.
@@ -450,21 +567,29 @@ See [docs/roadmap.md](https://github.com/WilliamSmithEdward/pyOpenVBA/blob/main/
450
567
 
451
568
  ```
452
569
  src/pyopenvba/
453
- __init__.py public API (ExcelFile, WordFile, PowerPointFile, AccessReader,
454
- pull/push, pull_word/push_word, pull_ppt/push_ppt,
455
- pull_access, VBAModuleKind, synthesize_class_header,
456
- exceptions)
457
- _host.py VBAHostFile: shared open/edit/pull/push/save pipeline
458
- excel.py ExcelFile (thin VBAHostFile subclass + create_new template)
459
- word.py WordFile (thin VBAHostFile subclass + create_new template)
460
- powerpoint.py PowerPointFile (thin VBAHostFile subclass + create_new template)
461
- access_read.py AccessReader (read-only ACE/Jet page + LVAL reader)
462
- vba.py VBA project parser + MS-OVBA codec
463
- vba_pcode.py VBA7 p-code disassembler
464
- cfb.py MS-CFB (Compound File Binary) parser/writer
465
- exceptions.py custom exception hierarchy
466
- _templates/ baked-in empty .xlsm/.xlsb/.docm/.pptm bytes for create_new()
467
- __main__.py `python -m pyopenvba {pull,push,ls,disasm,access-*}` CLI
570
+ __init__.py public API (ExcelFile, WordFile, PowerPointFile,
571
+ AccessReader, VBAForm, FormControl, Size,
572
+ pull/push, pull_word/push_word,
573
+ pull_ppt/push_ppt, pull_access,
574
+ VBAModuleKind, synthesize_class_header,
575
+ exceptions)
576
+ _host.py VBAHostFile: shared open/edit/pull/push/save pipeline
577
+ excel.py ExcelFile (thin VBAHostFile subclass + create_new template)
578
+ word.py WordFile (thin VBAHostFile subclass + create_new template)
579
+ powerpoint.py PowerPointFile (thin subclass; .ppt overrides the two
580
+ container hooks)
581
+ access_read.py AccessReader (read-only ACE/Jet page + LVAL reader)
582
+ vba.py VBA project parser + MS-OVBA codec
583
+ vba_pcode.py VBA7 p-code disassembler
584
+ cfb.py MS-CFB (Compound File Binary) parser/writer
585
+ forms.py UserForm designer streams: control tree, read and write
586
+ _oforms_records.py [MS-OFORMS] property table, one per control class
587
+ _oforms_pages.py a MultiPage's tabs and page bookkeeping
588
+ _ppt_container.py the VBA project a binary .ppt hides in its document stream
589
+ exceptions.py custom exception hierarchy
590
+ _templates/ baked-in empty .xlsm/.xlsb/.xlam/.docm/.pptm/.accdb bytes
591
+ for create_new()
592
+ __main__.py `python -m pyopenvba {pull,push,ls,forms,disasm,access-*}`
468
593
  ```
469
594
 
470
595
  For deeper documentation:
@@ -37,16 +37,22 @@ The write path is the whole point of the library:
37
37
  declaration, `PROJECTwm` name map, and `Attribute VB_Name` are all
38
38
  updated in lockstep).
39
39
  - **Delete** a module cleanly.
40
+ - **Design** UserForms, not just their code-behind: read a form's control
41
+ tree and its properties, edit them, add and remove controls, Frames,
42
+ MultiPages and pages, or compose a whole form from nothing.
40
43
  - **Save** the file and have it reopen in the host application with no
41
44
  repair dialog. Every supported format is verified against live Office.
42
- - **Create** new `.xlsm`, `.xlsb`, `.docm`, or `.pptm` files on the
43
- fly, and inject VBA code into them.
45
+ - **Create** new `.xlsm`, `.xlsb`, `.xlam`, `.docm`, or `.pptm` files on
46
+ the fly, and inject VBA code into them.
44
47
 
45
48
  That makes it a good fit for:
46
49
 
47
50
  - **Version-controlling your VBA** in git like normal source code, then
48
51
  pushing edits back without ever opening Office.
49
- - **Diffing** two workbooks or documents to see what changed in a module.
52
+ - **Diffing** two workbooks or documents to see what changed in a module --
53
+ or in a form's design, down to which properties an author actually set.
54
+ - **Building UserForms programmatically**, on a machine with no Office at
55
+ all.
50
56
  - **Generating or updating macros from a script** without scripting
51
57
  Office through COM automation.
52
58
  - **Reading and writing macros on a server** (Linux / CI) where Office
@@ -290,6 +296,120 @@ modules, `.cls` for class modules and code-behind.
290
296
 
291
297
  ---
292
298
 
299
+ ## Creating and editing a UserForm's design
300
+
301
+ A form's *code* is a module like any other. Its *design* -- which controls
302
+ exist, how they nest, and what their properties are -- lives in separate
303
+ streams that no module source carries. `forms()` reads them, with no
304
+ Office installed:
305
+
306
+ ```python
307
+ import pyopenvba
308
+
309
+ with pyopenvba.ExcelFile("book.xlsm") as wb:
310
+ for form in wb.forms():
311
+ print(form.name, len(form.walk()), "controls")
312
+ for control in form.walk():
313
+ print(f" {control.name:<16} {control.kind:<22} "
314
+ f"{control.properties()}")
315
+ ```
316
+
317
+ And edits them:
318
+
319
+ ```python
320
+ with pyopenvba.ExcelFile("book.xlsm") as wb:
321
+ form = wb.forms()[0]
322
+ form.control("OkButton").set_property("Caption", "Save")
323
+ form.control("NameBox").set_property("MaxLength", 40)
324
+ form.add_control("Label", "Hint", left=12, top=120, width=200)
325
+ form.remove_control("OldCheckbox")
326
+ wb.save()
327
+ ```
328
+
329
+ Containers work too. Each gets a storage of its own, and removing one
330
+ takes its children with it:
331
+
332
+ ```python
333
+ form.add_control("Frame", "Shipping", left=12, top=160, width=200, height=80)
334
+ form.add_control("OptionButton", "Ground", container="Shipping")
335
+ form.remove_control("OldFrame") # and everything inside it
336
+ ```
337
+
338
+ A `MultiPage` arrives with the two pages Excel gives it, and pages are
339
+ added and removed through it, because a page is also a *tab*:
340
+
341
+ ```python
342
+ form.add_control("MultiPage", "Wizard", left=12, top=40, width=300, height=200)
343
+ form.add_page("Wizard", name="Review", caption="Review && confirm")
344
+ form.add_control("Label", "Summary", container="Review")
345
+ form.remove_page("Page2", multipage="Wizard")
346
+ ```
347
+
348
+ Page names are scoped to their MultiPage rather than to the form, which is
349
+ why `remove_page` takes an optional `multipage` to disambiguate.
350
+
351
+ And a form can be built from nothing -- `add_form` creates the designer
352
+ storage and the code-behind module together, because a storage without a
353
+ module is not a component the host will show:
354
+
355
+ ```python
356
+ with pyopenvba.ExcelFile("book.xlsm") as wb:
357
+ form = wb.add_form("Wizard", caption="Setup", width=300, height=200)
358
+ form.add_control("Label", "Prompt", left=12, top=12, width=200)
359
+ form.add_control("TextBox", "Answer", left=12, top=40, width=200)
360
+ form.add_control("CommandButton", "Ok", left=12, top=80)
361
+ wb.set_module("Wizard", "Private Sub Ok_Click()\r\n Me.Hide\r\nEnd Sub\r\n")
362
+ wb.save()
363
+ ```
364
+
365
+ Geometry is in points, the unit the designer shows. `set_property(name,
366
+ None)` clears a property, which is how a control goes back to inheriting
367
+ the default.
368
+
369
+ Or from the command line:
370
+
371
+ ```bash
372
+ python -m pyopenvba forms book.xlsm
373
+ ```
374
+
375
+ ```
376
+ FrmNested (13 controls)
377
+ TopLabel MSForms.Label id=1 set=0x00000028
378
+ GroupBox MSForms.Frame id=2 set=0x0c0a0c48
379
+ OptOne MSForms.OptionButton id=3 set=0x0000000180c00146
380
+ Pages MSForms.MultiPage id=6 set=0x0c000c48
381
+ (unnamed) MSForms.TabStrip id=7 set=0x00fa8031
382
+ Page1 MSForms.Form id=8 set=0x0c000c48
383
+ PageOneCheck MSForms.CheckBox id=10 set=0x0000000080c00146
384
+ ```
385
+
386
+ Containers nest: a `Frame`'s children and a `MultiPage`'s `Page`s live in
387
+ storages of their own, and MSForms sites an unnamed `TabStrip` beside the
388
+ pages. `walk()` flattens the tree depth-first; `form.controls` gives just
389
+ the top level.
390
+
391
+ ### Only what the developer set
392
+
393
+ MSForms writes a property into a control's record **only when it differs
394
+ from that control's default**, so `properties()` returns the set the
395
+ developer chose -- not every property the control has. That is not
396
+ something a live host can tell you: a sited control reports inherited,
397
+ default, and chosen values indistinguishably.
398
+
399
+ `properties_set` is the same information as a raw bit mask, if you want to
400
+ diff two files without comparing names. Read a bit index together with
401
+ `property_mask_width`: MorphData controls (`TextBox`, `ListBox`,
402
+ `ComboBox`, `CheckBox`, `OptionButton`, `ToggleButton`) carry an 8-byte
403
+ mask and everything else carries 4.
404
+
405
+ Writing is lossless. An unedited form saves back byte for byte, because
406
+ alignment padding, string bytes, pictures, and anything the property
407
+ tables do not model are all replayed as they were read. If a form's
408
+ streams do not reconcile, this raises `FormParseError` rather than
409
+ returning a partly-guessed control list.
410
+
411
+ ---
412
+
293
413
  ## Supported formats
294
414
 
295
415
  ### Excel
@@ -396,12 +516,9 @@ wb.save(allow_invalidate_signature=True)
396
516
 
397
517
  ## What's out of scope
398
518
 
399
- This library is intentionally focused on **module source code**. The
519
+ A project's code and its UserForm designs are read and written. The
400
520
  following are preserved byte-for-byte but not interpreted:
401
521
 
402
- - UserForm **layout** (controls, properties, positions). Editing the
403
- **code-behind** of a UserForm works fine; editing the design surface
404
- does not.
405
522
  - VBA project password decryption / re-encryption.
406
523
  - Re-signing digitally signed projects.
407
524
  - ActiveX license editing.
@@ -414,21 +531,29 @@ See [docs/roadmap.md](https://github.com/WilliamSmithEdward/pyOpenVBA/blob/main/
414
531
 
415
532
  ```
416
533
  src/pyopenvba/
417
- __init__.py public API (ExcelFile, WordFile, PowerPointFile, AccessReader,
418
- pull/push, pull_word/push_word, pull_ppt/push_ppt,
419
- pull_access, VBAModuleKind, synthesize_class_header,
420
- exceptions)
421
- _host.py VBAHostFile: shared open/edit/pull/push/save pipeline
422
- excel.py ExcelFile (thin VBAHostFile subclass + create_new template)
423
- word.py WordFile (thin VBAHostFile subclass + create_new template)
424
- powerpoint.py PowerPointFile (thin VBAHostFile subclass + create_new template)
425
- access_read.py AccessReader (read-only ACE/Jet page + LVAL reader)
426
- vba.py VBA project parser + MS-OVBA codec
427
- vba_pcode.py VBA7 p-code disassembler
428
- cfb.py MS-CFB (Compound File Binary) parser/writer
429
- exceptions.py custom exception hierarchy
430
- _templates/ baked-in empty .xlsm/.xlsb/.docm/.pptm bytes for create_new()
431
- __main__.py `python -m pyopenvba {pull,push,ls,disasm,access-*}` CLI
534
+ __init__.py public API (ExcelFile, WordFile, PowerPointFile,
535
+ AccessReader, VBAForm, FormControl, Size,
536
+ pull/push, pull_word/push_word,
537
+ pull_ppt/push_ppt, pull_access,
538
+ VBAModuleKind, synthesize_class_header,
539
+ exceptions)
540
+ _host.py VBAHostFile: shared open/edit/pull/push/save pipeline
541
+ excel.py ExcelFile (thin VBAHostFile subclass + create_new template)
542
+ word.py WordFile (thin VBAHostFile subclass + create_new template)
543
+ powerpoint.py PowerPointFile (thin subclass; .ppt overrides the two
544
+ container hooks)
545
+ access_read.py AccessReader (read-only ACE/Jet page + LVAL reader)
546
+ vba.py VBA project parser + MS-OVBA codec
547
+ vba_pcode.py VBA7 p-code disassembler
548
+ cfb.py MS-CFB (Compound File Binary) parser/writer
549
+ forms.py UserForm designer streams: control tree, read and write
550
+ _oforms_records.py [MS-OFORMS] property table, one per control class
551
+ _oforms_pages.py a MultiPage's tabs and page bookkeeping
552
+ _ppt_container.py the VBA project a binary .ppt hides in its document stream
553
+ exceptions.py custom exception hierarchy
554
+ _templates/ baked-in empty .xlsm/.xlsb/.xlam/.docm/.pptm/.accdb bytes
555
+ for create_new()
556
+ __main__.py `python -m pyopenvba {pull,push,ls,forms,disasm,access-*}`
432
557
  ```
433
558
 
434
559
  For deeper documentation:
@@ -244,14 +244,17 @@ top-level helper `pyopenvba.pull_access(database, dest_dir)` mirrors
244
244
 
245
245
  ### Why no write path
246
246
 
247
- Access stores compiled VBA p-code (the `rU@` + `CAFE` rows) separately
248
- from the OVBA source cache. The compiled p-code is authoritative for
249
- the Access GUI; mutations to the source cache do not survive reload
250
- because Access never recompiles from the cache. We could not locate a
251
- recompile trigger. A production-quality writer would require a full
252
- VBA7 p-code assembler (instruction emission, identifier table
253
- re-indexing, jump fixup, MSysObjects long-value chunk reflow, ACE
254
- page allocator parity). That is out of scope. See
247
+ Access stores compiled VBA p-code (the `CAFE` rows) separately from the
248
+ OVBA source cache, and executes an `__SRP_*` compiled cache in
249
+ preference to either, so mutations to the source alone do not change
250
+ behaviour. Research later found the lever -- dropping those cache rows
251
+ makes a rewritten module take effect -- and got as far as rewriting
252
+ procedure bodies, including declarations, with output byte-identical to
253
+ Microsoft's compiler. It did **not** get to creating, renaming or
254
+ deleting a module, which is what a useful writer would need. That work
255
+ is parked and unsupported; a production-quality writer would still
256
+ require the `FuncDefn` declaration tables and ACE page allocator parity.
257
+ See
255
258
  [docs/msaccess_lessons_learned.md](msaccess_lessons_learned.md) for
256
259
  the empirical results matrix and the reasoning in full.
257
260
 
@@ -296,7 +299,7 @@ removes signatures.
296
299
  | Extension | Container | VBA entry path |
297
300
  |------------------|------------------------|------------------------|
298
301
  | `.pptm`, `.potm` | ZIP (OOXML) | `ppt/vbaProject.bin` |
299
- | `.ppt` | raw CFB (PPT 97) | (whole file is CFB) |
302
+ | `.ppt` | CFB (PPT 97) | embedded in `PowerPoint Document` |
300
303
  | anything else | n/a | `UnsupportedFormatError` |
301
304
 
302
305
  For the ZIP case, the VBA project is at the fixed path
@@ -304,8 +307,17 @@ For the ZIP case, the VBA project is at the fixed path
304
307
  every other ZIP entry is preserved byte-for-byte including its
305
308
  compression method, external attributes, create system, and timestamp.
306
309
 
307
- For `.xls`, the entire file *is* the CFB; `cfb.to_bytes()` is written
308
- straight to the output path.
310
+ For `.xls` and `.doc`, the entire file *is* the CFB; `cfb.to_bytes()`
311
+ is written straight to the output path.
312
+
313
+ `.ppt` is the exception among the legacy containers. Its root holds no
314
+ VBA storage at all: the project is a whole CFB, zlib-deflated, inside an
315
+ `ExOleObjStg` record of the `PowerPoint Document` stream, found through
316
+ the persist chain (`Current User` -> `UserEditAtom` -> `PersistDirectoryAtom`).
317
+ `_ppt_container.py` extracts it on open and splices it back on save,
318
+ shifting every absolute offset past the resized record. The two hooks
319
+ `VBAHostFile._vba_cfb_bytes` / `._container_bytes` are the seam; they are
320
+ identities for every other format.
309
321
 
310
322
  If a `.xlsm` exists but does not contain `xl/vbaProject.bin` (no VBA
311
323
  project has ever been created), `ExcelFile` raises a structured
@@ -313,6 +325,102 @@ project has ever been created), `ExcelFile` raises a structured
313
325
 
314
326
  ---
315
327
 
328
+ ## 5a. UserForm designer streams
329
+
330
+ A form's design lives beside the VBA storage, not inside it: a root
331
+ storage named for the form, holding `f` (the sites: which controls, in
332
+ what order), `o` (each control's own property record), and the
333
+ `VBFrame` text. Containers nest into storages of their own, named
334
+ for the site id -- a `Frame`'s children in `i02`, a `MultiPage`'s Pages
335
+ in `i08` / `i09` under its own `i06`.
336
+
337
+ `forms.py` reads that tree and writes it back; `_oforms_records.py`
338
+ carries one property table per control class. It never guesses: every
339
+ structure is counted or length-prefixed, so a misread collapses rather
340
+ than yielding a plausible control list, and it raises `FormParseError`
341
+ instead. Three checks have to agree -- `CountOfBytes` runs exactly to the
342
+ end of `f`, the per-site `ObjectStreamSize` values sum to exactly
343
+ `len(o)`, and every child storage is claimed by a site.
344
+
345
+ Writing is lossless first: alignment padding is captured and replayed
346
+ (the spec leaves those bytes undefined), string bytes are kept raw beside
347
+ their decoded text, pictures stay opaque runs, and any tail the tables do
348
+ not model is preserved. Bytes inside a record's `cb` that the tables
349
+ cannot explain are refused rather than dropped. The gate is that an
350
+ unedited form serializes to the bytes it was read from.
351
+
352
+ Four details cost the most and none are obvious from a first reading of
353
+ [MS-OFORMS]:
354
+
355
+ - a site's `cbSite` counts from the **mask**, so the next site begins at
356
+ `start + 4 + cbSite`;
357
+ - mask **bit 8 carries no fixed field**; reading two bytes for it puts
358
+ every name two characters late;
359
+ - a `MultiPage`'s `f` carries a trailing MultiPage record after the
360
+ FormControl, so the sites do not close the stream exactly there. It is
361
+ version-stamped and length-prefixed, so the reader checks for it rather
362
+ than merely tolerating a remainder;
363
+ - bit 8 selects no DataBlock field but does select an `fmPosition` in the
364
+ ExtraDataBlock, between the Name/Tag strings and the rest.
365
+
366
+ Three things a *written* form needs that reading never reveals, each
367
+ found by Excel refusing the result:
368
+
369
+ - **`NextAvailableID` is the highest id already handed out**, not the next
370
+ free one. A new control takes `NextAvailableID + 1`; using the field
371
+ as-is repeats the last control's id and MSForms refuses the form.
372
+ - **MorphData's mask bit 31 is reserved and MUST be 1**
373
+ ([MS-OFORMS] 2.2.5.2). Setting it is the single change that makes a new
374
+ `TextBox` or `OptionButton` load.
375
+ - **A designer edit must invalidate the `_VBA_PROJECT` performance cache.**
376
+ Adding or removing a control changes the form class's members, and with
377
+ a stale cache Office loads a member list the form no longer matches.
378
+ - **A container's storage is bound by its CLSID and its `\x01CompObj`**,
379
+ which names the kind fm20 should treat it as. Get either wrong and the
380
+ container loads without erroring and simply does not appear, so both are
381
+ reproduced verbatim from an Excel-authored fixture.
382
+ - **A container's site is not a leaf's.** It carries `BitFlags` and no
383
+ `ObjectStreamSize`, because its record is the `f` of its own storage
384
+ rather than a slice of the parent's `o`.
385
+ - **`NextAvailableID` is per container, and it is the highest id anywhere
386
+ beneath it** -- the fixture's MultiPage carries 11, which is a control
387
+ two levels down on one of its pages. A new id is recorded on every
388
+ ancestor up to the form.
389
+
390
+ Composing a form from nothing needs one more thing the format does not
391
+ announce: the **empty class-table count word**. With `BooleanProperties`
392
+ defaulted, `DONTSAVECLASSTABLE` is 0 and fm20 reads a count before
393
+ `CountOfSites`; omit the word and the low bytes of `CountOfSites` are read
394
+ *as* the count. An empty form survives that by luck -- both are zero -- and
395
+ the form's first control makes the misread count 1, so fm20 parses garbage
396
+ as class info and refuses the whole form.
397
+
398
+ A form is also two things at once: a designer storage and a code-behind
399
+ module, declared `BaseClass=` (not `Class=`) in the PROJECT stream. A
400
+ storage without a module is not a component the host shows; a module
401
+ without a storage is an ordinary class.
402
+
403
+ A page is four structures at once, and `_oforms_pages.py` moves them
404
+ together: a site and a storage of its own; an entry in each of the
405
+ MultiPage's five parallel TabStrip arrays (`Items`, `TipStrings`,
406
+ `TabNames`, `Tags`, `Accelerators`) with one flag word per tab after
407
+ them; `TabData` and `TabsAllocated`; and its position in the `x` stream,
408
+ which holds one more `PageProperties` record than there are pages (the
409
+ first ignored) followed by the page site ids. Page names are scoped to
410
+ their MultiPage, not to the form -- Excel gives a second MultiPage its own
411
+ `Page1` and `Page2` -- and pages never appear in `Designer.Controls`.
412
+
413
+ Nesting is resolved by matching a child storage's numeric suffix against
414
+ a site id, not by rebuilding the storage name from the id: the file says
415
+ which storages exist, and the padding of that name is only observed.
416
+
417
+ Reading `f` and `o` needs path-addressed CFB navigation
418
+ (`list_storages_at` / `get_stream_at`), because names repeat -- every
419
+ container owns an `f` -- and a name-based lookup finds whichever comes
420
+ first in directory order.
421
+
422
+ ---
423
+
316
424
  ## 6. Encoding conventions
317
425
 
318
426
  - The VBA `PROJECTCODEPAGE` value (typically `1252`) drives every
@@ -384,9 +492,11 @@ tests/
384
492
  - **No silent corruption.** Any code path that could leave a workbook
385
493
  inconsistent must either succeed completely or raise. Partial state
386
494
  is never written to disk.
387
- - **Preserve what you don't understand.** Bytes that the library does
388
- not interpret (designer sub-storages, PROJECTlk in v1, signature
389
- payloads on no-op saves) are round-tripped verbatim.
495
+ - **Preserve what you don't understand.** Bytes the library does not
496
+ interpret (PROJECTlk, signature payloads on no-op saves) are
497
+ round-tripped verbatim -- and so are the parts of a structure it *does*
498
+ interpret but cannot explain: a control record's alignment padding, an
499
+ unmodelled tail, a CompObj blob.
390
500
  - **Drop perf caches on mutation.** `_VBA_PROJECT` body is zeroed,
391
501
  `__SRP_*` streams are removed. Office regenerates both.
392
502
  - **ASCII only in user-facing strings.** Warnings, error messages,