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 +6 -0
- doc2docx/__main__.py +4 -0
- doc2docx/binary/__init__.py +4 -0
- doc2docx/binary/reader.py +87 -0
- doc2docx/cfb/__init__.py +5 -0
- doc2docx/cfb/constants.py +9 -0
- doc2docx/cfb/container.py +370 -0
- doc2docx/cfb/directory.py +135 -0
- doc2docx/cfb/header.py +108 -0
- doc2docx/cli.py +100 -0
- doc2docx/converter.py +321 -0
- doc2docx/diagnostics.py +105 -0
- doc2docx/errors.py +37 -0
- doc2docx/model/__init__.py +57 -0
- doc2docx/model/document.py +806 -0
- doc2docx/msdoc/__init__.py +24 -0
- doc2docx/msdoc/document_properties.py +31 -0
- doc2docx/msdoc/fib.py +246 -0
- doc2docx/msdoc/fonts.py +132 -0
- doc2docx/msdoc/formatting.py +467 -0
- doc2docx/msdoc/headers.py +184 -0
- doc2docx/msdoc/pieces.py +407 -0
- doc2docx/msdoc/sections.py +259 -0
- doc2docx/msdoc/sprm.py +753 -0
- doc2docx/msdoc/styles.py +288 -0
- doc2docx/ooxml/__init__.py +3 -0
- doc2docx/ooxml/package.py +1163 -0
- msdoc2docx-0.7.0.dist-info/METADATA +84 -0
- msdoc2docx-0.7.0.dist-info/RECORD +33 -0
- msdoc2docx-0.7.0.dist-info/WHEEL +5 -0
- msdoc2docx-0.7.0.dist-info/entry_points.txt +2 -0
- msdoc2docx-0.7.0.dist-info/licenses/LICENSE +21 -0
- msdoc2docx-0.7.0.dist-info/top_level.txt +1 -0
doc2docx/__init__.py
ADDED
doc2docx/__main__.py
ADDED
|
@@ -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
|
+
|
doc2docx/cfb/__init__.py
ADDED
|
@@ -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
|