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.
Files changed (34) hide show
  1. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/PKG-INFO +117 -6
  2. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/README.md +115 -4
  3. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/docs/architecture.md +124 -14
  4. pyopenvba-3.5.0/docs/research/access_write/README.md +1118 -0
  5. pyopenvba-3.5.0/docs/research/pcode/README.md +40 -0
  6. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/docs/roadmap.md +10 -6
  7. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/pyproject.toml +1 -1
  8. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/__init__.py +7 -1
  9. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/__main__.py +55 -0
  10. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_host.py +123 -14
  11. pyopenvba-3.5.0/src/pyopenvba/_oforms_pages.py +187 -0
  12. pyopenvba-3.5.0/src/pyopenvba/_oforms_records.py +733 -0
  13. pyopenvba-3.5.0/src/pyopenvba/_ppt_container.py +398 -0
  14. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/__init__.py +239 -0
  15. pyopenvba-3.5.0/src/pyopenvba/_templates/blank_files/blank_database_module.accdb +0 -0
  16. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/access_read.py +191 -70
  17. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/cfb.py +190 -1
  18. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/exceptions.py +9 -0
  19. pyopenvba-3.5.0/src/pyopenvba/forms.py +1943 -0
  20. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/powerpoint.py +22 -1
  21. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/vba.py +208 -29
  22. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/.gitignore +0 -0
  23. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/LICENSE.md +0 -0
  24. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/docs/ms-ovba-implementation-guide_v2.md +0 -0
  25. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/blank_files/blank_database.accdb +0 -0
  26. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/blank_files/blank_document.docm +0 -0
  27. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/blank_files/blank_excel_addin.xlam +0 -0
  28. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/blank_files/blank_presentation.pptm +0 -0
  29. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsb +0 -0
  30. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsm +0 -0
  31. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/excel.py +0 -0
  32. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/vba_pcode.py +0 -0
  33. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/src/pyopenvba/word.py +0 -0
  34. {pyopenvba-3.3.0 → pyopenvba-3.5.0}/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.3.0
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
- This library is intentionally focused on **module source code**. The
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
- This library is intentionally focused on **module source code**. The
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 `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,