pyOpenVBA 5.2.3__tar.gz → 5.2.4__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 (89) hide show
  1. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/PKG-INFO +1 -1
  2. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/pyproject.toml +1 -1
  3. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/__init__.py +1 -1
  4. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_designs.py +197 -33
  5. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/database.py +38 -22
  6. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/.gitignore +0 -0
  7. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/LICENSE.md +0 -0
  8. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/README.md +0 -0
  9. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/docs/architecture.md +0 -0
  10. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/docs/ms-ovba-implementation-guide_v2.md +0 -0
  11. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/docs/power_query.md +0 -0
  12. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/docs/research/access_complex/README.md +0 -0
  13. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/docs/research/access_designs/README.md +0 -0
  14. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/docs/research/access_macros/README.md +0 -0
  15. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/docs/research/access_write/README.md +0 -0
  16. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/docs/research/pcode/README.md +0 -0
  17. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/docs/roadmap.md +0 -0
  18. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/__main__.py +0 -0
  19. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_deflate.py +0 -0
  20. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_host.py +0 -0
  21. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_oforms_pages.py +0 -0
  22. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_oforms_records.py +0 -0
  23. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_ppt_container.py +0 -0
  24. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/__init__.py +0 -0
  25. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/blank_files/blank_database.accdb +0 -0
  26. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/blank_files/blank_database_module.accdb +0 -0
  27. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/blank_files/blank_document.docm +0 -0
  28. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/blank_files/blank_excel_addin.xlam +0 -0
  29. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/blank_files/blank_presentation.pptm +0 -0
  30. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsb +0 -0
  31. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsm +0 -0
  32. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsx +0 -0
  33. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/blank_files/engine_skeleton.accdb +0 -0
  34. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/blank_files/engine_skeleton.mdb +0 -0
  35. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/designs/form.blob +0 -0
  36. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/designs/form.lvprop +0 -0
  37. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/designs/form.propdata +0 -0
  38. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/designs/form.prototypes +0 -0
  39. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/designs/form.typeinfo +0 -0
  40. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/designs/report.blob +0 -0
  41. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/designs/report.lvprop +0 -0
  42. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/designs/report.propdata +0 -0
  43. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/designs/report.prototypes +0 -0
  44. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/_templates/designs/report.typeinfo +0 -0
  45. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/__init__.py +0 -0
  46. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_alloc.py +0 -0
  47. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_btree.py +0 -0
  48. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_collation.py +0 -0
  49. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_collation_general_legacy.py +0 -0
  50. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_compact.py +0 -0
  51. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_complex.py +0 -0
  52. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_datapage.py +0 -0
  53. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_ddl.py +0 -0
  54. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_facade.py +0 -0
  55. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_format.py +0 -0
  56. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_index.py +0 -0
  57. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_lval.py +0 -0
  58. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_macros.py +0 -0
  59. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_pages.py +0 -0
  60. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_props.py +0 -0
  61. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_queries.py +0 -0
  62. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_rows.py +0 -0
  63. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_schema.py +0 -0
  64. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_sql.py +0 -0
  65. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_storage.py +0 -0
  66. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_tdef.py +0 -0
  67. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_validate.py +0 -0
  68. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access/_vba.py +0 -0
  69. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/access_read.py +0 -0
  70. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/cfb.py +0 -0
  71. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/excel.py +0 -0
  72. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/exceptions.py +0 -0
  73. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/forms.py +0 -0
  74. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/powerpoint.py +0 -0
  75. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/powerquery/__init__.py +0 -0
  76. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/powerquery/_binary.py +0 -0
  77. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/powerquery/_files.py +0 -0
  78. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/powerquery/_mashup.py +0 -0
  79. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/powerquery/_metadata.py +0 -0
  80. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/powerquery/_opc.py +0 -0
  81. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/powerquery/_package.py +0 -0
  82. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/powerquery/_refresh.py +0 -0
  83. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/powerquery/_section.py +0 -0
  84. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/powerquery/_sheets.py +0 -0
  85. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/powerquery/workbook.py +0 -0
  86. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/vba.py +0 -0
  87. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/vba_pcode.py +0 -0
  88. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/src/pyopenvba/word.py +0 -0
  89. {pyopenvba-5.2.3 → pyopenvba-5.2.4}/tests/fuzz_corpus/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyOpenVBA
3
- Version: 5.2.3
3
+ Version: 5.2.4
4
4
  Summary: Read and write VBA macros and Excel Power Query 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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyOpenVBA"
7
- version = "5.2.3"
7
+ version = "5.2.4"
8
8
  description = "Read and write VBA macros and Excel Power Query in pure Python, no dependencies."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -291,4 +291,4 @@ def push_power_query(
291
291
  """
292
292
  return _push_queries(src_dir, workbook, out=out, encoding=encoding, remove_missing=remove_missing)
293
293
 
294
- __version__ = "5.2.3"
294
+ __version__ = "5.2.4"
@@ -31,8 +31,10 @@ answer it: a record's `code` is the property and a handful are named in
31
31
 
32
32
  from __future__ import annotations
33
33
 
34
+ import re
34
35
  import struct
35
- from collections.abc import Mapping, Sequence
36
+ import unicodedata
37
+ from collections.abc import Iterable, Mapping, Sequence
36
38
  from dataclasses import dataclass, field
37
39
  from pathlib import Path
38
40
  from typing import NamedTuple
@@ -2645,7 +2647,9 @@ def remove_control(blob: bytes, name: str) -> bytes:
2645
2647
  #: as the members VBA sees on the form's class: ``Me.Qty`` compiles, and
2646
2648
  #: ``Qty_Click`` binds, only for a name listed here. After the magic come
2647
2649
  #: a kind word, -1, the entry count and the design's CLSID; each entry is
2648
- #: then ``<u32 type id><u32 ordinal><name in the code page>00 00``.
2650
+ #: then ``<u32 type id><u32 ordinal><identifier>00<the design's name>00``,
2651
+ #: with the design's name left empty where the two are the same. An
2652
+ #: ActiveX control's 36-byte tail follows both.
2649
2653
  #:
2650
2654
  #: Access keeps the stream rather than rebuilding it (measured by editing
2651
2655
  #: forms in Access and reading the stream back): a new member is appended
@@ -2752,15 +2756,66 @@ ACTIVEX_CONTROL = "CustomControl"
2752
2756
  ACTIVEX_CODE = next(code for code, name in CONTROL_TYPES.items() if name == ACTIVEX_CONTROL)
2753
2757
  TYPE_INFO_REPORT_IDS[ACTIVEX_CONTROL] = TYPE_INFO_IDS[ACTIVEX_CONTROL]
2754
2758
  TYPE_INFO_ACTIVEX_TAIL = bytes(36)
2759
+ #: The page a TypeInfo stream is read in when the project's own does not
2760
+ #: fit its bytes. Western Access writes this one, and every stream
2761
+ #: measured here is in it.
2762
+ _FALLBACK_TYPE_INFO_ENCODING = "cp1252"
2763
+
2764
+
2765
+ def vba_identifier(name: str) -> str:
2766
+ """The name VBA compiles against for a design object called `name`.
2767
+
2768
+ A control is rarely named as VBA would name it: the form wizard names
2769
+ one after its field, so `Order Date` is ordinary and `Me.Order_Date`
2770
+ is how code reaches it. Every ASCII character that is not a letter,
2771
+ a digit or an underscore becomes an underscore, one for one, and a
2772
+ result opening with a digit or an underscore takes `Ctl` in front. A
2773
+ character outside ASCII is kept. Measured over every pair in the
2774
+ fixture's streams: `Tax (VAT)` is `Tax__VAT_`, `2ndBox` is
2775
+ `Ctl2ndBox`, `_under` is `Ctl_under`.
2776
+ """
2777
+ converted = re.sub(r"[^A-Za-z0-9_€-￿]", "_", name)
2778
+ return "Ctl" + converted if re.match(r"[0-9_]", converted) else converted
2779
+
2780
+
2781
+ def code_page_holds(text: str, encoding: str) -> bool:
2782
+ """Whether `encoding` holds every character of `text` exactly.
2783
+
2784
+ A name with a `?` in it names something else, so no best fit counts.
2785
+ That is how Access decides it: on a cp1252 machine a control named
2786
+ with an A-macron, a fullwidth A or a Greek omega is left out of its
2787
+ form's member list rather than listed as `A`, `A` or `O`.
2788
+ """
2789
+ try:
2790
+ round_tripped = text.encode(encoding).decode(encoding)
2791
+ except (UnicodeEncodeError, UnicodeDecodeError):
2792
+ return False
2793
+ # cp1258 stores a toned vowel as a base byte and a combining mark.
2794
+ return unicodedata.normalize("NFC", round_tripped) == unicodedata.normalize("NFC", text)
2755
2795
 
2756
2796
 
2757
2797
  class TypeInfoEntry(NamedTuple):
2758
- """One member a TypeInfo stream lists."""
2798
+ """One member a TypeInfo stream lists.
2799
+
2800
+ `name` is the name the design gives the object and the designer
2801
+ shows; `identifier` is the name VBA compiles against, which is `name`
2802
+ itself wherever that is already an identifier. `stored` is the two
2803
+ names exactly as the stream held them, NULs included, so an entry
2804
+ carried forward writes back the bytes it was read from rather than
2805
+ going through a code page twice. A copy under another name, from a
2806
+ rename or a repair, carries no stored bytes and is encoded afresh.
2807
+ """
2759
2808
 
2760
2809
  ident: int
2761
2810
  ordinal: int
2762
2811
  name: str
2763
2812
  tail: bytes = b""
2813
+ identifier: str = ""
2814
+ stored: bytes = b""
2815
+
2816
+ def named(self, name: str) -> "TypeInfoEntry":
2817
+ """The same member under a new name, encoded afresh."""
2818
+ return self._replace(name=name, identifier=vba_identifier(name), stored=b"")
2764
2819
 
2765
2820
 
2766
2821
  class TypeInfoMember(NamedTuple):
@@ -2770,23 +2825,23 @@ class TypeInfoMember(NamedTuple):
2770
2825
  ident: int
2771
2826
  tail: bytes = b""
2772
2827
 
2828
+ @property
2829
+ def identifier(self) -> str:
2830
+ return vba_identifier(self.name)
2773
2831
 
2774
- def type_info_entries(stream: bytes) -> tuple[TypeInfoEntry, ...]:
2832
+
2833
+ def type_info_entries(stream: bytes, encoding: str = "cp1252") -> tuple[TypeInfoEntry, ...]:
2775
2834
  """The members a TypeInfo stream lists, in the order it lists them.
2776
2835
 
2777
- Names here are cp1252, and that is measured rather than assumed
2778
- (GitHub issue #18 raised it as a possible second instance of the
2779
- VBA-side latin-1 bug; it is not one). Access named a control with an
2780
- em dash and wrote ``Em\\x97Dash`` -- one byte, which cp1252 gives
2781
- U+2014 and latin-1 cannot represent at all. Patching the project's
2782
- PROJECTCODEPAGE to 1251 and repeating changed nothing, so this stream
2783
- does not follow the VBA code page and must not be threaded with it.
2784
-
2785
- Access also drops a member whose name the page cannot hold rather
2786
- than substituting it: a control named in Cyrillic got no entry here
2787
- at all, while the design blob kept its name in UTF-16. The writer
2788
- below substitutes instead, which differs only for names cp1252
2789
- cannot express.
2836
+ An entry holds **two** names: the identifier, a NUL, the design's
2837
+ name, a NUL, with the design's name left empty where the two are the
2838
+ same. That is why a plain name reads as ``Plain 00 00``, and why
2839
+ reading to the first double NUL walked into the next entry's type id
2840
+ for any name that is not already an identifier (GitHub issue #22).
2841
+ An ActiveX control's 36-byte tail follows both names.
2842
+
2843
+ The names are bytes in a code page, and `encoding` is the page to
2844
+ read them in; `type_info_code_page` picks it.
2790
2845
  """
2791
2846
  if stream[:4] != TYPE_INFO_MAGIC or len(stream) < TYPE_INFO_ENTRIES_AT:
2792
2847
  raise AccessError("this is not a TypeInfo stream")
@@ -2797,19 +2852,61 @@ def type_info_entries(stream: bytes) -> tuple[TypeInfoEntry, ...]:
2797
2852
  if at + 8 > len(stream):
2798
2853
  raise AccessError("this TypeInfo stream ends inside an entry")
2799
2854
  ident, ordinal = struct.unpack_from("<II", stream, at)
2800
- end = stream.find(bytes(2), at + 8)
2801
- if end < 0:
2855
+ first = stream.find(b"\x00", at + 8)
2856
+ second = stream.find(b"\x00", first + 1) if first >= 0 else -1
2857
+ if first < 0 or second < 0:
2802
2858
  raise AccessError("this TypeInfo stream ends inside a name")
2803
- name = stream[at + 8 : end].decode("cp1252")
2804
- at = end + 2
2859
+ identifier = stream[at + 8 : first].decode(encoding, errors="replace")
2860
+ shown = stream[first + 1 : second].decode(encoding, errors="replace")
2861
+ stored = stream[at + 8 : second + 1]
2862
+ at = second + 1
2805
2863
  tail = b""
2806
2864
  if ident & 0xFF == ACTIVEX_CODE:
2807
2865
  tail = stream[at : at + len(TYPE_INFO_ACTIVEX_TAIL)]
2808
2866
  at += len(tail)
2809
- out.append(TypeInfoEntry(ident, ordinal, name, tail))
2867
+ out.append(
2868
+ TypeInfoEntry(ident, ordinal, shown or identifier, tail, identifier, stored)
2869
+ )
2810
2870
  return tuple(out)
2811
2871
 
2812
2872
 
2873
+ def type_info_code_page(
2874
+ stream: bytes, design_names: Iterable[str], project_encoding: str
2875
+ ) -> str:
2876
+ """The page a stream's names are in, given the names its design has.
2877
+
2878
+ The project's page is tried first, because PROJECTCODEPAGE is the
2879
+ page of the machine that last saved the project, and this stream is
2880
+ written in the page of the machine Access ran on. The stream's own
2881
+ bytes overrule it: only an entry carrying a byte at or above 0x80
2882
+ says anything, and the fallback wins only where it matches more of
2883
+ the design's names.
2884
+
2885
+ An earlier note here claimed the stream is fixed at cp1252 because
2886
+ patching PROJECTCODEPAGE to 1251 changed nothing. That experiment
2887
+ was void: VBA went on reading the project as cp1252 and Access wrote
2888
+ 1252 back over the patch on its next save, so it never tested what it
2889
+ appeared to (GitHub issue #23).
2890
+ """
2891
+ if project_encoding == _FALLBACK_TYPE_INFO_ENCODING:
2892
+ return project_encoding
2893
+ names = set(design_names)
2894
+
2895
+ def fits(encoding: str) -> int:
2896
+ try:
2897
+ entries = type_info_entries(stream, encoding)
2898
+ except AccessError:
2899
+ return -1
2900
+ return sum(
2901
+ 1
2902
+ for entry in entries
2903
+ if any(byte >= 0x80 for byte in entry.stored) and entry.name in names
2904
+ )
2905
+
2906
+ fallback = _FALLBACK_TYPE_INFO_ENCODING
2907
+ return fallback if fits(fallback) > fits(project_encoding) else project_encoding
2908
+
2909
+
2813
2910
  def _holders(objects: tuple[DesignObject, ...]) -> dict[int, int]:
2814
2911
  """What holds each control -- its section, or the control it sits
2815
2912
  inside -- as indexes into `objects`."""
@@ -2871,41 +2968,100 @@ def type_info_members(kind: str, objects: tuple[DesignObject, ...]) -> tuple[Typ
2871
2968
  return (*sections, *controls)
2872
2969
 
2873
2970
 
2971
+ def _carried(entry: TypeInfoEntry) -> TypeInfoEntry:
2972
+ """An entry as it is carried forward, repaired if it has to be.
2973
+
2974
+ The identifier Access stored is kept as it is. One that is not an
2975
+ identifier at all is not Access's: this module once read an entry as
2976
+ a single name and rewrote a member called `Order Date` as exactly
2977
+ that, which `Me.` cannot reach. That one is rebuilt from the design's
2978
+ name, and loses its stored bytes so it is written afresh.
2979
+ """
2980
+ if entry.identifier and vba_identifier(entry.identifier) == entry.identifier:
2981
+ return entry
2982
+ return entry._replace(identifier=vba_identifier(entry.name), stored=b"")
2983
+
2984
+
2985
+ def _refuse_shared_identifiers(entries: Sequence[TypeInfoEntry]) -> None:
2986
+ """VBA knows a member by its identifier alone, so two sharing one are
2987
+ a single name to the code behind the design. Access refuses the
2988
+ second control name as already in use, and so does this."""
2989
+ seen: dict[str, str] = {}
2990
+ for entry in entries:
2991
+ key = entry.identifier.lower()
2992
+ other = seen.get(key)
2993
+ if other is not None:
2994
+ raise AccessError(
2995
+ f"{entry.name!r} and {other!r} would both be {entry.identifier!r} to VBA, "
2996
+ "which knows a member by that name alone; Access refuses the second name "
2997
+ "as already in use"
2998
+ )
2999
+ seen[key] = entry.name
3000
+
3001
+
2874
3002
  def type_info(
2875
3003
  kind: str,
2876
3004
  objects: tuple[DesignObject, ...],
2877
3005
  existing: bytes,
2878
3006
  *,
2879
3007
  renamed: Mapping[str, str] | None = None,
3008
+ encoding: str = "cp1252",
2880
3009
  ) -> bytes:
2881
3010
  """The TypeInfo stream after the design changed, carried forward the
2882
3011
  way Access carries it: members the design no longer has drop out, the
2883
3012
  ones `renamed` maps (old name to new) move to the end keeping their
2884
3013
  ordinals, and new members are appended with the ordinals that follow
2885
- the highest present."""
2886
- entries = type_info_entries(existing)
3014
+ the highest present.
3015
+
3016
+ `encoding` is the project's code page, tried first and overruled by
3017
+ the stream's own bytes. A member whose name that page cannot hold is
3018
+ left out, as Access leaves it out, and takes no ordinal at all.
3019
+ """
2887
3020
  members = type_info_members(kind, objects)
2888
3021
  wanted = {member.name for member in members}
2889
3022
  renames = dict(renamed or {})
2890
- kept = [e for e in entries if e.name in wanted and e.name not in renames]
3023
+ page = type_info_code_page(existing, wanted | set(renames), encoding)
3024
+ entries = type_info_entries(existing, page)
3025
+ kept = [_carried(e) for e in entries if e.name in wanted and e.name not in renames]
2891
3026
  moved = [
2892
- e._replace(name=renames[e.name])
3027
+ e.named(renames[e.name])
2893
3028
  for e in entries
2894
3029
  if e.name in renames and renames[e.name] in wanted
2895
3030
  ]
3031
+ moved = [e for e in moved if code_page_holds(e.name, page)]
2896
3032
  present = {e.name for e in (*kept, *moved)}
2897
3033
  ordinal = max((e.ordinal for e in (*kept, *moved)), default=-1) + 1
2898
3034
  added: list[TypeInfoEntry] = []
2899
3035
  for member in members:
2900
- if member.name not in present:
2901
- added.append(TypeInfoEntry(member.ident, ordinal, member.name, member.tail))
2902
- ordinal += 1
3036
+ if member.name in present or not code_page_holds(member.name, page):
3037
+ continue
3038
+ added.append(
3039
+ TypeInfoEntry(
3040
+ member.ident, ordinal, member.name, member.tail, member.identifier
3041
+ )
3042
+ )
3043
+ ordinal += 1
3044
+ following = (*kept, *moved, *added)
3045
+ _refuse_shared_identifiers(following)
2903
3046
  clsid = existing[TYPE_INFO_CLSID_AT : TYPE_INFO_CLSID_AT + GUID_LENGTH]
2904
- return pack_type_info(kind, clsid, (*kept, *moved, *added))
3047
+ return pack_type_info(kind, clsid, following, encoding=page)
3048
+
2905
3049
 
3050
+ def pack_type_info(
3051
+ kind: str,
3052
+ clsid: bytes,
3053
+ entries: Sequence[TypeInfoEntry],
3054
+ *,
3055
+ encoding: str = "cp1252",
3056
+ ) -> bytes:
3057
+ """A TypeInfo stream listing `entries` in that order.
2906
3058
 
2907
- def pack_type_info(kind: str, clsid: bytes, entries: Sequence[TypeInfoEntry]) -> bytes:
2908
- """A TypeInfo stream listing `entries` in that order."""
3059
+ An entry that came from a stream writes back the bytes it was read
3060
+ from: a page can spell one character more than one way, so decoding
3061
+ and encoding again is not always what was there. One built here is
3062
+ encoded as the identifier, a NUL, the design's name, a NUL, with the
3063
+ design's name left out where the two are the same.
3064
+ """
2909
3065
  if kind not in TYPE_INFO_KIND:
2910
3066
  raise AccessError(f"kind must be 'form' or 'report', not {kind!r}")
2911
3067
  if len(clsid) != GUID_LENGTH:
@@ -2915,6 +3071,14 @@ def pack_type_info(kind: str, clsid: bytes, entries: Sequence[TypeInfoEntry]) ->
2915
3071
  out += clsid
2916
3072
  for entry in entries:
2917
3073
  out += struct.pack("<II", entry.ident, entry.ordinal)
2918
- out += entry.name.encode("cp1252", errors="replace") + bytes(2) + entry.tail
3074
+ if entry.stored:
3075
+ out += entry.stored
3076
+ else:
3077
+ identifier = entry.identifier or vba_identifier(entry.name)
3078
+ out += identifier.encode(encoding) + bytes(1)
3079
+ if entry.name != identifier:
3080
+ out += entry.name.encode(encoding)
3081
+ out += bytes(1)
3082
+ out += entry.tail
2919
3083
  return bytes(out)
2920
3084
 
@@ -2826,24 +2826,35 @@ class AccessDatabase:
2826
2826
  and str(r["Name"]) == folder
2827
2827
  )
2828
2828
  storage = self.table(STORAGE_TABLE)
2829
- for rid, row in list(storage.rows_with_ids()):
2830
- payload = row.get("Lv")
2831
- if (
2832
- _as_int(row["ParentId"]) == folder_id
2833
- and str(row["Name"]) == "Blob"
2834
- and isinstance(payload, bytes)
2835
- ):
2836
- storage.update_row(rid, {"Lv": change(payload)})
2837
- break
2838
- else: # pragma: no cover - a design without its own blob
2829
+ # Everything is computed before anything is written. The member
2830
+ # list can refuse the change -- two names that are one identifier
2831
+ # to VBA -- and a blob already written beside the old member list
2832
+ # would leave the database holding a design its class does not
2833
+ # describe (GitHub issue #23).
2834
+ def stream_row(name: str) -> tuple[RowId, bytes] | None:
2835
+ for rid, row in storage.rows_with_ids():
2836
+ payload = row.get("Lv")
2837
+ if (
2838
+ _as_int(row["ParentId"]) == folder_id
2839
+ and str(row["Name"]) == name
2840
+ and isinstance(payload, bytes)
2841
+ ):
2842
+ return rid, payload
2843
+ return None
2844
+
2845
+ blob_row = stream_row("Blob")
2846
+ if blob_row is None: # pragma: no cover - a design without its own blob
2839
2847
  raise AccessError(f"the {kind} {design!r} has no design blob")
2848
+ blob_rid, blob = blob_row
2849
+ updated = change(blob)
2850
+ rewritten = AccessDesign(found.name, kind, parse_design(updated)[1])
2851
+
2840
2852
  # The TypeInfo stream lists the design's sections and controls as
2841
2853
  # the members of the form's class, and follows every change to them
2842
2854
  # (measured on forms Access built: Me.<control> compiles and the
2843
2855
  # control's events bind only for a name it lists). A change that
2844
2856
  # keeps every object where it was and alters a name is a rename,
2845
2857
  # which Access carries differently from a removal and an addition.
2846
- rewritten = self._design(kind, design)
2847
2858
  renamed: dict[str, str] = {}
2848
2859
  if len(found.objects) == len(rewritten.objects) and all(
2849
2860
  (old.marker, old.type) == (new.marker, new.type)
@@ -2854,17 +2865,22 @@ class AccessDatabase:
2854
2865
  for old, new in zip(found.objects, rewritten.objects)
2855
2866
  if old.name and new.name and old.name != new.name
2856
2867
  }
2857
- for rid, row in list(storage.rows_with_ids()):
2858
- payload = row.get("Lv")
2859
- if (
2860
- _as_int(row["ParentId"]) == folder_id
2861
- and str(row["Name"]) == "TypeInfo"
2862
- and isinstance(payload, bytes)
2863
- ):
2864
- fresh = type_info(kind, rewritten.objects, payload, renamed=renamed)
2865
- if fresh != payload:
2866
- storage.update_row(rid, {"Lv": fresh})
2867
- break
2868
+ members = stream_row("TypeInfo")
2869
+ fresh = (
2870
+ type_info(
2871
+ kind,
2872
+ rewritten.objects,
2873
+ members[1],
2874
+ renamed=renamed,
2875
+ encoding=encoding_of(self._vba_dir()[1]),
2876
+ )
2877
+ if members is not None
2878
+ else b""
2879
+ )
2880
+
2881
+ storage.update_row(blob_rid, {"Lv": updated})
2882
+ if members is not None and fresh != members[1]:
2883
+ storage.update_row(members[0], {"Lv": fresh})
2868
2884
  return rewritten
2869
2885
 
2870
2886
  def set_control_property(
File without changes
File without changes
File without changes
File without changes
File without changes