vb6decompiler 0.2.0__py3-none-any.whl
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.
- vb6decompiler/__init__.py +11 -0
- vb6decompiler/blob.py +1067 -0
- vb6decompiler/cli.py +292 -0
- vb6decompiler/datareport.py +886 -0
- vb6decompiler/frm.py +1107 -0
- vb6decompiler/literals.py +275 -0
- vb6decompiler/mask.py +192 -0
- vb6decompiler/pe.py +387 -0
- vb6decompiler/procs.py +444 -0
- vb6decompiler/sqlstitch.py +227 -0
- vb6decompiler-0.2.0.dist-info/METADATA +279 -0
- vb6decompiler-0.2.0.dist-info/RECORD +16 -0
- vb6decompiler-0.2.0.dist-info/WHEEL +5 -0
- vb6decompiler-0.2.0.dist-info/entry_points.txt +2 -0
- vb6decompiler-0.2.0.dist-info/licenses/LICENSE +21 -0
- vb6decompiler-0.2.0.dist-info/top_level.txt +1 -0
vb6decompiler/frm.py
ADDED
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
"""VB6 ``.frm`` design-block parser (text only, stdlib only).
|
|
2
|
+
|
|
3
|
+
A VB6 form file is a text file: a ``VERSION 5.00`` header, a nested design block
|
|
4
|
+
opened by ``Begin <Type> <Name>`` and closed by ``End``, then the code section
|
|
5
|
+
which starts at ``Attribute VB_Name = "..."``. This module parses *only* the
|
|
6
|
+
design block; it never looks at the code and never touches ``.frx`` blobs.
|
|
7
|
+
|
|
8
|
+
Five properties of the format decide the shape of this API. They are not style
|
|
9
|
+
choices, they were measured on real decompiled output:
|
|
10
|
+
|
|
11
|
+
1. **Only non-default properties are written.** A ``TextBox`` with no ``Locked``
|
|
12
|
+
line is editable; no ``Visible`` line means visible; no ``Enabled`` line means
|
|
13
|
+
enabled. Therefore an absent property is *never* the same as ``false``. Every
|
|
14
|
+
boolean the format can omit is exposed as a tri-state: ``True``, ``False`` or
|
|
15
|
+
``None`` (not written). :func:`bool_tri_counts` reports the three counts
|
|
16
|
+
separately instead of folding "absent" into ``False``.
|
|
17
|
+
|
|
18
|
+
2. **Text is CP1254 (Turkish).** Bytes are decoded with
|
|
19
|
+
``errors="replace"`` and the raw bytes of every value stay reachable
|
|
20
|
+
(:attr:`Property.raw_bytes`), so a caller can re-decode or hash the original
|
|
21
|
+
without re-reading the file. Never ``latin-1``: it decodes without error and
|
|
22
|
+
silently produces wrong Turkish (``�`` vs ``ý``/``þ``).
|
|
23
|
+
|
|
24
|
+
3. **Geometry is in twips.** Form-level ``ClientWidth``/``ClientHeight`` (and
|
|
25
|
+
``Width``/``Height``) and every control's ``Left``/``Top``/``Width``/``Height``
|
|
26
|
+
are twips as written. Nothing is converted on the way in. Conversion is an
|
|
27
|
+
explicit, opt-in call (:attr:`Control.bounds_px`,
|
|
28
|
+
:func:`twips_to_px`) and rests on the *assumption* of 15 twips per pixel —
|
|
29
|
+
labelled as an assumption everywhere, because the file itself does not state
|
|
30
|
+
a scale.
|
|
31
|
+
|
|
32
|
+
4. **Control arrays are one type and one name with an ``Index``.** Elements must
|
|
33
|
+
be grouped by ``(type, name)`` and ordered by ``Index``; they must not be
|
|
34
|
+
merged into a single control and must not be emitted as unrelated siblings
|
|
35
|
+
(:meth:`Form.control_arrays`).
|
|
36
|
+
|
|
37
|
+
5. **``SSTab`` page ownership comes from the ``Begin``/``End`` nesting.** A child
|
|
38
|
+
of ``TabDlg.SSTab`` is written *inside* the ``Begin TabDlg.SSTab SSTab1``
|
|
39
|
+
block; there is no parent property on the child. Page captions, when they are
|
|
40
|
+
written at all, appear on the tab control itself as ``TabCaption(n) = "..."``
|
|
41
|
+
and the selected page as ``Tab = n``. Page *membership* is not in the text.
|
|
42
|
+
|
|
43
|
+
Measured on a real target (one decompiled VB6 application, 802 ``.frm`` files —
|
|
44
|
+
see ``docs/RULES.md`` §2: a parser ships with its coverage, not with "OK"):
|
|
45
|
+
|
|
46
|
+
* 369 files carry a design block (368 ``Begin VB.Form`` + 1 ``Begin VB.MDIForm``);
|
|
47
|
+
433 are code-only stubs with no design block at all. The stubs are reported by
|
|
48
|
+
name, not skipped.
|
|
49
|
+
* 30,000 controls inside those 369 forms, 24,970 of them with all four of
|
|
50
|
+
``Left``/``Top``/``Width``/``Height`` (the rest are ``Line``, ``Menu``,
|
|
51
|
+
``Timer`` and non-visual OCX controls, which have no rectangle).
|
|
52
|
+
* Control arrays: 1,165 groups / 14,268 elements in 126 forms — and exactly
|
|
53
|
+
14,268 ``Index =`` lines in the text, i.e. every array element writes one.
|
|
54
|
+
* ``SSTab``: 14 controls with 1,266 nested children, and **0** of them write
|
|
55
|
+
``Tab`` or ``TabCaption(n)`` — the page data lives in the ``OleObjectBlob``
|
|
56
|
+
inside the matching ``.frx``. So this parser reports 0 pages for that target
|
|
57
|
+
instead of guessing a page count.
|
|
58
|
+
* Only ``Visible = 0 'False`` is ever written (432 lines, never ``Visible = -1``):
|
|
59
|
+
the explicit-``false`` case exists, the explicit-``true`` case does not. Reading
|
|
60
|
+
absence as ``False`` would flip 29,568 controls.
|
|
61
|
+
* One file is truncated mid-control and never closes its design block; it is
|
|
62
|
+
flagged ``complete=False`` with the cut line number, not silently repaired.
|
|
63
|
+
|
|
64
|
+
Re-run those numbers with :func:`scan_directory` / :func:`format_summary`, or
|
|
65
|
+
``python -m vb6decompiler.frm <directory>``.
|
|
66
|
+
"""
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import os
|
|
70
|
+
import re
|
|
71
|
+
from dataclasses import dataclass, field
|
|
72
|
+
from typing import Iterable, Iterator, Sequence
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"TWIPS_PER_PX_ASSUMED",
|
|
76
|
+
"FrmError",
|
|
77
|
+
"NoDesignBlock",
|
|
78
|
+
"MalformedForm",
|
|
79
|
+
"ParseWarning",
|
|
80
|
+
"FrxRef",
|
|
81
|
+
"Property",
|
|
82
|
+
"Control",
|
|
83
|
+
"Form",
|
|
84
|
+
"ScanResult",
|
|
85
|
+
"parse_bytes",
|
|
86
|
+
"parse_frm_text",
|
|
87
|
+
"parse_frm",
|
|
88
|
+
"scan_directory",
|
|
89
|
+
"summarize",
|
|
90
|
+
"format_summary",
|
|
91
|
+
"bool_tri_counts",
|
|
92
|
+
"twips_to_px",
|
|
93
|
+
"decode_cp1254",
|
|
94
|
+
"TRUE_EXPLICIT",
|
|
95
|
+
"FALSE_EXPLICIT",
|
|
96
|
+
"ABSENT",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
#: Assumed device scale for the optional pixel conversion. **This is an
|
|
100
|
+
#: assumption, not a measurement**: a ``.frm`` file stores twips and never states
|
|
101
|
+
#: a scale; 15 twips/px is the classic 96 dpi value the downstream tool in this
|
|
102
|
+
#: target family uses. Every function that applies it says so in its name or
|
|
103
|
+
#: docstring, and all public geometry fields stay in twips.
|
|
104
|
+
TWIPS_PER_PX_ASSUMED = 15
|
|
105
|
+
|
|
106
|
+
#: Tri-state names. Strings rather than an ``enum`` so JSON output stays readable.
|
|
107
|
+
TRUE_EXPLICIT = "true" # property written and truthy
|
|
108
|
+
FALSE_EXPLICIT = "false" # property written and falsy
|
|
109
|
+
ABSENT = "absent" # property not written: VB6 default applies, *do not* read as false
|
|
110
|
+
|
|
111
|
+
#: Encoding of a VB6 ``.frm``'s non-ASCII text. Measured: Turkish captions in
|
|
112
|
+
#: the target decode correctly as CP1254 and incorrectly as latin-1.
|
|
113
|
+
FRM_ENCODING = "cp1254"
|
|
114
|
+
|
|
115
|
+
# --------------------------------------------------------------------------- grammar
|
|
116
|
+
# ``Begin VB.TextBox Sutun4`` / ``Begin TabDlg.SSTab SSTab1`` /
|
|
117
|
+
# ``Begin MSComctlLib.ProgressBar ProgressBar1``; a licenced OCX may appear as a
|
|
118
|
+
# GUID (``Begin {C62A69F0-...} Ctrl1``), which is why the type alternative allows
|
|
119
|
+
# braces.
|
|
120
|
+
RE_BEGIN = re.compile(r"^(?P<indent>[ \t]*)Begin\s+(?P<type>[A-Za-z_][\w.]*|\{[0-9A-Fa-f-]+\})"
|
|
121
|
+
r"\s+(?P<name>[A-Za-z_]\w*)\s*$")
|
|
122
|
+
#: ``End`` closes a control; ``EndProperty`` closes a ``BeginProperty`` group and
|
|
123
|
+
#: must be tested first (it also starts with "End").
|
|
124
|
+
RE_END = re.compile(r"^(?P<indent>[ \t]*)End\s*$")
|
|
125
|
+
RE_END_PROPERTY = re.compile(r"^(?P<indent>[ \t]*)EndProperty\s*$")
|
|
126
|
+
#: ``BeginProperty Font`` groups sit *inside* a control (9,438 occurrences in the
|
|
127
|
+
#: measured target, all of them ``Font``). Their members are flattened to
|
|
128
|
+
#: ``Font.Name``, ``Font.Size`` ... so the control keeps a single property map.
|
|
129
|
+
RE_BEGIN_PROPERTY = re.compile(r"^(?P<indent>[ \t]*)BeginProperty\s+(?P<type>[A-Za-z_][\w.]*)"
|
|
130
|
+
r"(?:\s+(?P<name>[A-Za-z_]\w*))?\s*$")
|
|
131
|
+
#: ``Name = value``. The optional ``(n)`` covers indexed properties
|
|
132
|
+
#: (``TabCaption(0) = "..."``, ``Item(3) = ...``).
|
|
133
|
+
RE_PROP = re.compile(r"^(?P<indent>[ \t]*)(?P<name>[A-Za-z_]\w*)"
|
|
134
|
+
r"(?:\((?P<param>\d+)\))?\s*=\s*(?P<value>.*?)\s*$")
|
|
135
|
+
#: The code section starts here; the design block never continues past it.
|
|
136
|
+
RE_CODE_START = re.compile(r"^\s*Attribute\s+VB_")
|
|
137
|
+
|
|
138
|
+
RE_HEX = re.compile(r"^&[Hh]([0-9A-Fa-f]+)&?$")
|
|
139
|
+
RE_INT = re.compile(r"^[-+]?\d+$")
|
|
140
|
+
#: VB6 writes non-integral numbers with the authoring machine's decimal
|
|
141
|
+
#: separator; the measured target writes ``Size = 8,25`` throughout (9,419
|
|
142
|
+
#: occurrences). A dot never appears in the 368 measured files.
|
|
143
|
+
RE_FLOAT_COMMA = re.compile(r"^[-+]?\d+,\d+$")
|
|
144
|
+
#: ``OleObjectBlob = "fDemo011.frx":1042`` — a reference into the sibling binary
|
|
145
|
+
#: resource file. The value is *not* text and *not* geometry.
|
|
146
|
+
#:
|
|
147
|
+
#: The offset is **hexadecimal without a prefix**, and that was measured, not
|
|
148
|
+
#: assumed: 1,626 references across the target's 369 design blocks, every one of
|
|
149
|
+
#: them an offset below the size of the ``.frx`` it names, zero above; 837 of them
|
|
150
|
+
#: are digits-only (``:1042``) and the hex reading is the only one that stays in
|
|
151
|
+
#: range for the whole file. Some are unambiguously hex (``:10AA``, ``:14A4``),
|
|
152
|
+
#: so a decimal reading of the digits-only ones would mix two bases in one file.
|
|
153
|
+
RE_FRX_REF = re.compile(r'^"((?:[^"]|"")*)"\s*:\s*([0-9A-Fa-f]+)$')
|
|
154
|
+
|
|
155
|
+
#: Value kinds produced by :func:`_parse_value`.
|
|
156
|
+
KIND_STR = "str"
|
|
157
|
+
KIND_BOOL = "bool"
|
|
158
|
+
KIND_INT = "int"
|
|
159
|
+
KIND_FLOAT = "float"
|
|
160
|
+
KIND_FRX = "frx"
|
|
161
|
+
KIND_UNKNOWN = "unknown"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class FrmError(ValueError):
|
|
165
|
+
"""Base class for design-block problems that stop parsing."""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class NoDesignBlock(FrmError):
|
|
169
|
+
"""The file has no ``Begin ...Form`` design block (code-only stub).
|
|
170
|
+
|
|
171
|
+
Measured: 434 of 802 files in the target directory are in this state. They
|
|
172
|
+
are raised on purpose rather than returned as an empty form, so a caller
|
|
173
|
+
cannot mistake "no design" for "a form with zero controls".
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class MalformedForm(FrmError):
|
|
178
|
+
"""The design block opened but could not be closed consistently."""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# --------------------------------------------------------------------------- values
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class FrxRef:
|
|
184
|
+
"""A reference into the sibling ``.frx`` binary resource.
|
|
185
|
+
|
|
186
|
+
``"fDemo011.frx":10AA`` means "offset 0x10AA of fDemo011.frx" — the offset is
|
|
187
|
+
hexadecimal (see :data:`RE_FRX_REF` for the measurement). ``offset_text``
|
|
188
|
+
keeps the digits exactly as written so a caller that disagrees with the hex
|
|
189
|
+
reading still has the evidence. The bytes are not read here; this module is
|
|
190
|
+
text-only.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
file: str
|
|
194
|
+
offset: int
|
|
195
|
+
offset_text: str = ""
|
|
196
|
+
|
|
197
|
+
def __str__(self) -> str: # pragma: no cover - convenience only
|
|
198
|
+
return f"{self.file}:{self.offset_text or self.offset}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class Property:
|
|
203
|
+
"""One written ``Name = value`` line.
|
|
204
|
+
|
|
205
|
+
``value`` is normalised (``int``/``float``/``bool``/``str``/:class:`FrxRef`);
|
|
206
|
+
``raw`` is the value text as written with the VB6 type-hint comment removed
|
|
207
|
+
and ``comment`` is that removed part. Keeping the comment matters: it is the
|
|
208
|
+
only way to tell the boolean ``0 'False`` from the enumeration value
|
|
209
|
+
``0`` (``BorderStyle = 0 'None``), and VB6 writes booleans as ``-1``/``0``.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
name: str
|
|
213
|
+
value: object
|
|
214
|
+
kind: str
|
|
215
|
+
raw: str
|
|
216
|
+
raw_bytes: bytes
|
|
217
|
+
comment: str | None = None
|
|
218
|
+
param: int | None = None # index of `Name(n) = ...`, else None
|
|
219
|
+
line: int = 0
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def is_bool(self) -> bool:
|
|
223
|
+
return self.kind == KIND_BOOL
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass
|
|
227
|
+
class ParseWarning:
|
|
228
|
+
"""A line the parser could not interpret. Collected, never swallowed."""
|
|
229
|
+
|
|
230
|
+
path: str
|
|
231
|
+
line: int
|
|
232
|
+
text: str
|
|
233
|
+
reason: str
|
|
234
|
+
|
|
235
|
+
def __str__(self) -> str:
|
|
236
|
+
return f"{self.path}:{self.line}: {self.reason}: {self.text!r}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def decode_cp1254(data: bytes) -> str:
|
|
240
|
+
"""Decode VB6 text the way the format requires, keeping a lossy path explicit.
|
|
241
|
+
|
|
242
|
+
``errors="replace"`` is deliberate: a decompiled file may contain bytes that
|
|
243
|
+
are not valid CP1254, and dropping the whole file would lose the other 25,000
|
|
244
|
+
controls. U+FFFD in the output is a *finding* that :func:`summarize` counts,
|
|
245
|
+
not something to hide. The raw bytes stay available on
|
|
246
|
+
:attr:`Property.raw_bytes`.
|
|
247
|
+
"""
|
|
248
|
+
return data.decode(FRM_ENCODING, "replace")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _split_comment(text: str) -> tuple[str, str | None]:
|
|
252
|
+
"""Split ``-1 'True`` into ``('-1', 'True')``.
|
|
253
|
+
|
|
254
|
+
The apostrophe only starts a comment outside a double-quoted string; VB6
|
|
255
|
+
doubles embedded quotes (``""``), which toggles the flag twice and is
|
|
256
|
+
therefore safe here.
|
|
257
|
+
"""
|
|
258
|
+
in_str = False
|
|
259
|
+
for i, ch in enumerate(text):
|
|
260
|
+
if ch == '"':
|
|
261
|
+
in_str = not in_str
|
|
262
|
+
elif ch == "'" and not in_str:
|
|
263
|
+
return text[:i].rstrip(), text[i + 1:].strip()
|
|
264
|
+
return text.rstrip(), None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _unquote(text: str) -> str:
|
|
268
|
+
"""Turn ``"a""b"`` into ``a"b`` (VB6 doubles embedded quotes)."""
|
|
269
|
+
body = text[1:-1]
|
|
270
|
+
return body.replace('""', '"')
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_value(text: str, comment: str | None) -> tuple[object, str]:
|
|
274
|
+
"""Classify a property value and return ``(value, kind)``.
|
|
275
|
+
|
|
276
|
+
The order of the tests is the grammar order, not a preference:
|
|
277
|
+
|
|
278
|
+
* an ``.frx`` reference is checked before a plain string because it *starts*
|
|
279
|
+
with a quote too;
|
|
280
|
+
* hex literals (``&HC0C0C0&``) are integers in VB6 and must not fall through
|
|
281
|
+
to ``str``;
|
|
282
|
+
* a ``-1``/``0`` carrying the comment ``'True``/``'False`` is a boolean, while
|
|
283
|
+
the same number with ``'None``/``'Fixed Single`` is an enumeration member.
|
|
284
|
+
"""
|
|
285
|
+
if not text:
|
|
286
|
+
return None, KIND_UNKNOWN
|
|
287
|
+
m = RE_FRX_REF.match(text)
|
|
288
|
+
if m:
|
|
289
|
+
return FrxRef(m.group(1).replace('""', '"'), int(m.group(2), 16),
|
|
290
|
+
m.group(2)), KIND_FRX
|
|
291
|
+
if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
|
|
292
|
+
return _unquote(text), KIND_STR
|
|
293
|
+
m = RE_HEX.match(text)
|
|
294
|
+
if m:
|
|
295
|
+
return int(m.group(1), 16), KIND_INT
|
|
296
|
+
if text in ("True", "False"):
|
|
297
|
+
return text == "True", KIND_BOOL
|
|
298
|
+
if RE_INT.match(text):
|
|
299
|
+
n = int(text)
|
|
300
|
+
if comment in ("True", "False"):
|
|
301
|
+
return bool(n), KIND_BOOL
|
|
302
|
+
return n, KIND_INT
|
|
303
|
+
if RE_FLOAT_COMMA.match(text):
|
|
304
|
+
return float(text.replace(",", ".")), KIND_FLOAT
|
|
305
|
+
return text, KIND_UNKNOWN
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# --------------------------------------------------------------------------- nodes
|
|
309
|
+
@dataclass
|
|
310
|
+
class Control:
|
|
311
|
+
"""One ``Begin <type> <name>`` block inside the form.
|
|
312
|
+
|
|
313
|
+
``parent`` is the enclosing control's name (``None`` for a direct child of the
|
|
314
|
+
form). It is derived from the ``Begin``/``End`` nesting — there is no parent
|
|
315
|
+
property on a VB6 control — which is also how ``SSTab`` ownership is decided
|
|
316
|
+
(see the module docstring, point 5).
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
type: str # full type as written, e.g. "TabDlg.SSTab"
|
|
320
|
+
name: str
|
|
321
|
+
props: dict[str, Property] = field(default_factory=dict)
|
|
322
|
+
children: list["Control"] = field(default_factory=list)
|
|
323
|
+
parent: str | None = None
|
|
324
|
+
parent_type: str | None = None
|
|
325
|
+
depth: int = 0
|
|
326
|
+
line: int = 0
|
|
327
|
+
indent: int = 0
|
|
328
|
+
|
|
329
|
+
# -- naming -------------------------------------------------------------
|
|
330
|
+
@property
|
|
331
|
+
def short_type(self) -> str:
|
|
332
|
+
"""``TabDlg.SSTab`` -> ``SSTab`` (lib prefix dropped)."""
|
|
333
|
+
return self.type.rsplit(".", 1)[-1]
|
|
334
|
+
|
|
335
|
+
# -- raw property access ------------------------------------------------
|
|
336
|
+
def prop(self, name: str, param: int | None = None) -> Property | None:
|
|
337
|
+
"""Written property or ``None``. ``None`` means *not written*, not false.
|
|
338
|
+
|
|
339
|
+
Indexed properties must be asked for with their index
|
|
340
|
+
(``prop("TabCaption", 1)``); without one this returns the plain
|
|
341
|
+
``Name = ...`` property, else the first indexed variant.
|
|
342
|
+
"""
|
|
343
|
+
if param is not None:
|
|
344
|
+
return self.props.get(f"{name}({param})")
|
|
345
|
+
p = self.props.get(name)
|
|
346
|
+
if p is not None:
|
|
347
|
+
return p
|
|
348
|
+
for cand in self.props.values():
|
|
349
|
+
if cand.name == name:
|
|
350
|
+
return cand
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
def has(self, name: str) -> bool:
|
|
354
|
+
return self.prop(name) is not None
|
|
355
|
+
|
|
356
|
+
def tri(self, name: str) -> str:
|
|
357
|
+
"""Tri-state of a boolean property: ``"true"``/``"false"``/``"absent"``.
|
|
358
|
+
|
|
359
|
+
Measured reason for the third state: the target writes only
|
|
360
|
+
``Visible = 0 'False`` (432 lines) and never ``Visible = -1``; absence is
|
|
361
|
+
the *normal* way to say "at the default, i.e. visible".
|
|
362
|
+
"""
|
|
363
|
+
p = self.prop(name)
|
|
364
|
+
if p is None:
|
|
365
|
+
return ABSENT
|
|
366
|
+
if p.kind == KIND_BOOL:
|
|
367
|
+
return TRUE_EXPLICIT if p.value else FALSE_EXPLICIT
|
|
368
|
+
# Written but not a boolean: ``BorderStyle = 0 'None`` and ``Visible = 0``
|
|
369
|
+
# look alike numerically, and only the VB6 type-hint comment separates
|
|
370
|
+
# them. Guessing here would flip enumerations into "false", so an
|
|
371
|
+
# un-hinted value is reported as absent rather than as a boolean.
|
|
372
|
+
return ABSENT
|
|
373
|
+
|
|
374
|
+
def bool_prop(self, name: str) -> bool | None:
|
|
375
|
+
"""``True``/``False`` when written, ``None`` when absent (VB6 default)."""
|
|
376
|
+
tri = self.tri(name)
|
|
377
|
+
if tri == TRUE_EXPLICIT:
|
|
378
|
+
return True
|
|
379
|
+
if tri == FALSE_EXPLICIT:
|
|
380
|
+
return False
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
def effective_bool(self, name: str, default: bool) -> bool:
|
|
384
|
+
"""Resolve a tri-state against the *caller's* stated default.
|
|
385
|
+
|
|
386
|
+
The default is a parameter because this module does not carry a table of
|
|
387
|
+
VB6 per-class defaults; guessing one would be invented data. Passing
|
|
388
|
+
``default=True`` for ``Visible`` on a ``TextBox`` is the caller's claim,
|
|
389
|
+
not this parser's.
|
|
390
|
+
"""
|
|
391
|
+
v = self.bool_prop(name)
|
|
392
|
+
return default if v is None else v
|
|
393
|
+
|
|
394
|
+
def number(self, name: str) -> int | float | None:
|
|
395
|
+
p = self.prop(name)
|
|
396
|
+
if p is None or p.kind not in (KIND_INT, KIND_FLOAT):
|
|
397
|
+
return None
|
|
398
|
+
return p.value
|
|
399
|
+
|
|
400
|
+
def twips(self, name: str) -> int | None:
|
|
401
|
+
"""A geometry property exactly as written (twips). ``None`` if absent."""
|
|
402
|
+
p = self.prop(name)
|
|
403
|
+
if p is None:
|
|
404
|
+
return None
|
|
405
|
+
if p.kind == KIND_FLOAT:
|
|
406
|
+
# Fractional twips exist (``Size = 8,25``) but not for geometry;
|
|
407
|
+
# keep the value rather than silently rounding.
|
|
408
|
+
return p.value # type: ignore[return-value]
|
|
409
|
+
if p.kind == KIND_INT:
|
|
410
|
+
return int(p.value) # type: ignore[arg-type]
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
def string(self, name: str) -> str | None:
|
|
414
|
+
p = self.prop(name)
|
|
415
|
+
if p is None:
|
|
416
|
+
return None
|
|
417
|
+
return p.value if p.kind == KIND_STR else None
|
|
418
|
+
|
|
419
|
+
@property
|
|
420
|
+
def caption(self) -> str | None:
|
|
421
|
+
"""``Caption`` as written (CP1254-decoded), or ``None`` if not written."""
|
|
422
|
+
return self.string("Caption")
|
|
423
|
+
|
|
424
|
+
@property
|
|
425
|
+
def text(self) -> str | None:
|
|
426
|
+
return self.string("Text")
|
|
427
|
+
|
|
428
|
+
@property
|
|
429
|
+
def index(self) -> int | None:
|
|
430
|
+
"""Control-array ``Index``, or ``None`` when the control is not in an array."""
|
|
431
|
+
p = self.prop("Index")
|
|
432
|
+
if p is None or not isinstance(p.value, int):
|
|
433
|
+
return None
|
|
434
|
+
return int(p.value)
|
|
435
|
+
|
|
436
|
+
@property
|
|
437
|
+
def caption_bytes(self) -> bytes | None:
|
|
438
|
+
"""Raw CP1254 bytes of ``Caption``, for callers that must not re-encode."""
|
|
439
|
+
p = self.prop("Caption")
|
|
440
|
+
return p.raw_bytes if p is not None and p.kind == KIND_STR else None
|
|
441
|
+
|
|
442
|
+
# -- geometry -----------------------------------------------------------
|
|
443
|
+
@property
|
|
444
|
+
def bounds(self) -> tuple[int, int, int, int] | None:
|
|
445
|
+
"""``(left, top, width, height)`` in twips, or ``None`` if any is absent.
|
|
446
|
+
|
|
447
|
+
No conversion, no defaulting: a partially written rectangle returns
|
|
448
|
+
``None`` instead of a guessed 0.
|
|
449
|
+
"""
|
|
450
|
+
vals = [self.twips(k) for k in ("Left", "Top", "Width", "Height")]
|
|
451
|
+
if any(v is None for v in vals):
|
|
452
|
+
return None
|
|
453
|
+
return tuple(int(v) for v in vals) # type: ignore[return-value]
|
|
454
|
+
|
|
455
|
+
@property
|
|
456
|
+
def has_bounds(self) -> bool:
|
|
457
|
+
return self.bounds is not None
|
|
458
|
+
|
|
459
|
+
def bounds_px(self, twips_per_px: int = TWIPS_PER_PX_ASSUMED) -> tuple[float, ...] | None:
|
|
460
|
+
"""Twips converted to pixels with the **assumed** 15 twips/px scale.
|
|
461
|
+
|
|
462
|
+
The file carries no scale, so this is a convenience for rendering only;
|
|
463
|
+
nothing in this module calls it implicitly.
|
|
464
|
+
"""
|
|
465
|
+
b = self.bounds
|
|
466
|
+
if b is None:
|
|
467
|
+
return None
|
|
468
|
+
return tuple(v / twips_per_px for v in b)
|
|
469
|
+
|
|
470
|
+
@property
|
|
471
|
+
def line_coords(self) -> tuple[int, int, int, int] | None:
|
|
472
|
+
"""``(X1, Y1, X2, Y2)`` for ``VB.Line`` (twips), which has no Left/Width."""
|
|
473
|
+
vals = [self.twips(k) for k in ("X1", "Y1", "X2", "Y2")]
|
|
474
|
+
if any(v is None for v in vals):
|
|
475
|
+
return None
|
|
476
|
+
return tuple(int(v) for v in vals) # type: ignore[return-value]
|
|
477
|
+
|
|
478
|
+
@property
|
|
479
|
+
def derived_bounds(self) -> tuple[int, int, int, int] | None:
|
|
480
|
+
"""Bounding box **derived** from ``X1/Y1/X2/Y2`` (as the downstream tool does).
|
|
481
|
+
|
|
482
|
+
Named "derived" on purpose: the file does not contain these four numbers
|
|
483
|
+
for a ``Line``, so they must never be presented as written properties.
|
|
484
|
+
Width/height are clamped to 1 twip so a purely horizontal or vertical line
|
|
485
|
+
still has a drawable rectangle.
|
|
486
|
+
"""
|
|
487
|
+
c = self.line_coords
|
|
488
|
+
if c is None:
|
|
489
|
+
return None
|
|
490
|
+
x1, y1, x2, y2 = c
|
|
491
|
+
return (min(x1, x2), min(y1, y2), max(abs(x2 - x1), 1), max(abs(y2 - y1), 1))
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
def font(self) -> dict[str, object]:
|
|
495
|
+
"""Flattened ``BeginProperty Font`` members (``{"Name": ..., "Size": ...}``)."""
|
|
496
|
+
out: dict[str, object] = {}
|
|
497
|
+
for key, prop in self.props.items():
|
|
498
|
+
if "." in key:
|
|
499
|
+
out[key.split(".", 1)[1]] = prop.value
|
|
500
|
+
return out
|
|
501
|
+
|
|
502
|
+
# -- tab pages ----------------------------------------------------------
|
|
503
|
+
def tab_pages(self) -> list[tuple[int, str | None]]:
|
|
504
|
+
"""``[(page_index, caption_or_None), ...]`` from written ``TabCaption(n)``.
|
|
505
|
+
|
|
506
|
+
Only what the text contains: pages are sorted by index and a page whose
|
|
507
|
+
caption is empty/absent is reported with ``None``. An empty list means
|
|
508
|
+
*no page captions are written in this file* — measured on the target, all
|
|
509
|
+
14 ``SSTab`` controls store their page data in the ``.frx``
|
|
510
|
+
``OleObjectBlob`` and write neither ``Tab`` nor ``TabCaption``, so an
|
|
511
|
+
empty list here is the correct answer, not a parser gap.
|
|
512
|
+
"""
|
|
513
|
+
pages: dict[int, str | None] = {}
|
|
514
|
+
for prop in self.props.values():
|
|
515
|
+
if prop.name == "TabCaption" and prop.param is not None:
|
|
516
|
+
pages[prop.param] = prop.value if prop.kind == KIND_STR else None # type: ignore[assignment]
|
|
517
|
+
return sorted(pages.items())
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def selected_page(self) -> int | None:
|
|
521
|
+
"""``Tab = n`` on the ``SSTab`` itself: the page shown in the designer."""
|
|
522
|
+
p = self.prop("Tab")
|
|
523
|
+
if p is None or not isinstance(p.value, int):
|
|
524
|
+
return None
|
|
525
|
+
return int(p.value)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@dataclass
|
|
529
|
+
class Form:
|
|
530
|
+
"""A parsed ``.frm`` design block."""
|
|
531
|
+
|
|
532
|
+
name: str
|
|
533
|
+
type: str
|
|
534
|
+
props: dict[str, Property] = field(default_factory=dict)
|
|
535
|
+
controls: list[Control] = field(default_factory=list) # flattened, document order
|
|
536
|
+
roots: list[Control] = field(default_factory=list) # direct children of the form
|
|
537
|
+
warnings: list[ParseWarning] = field(default_factory=list)
|
|
538
|
+
path: str | None = None
|
|
539
|
+
#: ``False`` when the design block had no closing ``End`` (truncated file).
|
|
540
|
+
#: The controls recovered before the cut are kept; this flag says so.
|
|
541
|
+
complete: bool = True
|
|
542
|
+
incomplete_reason: str | None = None
|
|
543
|
+
|
|
544
|
+
# -- form-level ---------------------------------------------------------
|
|
545
|
+
def prop(self, name: str) -> Property | None:
|
|
546
|
+
return self.props.get(name)
|
|
547
|
+
|
|
548
|
+
def string(self, name: str) -> str | None:
|
|
549
|
+
p = self.prop(name)
|
|
550
|
+
return p.value if p is not None and p.kind == KIND_STR else None
|
|
551
|
+
|
|
552
|
+
@property
|
|
553
|
+
def caption(self) -> str | None:
|
|
554
|
+
return self.string("Caption")
|
|
555
|
+
|
|
556
|
+
def twips(self, name: str) -> int | None:
|
|
557
|
+
p = self.prop(name)
|
|
558
|
+
return int(p.value) if p is not None and p.kind == KIND_INT else None
|
|
559
|
+
|
|
560
|
+
@property
|
|
561
|
+
def client_size(self) -> tuple[int, int] | None:
|
|
562
|
+
"""``(ClientWidth, ClientHeight)`` in twips, or ``None`` if either absent.
|
|
563
|
+
|
|
564
|
+
Measured: all 369 parsed design forms write both, and the values match the
|
|
565
|
+
form geometry recovered from the compiled image (the ``--validate`` path of
|
|
566
|
+
the blob extractor this parser was derived from). A form that omits them
|
|
567
|
+
is reported as ``None``, never as 0 — the two are different facts.
|
|
568
|
+
"""
|
|
569
|
+
w, h = self.twips("ClientWidth"), self.twips("ClientHeight")
|
|
570
|
+
if w is None or h is None:
|
|
571
|
+
return None
|
|
572
|
+
return (w, h)
|
|
573
|
+
|
|
574
|
+
@property
|
|
575
|
+
def outer_size(self) -> tuple[int, int] | None:
|
|
576
|
+
"""``(Width, Height)`` in twips — the whole window, not the client area."""
|
|
577
|
+
w, h = self.twips("Width"), self.twips("Height")
|
|
578
|
+
if w is None or h is None:
|
|
579
|
+
return None
|
|
580
|
+
return (w, h)
|
|
581
|
+
|
|
582
|
+
def tri(self, name: str) -> str:
|
|
583
|
+
p = self.prop(name)
|
|
584
|
+
if p is None:
|
|
585
|
+
return ABSENT
|
|
586
|
+
if p.kind == KIND_BOOL:
|
|
587
|
+
return TRUE_EXPLICIT if p.value else FALSE_EXPLICIT
|
|
588
|
+
return ABSENT
|
|
589
|
+
|
|
590
|
+
# -- lookups ------------------------------------------------------------
|
|
591
|
+
def control(self, name: str, index: int | None = None) -> Control | None:
|
|
592
|
+
"""Find a control by name; for an array pass the ``Index`` too.
|
|
593
|
+
|
|
594
|
+
VB6 identifiers are case-insensitive, but this lookup is exact on purpose:
|
|
595
|
+
an approximate match between two different names would be invented data
|
|
596
|
+
(see ``docs/RULES.md`` §4). Case-fold yourself if that is what you want.
|
|
597
|
+
"""
|
|
598
|
+
for c in self.controls:
|
|
599
|
+
if c.name != name:
|
|
600
|
+
continue
|
|
601
|
+
if index is None or c.index == index:
|
|
602
|
+
return c
|
|
603
|
+
return None
|
|
604
|
+
|
|
605
|
+
def by_name(self, name: str) -> list[Control]:
|
|
606
|
+
return [c for c in self.controls if c.name == name]
|
|
607
|
+
|
|
608
|
+
def control_arrays(self) -> dict[tuple[str, str], list[Control]]:
|
|
609
|
+
"""Control arrays grouped by ``(type, name)`` and ordered by ``Index``.
|
|
610
|
+
|
|
611
|
+
A VB6 control array is *one* control with one name and one type whose
|
|
612
|
+
elements differ only by ``Index``; each element is its own design record
|
|
613
|
+
with its own geometry. Merging them loses all but one position; keeping
|
|
614
|
+
them as unrelated siblings loses the fact that they are one object (event
|
|
615
|
+
handlers are ``Sub Sutun4_Change(Index As Integer)``).
|
|
616
|
+
|
|
617
|
+
A group counts as an array when at least one member writes ``Index``.
|
|
618
|
+
Measured trap: a same-named pair with *different* types is two scalar
|
|
619
|
+
controls, not an array — ``(type, name)`` is the key for that reason.
|
|
620
|
+
"""
|
|
621
|
+
groups: dict[tuple[str, str], list[Control]] = {}
|
|
622
|
+
for c in self.controls:
|
|
623
|
+
groups.setdefault((c.short_type, c.name), []).append(c)
|
|
624
|
+
arrays: dict[tuple[str, str], list[Control]] = {}
|
|
625
|
+
for key, members in groups.items():
|
|
626
|
+
if any(m.index is not None for m in members):
|
|
627
|
+
# Sorted by Index; an element that (illegally) lacks Index sorts
|
|
628
|
+
# last rather than crashing the parse.
|
|
629
|
+
arrays[key] = sorted(members, key=lambda m: (m.index is None, m.index or 0))
|
|
630
|
+
return arrays
|
|
631
|
+
|
|
632
|
+
def sstabs(self) -> list[Control]:
|
|
633
|
+
"""All ``SSTab`` controls of the form."""
|
|
634
|
+
return [c for c in self.controls if c.short_type == "SSTab"]
|
|
635
|
+
|
|
636
|
+
def sstab_children(self, sstab_name: str) -> list[Control]:
|
|
637
|
+
"""Direct children of one ``SSTab`` — ownership by nesting, not by property."""
|
|
638
|
+
return [c for c in self.controls if c.parent == sstab_name]
|
|
639
|
+
|
|
640
|
+
def sstab_of(self, control: Control) -> str | None:
|
|
641
|
+
"""Name of the nearest ``SSTab`` ancestor, walking the parse tree."""
|
|
642
|
+
cur = control.parent
|
|
643
|
+
while cur is not None:
|
|
644
|
+
node = self.control(cur)
|
|
645
|
+
if node is None:
|
|
646
|
+
return None
|
|
647
|
+
if node.short_type == "SSTab":
|
|
648
|
+
return node.name
|
|
649
|
+
cur = node.parent
|
|
650
|
+
return None
|
|
651
|
+
|
|
652
|
+
# -- tree ---------------------------------------------------------------
|
|
653
|
+
def walk(self) -> Iterator[Control]:
|
|
654
|
+
"""Depth-first walk over ``roots`` (same order as ``controls``)."""
|
|
655
|
+
def rec(nodes: Sequence[Control]) -> Iterator[Control]:
|
|
656
|
+
for n in nodes:
|
|
657
|
+
yield n
|
|
658
|
+
yield from rec(n.children)
|
|
659
|
+
return rec(self.roots)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
# --------------------------------------------------------------------------- parser
|
|
663
|
+
@dataclass
|
|
664
|
+
class _Frame:
|
|
665
|
+
"""One open ``Begin`` block or ``BeginProperty`` group during parsing."""
|
|
666
|
+
|
|
667
|
+
kind: str # "control" | "group" | "form"
|
|
668
|
+
indent: int
|
|
669
|
+
line: int
|
|
670
|
+
control: Control | None = None
|
|
671
|
+
group: str | None = None
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def parse_frm_text(text: str, *, path: str | None = None,
|
|
675
|
+
raw: bytes | None = None) -> Form:
|
|
676
|
+
"""Parse a decoded design block.
|
|
677
|
+
|
|
678
|
+
``text`` must already be decoded (see :func:`decode_cp1254`); ``raw`` is the
|
|
679
|
+
matching byte string and is only used to keep :attr:`Property.raw_bytes`
|
|
680
|
+
pointing at the file's own bytes. When ``raw`` is omitted the raw bytes are
|
|
681
|
+
the CP1254 encoding of ``text``, which is exact for every file this parser
|
|
682
|
+
can read and lossy only in the U+FFFD cases that are reported as warnings.
|
|
683
|
+
"""
|
|
684
|
+
if raw is None:
|
|
685
|
+
raw = text.encode(FRM_ENCODING, "replace")
|
|
686
|
+
# Split the decoded text and the raw bytes in lockstep so a property's bytes
|
|
687
|
+
# can be located without re-encoding (CP1254 is single-byte, so byte offsets
|
|
688
|
+
# of ASCII '=' and quotes equal character offsets).
|
|
689
|
+
lines = text.split("\n")
|
|
690
|
+
raw_lines = raw.split(b"\n") if raw.count(b"\n") == len(lines) - 1 else []
|
|
691
|
+
|
|
692
|
+
form: Form | None = None
|
|
693
|
+
warnings: list[ParseWarning] = []
|
|
694
|
+
stack: list[_Frame] = []
|
|
695
|
+
all_controls: list[Control] = []
|
|
696
|
+
truncated = False
|
|
697
|
+
|
|
698
|
+
def warn(lineno: int, txt: str, reason: str) -> None:
|
|
699
|
+
warnings.append(ParseWarning(path or "<text>", lineno, txt, reason))
|
|
700
|
+
|
|
701
|
+
for lineno, raw_line in enumerate(lines, 1):
|
|
702
|
+
line = raw_line.rstrip("\r")
|
|
703
|
+
stripped = line.strip()
|
|
704
|
+
|
|
705
|
+
if not stripped:
|
|
706
|
+
continue
|
|
707
|
+
# A decompiler may emit a commented-out property (measured: `'Icon = n/a`).
|
|
708
|
+
# The leading apostrophe is VB6's comment marker, so the line is not data.
|
|
709
|
+
if stripped.startswith("'"):
|
|
710
|
+
continue
|
|
711
|
+
# A design block ends at the first Attribute statement (measured: always
|
|
712
|
+
# `Attribute VB_Name`); anything after it is VB6 code, not design data.
|
|
713
|
+
if RE_CODE_START.match(line):
|
|
714
|
+
break
|
|
715
|
+
|
|
716
|
+
m = RE_BEGIN.match(line)
|
|
717
|
+
if m:
|
|
718
|
+
indent = len(m.group("indent").expandtabs(4))
|
|
719
|
+
typ, name = m.group("type"), m.group("name")
|
|
720
|
+
# Indentation is the authoritative nesting signal for the siblings,
|
|
721
|
+
# `End` for closing: pop anything at the same or deeper level so a
|
|
722
|
+
# missing `End` degrades a subtree instead of corrupting the rest.
|
|
723
|
+
while stack and stack[-1].indent >= indent and stack[-1].kind != "form":
|
|
724
|
+
warn(lineno, line, f"'{name}' opened while {stack[-1].kind} "
|
|
725
|
+
f"from line {stack[-1].line} still open (unclosed End)")
|
|
726
|
+
stack.pop()
|
|
727
|
+
ctrl = Control(type=typ, name=name, line=lineno, indent=indent)
|
|
728
|
+
parent: Control | None = None
|
|
729
|
+
for frame in reversed(stack):
|
|
730
|
+
if frame.control is not None:
|
|
731
|
+
parent = frame.control
|
|
732
|
+
break
|
|
733
|
+
if parent is not None:
|
|
734
|
+
ctrl.parent = parent.name
|
|
735
|
+
ctrl.parent_type = parent.type
|
|
736
|
+
ctrl.depth = parent.depth + 1
|
|
737
|
+
parent.children.append(ctrl)
|
|
738
|
+
else:
|
|
739
|
+
ctrl.depth = 0
|
|
740
|
+
kind = "form" if (form is None and typ.rsplit(".", 1)[-1].endswith("Form")) else "control"
|
|
741
|
+
if kind == "form":
|
|
742
|
+
form = Form(name=name, type=typ, path=path)
|
|
743
|
+
stack.append(_Frame(kind="form", indent=indent, line=lineno))
|
|
744
|
+
continue
|
|
745
|
+
if form is None:
|
|
746
|
+
warn(lineno, line, "control before any form block")
|
|
747
|
+
all_controls.append(ctrl)
|
|
748
|
+
if parent is None and form is not None:
|
|
749
|
+
form.roots.append(ctrl)
|
|
750
|
+
stack.append(_Frame(kind="control", indent=indent, line=lineno, control=ctrl))
|
|
751
|
+
continue
|
|
752
|
+
|
|
753
|
+
if RE_END_PROPERTY.match(line):
|
|
754
|
+
indent = len(RE_END_PROPERTY.match(line).group("indent").expandtabs(4))
|
|
755
|
+
if stack and stack[-1].kind == "group":
|
|
756
|
+
stack.pop()
|
|
757
|
+
else:
|
|
758
|
+
warn(lineno, line, "EndProperty without BeginProperty")
|
|
759
|
+
continue
|
|
760
|
+
|
|
761
|
+
if RE_BEGIN_PROPERTY.match(line):
|
|
762
|
+
indent = len(RE_BEGIN_PROPERTY.match(line).group("indent").expandtabs(4))
|
|
763
|
+
grp = RE_BEGIN_PROPERTY.match(line)
|
|
764
|
+
stack.append(_Frame(kind="group", indent=indent, line=lineno,
|
|
765
|
+
group=grp.group("type")))
|
|
766
|
+
continue
|
|
767
|
+
|
|
768
|
+
if RE_END.match(line):
|
|
769
|
+
indent = len(RE_END.match(line).group("indent").expandtabs(4))
|
|
770
|
+
if not stack:
|
|
771
|
+
warn(lineno, line, "End without Begin")
|
|
772
|
+
continue
|
|
773
|
+
if stack[-1].kind == "group":
|
|
774
|
+
warn(lineno, line, "End inside BeginProperty group")
|
|
775
|
+
stack.pop()
|
|
776
|
+
# Pop the innermost frame at this indent level.
|
|
777
|
+
while stack and stack[-1].indent > indent:
|
|
778
|
+
warn(lineno, line, f"forced close of {stack[-1].kind} "
|
|
779
|
+
f"from line {stack[-1].line}")
|
|
780
|
+
stack.pop()
|
|
781
|
+
closed = stack.pop() if stack else None
|
|
782
|
+
# The root form's `End` is the authoritative end of the design block:
|
|
783
|
+
# measured, the code that follows does not always start with
|
|
784
|
+
# `Attribute VB_Name` (one file continues with `Public mblnAktif As
|
|
785
|
+
# Boolean` and even a disassembly listing), so waiting for the
|
|
786
|
+
# Attribute line would swallow code into the design block.
|
|
787
|
+
if closed is not None and closed.kind == "form":
|
|
788
|
+
break
|
|
789
|
+
continue
|
|
790
|
+
|
|
791
|
+
m = RE_PROP.match(line)
|
|
792
|
+
if m:
|
|
793
|
+
if not stack:
|
|
794
|
+
# Header lines before `Begin VB.Form` (`VERSION`, `Object`) are
|
|
795
|
+
# not form properties; ignore them silently.
|
|
796
|
+
continue
|
|
797
|
+
frame = stack[-1]
|
|
798
|
+
base = m.group("name")
|
|
799
|
+
param = int(m.group("param")) if m.group("param") else None
|
|
800
|
+
if frame.kind == "group":
|
|
801
|
+
# `BeginProperty Font ... EndProperty` members belong to the
|
|
802
|
+
# control that owns the group, which is further down the stack.
|
|
803
|
+
owner = next((f.control for f in reversed(stack)
|
|
804
|
+
if f.kind == "control"), None)
|
|
805
|
+
key = f"{frame.group}.{base}"
|
|
806
|
+
else:
|
|
807
|
+
owner = frame.control if frame.kind == "control" else None
|
|
808
|
+
# Indexed properties (`TabCaption(0)`, `TabCaption(1)`, ...) keep
|
|
809
|
+
# their index in the key: keying by name alone would let page 1
|
|
810
|
+
# overwrite page 0 and report a single page.
|
|
811
|
+
key = base if param is None else f"{base}({param})"
|
|
812
|
+
if frame.kind == "form" and form is not None:
|
|
813
|
+
owner = None
|
|
814
|
+
value_text, comment = _split_comment(m.group("value"))
|
|
815
|
+
vstart = line.find(m.group("value"))
|
|
816
|
+
vend = vstart + len(value_text) if vstart >= 0 else -1
|
|
817
|
+
if raw_lines and lineno - 1 < len(raw_lines) and vstart >= 0:
|
|
818
|
+
vbytes = raw_lines[lineno - 1][vstart:vend]
|
|
819
|
+
else:
|
|
820
|
+
vbytes = value_text.encode(FRM_ENCODING, "replace")
|
|
821
|
+
value, kind = _parse_value(value_text, comment)
|
|
822
|
+
if kind == KIND_UNKNOWN:
|
|
823
|
+
warn(lineno, line, "unrecognised property value")
|
|
824
|
+
prop = Property(name=base, value=value, kind=kind,
|
|
825
|
+
raw=value_text, raw_bytes=vbytes, comment=comment,
|
|
826
|
+
param=param, line=lineno)
|
|
827
|
+
if owner is not None:
|
|
828
|
+
owner.props[key] = prop
|
|
829
|
+
elif frame.kind == "form" and form is not None:
|
|
830
|
+
form.props[key] = prop
|
|
831
|
+
elif frame.kind == "group":
|
|
832
|
+
warn(lineno, line, "property group outside a control")
|
|
833
|
+
continue
|
|
834
|
+
|
|
835
|
+
if form is None:
|
|
836
|
+
# Everything before the `Begin VB.Form` line is file header
|
|
837
|
+
# (`VERSION 5.00`, `Object = "{guid}"; "path"`). It is not design
|
|
838
|
+
# data, so it is skipped without a warning.
|
|
839
|
+
continue
|
|
840
|
+
# A line inside an open form that is neither Begin/End/property/comment
|
|
841
|
+
# means the design block stopped without its closing `End` — a truncated
|
|
842
|
+
# or hand-edited file. Reported once, with the recovery count, instead of
|
|
843
|
+
# one warning per code line: measured case, `formRec01` ends inside a
|
|
844
|
+
# `Begin VB.PictureBox` and continues straight into `Public mblnAktif As
|
|
845
|
+
# Boolean` and a disassembly listing.
|
|
846
|
+
warn(lineno, line, f"design block ended without closing End at line {lineno}; "
|
|
847
|
+
f"{len(all_controls)} control(s) recovered before it")
|
|
848
|
+
truncated = True
|
|
849
|
+
break
|
|
850
|
+
|
|
851
|
+
if form is None:
|
|
852
|
+
raise NoDesignBlock(f"{path or '<text>'}: no Begin ...Form design block")
|
|
853
|
+
form.complete = not truncated
|
|
854
|
+
if not truncated and any(f.kind != "form" for f in stack):
|
|
855
|
+
# Reaching the end of the text with blocks still open means the closing
|
|
856
|
+
# `End`s are missing; the controls found are kept and the file is flagged.
|
|
857
|
+
form.complete = False
|
|
858
|
+
warn(len(lines), "<eof>", f"{sum(1 for f in stack if f.kind != 'form')} block(s) "
|
|
859
|
+
"still open at end of design block")
|
|
860
|
+
form.controls = all_controls
|
|
861
|
+
form.warnings = warnings
|
|
862
|
+
form.incomplete_reason = warnings[-1].reason if (warnings and not form.complete) else None
|
|
863
|
+
return form
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def parse_bytes(data: bytes, *, path: str | None = None) -> Form:
|
|
867
|
+
"""Decode ``data`` as CP1254 and parse its design block.
|
|
868
|
+
|
|
869
|
+
The whole file is decoded, not just the design block: finding the block
|
|
870
|
+
boundary requires scanning to ``Attribute``, and byte offsets must stay in
|
|
871
|
+
step with character offsets (true for a single-byte codec like CP1254).
|
|
872
|
+
"""
|
|
873
|
+
return parse_frm_text(decode_cp1254(data), path=path, raw=data)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def parse_frm(path: str) -> Form:
|
|
877
|
+
"""Parse one ``.frm`` file from disk. Raises :class:`NoDesignBlock` if it has none."""
|
|
878
|
+
with open(path, "rb") as fh:
|
|
879
|
+
data = fh.read()
|
|
880
|
+
return parse_bytes(data, path=path)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
# --------------------------------------------------------------------------- directory
|
|
884
|
+
@dataclass
|
|
885
|
+
class ScanResult:
|
|
886
|
+
"""Outcome of :func:`scan_directory`. Nothing is dropped silently.
|
|
887
|
+
|
|
888
|
+
* ``forms`` — parsed design blocks.
|
|
889
|
+
* ``no_design`` — files with no design block (code-only stubs). Listed by
|
|
890
|
+
path, not skipped: "no design block" is a measurement, not a failure of the
|
|
891
|
+
parser, and must not be confused with "form with zero controls".
|
|
892
|
+
* ``failures`` — ``(path, message)`` for files that *did* open a design block
|
|
893
|
+
and could not be finished.
|
|
894
|
+
* ``warnings`` — per-file line-level problems, kept with their file names.
|
|
895
|
+
"""
|
|
896
|
+
|
|
897
|
+
root: str
|
|
898
|
+
files_seen: int = 0
|
|
899
|
+
forms: list[Form] = field(default_factory=list)
|
|
900
|
+
no_design: list[str] = field(default_factory=list)
|
|
901
|
+
failures: list[tuple[str, str]] = field(default_factory=list)
|
|
902
|
+
warnings: list[ParseWarning] = field(default_factory=list)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _frm_files(root: str, recursive: bool = False) -> list[str]:
|
|
906
|
+
out: list[str] = []
|
|
907
|
+
if recursive:
|
|
908
|
+
for dirpath, _dirnames, filenames in os.walk(root):
|
|
909
|
+
for fn in filenames:
|
|
910
|
+
if fn.lower().endswith(".frm"):
|
|
911
|
+
out.append(os.path.join(dirpath, fn))
|
|
912
|
+
else:
|
|
913
|
+
for fn in os.listdir(root):
|
|
914
|
+
p = os.path.join(root, fn)
|
|
915
|
+
if fn.lower().endswith(".frm") and os.path.isfile(p):
|
|
916
|
+
out.append(p)
|
|
917
|
+
return sorted(out)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def scan_directory(root: str, *, recursive: bool = False) -> ScanResult:
|
|
921
|
+
"""Parse every ``.frm`` under ``root`` and account for all of them."""
|
|
922
|
+
res = ScanResult(root=root)
|
|
923
|
+
for path in _frm_files(root, recursive):
|
|
924
|
+
res.files_seen += 1
|
|
925
|
+
try:
|
|
926
|
+
form = parse_frm(path)
|
|
927
|
+
except NoDesignBlock:
|
|
928
|
+
res.no_design.append(path)
|
|
929
|
+
continue
|
|
930
|
+
except (FrmError, OSError) as exc: # MalformedForm / unreadable file
|
|
931
|
+
res.failures.append((path, str(exc)))
|
|
932
|
+
continue
|
|
933
|
+
res.forms.append(form)
|
|
934
|
+
res.warnings.extend(form.warnings)
|
|
935
|
+
return res
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
# --------------------------------------------------------------------------- reporting
|
|
939
|
+
def bool_tri_counts(controls: Iterable[Control], name: str) -> dict[str, int]:
|
|
940
|
+
"""Count ``true`` / ``false`` / ``absent`` for one boolean property.
|
|
941
|
+
|
|
942
|
+
Three states, three numbers, always summing to the denominator the caller
|
|
943
|
+
supplied. Collapsing ``absent`` into ``false`` is the single biggest way to
|
|
944
|
+
get a VB6 design wrong, so it has no shorthand here.
|
|
945
|
+
"""
|
|
946
|
+
counts = {TRUE_EXPLICIT: 0, FALSE_EXPLICIT: 0, ABSENT: 0}
|
|
947
|
+
for c in controls:
|
|
948
|
+
counts[c.tri(name)] += 1
|
|
949
|
+
return counts
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def twips_to_px(value: float, twips_per_px: int = TWIPS_PER_PX_ASSUMED) -> float:
|
|
953
|
+
"""Convert twips to pixels using the **assumed** 15 twips/px scale."""
|
|
954
|
+
if twips_per_px <= 0:
|
|
955
|
+
raise ValueError("twips_per_px must be positive")
|
|
956
|
+
return value / twips_per_px
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def summarize(res: ScanResult) -> dict[str, object]:
|
|
960
|
+
"""Turn a :class:`ScanResult` into counts. Every count carries its denominator."""
|
|
961
|
+
controls = [c for f in res.forms for c in f.controls]
|
|
962
|
+
by_type: dict[str, int] = {}
|
|
963
|
+
for c in controls:
|
|
964
|
+
by_type[c.short_type] = by_type.get(c.short_type, 0) + 1
|
|
965
|
+
|
|
966
|
+
forms_with_arrays = 0
|
|
967
|
+
array_groups = 0
|
|
968
|
+
array_elements = 0
|
|
969
|
+
arrays_with_gaps = 0
|
|
970
|
+
for f in res.forms:
|
|
971
|
+
arrays = f.control_arrays()
|
|
972
|
+
if arrays:
|
|
973
|
+
forms_with_arrays += 1
|
|
974
|
+
array_groups += len(arrays)
|
|
975
|
+
for members in arrays.values():
|
|
976
|
+
array_elements += len(members)
|
|
977
|
+
idx = sorted(m.index for m in members if m.index is not None)
|
|
978
|
+
if idx and idx != list(range(len(idx))):
|
|
979
|
+
arrays_with_gaps += 1
|
|
980
|
+
|
|
981
|
+
sstab_controls = [c for c in controls if c.short_type == "SSTab"]
|
|
982
|
+
sstab_forms = [f for f in res.forms if f.sstabs()]
|
|
983
|
+
sstab_children = 0
|
|
984
|
+
pages_with_caption = 0
|
|
985
|
+
pages_without_caption = 0
|
|
986
|
+
selected_written = 0
|
|
987
|
+
for f in res.forms:
|
|
988
|
+
for s in f.sstabs():
|
|
989
|
+
sstab_children += len(f.sstab_children(s.name))
|
|
990
|
+
for _idx, cap in s.tab_pages():
|
|
991
|
+
if cap:
|
|
992
|
+
pages_with_caption += 1
|
|
993
|
+
else:
|
|
994
|
+
pages_without_caption += 1
|
|
995
|
+
if s.selected_page is not None:
|
|
996
|
+
selected_written += 1
|
|
997
|
+
|
|
998
|
+
replacement_captions = sum(
|
|
999
|
+
1 for c in controls
|
|
1000
|
+
if isinstance(c.caption, str) and "\ufffd" in c.caption
|
|
1001
|
+
)
|
|
1002
|
+
# A Caption whose value is a `.frx` reference (`Caption = "fX.frx":14A4`) is
|
|
1003
|
+
# stored as binary, usually a Unicode/exotic caption; the text is not in the
|
|
1004
|
+
# design block, so it is counted here instead of being reported as "no caption".
|
|
1005
|
+
captions_in_frx = sum(
|
|
1006
|
+
1 for c in controls
|
|
1007
|
+
if c.prop("Caption") is not None and c.prop("Caption").kind == KIND_FRX # type: ignore[union-attr]
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
client_sizes = sum(1 for f in res.forms if f.client_size is not None)
|
|
1011
|
+
incomplete = [f for f in res.forms if not f.complete]
|
|
1012
|
+
|
|
1013
|
+
return {
|
|
1014
|
+
"directory": res.root,
|
|
1015
|
+
"files_seen": res.files_seen,
|
|
1016
|
+
"files_parsed": len(res.forms),
|
|
1017
|
+
"files_without_design_block": len(res.no_design),
|
|
1018
|
+
"files_without_design_block_paths": list(res.no_design),
|
|
1019
|
+
"parse_failures": len(res.failures),
|
|
1020
|
+
"parse_failure_details": list(res.failures),
|
|
1021
|
+
"controls_total": len(controls),
|
|
1022
|
+
"controls_with_bounds": sum(1 for c in controls if c.has_bounds),
|
|
1023
|
+
"controls_by_type": dict(sorted(by_type.items(), key=lambda kv: -kv[1])),
|
|
1024
|
+
"forms_with_control_arrays": forms_with_arrays,
|
|
1025
|
+
"control_array_groups": array_groups,
|
|
1026
|
+
"control_array_elements": array_elements,
|
|
1027
|
+
"control_arrays_with_index_gaps": arrays_with_gaps,
|
|
1028
|
+
"sstab_controls": len(sstab_controls),
|
|
1029
|
+
"sstab_forms": len(sstab_forms),
|
|
1030
|
+
"sstab_children": sstab_children,
|
|
1031
|
+
"sstab_pages_with_caption": pages_with_caption,
|
|
1032
|
+
"sstab_pages_without_caption": pages_without_caption,
|
|
1033
|
+
"sstab_selected_page_written": selected_written,
|
|
1034
|
+
"forms_with_client_size": client_sizes,
|
|
1035
|
+
"forms_incomplete": len(incomplete),
|
|
1036
|
+
"incomplete_forms": [(f.path, f.incomplete_reason) for f in incomplete],
|
|
1037
|
+
"captions_containing_replacement_char": replacement_captions,
|
|
1038
|
+
"captions_stored_in_frx": captions_in_frx,
|
|
1039
|
+
"bool_tristate": {
|
|
1040
|
+
name: bool_tri_counts(controls, name)
|
|
1041
|
+
for name in ("Visible", "Enabled", "Locked", "MultiLine")
|
|
1042
|
+
},
|
|
1043
|
+
"warnings": len(res.warnings),
|
|
1044
|
+
"warning_examples": [str(w) for w in res.warnings[:10]],
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def format_summary(summary: dict[str, object]) -> str:
|
|
1049
|
+
"""Render :func:`summarize` output as text with denominators on every line."""
|
|
1050
|
+
s = summary
|
|
1051
|
+
lines = [
|
|
1052
|
+
f"directory: {s['directory']}",
|
|
1053
|
+
f"files: {s['files_parsed']} parsed / {s['files_seen']} seen "
|
|
1054
|
+
f"({s['files_without_design_block']} without design block, "
|
|
1055
|
+
f"{s['parse_failures']} failures)",
|
|
1056
|
+
f"controls: {s['controls_total']} "
|
|
1057
|
+
f"({s['controls_with_bounds']} with written Left/Top/Width/Height)",
|
|
1058
|
+
f"forms with control arrays: {s['forms_with_control_arrays']} / {s['files_parsed']}",
|
|
1059
|
+
f"control arrays: {s['control_array_groups']} groups, "
|
|
1060
|
+
f"{s['control_array_elements']} elements "
|
|
1061
|
+
f"({s['control_arrays_with_index_gaps']} with non-contiguous Index)",
|
|
1062
|
+
f"SSTab: {s['sstab_controls']} controls in {s['sstab_forms']} forms, "
|
|
1063
|
+
f"{s['sstab_children']} nested children, pages written: "
|
|
1064
|
+
f"{s['sstab_pages_with_caption']} with caption + "
|
|
1065
|
+
f"{s['sstab_pages_without_caption']} without "
|
|
1066
|
+
f"(Tab= written on {s['sstab_selected_page_written']})",
|
|
1067
|
+
f"forms writing ClientWidth+ClientHeight: {s['forms_with_client_size']} / {s['files_parsed']}",
|
|
1068
|
+
f"incomplete design blocks (no closing End): {s['forms_incomplete']} / {s['files_parsed']}",
|
|
1069
|
+
f"captions containing U+FFFD after CP1254 decode: "
|
|
1070
|
+
f"{s['captions_containing_replacement_char']} / {s['controls_total']}",
|
|
1071
|
+
f"captions stored in .frx (text not in the design block): "
|
|
1072
|
+
f"{s['captions_stored_in_frx']} / {s['controls_total']}",
|
|
1073
|
+
]
|
|
1074
|
+
tri = s["bool_tristate"]
|
|
1075
|
+
for name in ("Visible", "Enabled", "Locked", "MultiLine"):
|
|
1076
|
+
c = tri[name] # type: ignore[index]
|
|
1077
|
+
lines.append(
|
|
1078
|
+
f" {name}: true={c[TRUE_EXPLICIT]} false={c[FALSE_EXPLICIT]} "
|
|
1079
|
+
f"absent={c[ABSENT]} of {s['controls_total']}"
|
|
1080
|
+
)
|
|
1081
|
+
if s["parse_failures"]:
|
|
1082
|
+
lines.append("parse failures:")
|
|
1083
|
+
lines.extend(f" {p}: {msg}" for p, msg in s["parse_failure_details"]) # type: ignore[union-attr]
|
|
1084
|
+
if s["warnings"]:
|
|
1085
|
+
lines.append(f"warnings: {s['warnings']} (first {min(10, s['warnings'])})")
|
|
1086
|
+
lines.extend(f" {w}" for w in s["warning_examples"]) # type: ignore[union-attr]
|
|
1087
|
+
return "\n".join(lines)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1091
|
+
"""``python -m vb6decompiler.frm <dir> [--recursive] [--json]`` — coverage report."""
|
|
1092
|
+
import argparse
|
|
1093
|
+
import json
|
|
1094
|
+
|
|
1095
|
+
ap = argparse.ArgumentParser(description="Parse VB6 .frm design blocks and report coverage.")
|
|
1096
|
+
ap.add_argument("directory", help="directory containing .frm files")
|
|
1097
|
+
ap.add_argument("--recursive", action="store_true", help="walk subdirectories")
|
|
1098
|
+
ap.add_argument("--json", action="store_true", help="emit JSON instead of text")
|
|
1099
|
+
args = ap.parse_args(argv)
|
|
1100
|
+
res = scan_directory(args.directory, recursive=args.recursive)
|
|
1101
|
+
s = summarize(res)
|
|
1102
|
+
print(json.dumps(s, indent=1, ensure_ascii=False) if args.json else format_summary(s))
|
|
1103
|
+
return 0 if not res.failures else 1
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
if __name__ == "__main__": # pragma: no cover
|
|
1107
|
+
raise SystemExit(main())
|