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/cfb/header.py ADDED
@@ -0,0 +1,108 @@
1
+ """CFB header parsing and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import struct
7
+
8
+ from .constants import CFB_SIGNATURE, ENDOFCHAIN, FREESECT
9
+ from ..errors import InvalidCompoundFile
10
+
11
+
12
+ @dataclass(slots=True, frozen=True)
13
+ class CompoundFileHeader:
14
+ minor_version: int
15
+ major_version: int
16
+ sector_size: int
17
+ mini_sector_size: int
18
+ number_of_directory_sectors: int
19
+ number_of_fat_sectors: int
20
+ first_directory_sector: int
21
+ transaction_signature: int
22
+ mini_stream_cutoff: int
23
+ first_mini_fat_sector: int
24
+ number_of_mini_fat_sectors: int
25
+ first_difat_sector: int
26
+ number_of_difat_sectors: int
27
+ difat: tuple[int, ...]
28
+
29
+ @classmethod
30
+ def parse(cls, data: bytes | memoryview) -> "CompoundFileHeader":
31
+ view = memoryview(data)
32
+ if len(view) < 512:
33
+ raise InvalidCompoundFile("CFB file is shorter than its 512-byte header")
34
+ if view[:8].tobytes() != CFB_SIGNATURE:
35
+ raise InvalidCompoundFile(
36
+ "input does not have the CFB/OLE compound-file signature"
37
+ )
38
+ if any(view[8:24]):
39
+ raise InvalidCompoundFile("CFB header CLSID must be zero")
40
+
41
+ minor, major, byte_order, sector_shift, mini_shift = struct.unpack_from(
42
+ "<HHHHH", view, 24
43
+ )
44
+ if byte_order != 0xFFFE:
45
+ raise InvalidCompoundFile(
46
+ f"unsupported CFB byte order 0x{byte_order:04X}"
47
+ )
48
+ if major not in (3, 4):
49
+ raise InvalidCompoundFile(f"unsupported CFB major version {major}")
50
+ expected_shift = 9 if major == 3 else 12
51
+ if sector_shift != expected_shift:
52
+ raise InvalidCompoundFile(
53
+ f"CFB version {major} requires sector shift {expected_shift}, "
54
+ f"got {sector_shift}"
55
+ )
56
+ if mini_shift != 6:
57
+ raise InvalidCompoundFile(
58
+ f"CFB mini-sector shift must be 6, got {mini_shift}"
59
+ )
60
+ if any(view[34:40]):
61
+ raise InvalidCompoundFile("reserved CFB header bytes must be zero")
62
+
63
+ fields = struct.unpack_from("<IIIIIIIII", view, 40)
64
+ (
65
+ number_of_directory_sectors,
66
+ number_of_fat_sectors,
67
+ first_directory_sector,
68
+ transaction_signature,
69
+ mini_stream_cutoff,
70
+ first_mini_fat_sector,
71
+ number_of_mini_fat_sectors,
72
+ first_difat_sector,
73
+ number_of_difat_sectors,
74
+ ) = fields
75
+ if major == 3 and number_of_directory_sectors != 0:
76
+ raise InvalidCompoundFile(
77
+ "CFB version 3 must declare zero directory sectors"
78
+ )
79
+ if mini_stream_cutoff != 0x1000:
80
+ raise InvalidCompoundFile(
81
+ f"CFB mini-stream cutoff must be 4096, got {mini_stream_cutoff}"
82
+ )
83
+ difat = struct.unpack_from("<109I", view, 76)
84
+
85
+ if number_of_difat_sectors == 0 and first_difat_sector not in (
86
+ ENDOFCHAIN,
87
+ FREESECT,
88
+ ):
89
+ raise InvalidCompoundFile(
90
+ "CFB header has no DIFAT sectors but a non-free first DIFAT sector"
91
+ )
92
+
93
+ return cls(
94
+ minor,
95
+ major,
96
+ 1 << sector_shift,
97
+ 1 << mini_shift,
98
+ number_of_directory_sectors,
99
+ number_of_fat_sectors,
100
+ first_directory_sector,
101
+ transaction_signature,
102
+ mini_stream_cutoff,
103
+ first_mini_fat_sector,
104
+ number_of_mini_fat_sectors,
105
+ first_difat_sector,
106
+ number_of_difat_sectors,
107
+ tuple(difat),
108
+ )
doc2docx/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ import sys
9
+ from typing import Sequence
10
+
11
+ from . import __version__
12
+ from .cfb import CompoundFileLimits
13
+ from .converter import convert, inspect_doc
14
+ from .errors import Doc2DocxError
15
+
16
+
17
+ def _limits(value: int) -> CompoundFileLimits:
18
+ return CompoundFileLimits(max_input_bytes=value)
19
+
20
+
21
+ def _inspection_parser() -> argparse.ArgumentParser:
22
+ parser = argparse.ArgumentParser(prog="doc2docx inspect")
23
+ parser.add_argument("input", type=Path)
24
+ parser.add_argument("--json", action="store_true", dest="as_json")
25
+ parser.add_argument(
26
+ "--max-input-bytes", type=int, default=CompoundFileLimits().max_input_bytes
27
+ )
28
+ return parser
29
+
30
+
31
+ def _conversion_parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(
33
+ prog="doc2docx",
34
+ description="Convert an unencrypted Word 97-2003 .doc file to .docx",
35
+ )
36
+ parser.add_argument("input", type=Path)
37
+ parser.add_argument("-o", "--output", type=Path)
38
+ parser.add_argument("--report", type=Path)
39
+ parser.add_argument(
40
+ "--max-input-bytes", type=int, default=CompoundFileLimits().max_input_bytes
41
+ )
42
+ parser.add_argument("--version", action="version", version=__version__)
43
+ return parser
44
+
45
+
46
+ def _print_inspection(info: dict[str, object]) -> None:
47
+ cfb = info["cfb"]
48
+ fib = info["fib"]
49
+ assert isinstance(cfb, dict) and isinstance(fib, dict)
50
+ print(f"Input: {info['path']}")
51
+ print(
52
+ f"CFB: version {cfb['major_version']}, sector size {cfb['sector_size']}, "
53
+ f"{cfb['sector_count']} sectors"
54
+ )
55
+ print(
56
+ f"FIB: nFib=0x{int(fib['nFib']):04X}, table={fib['table_stream']}, "
57
+ f"ccpText={fib['ccpText']}, encrypted={fib['encrypted']}"
58
+ )
59
+ print("Streams and storages:")
60
+ entries = info["entries"]
61
+ assert isinstance(entries, list)
62
+ for item in entries:
63
+ size = "" if item["size"] is None else f" ({item['size']} bytes)"
64
+ print(f" {item['type']:12} {item['path']}{size}")
65
+
66
+
67
+ def main(argv: Sequence[str] | None = None) -> int:
68
+ arguments = list(sys.argv[1:] if argv is None else argv)
69
+ try:
70
+ if arguments[:1] == ["inspect"]:
71
+ args = _inspection_parser().parse_args(arguments[1:])
72
+ info = inspect_doc(
73
+ args.input,
74
+ limits=_limits(args.max_input_bytes),
75
+ )
76
+ if args.as_json:
77
+ print(json.dumps(info, ensure_ascii=False, indent=2))
78
+ else:
79
+ _print_inspection(info)
80
+ return 0
81
+
82
+ args = _conversion_parser().parse_args(arguments)
83
+ result = convert(
84
+ args.input,
85
+ args.output,
86
+ limits=_limits(args.max_input_bytes),
87
+ )
88
+ if args.report:
89
+ result.report.write_json(args.report)
90
+ print(f"Converted {args.input} -> {result.output_path}")
91
+ if result.report.warnings:
92
+ print(
93
+ f"Completed with {len(result.report.warnings)} warning(s)",
94
+ file=sys.stderr,
95
+ )
96
+ return 0
97
+ except (Doc2DocxError, OSError) as exc:
98
+ print(f"doc2docx: error: {exc}", file=sys.stderr)
99
+ return 2
100
+
doc2docx/converter.py ADDED
@@ -0,0 +1,321 @@
1
+ """End-to-end conversion and inspection APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Iterator
6
+ from dataclasses import dataclass
7
+ import os
8
+ from pathlib import Path
9
+ import stat
10
+ import tempfile
11
+ from typing import Any
12
+
13
+ from .cfb import CompoundFile, CompoundFileLimits, ObjectType
14
+ from .diagnostics import ConversionReport
15
+ from .errors import (
16
+ EncryptedDocumentError,
17
+ InvalidWordDocument,
18
+ UnsafeOutputPathError,
19
+ )
20
+ from .model import Document, Paragraph, Table, parse_main_story
21
+ from .msdoc import (
22
+ FileInformationBlock,
23
+ read_document_settings,
24
+ read_font_table,
25
+ read_formatting,
26
+ read_header_footer_stories,
27
+ read_piece_table,
28
+ read_sections,
29
+ read_style_sheet,
30
+ )
31
+ from .ooxml import validate_docx, write_docx
32
+
33
+
34
+ @dataclass(slots=True, frozen=True)
35
+ class ConversionResult:
36
+ output_path: Path
37
+ report: ConversionReport
38
+ document: Document
39
+
40
+
41
+ def _iter_tables(blocks: Iterable[Paragraph | Table]) -> Iterator[Table]:
42
+ for block in blocks:
43
+ if not isinstance(block, Table):
44
+ continue
45
+ yield block
46
+ for row in block.rows:
47
+ for cell in row.cells:
48
+ yield from _iter_tables(cell.body_blocks)
49
+
50
+
51
+ def _load_word_parts(
52
+ source: str | Path,
53
+ *,
54
+ limits: CompoundFileLimits | None,
55
+ ) -> tuple[CompoundFile, bytes, FileInformationBlock]:
56
+ compound = CompoundFile.from_path(source, limits=limits)
57
+ word_document = compound.open_stream("WordDocument")
58
+ fib = FileInformationBlock.parse(word_document)
59
+ return compound, word_document, fib
60
+
61
+
62
+ def convert(
63
+ source: str | Path,
64
+ destination: str | Path | None = None,
65
+ *,
66
+ limits: CompoundFileLimits | None = None,
67
+ ) -> ConversionResult:
68
+ source_path = Path(source)
69
+ destination_path = (
70
+ Path(destination) if destination is not None else source_path.with_suffix(".docx")
71
+ )
72
+ if destination_path.suffix.lower() != ".docx":
73
+ raise UnsafeOutputPathError("destination must use the .docx extension")
74
+ if source_path.resolve() == destination_path.resolve():
75
+ raise UnsafeOutputPathError("destination must not overwrite the source document")
76
+ report = ConversionReport(str(source_path), str(destination_path))
77
+
78
+ compound, word_document, fib = _load_word_parts(source_path, limits=limits)
79
+ repaired_entries = [entry for entry in compound.entries if entry.name_was_repaired]
80
+ if repaired_entries:
81
+ report.warning(
82
+ "CFB_DIRECTORY_REPAIRED",
83
+ "a malformed root directory name was safely normalized",
84
+ entry_count=len(repaired_entries),
85
+ )
86
+ if fib.base.is_encrypted:
87
+ method = "XOR obfuscation" if fib.base.is_obfuscated else "RC4 encryption"
88
+ raise EncryptedDocumentError(
89
+ f"password-protected Word documents using {method} are not yet supported"
90
+ )
91
+
92
+ table_name = fib.base.table_stream_name
93
+ table_stream = compound.open_stream(table_name)
94
+ section_table = fib.plcf_sed
95
+ sections = read_sections(
96
+ table_stream,
97
+ word_document,
98
+ offset=section_table.fc,
99
+ size=section_table.lcb,
100
+ main_story_cp_count=fib.ccp_text,
101
+ document_lid=fib.base.lid,
102
+ report=report,
103
+ )
104
+ dop = fib.dop
105
+ document_settings = read_document_settings(
106
+ table_stream,
107
+ offset=dop.fc,
108
+ size=dop.lcb,
109
+ )
110
+ font_table = fib.sttbf_ffn
111
+ fonts = read_font_table(
112
+ table_stream,
113
+ offset=font_table.fc,
114
+ size=font_table.lcb,
115
+ )
116
+ style_table = fib.stshf
117
+ style_sheet = read_style_sheet(
118
+ table_stream,
119
+ offset=style_table.fc,
120
+ size=style_table.lcb,
121
+ fonts=fonts,
122
+ report=report,
123
+ )
124
+ clx = fib.clx
125
+ piece_table = read_piece_table(
126
+ table_stream,
127
+ word_document,
128
+ fc_clx=clx.fc,
129
+ lcb_clx=clx.lcb,
130
+ report=report,
131
+ )
132
+ if fib.ccp_text > piece_table.cp_end:
133
+ raise InvalidWordDocument(
134
+ f"FIB main-story length {fib.ccp_text} exceeds Piece Table range "
135
+ f"{piece_table.cp_end}"
136
+ )
137
+ chpx = fib.plcf_bte_chpx
138
+ papx = fib.plcf_bte_papx
139
+ formatting = read_formatting(
140
+ table_stream,
141
+ word_document,
142
+ piece_table,
143
+ fc_plcf_bte_chpx=chpx.fc,
144
+ lcb_plcf_bte_chpx=chpx.lcb,
145
+ fc_plcf_bte_papx=papx.fc,
146
+ lcb_plcf_bte_papx=papx.lcb,
147
+ report=report,
148
+ fonts=fonts,
149
+ style_sheet=style_sheet,
150
+ )
151
+ header_table = fib.plcf_hdd
152
+ header_footers = read_header_footer_stories(
153
+ table_stream,
154
+ piece_table,
155
+ sections,
156
+ offset=header_table.fc,
157
+ size=header_table.lcb,
158
+ ccp_headers=fib.ccp_headers,
159
+ header_story_cp_start=fib.header_story_cp_start,
160
+ report=report,
161
+ character_properties_at=formatting.character_properties_at,
162
+ paragraph_properties_at=formatting.paragraph_properties_at,
163
+ )
164
+ sections = header_footers.sections
165
+ main_characters = piece_table.extract_characters(
166
+ 0,
167
+ fib.ccp_text,
168
+ report,
169
+ story="main",
170
+ )
171
+ parsed_document = parse_main_story(
172
+ main_characters,
173
+ report,
174
+ character_properties_at=formatting.character_properties_at,
175
+ paragraph_properties_at=formatting.paragraph_properties_at,
176
+ sections=sections,
177
+ )
178
+ document = Document(
179
+ paragraphs=parsed_document.paragraphs,
180
+ fonts=fonts,
181
+ styles=style_sheet,
182
+ blocks=parsed_document.blocks,
183
+ sections=sections,
184
+ even_and_odd_headers=document_settings.even_and_odd_headers,
185
+ )
186
+ tables = tuple(_iter_tables(document.body_blocks))
187
+
188
+ report.statistics.update(
189
+ {
190
+ "cfb_sector_count": compound.sector_count,
191
+ "table_stream": table_name,
192
+ "fib_version": fib.n_fib,
193
+ "piece_count": len(piece_table.pieces),
194
+ "main_story_cp_count": fib.ccp_text,
195
+ "paragraph_count": len(document.paragraphs),
196
+ "character_format_span_count": len(formatting.character_spans),
197
+ "paragraph_format_span_count": len(formatting.paragraph_spans),
198
+ "character_fkp_run_count": formatting.character_fkp_run_count,
199
+ "paragraph_fkp_run_count": formatting.paragraph_fkp_run_count,
200
+ "font_count": len(fonts),
201
+ "style_count": sum(
202
+ 1 for style in style_sheet.styles if style is not None
203
+ ),
204
+ "piece_prm_count": sum(
205
+ 1 for piece in piece_table.pieces if piece.prm
206
+ ),
207
+ "clx_prc_count": len(piece_table.prcs),
208
+ "table_count": len(tables),
209
+ "table_row_count": sum(len(table.rows) for table in tables),
210
+ "table_cell_count": sum(
211
+ len(row.cells) for table in tables for row in table.rows
212
+ ),
213
+ "section_count": len(sections),
214
+ "header_footer_story_count": header_footers.story_count,
215
+ "header_footer_paragraph_count": header_footers.paragraph_count,
216
+ }
217
+ )
218
+ if fib.base.has_pictures:
219
+ report.warning(
220
+ "PICTURES_DEFERRED",
221
+ "the FIB reports pictures; picture conversion is not yet supported",
222
+ )
223
+ secondary_stories = fib.secondary_story_character_counts
224
+ secondary_stories.pop("headers", None)
225
+ if secondary_stories:
226
+ report.warning(
227
+ "SECONDARY_STORIES_DEFERRED",
228
+ "some secondary document stories remain unsupported after M5b",
229
+ stories=secondary_stories,
230
+ )
231
+
232
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
233
+ destination_mode = (
234
+ stat.S_IMODE(destination_path.stat().st_mode)
235
+ if destination_path.exists()
236
+ else 0o644
237
+ )
238
+ temporary_path: Path | None = None
239
+ try:
240
+ with tempfile.NamedTemporaryFile(
241
+ prefix=f".{destination_path.name}.",
242
+ suffix=".tmp",
243
+ dir=destination_path.parent,
244
+ delete=False,
245
+ ) as temporary:
246
+ temporary_path = Path(temporary.name)
247
+ write_docx(document, temporary_path)
248
+ validate_docx(temporary_path)
249
+ os.chmod(temporary_path, destination_mode)
250
+ os.replace(temporary_path, destination_path)
251
+ temporary_path = None
252
+ finally:
253
+ if temporary_path is not None:
254
+ try:
255
+ temporary_path.unlink()
256
+ except FileNotFoundError:
257
+ pass
258
+
259
+ report.info("CONVERSION_COMPLETE", "M0-M5b conversion completed")
260
+ return ConversionResult(destination_path, report, document)
261
+
262
+
263
+ def inspect_doc(
264
+ source: str | Path,
265
+ *,
266
+ limits: CompoundFileLimits | None = None,
267
+ ) -> dict[str, Any]:
268
+ compound, _, fib = _load_word_parts(source, limits=limits)
269
+ entries = []
270
+ for entry in compound.entries:
271
+ if entry.path == "":
272
+ continue
273
+ entries.append(
274
+ {
275
+ "path": entry.path,
276
+ "type": entry.object_type.name.lower(),
277
+ "size": entry.stream_size
278
+ if entry.object_type is ObjectType.STREAM
279
+ else None,
280
+ "starting_sector": entry.starting_sector,
281
+ }
282
+ )
283
+ return {
284
+ "path": str(Path(source)),
285
+ "cfb": {
286
+ "major_version": compound.header.major_version,
287
+ "minor_version": compound.header.minor_version,
288
+ "sector_size": compound.header.sector_size,
289
+ "mini_sector_size": compound.header.mini_sector_size,
290
+ "sector_count": compound.sector_count,
291
+ "directory_repairs": sum(
292
+ 1 for entry in compound.entries if entry.name_was_repaired
293
+ ),
294
+ },
295
+ "fib": {
296
+ "nFib": fib.n_fib,
297
+ "lid": fib.base.lid,
298
+ "encrypted": fib.base.is_encrypted,
299
+ "obfuscated": fib.base.is_obfuscated,
300
+ "table_stream": fib.base.table_stream_name,
301
+ "ccpText": fib.ccp_text,
302
+ "ccpHdd": fib.ccp_headers,
303
+ "fcClx": fib.clx.fc,
304
+ "lcbClx": fib.clx.lcb,
305
+ "fcPlcfBteChpx": fib.plcf_bte_chpx.fc,
306
+ "lcbPlcfBteChpx": fib.plcf_bte_chpx.lcb,
307
+ "fcPlcfBtePapx": fib.plcf_bte_papx.fc,
308
+ "lcbPlcfBtePapx": fib.plcf_bte_papx.lcb,
309
+ "fcStshf": fib.stshf.fc,
310
+ "lcbStshf": fib.stshf.lcb,
311
+ "fcPlcfSed": fib.plcf_sed.fc,
312
+ "lcbPlcfSed": fib.plcf_sed.lcb,
313
+ "fcPlcfHdd": fib.plcf_hdd.fc,
314
+ "lcbPlcfHdd": fib.plcf_hdd.lcb,
315
+ "fcDop": fib.dop.fc,
316
+ "lcbDop": fib.dop.lcb,
317
+ "fcSttbfFfn": fib.sttbf_ffn.fc,
318
+ "lcbSttbfFfn": fib.sttbf_ffn.lcb,
319
+ },
320
+ "entries": sorted(entries, key=lambda item: item["path"]),
321
+ }
@@ -0,0 +1,105 @@
1
+ """Structured diagnostics emitted during inspection and conversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from enum import StrEnum
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ class Severity(StrEnum):
13
+ INFO = "info"
14
+ WARNING = "warning"
15
+ ERROR = "error"
16
+
17
+
18
+ @dataclass(slots=True, frozen=True)
19
+ class SourceLocation:
20
+ story: str | None = None
21
+ cp_start: int | None = None
22
+ cp_end: int | None = None
23
+ stream: str | None = None
24
+ fc_start: int | None = None
25
+ fc_end: int | None = None
26
+
27
+
28
+ @dataclass(slots=True, frozen=True)
29
+ class Diagnostic:
30
+ severity: Severity
31
+ code: str
32
+ message: str
33
+ location: SourceLocation | None = None
34
+ details: dict[str, Any] = field(default_factory=dict)
35
+
36
+ def to_dict(self) -> dict[str, Any]:
37
+ result = asdict(self)
38
+ result["severity"] = self.severity.value
39
+ if self.location is None:
40
+ result.pop("location")
41
+ if not self.details:
42
+ result.pop("details")
43
+ return result
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class ConversionReport:
48
+ source: str
49
+ destination: str | None = None
50
+ diagnostics: list[Diagnostic] = field(default_factory=list)
51
+ statistics: dict[str, int | str | bool] = field(default_factory=dict)
52
+
53
+ def add(
54
+ self,
55
+ severity: Severity,
56
+ code: str,
57
+ message: str,
58
+ *,
59
+ location: SourceLocation | None = None,
60
+ **details: Any,
61
+ ) -> None:
62
+ self.diagnostics.append(
63
+ Diagnostic(severity, code, message, location, details)
64
+ )
65
+
66
+ def info(self, code: str, message: str, **details: Any) -> None:
67
+ self.add(Severity.INFO, code, message, **details)
68
+
69
+ def warning(
70
+ self,
71
+ code: str,
72
+ message: str,
73
+ *,
74
+ location: SourceLocation | None = None,
75
+ **details: Any,
76
+ ) -> None:
77
+ self.add(
78
+ Severity.WARNING,
79
+ code,
80
+ message,
81
+ location=location,
82
+ **details,
83
+ )
84
+
85
+ def error(self, code: str, message: str, **details: Any) -> None:
86
+ self.add(Severity.ERROR, code, message, **details)
87
+
88
+ @property
89
+ def warnings(self) -> list[Diagnostic]:
90
+ return [d for d in self.diagnostics if d.severity is Severity.WARNING]
91
+
92
+ def to_dict(self) -> dict[str, Any]:
93
+ return {
94
+ "source": self.source,
95
+ "destination": self.destination,
96
+ "statistics": dict(self.statistics),
97
+ "diagnostics": [item.to_dict() for item in self.diagnostics],
98
+ }
99
+
100
+ def write_json(self, path: str | Path) -> None:
101
+ Path(path).write_text(
102
+ json.dumps(self.to_dict(), ensure_ascii=False, indent=2) + "\n",
103
+ encoding="utf-8",
104
+ )
105
+
doc2docx/errors.py ADDED
@@ -0,0 +1,37 @@
1
+ """Domain-specific exceptions raised by the converter."""
2
+
3
+
4
+ class Doc2DocxError(Exception):
5
+ """Base class for expected conversion failures."""
6
+
7
+
8
+ class BinaryBoundsError(Doc2DocxError):
9
+ """A binary structure tried to read outside its declared bounds."""
10
+
11
+
12
+ class InvalidCompoundFile(Doc2DocxError):
13
+ """The input is not a valid or safely readable CFB/OLE compound file."""
14
+
15
+
16
+ class StreamNotFound(InvalidCompoundFile):
17
+ """A required stream is absent from the compound file."""
18
+
19
+
20
+ class InvalidWordDocument(Doc2DocxError):
21
+ """The compound file is not a supported MS-DOC Word document."""
22
+
23
+
24
+ class EncryptedDocumentError(Doc2DocxError):
25
+ """The input requires a password/decryption implementation."""
26
+
27
+
28
+ class UnsupportedFeatureError(Doc2DocxError):
29
+ """A valid feature is outside the currently supported milestone."""
30
+
31
+
32
+ class PackageWriteError(Doc2DocxError):
33
+ """The DOCX package could not be generated safely."""
34
+
35
+
36
+ class UnsafeOutputPathError(Doc2DocxError):
37
+ """The requested destination could overwrite the source or is not DOCX."""