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.
@@ -0,0 +1,24 @@
1
+ from .document_properties import WordDocumentSettings, read_document_settings
2
+ from .fib import FileInformationBlock
3
+ from .fonts import read_font_table
4
+ from .formatting import FormattingMap, read_formatting
5
+ from .headers import HeaderFooterCollection, read_header_footer_stories
6
+ from .pieces import Piece, PieceTable, read_piece_table
7
+ from .sections import read_sections
8
+ from .styles import read_style_sheet
9
+
10
+ __all__ = [
11
+ "FileInformationBlock",
12
+ "FormattingMap",
13
+ "HeaderFooterCollection",
14
+ "Piece",
15
+ "PieceTable",
16
+ "WordDocumentSettings",
17
+ "read_document_settings",
18
+ "read_font_table",
19
+ "read_formatting",
20
+ "read_header_footer_stories",
21
+ "read_piece_table",
22
+ "read_sections",
23
+ "read_style_sheet",
24
+ ]
@@ -0,0 +1,31 @@
1
+ """Minimal parsing of document-wide settings from the MS-DOC DOP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import struct
7
+
8
+ from ..errors import InvalidWordDocument
9
+
10
+
11
+ @dataclass(slots=True, frozen=True)
12
+ class WordDocumentSettings:
13
+ even_and_odd_headers: bool = False
14
+
15
+
16
+ def read_document_settings(
17
+ table_stream: bytes,
18
+ *,
19
+ offset: int,
20
+ size: int,
21
+ ) -> WordDocumentSettings:
22
+ if size == 0:
23
+ return WordDocumentSettings()
24
+ if offset < 0 or size < 0 or offset > len(table_stream) - size:
25
+ raise InvalidWordDocument(
26
+ f"DOP range [{offset}, {offset + size}) exceeds Table stream"
27
+ )
28
+ if size < 2:
29
+ raise InvalidWordDocument("DOP is truncated before DopBase flags")
30
+ flags = struct.unpack_from("<H", table_stream, offset)[0]
31
+ return WordDocumentSettings(even_and_odd_headers=bool(flags & 0x0001))
doc2docx/msdoc/fib.py ADDED
@@ -0,0 +1,246 @@
1
+ """File Information Block parsing for Word 97-2003 binary documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ..binary import BinaryReader
8
+ from ..errors import BinaryBoundsError, InvalidWordDocument
9
+
10
+
11
+ FIB_IDENT = 0xA5EC
12
+ # FibRgFcLcb97 includes the deprecated fcPlcPad/lcbPlcPad pair after
13
+ # fcPlcfSed/lcbPlcfSed. Therefore fcClx/lcbClx is pair 33 (zero-based).
14
+ FCLCB97_STSHF_INDEX = 1
15
+ FCLCB97_PLCF_SED_INDEX = 6
16
+ FCLCB97_PLCF_HDD_INDEX = 11
17
+ FCLCB97_PLCF_BTE_CHPX_INDEX = 12
18
+ FCLCB97_PLCF_BTE_PAPX_INDEX = 13
19
+ FCLCB97_STTBF_FFN_INDEX = 15
20
+ FCLCB97_DOP_INDEX = 31
21
+ FCLCB97_CLX_INDEX = 33
22
+
23
+
24
+ @dataclass(slots=True, frozen=True)
25
+ class FibBase:
26
+ w_ident: int
27
+ n_fib: int
28
+ lid: int
29
+ flags: int
30
+ n_fib_back: int
31
+ l_key: int
32
+ environment: int
33
+ flags2: int
34
+
35
+ @property
36
+ def is_template(self) -> bool:
37
+ return bool(self.flags & 0x0001)
38
+
39
+ @property
40
+ def is_complex(self) -> bool:
41
+ return bool(self.flags & 0x0004)
42
+
43
+ @property
44
+ def has_pictures(self) -> bool:
45
+ return bool(self.flags & 0x0008)
46
+
47
+ @property
48
+ def is_encrypted(self) -> bool:
49
+ return bool(self.flags & 0x0100)
50
+
51
+ @property
52
+ def uses_1table(self) -> bool:
53
+ return bool(self.flags & 0x0200)
54
+
55
+ @property
56
+ def is_write_reserved(self) -> bool:
57
+ return bool(self.flags & 0x0800)
58
+
59
+ @property
60
+ def is_obfuscated(self) -> bool:
61
+ return bool(self.flags & 0x8000)
62
+
63
+ @property
64
+ def table_stream_name(self) -> str:
65
+ return "1Table" if self.uses_1table else "0Table"
66
+
67
+
68
+ @dataclass(slots=True, frozen=True)
69
+ class FcLcb:
70
+ fc: int
71
+ lcb: int
72
+
73
+
74
+ @dataclass(slots=True, frozen=True)
75
+ class FileInformationBlock:
76
+ base: FibBase
77
+ fib_rg_w: tuple[int, ...]
78
+ fib_rg_lw: tuple[int, ...]
79
+ fib_rg_fc_lcb: tuple[FcLcb, ...]
80
+ fib_rg_csw_new: tuple[int, ...]
81
+ fib_size: int
82
+
83
+ @property
84
+ def n_fib(self) -> int:
85
+ return self.fib_rg_csw_new[0] if self.fib_rg_csw_new else self.base.n_fib
86
+
87
+ @property
88
+ def ccp_text(self) -> int:
89
+ if len(self.fib_rg_lw) <= 3:
90
+ raise InvalidWordDocument("FIB does not contain FibRgLw97.ccpText")
91
+ return self.fib_rg_lw[3]
92
+
93
+ @property
94
+ def ccp_footnotes(self) -> int:
95
+ return self.fib_rg_lw[4] if len(self.fib_rg_lw) > 4 else 0
96
+
97
+ @property
98
+ def ccp_headers(self) -> int:
99
+ return self.fib_rg_lw[5] if len(self.fib_rg_lw) > 5 else 0
100
+
101
+ @property
102
+ def header_story_cp_start(self) -> int:
103
+ return self.ccp_text + self.ccp_footnotes
104
+
105
+ @property
106
+ def cb_mac(self) -> int:
107
+ if not self.fib_rg_lw:
108
+ raise InvalidWordDocument("FIB does not contain FibRgLw97.cbMac")
109
+ return self.fib_rg_lw[0]
110
+
111
+ @property
112
+ def secondary_story_character_counts(self) -> dict[str, int]:
113
+ names_and_indexes = (
114
+ ("footnotes", 4),
115
+ ("headers", 5),
116
+ ("comments", 7),
117
+ ("endnotes", 8),
118
+ ("textboxes", 9),
119
+ ("header_textboxes", 10),
120
+ )
121
+ return {
122
+ name: self.fib_rg_lw[index]
123
+ for name, index in names_and_indexes
124
+ if index < len(self.fib_rg_lw) and self.fib_rg_lw[index]
125
+ }
126
+
127
+ @property
128
+ def clx(self) -> FcLcb:
129
+ if len(self.fib_rg_fc_lcb) <= FCLCB97_CLX_INDEX:
130
+ raise InvalidWordDocument("FIB does not contain fcClx/lcbClx")
131
+ return self.fib_rg_fc_lcb[FCLCB97_CLX_INDEX]
132
+
133
+ @property
134
+ def stshf(self) -> FcLcb:
135
+ if len(self.fib_rg_fc_lcb) <= FCLCB97_STSHF_INDEX:
136
+ return FcLcb(0, 0)
137
+ return self.fib_rg_fc_lcb[FCLCB97_STSHF_INDEX]
138
+
139
+ @property
140
+ def plcf_sed(self) -> FcLcb:
141
+ if len(self.fib_rg_fc_lcb) <= FCLCB97_PLCF_SED_INDEX:
142
+ return FcLcb(0, 0)
143
+ return self.fib_rg_fc_lcb[FCLCB97_PLCF_SED_INDEX]
144
+
145
+ @property
146
+ def plcf_hdd(self) -> FcLcb:
147
+ if len(self.fib_rg_fc_lcb) <= FCLCB97_PLCF_HDD_INDEX:
148
+ return FcLcb(0, 0)
149
+ return self.fib_rg_fc_lcb[FCLCB97_PLCF_HDD_INDEX]
150
+
151
+ @property
152
+ def dop(self) -> FcLcb:
153
+ if len(self.fib_rg_fc_lcb) <= FCLCB97_DOP_INDEX:
154
+ return FcLcb(0, 0)
155
+ return self.fib_rg_fc_lcb[FCLCB97_DOP_INDEX]
156
+
157
+ @property
158
+ def sttbf_ffn(self) -> FcLcb:
159
+ if len(self.fib_rg_fc_lcb) <= FCLCB97_STTBF_FFN_INDEX:
160
+ return FcLcb(0, 0)
161
+ return self.fib_rg_fc_lcb[FCLCB97_STTBF_FFN_INDEX]
162
+
163
+ @property
164
+ def plcf_bte_chpx(self) -> FcLcb:
165
+ if len(self.fib_rg_fc_lcb) <= FCLCB97_PLCF_BTE_CHPX_INDEX:
166
+ return FcLcb(0, 0)
167
+ return self.fib_rg_fc_lcb[FCLCB97_PLCF_BTE_CHPX_INDEX]
168
+
169
+ @property
170
+ def plcf_bte_papx(self) -> FcLcb:
171
+ if len(self.fib_rg_fc_lcb) <= FCLCB97_PLCF_BTE_PAPX_INDEX:
172
+ return FcLcb(0, 0)
173
+ return self.fib_rg_fc_lcb[FCLCB97_PLCF_BTE_PAPX_INDEX]
174
+
175
+ @classmethod
176
+ def parse(cls, word_document_stream: bytes) -> "FileInformationBlock":
177
+ reader = BinaryReader(word_document_stream, label="WordDocument FIB")
178
+ try:
179
+ w_ident = reader.u16()
180
+ n_fib = reader.u16()
181
+ reader.skip(2) # unused
182
+ lid = reader.u16()
183
+ reader.skip(2) # pnNext
184
+ flags = reader.u16()
185
+ n_fib_back = reader.u16()
186
+ l_key = reader.u32()
187
+ environment = reader.u8()
188
+ flags2 = reader.u8()
189
+ reader.skip(12) # reserved3 through reserved6
190
+
191
+ if w_ident != FIB_IDENT:
192
+ raise InvalidWordDocument(
193
+ f"WordDocument FIB has wIdent 0x{w_ident:04X}; expected 0xA5EC"
194
+ )
195
+
196
+ csw = reader.u16()
197
+ fib_rg_w = tuple(reader.u16() for _ in range(csw))
198
+
199
+ cslw = reader.u16()
200
+ fib_rg_lw = tuple(reader.u32() for _ in range(cslw))
201
+
202
+ cb_rg_fc_lcb = reader.u16()
203
+ fib_rg_fc_lcb = tuple(
204
+ FcLcb(reader.u32(), reader.u32()) for _ in range(cb_rg_fc_lcb)
205
+ )
206
+ if reader.remaining >= 2:
207
+ csw_new = reader.u16()
208
+ fib_rg_csw_new = tuple(reader.u16() for _ in range(csw_new))
209
+ else:
210
+ fib_rg_csw_new = ()
211
+ except BinaryBoundsError as exc:
212
+ raise InvalidWordDocument(f"truncated or invalid FIB: {exc}") from exc
213
+
214
+ if csw < 14:
215
+ raise InvalidWordDocument(
216
+ f"FIB FibRgW has {csw} words; Word 97 layout requires at least 14"
217
+ )
218
+ if cslw < 11:
219
+ raise InvalidWordDocument(
220
+ f"FIB FibRgLw has {cslw} values; expected at least 11"
221
+ )
222
+ if cb_rg_fc_lcb <= FCLCB97_CLX_INDEX:
223
+ raise InvalidWordDocument(
224
+ f"FIB has {cb_rg_fc_lcb} fc/lcb pairs and no CLX entry"
225
+ )
226
+ ccp_text = fib_rg_lw[3]
227
+ if ccp_text >= 0x7FFFFFFF:
228
+ raise InvalidWordDocument(f"invalid main-story character count {ccp_text}")
229
+
230
+ return cls(
231
+ base=FibBase(
232
+ w_ident,
233
+ n_fib,
234
+ lid,
235
+ flags,
236
+ n_fib_back,
237
+ l_key,
238
+ environment,
239
+ flags2,
240
+ ),
241
+ fib_rg_w=fib_rg_w,
242
+ fib_rg_lw=fib_rg_lw,
243
+ fib_rg_fc_lcb=fib_rg_fc_lcb,
244
+ fib_rg_csw_new=fib_rg_csw_new,
245
+ fib_size=reader.position,
246
+ )
@@ -0,0 +1,132 @@
1
+ """MS-DOC SttbfFfn and FFN font-table parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+
7
+ from ..errors import InvalidWordDocument
8
+ from ..model import FontDefinition
9
+
10
+
11
+ _FONT_FAMILIES = {
12
+ 0: "auto",
13
+ 1: "roman",
14
+ 2: "swiss",
15
+ 3: "modern",
16
+ 4: "script",
17
+ 5: "decorative",
18
+ }
19
+
20
+ _FONT_PITCHES = {
21
+ 0: "default",
22
+ 1: "fixed",
23
+ 2: "variable",
24
+ }
25
+
26
+
27
+ def _decode_xsz_ffn(data: bytes, *, label: str) -> tuple[str, ...]:
28
+ if len(data) % 2:
29
+ raise InvalidWordDocument(f"{label} font names have an odd byte count")
30
+ code_units = struct.unpack(f"<{len(data) // 2}H", data)
31
+ names: list[str] = []
32
+ start = 0
33
+ for index, value in enumerate(code_units):
34
+ if value != 0:
35
+ continue
36
+ raw_name = data[start * 2 : index * 2]
37
+ names.append(raw_name.decode("utf-16le", errors="replace"))
38
+ start = index + 1
39
+ if start != len(code_units):
40
+ raise InvalidWordDocument(f"{label} font name is not null-terminated")
41
+ return tuple(names)
42
+
43
+
44
+ def _parse_ffn(index: int, data: bytes) -> FontDefinition:
45
+ # ffid(1), wWeight(2), chs(1), ixchSzAlt(1), PANOSE(10), FONTSIGNATURE(24)
46
+ if len(data) < 41:
47
+ raise InvalidWordDocument(
48
+ f"SttbfFfn font {index} has {len(data)} bytes; expected at least 41"
49
+ )
50
+ ffid = data[0]
51
+ weight = struct.unpack_from("<h", data, 1)[0]
52
+ charset = data[3]
53
+ alternate_index = data[4]
54
+ panose = data[5:15]
55
+ signature = data[15:39]
56
+ names = _decode_xsz_ffn(data[39:], label=f"SttbfFfn font {index}")
57
+ if not names or not names[0]:
58
+ raise InvalidWordDocument(f"SttbfFfn font {index} has no primary name")
59
+
60
+ alternate_name: str | None = None
61
+ if alternate_index:
62
+ # ixchSzAlt is an index in UTF-16 code units from xszFfn. Real-world
63
+ # files normally place the alternate name immediately after the first
64
+ # terminator; prefer the indexed value but tolerate that common layout.
65
+ name_data = data[39:]
66
+ byte_offset = alternate_index * 2
67
+ if byte_offset < len(name_data):
68
+ tail = _decode_xsz_ffn(
69
+ name_data[byte_offset:],
70
+ label=f"SttbfFfn font {index} alternate name",
71
+ )
72
+ alternate_name = tail[0] if tail and tail[0] else None
73
+ elif len(names) > 1:
74
+ alternate_name = names[1] or None
75
+ elif len(names) > 1:
76
+ alternate_name = names[1] or None
77
+
78
+ return FontDefinition(
79
+ index=index,
80
+ name=names[0],
81
+ alternate_name=alternate_name,
82
+ charset=charset,
83
+ family=_FONT_FAMILIES.get((ffid >> 4) & 0x07),
84
+ pitch=_FONT_PITCHES.get(ffid & 0x03),
85
+ weight=weight if weight > 0 else None,
86
+ panose=panose,
87
+ signature=signature,
88
+ )
89
+
90
+
91
+ def read_font_table(
92
+ table_stream: bytes,
93
+ *,
94
+ offset: int,
95
+ size: int,
96
+ ) -> tuple[FontDefinition, ...]:
97
+ """Parse the non-extended STTB used by SttbfFfn."""
98
+
99
+ if size == 0:
100
+ return ()
101
+ if offset < 0 or size < 0 or offset > len(table_stream) - size:
102
+ raise InvalidWordDocument(
103
+ f"SttbfFfn range [{offset}, {offset + size}) exceeds Table stream"
104
+ )
105
+ data = table_stream[offset : offset + size]
106
+ if len(data) < 4:
107
+ raise InvalidWordDocument("SttbfFfn is truncated")
108
+ count, extra_size = struct.unpack_from("<HH", data)
109
+ if count == 0xFFFF:
110
+ raise InvalidWordDocument("extended SttbfFfn layout is not valid for MS-DOC")
111
+ if extra_size:
112
+ raise InvalidWordDocument(
113
+ f"SttbfFfn has unsupported cbExtra value {extra_size}"
114
+ )
115
+
116
+ fonts: list[FontDefinition] = []
117
+ position = 4
118
+ for index in range(count):
119
+ if position >= len(data):
120
+ raise InvalidWordDocument(f"SttbfFfn is truncated before font {index}")
121
+ byte_count = data[position]
122
+ position += 1
123
+ end = position + byte_count
124
+ if end > len(data):
125
+ raise InvalidWordDocument(f"SttbfFfn font {index} exceeds the table")
126
+ fonts.append(_parse_ffn(index, data[position:end]))
127
+ position = end
128
+ if position != len(data) and any(data[position:]):
129
+ raise InvalidWordDocument(
130
+ f"SttbfFfn has {len(data) - position} unexpected trailing bytes"
131
+ )
132
+ return tuple(fonts)