msdoc2docx 0.7.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.
doc2docx/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Public API for doc2docx."""
2
+
3
+ from .converter import ConversionResult, convert, inspect_doc
4
+
5
+ __all__ = ["ConversionResult", "convert", "inspect_doc"]
6
+ __version__ = "0.7.0"
doc2docx/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
4
+
@@ -0,0 +1,4 @@
1
+ from .reader import BinaryReader
2
+
3
+ __all__ = ["BinaryReader"]
4
+
@@ -0,0 +1,87 @@
1
+ """Small, bounded little-endian binary reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+
7
+ from ..errors import BinaryBoundsError
8
+
9
+
10
+ class BinaryReader:
11
+ def __init__(
12
+ self,
13
+ data: bytes | bytearray | memoryview,
14
+ *,
15
+ label: str = "binary data",
16
+ base_offset: int = 0,
17
+ ) -> None:
18
+ self._data = memoryview(data).cast("B")
19
+ self._position = 0
20
+ self.label = label
21
+ self.base_offset = base_offset
22
+
23
+ def __len__(self) -> int:
24
+ return len(self._data)
25
+
26
+ @property
27
+ def position(self) -> int:
28
+ return self._position
29
+
30
+ @property
31
+ def remaining(self) -> int:
32
+ return len(self._data) - self._position
33
+
34
+ def _check(self, offset: int, size: int) -> None:
35
+ if offset < 0 or size < 0 or offset > len(self._data) - size:
36
+ absolute = self.base_offset + max(offset, 0)
37
+ raise BinaryBoundsError(
38
+ f"{self.label}: read of {size} bytes at offset 0x{absolute:X} "
39
+ f"exceeds {len(self._data)}-byte structure"
40
+ )
41
+
42
+ def seek(self, offset: int) -> None:
43
+ self._check(offset, 0)
44
+ self._position = offset
45
+
46
+ def skip(self, size: int) -> None:
47
+ self.seek(self._position + size)
48
+
49
+ def read(self, size: int) -> bytes:
50
+ self._check(self._position, size)
51
+ start = self._position
52
+ self._position += size
53
+ return self._data[start : start + size].tobytes()
54
+
55
+ def read_at(self, offset: int, size: int) -> bytes:
56
+ self._check(offset, size)
57
+ return self._data[offset : offset + size].tobytes()
58
+
59
+ def subreader(self, offset: int, size: int, *, label: str | None = None) -> "BinaryReader":
60
+ self._check(offset, size)
61
+ return BinaryReader(
62
+ self._data[offset : offset + size],
63
+ label=label or self.label,
64
+ base_offset=self.base_offset + offset,
65
+ )
66
+
67
+ def _unpack(self, fmt: str, size: int) -> int:
68
+ self._check(self._position, size)
69
+ value = struct.unpack_from(fmt, self._data, self._position)[0]
70
+ self._position += size
71
+ return int(value)
72
+
73
+ def u8(self) -> int:
74
+ return self._unpack("<B", 1)
75
+
76
+ def u16(self) -> int:
77
+ return self._unpack("<H", 2)
78
+
79
+ def u32(self) -> int:
80
+ return self._unpack("<I", 4)
81
+
82
+ def i32(self) -> int:
83
+ return self._unpack("<i", 4)
84
+
85
+ def u64(self) -> int:
86
+ return self._unpack("<Q", 8)
87
+
@@ -0,0 +1,5 @@
1
+ from .container import CompoundFile, CompoundFileLimits
2
+ from .directory import DirectoryEntry, ObjectType
3
+
4
+ __all__ = ["CompoundFile", "CompoundFileLimits", "DirectoryEntry", "ObjectType"]
5
+
@@ -0,0 +1,9 @@
1
+ CFB_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1")
2
+
3
+ MAXREGSECT = 0xFFFFFFFA
4
+ DIFSECT = 0xFFFFFFFC
5
+ FATSECT = 0xFFFFFFFD
6
+ ENDOFCHAIN = 0xFFFFFFFE
7
+ FREESECT = 0xFFFFFFFF
8
+ NOSTREAM = 0xFFFFFFFF
9
+
@@ -0,0 +1,370 @@
1
+ """Safe, read-only CFB/OLE compound-file reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from pathlib import Path
7
+ import struct
8
+
9
+ from .constants import (
10
+ DIFSECT,
11
+ ENDOFCHAIN,
12
+ FATSECT,
13
+ FREESECT,
14
+ MAXREGSECT,
15
+ NOSTREAM,
16
+ )
17
+ from .directory import DirectoryEntry, ObjectType
18
+ from .header import CompoundFileHeader
19
+ from ..errors import InvalidCompoundFile, StreamNotFound
20
+
21
+
22
+ @dataclass(slots=True, frozen=True)
23
+ class CompoundFileLimits:
24
+ max_input_bytes: int = 512 * 1024 * 1024
25
+ max_stream_bytes: int = 256 * 1024 * 1024
26
+ max_directory_entries: int = 1_000_000
27
+
28
+
29
+ class CompoundFile:
30
+ def __init__(
31
+ self,
32
+ data: bytes | bytearray | memoryview,
33
+ *,
34
+ limits: CompoundFileLimits | None = None,
35
+ ) -> None:
36
+ self.limits = limits or CompoundFileLimits()
37
+ if len(data) > self.limits.max_input_bytes:
38
+ raise InvalidCompoundFile(
39
+ f"input is {len(data)} bytes, exceeding the configured "
40
+ f"{self.limits.max_input_bytes}-byte limit"
41
+ )
42
+ self._data = memoryview(data).cast("B")
43
+ self.header = CompoundFileHeader.parse(self._data)
44
+ if len(self._data) < self.header.sector_size:
45
+ raise InvalidCompoundFile("CFB file is shorter than one header sector")
46
+ payload_size = len(self._data) - self.header.sector_size
47
+ if payload_size % self.header.sector_size:
48
+ raise InvalidCompoundFile(
49
+ "CFB file length is not aligned to its declared sector size"
50
+ )
51
+ self._sector_count = payload_size // self.header.sector_size
52
+ if self._sector_count == 0:
53
+ raise InvalidCompoundFile("CFB file contains no sectors")
54
+ if self.header.number_of_fat_sectors > self._sector_count:
55
+ raise InvalidCompoundFile("declared FAT sector count exceeds file size")
56
+ if self.header.number_of_difat_sectors > self._sector_count:
57
+ raise InvalidCompoundFile("declared DIFAT sector count exceeds file size")
58
+
59
+ self._fat_sector_ids = self._load_difat()
60
+ self._fat = self._load_fat()
61
+ self._directory_entries = self._load_directory()
62
+ self._entries_by_path = self._index_directory_tree()
63
+ self._mini_fat = self._load_mini_fat()
64
+ self._mini_stream: bytes | None = None
65
+
66
+ @classmethod
67
+ def from_path(
68
+ cls,
69
+ path: str | Path,
70
+ *,
71
+ limits: CompoundFileLimits | None = None,
72
+ ) -> "CompoundFile":
73
+ file_path = Path(path)
74
+ actual_limits = limits or CompoundFileLimits()
75
+ size = file_path.stat().st_size
76
+ if size > actual_limits.max_input_bytes:
77
+ raise InvalidCompoundFile(
78
+ f"input is {size} bytes, exceeding the configured "
79
+ f"{actual_limits.max_input_bytes}-byte limit"
80
+ )
81
+ return cls(file_path.read_bytes(), limits=actual_limits)
82
+
83
+ @property
84
+ def sector_count(self) -> int:
85
+ return self._sector_count
86
+
87
+ @property
88
+ def entries(self) -> tuple[DirectoryEntry, ...]:
89
+ return tuple(self._entries_by_path.values())
90
+
91
+ def _sector(self, sector_id: int) -> memoryview:
92
+ if sector_id > MAXREGSECT or sector_id >= self._sector_count:
93
+ raise InvalidCompoundFile(f"sector {sector_id} is outside the CFB file")
94
+ start = (sector_id + 1) * self.header.sector_size
95
+ return self._data[start : start + self.header.sector_size]
96
+
97
+ def _load_difat(self) -> list[int]:
98
+ fat_sector_ids = [sid for sid in self.header.difat if sid != FREESECT]
99
+ next_sector = self.header.first_difat_sector
100
+ seen: set[int] = set()
101
+ entries_per_sector = self.header.sector_size // 4 - 1
102
+
103
+ for _ in range(self.header.number_of_difat_sectors):
104
+ if next_sector in seen:
105
+ raise InvalidCompoundFile("cycle detected in DIFAT sector chain")
106
+ seen.add(next_sector)
107
+ sector = self._sector(next_sector)
108
+ values = struct.unpack_from(
109
+ f"<{entries_per_sector + 1}I", sector, 0
110
+ )
111
+ fat_sector_ids.extend(
112
+ sid for sid in values[:entries_per_sector] if sid != FREESECT
113
+ )
114
+ next_sector = values[-1]
115
+
116
+ if self.header.number_of_difat_sectors and next_sector != ENDOFCHAIN:
117
+ raise InvalidCompoundFile("DIFAT chain does not end with ENDOFCHAIN")
118
+ if len(fat_sector_ids) < self.header.number_of_fat_sectors:
119
+ raise InvalidCompoundFile(
120
+ "DIFAT does not contain the declared number of FAT sectors"
121
+ )
122
+ fat_sector_ids = fat_sector_ids[: self.header.number_of_fat_sectors]
123
+ if len(set(fat_sector_ids)) != len(fat_sector_ids):
124
+ raise InvalidCompoundFile("DIFAT contains duplicate FAT sector IDs")
125
+ for sector_id in fat_sector_ids:
126
+ self._sector(sector_id)
127
+ return fat_sector_ids
128
+
129
+ def _load_fat(self) -> tuple[int, ...]:
130
+ values: list[int] = []
131
+ entries_per_sector = self.header.sector_size // 4
132
+ for sector_id in self._fat_sector_ids:
133
+ sector = self._sector(sector_id)
134
+ values.extend(struct.unpack_from(f"<{entries_per_sector}I", sector, 0))
135
+ if len(values) < self._sector_count:
136
+ raise InvalidCompoundFile("FAT does not cover every sector in the file")
137
+ for sector_id in self._fat_sector_ids:
138
+ if values[sector_id] != FATSECT:
139
+ raise InvalidCompoundFile(
140
+ f"FAT sector {sector_id} is not marked FATSECT in the FAT"
141
+ )
142
+ return tuple(values)
143
+
144
+ def _chain(
145
+ self,
146
+ start_sector: int,
147
+ allocation_table: tuple[int, ...],
148
+ *,
149
+ label: str,
150
+ expected_sectors: int | None = None,
151
+ ) -> list[int]:
152
+ if start_sector == ENDOFCHAIN:
153
+ if expected_sectors in (None, 0):
154
+ return []
155
+ raise InvalidCompoundFile(f"{label} is empty but data was expected")
156
+ if start_sector in (FREESECT, FATSECT, DIFSECT) or start_sector > MAXREGSECT:
157
+ raise InvalidCompoundFile(
158
+ f"{label} starts at invalid sector marker 0x{start_sector:08X}"
159
+ )
160
+
161
+ result: list[int] = []
162
+ seen: set[int] = set()
163
+ current = start_sector
164
+ hard_limit = len(allocation_table) + 1
165
+ while current != ENDOFCHAIN:
166
+ if current in seen:
167
+ raise InvalidCompoundFile(f"cycle detected in {label} sector chain")
168
+ if current >= len(allocation_table) or current > MAXREGSECT:
169
+ raise InvalidCompoundFile(
170
+ f"{label} references sector {current} outside its allocation table"
171
+ )
172
+ seen.add(current)
173
+ result.append(current)
174
+ if len(result) > hard_limit:
175
+ raise InvalidCompoundFile(f"{label} sector chain is unbounded")
176
+ current = allocation_table[current]
177
+ if current in (FREESECT, FATSECT, DIFSECT):
178
+ raise InvalidCompoundFile(
179
+ f"{label} chain terminates with invalid marker 0x{current:08X}"
180
+ )
181
+
182
+ if expected_sectors is not None and len(result) < expected_sectors:
183
+ raise InvalidCompoundFile(
184
+ f"{label} has {len(result)} sectors but needs {expected_sectors}"
185
+ )
186
+ return result
187
+
188
+ def _read_regular_stream(
189
+ self, start_sector: int, size: int, *, label: str
190
+ ) -> bytes:
191
+ if size < 0 or size > self.limits.max_stream_bytes:
192
+ raise InvalidCompoundFile(
193
+ f"{label} size {size} exceeds the configured stream limit"
194
+ )
195
+ if size == 0:
196
+ return b""
197
+ required = (size + self.header.sector_size - 1) // self.header.sector_size
198
+ chain = self._chain(
199
+ start_sector,
200
+ self._fat,
201
+ label=label,
202
+ expected_sectors=required,
203
+ )
204
+ return b"".join(self._sector(sid) for sid in chain[:required])[:size]
205
+
206
+ def _load_directory(self) -> list[DirectoryEntry]:
207
+ expected = (
208
+ self.header.number_of_directory_sectors
209
+ if self.header.major_version == 4
210
+ else None
211
+ )
212
+ chain = self._chain(
213
+ self.header.first_directory_sector,
214
+ self._fat,
215
+ label="directory",
216
+ expected_sectors=expected,
217
+ )
218
+ if expected is not None:
219
+ chain = chain[:expected]
220
+ raw = b"".join(self._sector(sid) for sid in chain)
221
+ count = len(raw) // 128
222
+ if count > self.limits.max_directory_entries:
223
+ raise InvalidCompoundFile(
224
+ f"directory contains {count} entries, exceeding configured limit"
225
+ )
226
+ entries = [
227
+ DirectoryEntry.parse(
228
+ raw[index * 128 : (index + 1) * 128],
229
+ index,
230
+ major_version=self.header.major_version,
231
+ )
232
+ for index in range(count)
233
+ ]
234
+ if not entries or entries[0].object_type is not ObjectType.ROOT_STORAGE:
235
+ raise InvalidCompoundFile("directory entry 0 is not the root storage")
236
+ return entries
237
+
238
+ def _index_directory_tree(self) -> dict[str, DirectoryEntry]:
239
+ entries = self._directory_entries
240
+ indexed: dict[str, DirectoryEntry] = {}
241
+ visited: set[int] = set()
242
+
243
+ def validate_id(entry_id: int, relation: str) -> None:
244
+ if entry_id == NOSTREAM:
245
+ return
246
+ if entry_id >= len(entries):
247
+ raise InvalidCompoundFile(
248
+ f"directory {relation} ID {entry_id} is out of range"
249
+ )
250
+
251
+ def walk_sibling_tree(entry_id: int, parent: str, depth: int = 0) -> None:
252
+ if entry_id == NOSTREAM:
253
+ return
254
+ if depth > 128:
255
+ raise InvalidCompoundFile("directory tree exceeds safe nesting depth")
256
+ validate_id(entry_id, "entry")
257
+ if entry_id in visited:
258
+ raise InvalidCompoundFile("cycle or duplicate detected in directory tree")
259
+ visited.add(entry_id)
260
+ entry = entries[entry_id]
261
+ if entry.object_type is ObjectType.UNALLOCATED:
262
+ raise InvalidCompoundFile(
263
+ "directory tree references an unallocated entry"
264
+ )
265
+ validate_id(entry.left_sibling_id, "left sibling")
266
+ validate_id(entry.right_sibling_id, "right sibling")
267
+ validate_id(entry.child_id, "child")
268
+
269
+ walk_sibling_tree(entry.left_sibling_id, parent, depth + 1)
270
+ path = f"{parent}/{entry.name}" if parent else entry.name
271
+ if path in indexed:
272
+ raise InvalidCompoundFile(f"duplicate directory path {path!r}")
273
+ indexed[path] = replace(entry, path=path)
274
+ if entry.object_type is ObjectType.STORAGE:
275
+ walk_sibling_tree(entry.child_id, path, depth + 1)
276
+ elif entry.child_id != NOSTREAM:
277
+ raise InvalidCompoundFile(
278
+ f"non-storage directory entry {path!r} has a child"
279
+ )
280
+ walk_sibling_tree(entry.right_sibling_id, parent, depth + 1)
281
+
282
+ root = entries[0]
283
+ validate_id(root.child_id, "root child")
284
+ indexed[""] = replace(root, path="")
285
+ visited.add(0)
286
+ walk_sibling_tree(root.child_id, "")
287
+ return indexed
288
+
289
+ def _load_mini_fat(self) -> tuple[int, ...]:
290
+ count = self.header.number_of_mini_fat_sectors
291
+ if count == 0:
292
+ return ()
293
+ chain = self._chain(
294
+ self.header.first_mini_fat_sector,
295
+ self._fat,
296
+ label="mini FAT",
297
+ expected_sectors=count,
298
+ )[:count]
299
+ entries_per_sector = self.header.sector_size // 4
300
+ values: list[int] = []
301
+ for sector_id in chain:
302
+ values.extend(
303
+ struct.unpack_from(
304
+ f"<{entries_per_sector}I", self._sector(sector_id), 0
305
+ )
306
+ )
307
+ return tuple(values)
308
+
309
+ def _root_mini_stream(self) -> bytes:
310
+ if self._mini_stream is None:
311
+ root = self._directory_entries[0]
312
+ self._mini_stream = self._read_regular_stream(
313
+ root.starting_sector,
314
+ root.stream_size,
315
+ label="root mini stream",
316
+ )
317
+ return self._mini_stream
318
+
319
+ def _read_mini_stream(
320
+ self, start_sector: int, size: int, *, label: str
321
+ ) -> bytes:
322
+ if not self._mini_fat:
323
+ raise InvalidCompoundFile(f"{label} requires a mini FAT, but none exists")
324
+ if size > self.limits.max_stream_bytes:
325
+ raise InvalidCompoundFile(
326
+ f"{label} size {size} exceeds the configured stream limit"
327
+ )
328
+ required = (size + self.header.mini_sector_size - 1) // self.header.mini_sector_size
329
+ chain = self._chain(
330
+ start_sector,
331
+ self._mini_fat,
332
+ label=f"{label} mini",
333
+ expected_sectors=required,
334
+ )[:required]
335
+ mini_stream = self._root_mini_stream()
336
+ chunks: list[bytes] = []
337
+ for mini_sector in chain:
338
+ start = mini_sector * self.header.mini_sector_size
339
+ end = start + self.header.mini_sector_size
340
+ if end > len(mini_stream):
341
+ raise InvalidCompoundFile(
342
+ f"{label} mini sector {mini_sector} exceeds the root mini stream"
343
+ )
344
+ chunks.append(mini_stream[start:end])
345
+ return b"".join(chunks)[:size]
346
+
347
+ def get_entry(self, path: str) -> DirectoryEntry:
348
+ normalized = path.strip("/")
349
+ try:
350
+ return self._entries_by_path[normalized]
351
+ except KeyError as exc:
352
+ raise StreamNotFound(f"compound-file entry {path!r} was not found") from exc
353
+
354
+ def open_stream(self, path: str) -> bytes:
355
+ entry = self.get_entry(path)
356
+ if entry.object_type is not ObjectType.STREAM:
357
+ raise StreamNotFound(f"compound-file entry {path!r} is not a stream")
358
+ if entry.stream_size == 0:
359
+ return b""
360
+ if entry.stream_size < self.header.mini_stream_cutoff:
361
+ return self._read_mini_stream(
362
+ entry.starting_sector,
363
+ entry.stream_size,
364
+ label=f"stream {entry.path!r}",
365
+ )
366
+ return self._read_regular_stream(
367
+ entry.starting_sector,
368
+ entry.stream_size,
369
+ label=f"stream {entry.path!r}",
370
+ )
@@ -0,0 +1,135 @@
1
+ """CFB directory entry structures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import IntEnum
7
+ import struct
8
+
9
+ from .constants import NOSTREAM
10
+ from ..errors import InvalidCompoundFile
11
+
12
+
13
+ class ObjectType(IntEnum):
14
+ UNALLOCATED = 0
15
+ STORAGE = 1
16
+ STREAM = 2
17
+ ROOT_STORAGE = 5
18
+
19
+
20
+ @dataclass(slots=True, frozen=True)
21
+ class DirectoryEntry:
22
+ entry_id: int
23
+ name: str
24
+ object_type: ObjectType
25
+ color_flag: int
26
+ left_sibling_id: int
27
+ right_sibling_id: int
28
+ child_id: int
29
+ clsid: bytes
30
+ state_bits: int
31
+ creation_time: int
32
+ modified_time: int
33
+ starting_sector: int
34
+ stream_size: int
35
+ path: str = ""
36
+ name_was_repaired: bool = False
37
+
38
+ @classmethod
39
+ def parse(
40
+ cls, data: bytes | memoryview, entry_id: int, *, major_version: int
41
+ ) -> "DirectoryEntry":
42
+ view = memoryview(data)
43
+ if len(view) != 128:
44
+ raise InvalidCompoundFile("CFB directory entries must be 128 bytes")
45
+
46
+ name_length = struct.unpack_from("<H", view, 64)[0]
47
+ raw_type = view[66]
48
+ try:
49
+ object_type = ObjectType(raw_type)
50
+ except ValueError as exc:
51
+ raise InvalidCompoundFile(
52
+ f"directory entry {entry_id} has invalid object type {raw_type}"
53
+ ) from exc
54
+
55
+ name_was_repaired = False
56
+ if object_type is ObjectType.UNALLOCATED:
57
+ name = ""
58
+ else:
59
+ valid_length = 2 <= name_length <= 64 and name_length % 2 == 0
60
+ is_terminated = valid_length and (
61
+ view[name_length - 2 : name_length].tobytes() == b"\x00\x00"
62
+ )
63
+ if not valid_length or not is_terminated:
64
+ # Some Word-produced temporary documents contain a malformed root
65
+ # storage name/length. The root name is not used to resolve child
66
+ # streams, so normalizing only this entry is safe and improves real
67
+ # world compatibility without weakening child-stream validation.
68
+ if entry_id == 0 and object_type is ObjectType.ROOT_STORAGE:
69
+ name = "Root Entry"
70
+ name_was_repaired = True
71
+ elif not valid_length:
72
+ raise InvalidCompoundFile(
73
+ f"directory entry {entry_id} has invalid UTF-16 name length "
74
+ f"{name_length}"
75
+ )
76
+ else:
77
+ raise InvalidCompoundFile(
78
+ f"directory entry {entry_id} name is not null terminated"
79
+ )
80
+ else:
81
+ raw_name = view[: name_length - 2].tobytes()
82
+ try:
83
+ name = raw_name.decode("utf-16le")
84
+ except UnicodeDecodeError as exc:
85
+ if entry_id == 0 and object_type is ObjectType.ROOT_STORAGE:
86
+ name = "Root Entry"
87
+ name_was_repaired = True
88
+ else:
89
+ raise InvalidCompoundFile(
90
+ f"directory entry {entry_id} has an invalid UTF-16 name"
91
+ ) from exc
92
+ if (
93
+ entry_id == 0
94
+ and object_type is ObjectType.ROOT_STORAGE
95
+ and name != "Root Entry"
96
+ ):
97
+ name = "Root Entry"
98
+ name_was_repaired = True
99
+
100
+ left, right, child = struct.unpack_from("<III", view, 68)
101
+ state_bits = struct.unpack_from("<I", view, 96)[0]
102
+ creation_time, modified_time = struct.unpack_from("<QQ", view, 100)
103
+ starting_sector = struct.unpack_from("<I", view, 116)[0]
104
+ stream_size = struct.unpack_from("<Q", view, 120)[0]
105
+ if major_version == 3:
106
+ stream_size &= 0xFFFFFFFF
107
+
108
+ return cls(
109
+ entry_id=entry_id,
110
+ name=name,
111
+ object_type=object_type,
112
+ color_flag=int(view[67]),
113
+ left_sibling_id=left,
114
+ right_sibling_id=right,
115
+ child_id=child,
116
+ clsid=view[80:96].tobytes(),
117
+ state_bits=state_bits,
118
+ creation_time=creation_time,
119
+ modified_time=modified_time,
120
+ starting_sector=starting_sector,
121
+ stream_size=stream_size,
122
+ name_was_repaired=name_was_repaired,
123
+ )
124
+
125
+ @property
126
+ def has_left(self) -> bool:
127
+ return self.left_sibling_id != NOSTREAM
128
+
129
+ @property
130
+ def has_right(self) -> bool:
131
+ return self.right_sibling_id != NOSTREAM
132
+
133
+ @property
134
+ def has_child(self) -> bool:
135
+ return self.child_id != NOSTREAM