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/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
|
+
}
|
doc2docx/diagnostics.py
ADDED
|
@@ -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."""
|