pyOpenVBA 3.3.0__tar.gz → 3.5.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.
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/PKG-INFO +117 -6
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/README.md +115 -4
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/docs/architecture.md +124 -14
- pyopenvba-3.5.0/docs/research/access_write/README.md +1118 -0
- pyopenvba-3.5.0/docs/research/pcode/README.md +40 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/docs/roadmap.md +10 -6
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/pyproject.toml +1 -1
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/__init__.py +7 -1
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/__main__.py +55 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_host.py +123 -14
- pyopenvba-3.5.0/src/pyopenvba/_oforms_pages.py +187 -0
- pyopenvba-3.5.0/src/pyopenvba/_oforms_records.py +733 -0
- pyopenvba-3.5.0/src/pyopenvba/_ppt_container.py +398 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/__init__.py +239 -0
- pyopenvba-3.5.0/src/pyopenvba/_templates/blank_files/blank_database_module.accdb +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/access_read.py +191 -70
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/cfb.py +190 -1
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/exceptions.py +9 -0
- pyopenvba-3.5.0/src/pyopenvba/forms.py +1943 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/powerpoint.py +22 -1
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/vba.py +208 -29
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/.gitignore +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/LICENSE.md +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/docs/ms-ovba-implementation-guide_v2.md +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/blank_files/blank_database.accdb +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/blank_files/blank_document.docm +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/blank_files/blank_excel_addin.xlam +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/blank_files/blank_presentation.pptm +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsb +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsm +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/excel.py +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/vba_pcode.py +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/word.py +0 -0
- {pyopenvba-3.3.0 → pyopenvba-3.5.0}/tests/fuzz_corpus/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: pyOpenVBA
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.5.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
|
|
@@ -326,6 +326,120 @@ modules, `.cls` for class modules and code-behind.
|
|
|
326
326
|
|
|
327
327
|
---
|
|
328
328
|
|
|
329
|
+
## Creating and editing a UserForm's design
|
|
330
|
+
|
|
331
|
+
A form's *code* is a module like any other. Its *design* -- which controls
|
|
332
|
+
exist, how they nest, and what their properties are -- lives in separate
|
|
333
|
+
streams that no module source carries. `forms()` reads them, with no
|
|
334
|
+
Office installed:
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
import pyopenvba
|
|
338
|
+
|
|
339
|
+
with pyopenvba.ExcelFile("book.xlsm") as wb:
|
|
340
|
+
for form in wb.forms():
|
|
341
|
+
print(form.name, len(form.walk()), "controls")
|
|
342
|
+
for control in form.walk():
|
|
343
|
+
print(f" {control.name:<16} {control.kind:<22} "
|
|
344
|
+
f"{control.properties()}")
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
And edits them:
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
with pyopenvba.ExcelFile("book.xlsm") as wb:
|
|
351
|
+
form = wb.forms()[0]
|
|
352
|
+
form.control("OkButton").set_property("Caption", "Save")
|
|
353
|
+
form.control("NameBox").set_property("MaxLength", 40)
|
|
354
|
+
form.add_control("Label", "Hint", left=12, top=120, width=200)
|
|
355
|
+
form.remove_control("OldCheckbox")
|
|
356
|
+
wb.save()
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Containers work too. Each gets a storage of its own, and removing one
|
|
360
|
+
takes its children with it:
|
|
361
|
+
|
|
362
|
+
```python
|
|
363
|
+
form.add_control("Frame", "Shipping", left=12, top=160, width=200, height=80)
|
|
364
|
+
form.add_control("OptionButton", "Ground", container="Shipping")
|
|
365
|
+
form.remove_control("OldFrame") # and everything inside it
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
A `MultiPage` arrives with the two pages Excel gives it, and pages are
|
|
369
|
+
added and removed through it, because a page is also a *tab*:
|
|
370
|
+
|
|
371
|
+
```python
|
|
372
|
+
form.add_control("MultiPage", "Wizard", left=12, top=40, width=300, height=200)
|
|
373
|
+
form.add_page("Wizard", name="Review", caption="Review && confirm")
|
|
374
|
+
form.add_control("Label", "Summary", container="Review")
|
|
375
|
+
form.remove_page("Page2", multipage="Wizard")
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Page names are scoped to their MultiPage rather than to the form, which is
|
|
379
|
+
why `remove_page` takes an optional `multipage` to disambiguate.
|
|
380
|
+
|
|
381
|
+
And a form can be built from nothing -- `add_form` creates the designer
|
|
382
|
+
storage and the code-behind module together, because a storage without a
|
|
383
|
+
module is not a component the host will show:
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
with pyopenvba.ExcelFile("book.xlsm") as wb:
|
|
387
|
+
form = wb.add_form("Wizard", caption="Setup", width=300, height=200)
|
|
388
|
+
form.add_control("Label", "Prompt", left=12, top=12, width=200)
|
|
389
|
+
form.add_control("TextBox", "Answer", left=12, top=40, width=200)
|
|
390
|
+
form.add_control("CommandButton", "Ok", left=12, top=80)
|
|
391
|
+
wb.set_module("Wizard", "Private Sub Ok_Click()\r\n Me.Hide\r\nEnd Sub\r\n")
|
|
392
|
+
wb.save()
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Geometry is in points, the unit the designer shows. `set_property(name,
|
|
396
|
+
None)` clears a property, which is how a control goes back to inheriting
|
|
397
|
+
the default.
|
|
398
|
+
|
|
399
|
+
Or from the command line:
|
|
400
|
+
|
|
401
|
+
```bash
|
|
402
|
+
python -m pyopenvba forms book.xlsm
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
```
|
|
406
|
+
FrmNested (13 controls)
|
|
407
|
+
TopLabel MSForms.Label id=1 set=0x00000028
|
|
408
|
+
GroupBox MSForms.Frame id=2 set=0x0c0a0c48
|
|
409
|
+
OptOne MSForms.OptionButton id=3 set=0x0000000180c00146
|
|
410
|
+
Pages MSForms.MultiPage id=6 set=0x0c000c48
|
|
411
|
+
(unnamed) MSForms.TabStrip id=7 set=0x00fa8031
|
|
412
|
+
Page1 MSForms.Form id=8 set=0x0c000c48
|
|
413
|
+
PageOneCheck MSForms.CheckBox id=10 set=0x0000000080c00146
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Containers nest: a `Frame`'s children and a `MultiPage`'s `Page`s live in
|
|
417
|
+
storages of their own, and MSForms sites an unnamed `TabStrip` beside the
|
|
418
|
+
pages. `walk()` flattens the tree depth-first; `form.controls` gives just
|
|
419
|
+
the top level.
|
|
420
|
+
|
|
421
|
+
### Only what the developer set
|
|
422
|
+
|
|
423
|
+
MSForms writes a property into a control's record **only when it differs
|
|
424
|
+
from that control's default**, so `properties()` returns the set the
|
|
425
|
+
developer chose -- not every property the control has. That is not
|
|
426
|
+
something a live host can tell you: a sited control reports inherited,
|
|
427
|
+
default, and chosen values indistinguishably.
|
|
428
|
+
|
|
429
|
+
`properties_set` is the same information as a raw bit mask, if you want to
|
|
430
|
+
diff two files without comparing names. Read a bit index together with
|
|
431
|
+
`property_mask_width`: MorphData controls (`TextBox`, `ListBox`,
|
|
432
|
+
`ComboBox`, `CheckBox`, `OptionButton`, `ToggleButton`) carry an 8-byte
|
|
433
|
+
mask and everything else carries 4.
|
|
434
|
+
|
|
435
|
+
Writing is lossless. An unedited form saves back byte for byte, because
|
|
436
|
+
alignment padding, string bytes, pictures, and anything the property
|
|
437
|
+
tables do not model are all replayed as they were read. If a form's
|
|
438
|
+
streams do not reconcile, this raises `FormParseError` rather than
|
|
439
|
+
returning a partly-guessed control list.
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
329
443
|
## Supported formats
|
|
330
444
|
|
|
331
445
|
### Excel
|
|
@@ -432,12 +546,9 @@ wb.save(allow_invalidate_signature=True)
|
|
|
432
546
|
|
|
433
547
|
## What's out of scope
|
|
434
548
|
|
|
435
|
-
|
|
549
|
+
A project's code and its UserForm designs are read and written. The
|
|
436
550
|
following are preserved byte-for-byte but not interpreted:
|
|
437
551
|
|
|
438
|
-
- UserForm **layout** (controls, properties, positions). Editing the
|
|
439
|
-
**code-behind** of a UserForm works fine; editing the design surface
|
|
440
|
-
does not.
|
|
441
552
|
- VBA project password decryption / re-encryption.
|
|
442
553
|
- Re-signing digitally signed projects.
|
|
443
554
|
- ActiveX license editing.
|
|
@@ -290,6 +290,120 @@ modules, `.cls` for class modules and code-behind.
|
|
|
290
290
|
|
|
291
291
|
---
|
|
292
292
|
|
|
293
|
+
## Creating and editing a UserForm's design
|
|
294
|
+
|
|
295
|
+
A form's *code* is a module like any other. Its *design* -- which controls
|
|
296
|
+
exist, how they nest, and what their properties are -- lives in separate
|
|
297
|
+
streams that no module source carries. `forms()` reads them, with no
|
|
298
|
+
Office installed:
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
import pyopenvba
|
|
302
|
+
|
|
303
|
+
with pyopenvba.ExcelFile("book.xlsm") as wb:
|
|
304
|
+
for form in wb.forms():
|
|
305
|
+
print(form.name, len(form.walk()), "controls")
|
|
306
|
+
for control in form.walk():
|
|
307
|
+
print(f" {control.name:<16} {control.kind:<22} "
|
|
308
|
+
f"{control.properties()}")
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
And edits them:
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
with pyopenvba.ExcelFile("book.xlsm") as wb:
|
|
315
|
+
form = wb.forms()[0]
|
|
316
|
+
form.control("OkButton").set_property("Caption", "Save")
|
|
317
|
+
form.control("NameBox").set_property("MaxLength", 40)
|
|
318
|
+
form.add_control("Label", "Hint", left=12, top=120, width=200)
|
|
319
|
+
form.remove_control("OldCheckbox")
|
|
320
|
+
wb.save()
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Containers work too. Each gets a storage of its own, and removing one
|
|
324
|
+
takes its children with it:
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
form.add_control("Frame", "Shipping", left=12, top=160, width=200, height=80)
|
|
328
|
+
form.add_control("OptionButton", "Ground", container="Shipping")
|
|
329
|
+
form.remove_control("OldFrame") # and everything inside it
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
A `MultiPage` arrives with the two pages Excel gives it, and pages are
|
|
333
|
+
added and removed through it, because a page is also a *tab*:
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
form.add_control("MultiPage", "Wizard", left=12, top=40, width=300, height=200)
|
|
337
|
+
form.add_page("Wizard", name="Review", caption="Review && confirm")
|
|
338
|
+
form.add_control("Label", "Summary", container="Review")
|
|
339
|
+
form.remove_page("Page2", multipage="Wizard")
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Page names are scoped to their MultiPage rather than to the form, which is
|
|
343
|
+
why `remove_page` takes an optional `multipage` to disambiguate.
|
|
344
|
+
|
|
345
|
+
And a form can be built from nothing -- `add_form` creates the designer
|
|
346
|
+
storage and the code-behind module together, because a storage without a
|
|
347
|
+
module is not a component the host will show:
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
with pyopenvba.ExcelFile("book.xlsm") as wb:
|
|
351
|
+
form = wb.add_form("Wizard", caption="Setup", width=300, height=200)
|
|
352
|
+
form.add_control("Label", "Prompt", left=12, top=12, width=200)
|
|
353
|
+
form.add_control("TextBox", "Answer", left=12, top=40, width=200)
|
|
354
|
+
form.add_control("CommandButton", "Ok", left=12, top=80)
|
|
355
|
+
wb.set_module("Wizard", "Private Sub Ok_Click()\r\n Me.Hide\r\nEnd Sub\r\n")
|
|
356
|
+
wb.save()
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Geometry is in points, the unit the designer shows. `set_property(name,
|
|
360
|
+
None)` clears a property, which is how a control goes back to inheriting
|
|
361
|
+
the default.
|
|
362
|
+
|
|
363
|
+
Or from the command line:
|
|
364
|
+
|
|
365
|
+
```bash
|
|
366
|
+
python -m pyopenvba forms book.xlsm
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
```
|
|
370
|
+
FrmNested (13 controls)
|
|
371
|
+
TopLabel MSForms.Label id=1 set=0x00000028
|
|
372
|
+
GroupBox MSForms.Frame id=2 set=0x0c0a0c48
|
|
373
|
+
OptOne MSForms.OptionButton id=3 set=0x0000000180c00146
|
|
374
|
+
Pages MSForms.MultiPage id=6 set=0x0c000c48
|
|
375
|
+
(unnamed) MSForms.TabStrip id=7 set=0x00fa8031
|
|
376
|
+
Page1 MSForms.Form id=8 set=0x0c000c48
|
|
377
|
+
PageOneCheck MSForms.CheckBox id=10 set=0x0000000080c00146
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Containers nest: a `Frame`'s children and a `MultiPage`'s `Page`s live in
|
|
381
|
+
storages of their own, and MSForms sites an unnamed `TabStrip` beside the
|
|
382
|
+
pages. `walk()` flattens the tree depth-first; `form.controls` gives just
|
|
383
|
+
the top level.
|
|
384
|
+
|
|
385
|
+
### Only what the developer set
|
|
386
|
+
|
|
387
|
+
MSForms writes a property into a control's record **only when it differs
|
|
388
|
+
from that control's default**, so `properties()` returns the set the
|
|
389
|
+
developer chose -- not every property the control has. That is not
|
|
390
|
+
something a live host can tell you: a sited control reports inherited,
|
|
391
|
+
default, and chosen values indistinguishably.
|
|
392
|
+
|
|
393
|
+
`properties_set` is the same information as a raw bit mask, if you want to
|
|
394
|
+
diff two files without comparing names. Read a bit index together with
|
|
395
|
+
`property_mask_width`: MorphData controls (`TextBox`, `ListBox`,
|
|
396
|
+
`ComboBox`, `CheckBox`, `OptionButton`, `ToggleButton`) carry an 8-byte
|
|
397
|
+
mask and everything else carries 4.
|
|
398
|
+
|
|
399
|
+
Writing is lossless. An unedited form saves back byte for byte, because
|
|
400
|
+
alignment padding, string bytes, pictures, and anything the property
|
|
401
|
+
tables do not model are all replayed as they were read. If a form's
|
|
402
|
+
streams do not reconcile, this raises `FormParseError` rather than
|
|
403
|
+
returning a partly-guessed control list.
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
293
407
|
## Supported formats
|
|
294
408
|
|
|
295
409
|
### Excel
|
|
@@ -396,12 +510,9 @@ wb.save(allow_invalidate_signature=True)
|
|
|
396
510
|
|
|
397
511
|
## What's out of scope
|
|
398
512
|
|
|
399
|
-
|
|
513
|
+
A project's code and its UserForm designs are read and written. The
|
|
400
514
|
following are preserved byte-for-byte but not interpreted:
|
|
401
515
|
|
|
402
|
-
- UserForm **layout** (controls, properties, positions). Editing the
|
|
403
|
-
**code-behind** of a UserForm works fine; editing the design surface
|
|
404
|
-
does not.
|
|
405
516
|
- VBA project password decryption / re-encryption.
|
|
406
517
|
- Re-signing digitally signed projects.
|
|
407
518
|
- ActiveX license editing.
|
|
@@ -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 `
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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` |
|
|
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()`
|
|
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
|
|
388
|
-
|
|
389
|
-
|
|
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,
|